# Theme Builder — Architecture > Saltcorn plugin `theme-builder` — a visual theme designer. Phase 1 ships a Bootstrap-5 CSS custom-property **skin overlay** (no Sass); Phase 2 flips on a selectable **layout** assembled from a Craft.js tree with an optional full dart-sass recompile. Themes live in a **real, backup-safe, per-tenant Saltcorn Table**; the active pointer lives in **plugin config**. Compiled CSS is a **derived cache**, never stored. This document is the authoritative, implementation-ready specification. It supersedes the per-subsystem designs it was assembled from and folds in every adversarial-review fix. --- ## Table of contents 1. [Overview & goals](#1-overview--goals) 2. [Design principles](#2-design-principles) 3. [Saltcorn integration points](#3-saltcorn-integration-points) 4. [Data model & storage](#4-data-model--storage) 5. [Plugin module contract & lifecycle](#5-plugin-module-contract--lifecycle) 6. [HTTP API surface](#6-http-api-surface) 7. [Compile & render pipeline](#7-compile--render-pipeline) 8. [Builder SPA](#8-builder-spa) 9. [Export / import](#9-export--import) 10. [Package / file layout](#10-package--file-layout) 11. [Phase plan & milestones](#11-phase-plan--milestones) 12. [Open questions](#12-open-questions) --- ## 1. Overview & goals ### 1.1 What it is `theme-builder` is a Saltcorn plugin (sibling to `dev-deploy`, `idp`, `postiz` under `/home/scott/claude/saltcorn/`) that lets an admin design site themes visually and publish them to the live site. A Saltcorn theme is fundamentally **a plugin that exports `layout.wrap()` (the HTML skeleton) plus linked CSS**; `theme-builder` realizes both halves through config-driven facilities so that one stored theme drives the live appearance. It maintains a **library** of themes (built-in starters shipped in code + user themes in a real Table), a **token editor** with live preview, an **activate** operation that publishes exactly one theme to the live site, and **export/import** for cross-instance portability. ### 1.2 The phased approach The plugin ships in two phases that share the same data model, store, compile entry point, and SPA shell. The phase is a single config flag, `phase2Enabled`. | Aspect | **Phase 1 — the "skin"** | **Phase 2 — the "bones"** | |---|---|---| | Mechanism | A config-driven CSS custom-property overlay injected via `headers(cfg)` as a `{ css: ".../theme.css?v=" }` link, loaded *after* Bootstrap so it wins by cascade. | The plugin registers a selectable `layout(cfg)` so it becomes choosable via `layout_by_role` / `user._attributes.layout`. `wrap()` is assembled from a Craft.js serialized tree. | | Composition | **Composes** with the active theme (e.g. `sbadmin2`); it is *not* itself a layout. | The plugin **owns** the stylesheet, so an optional **full dart-sass recompile** of Bootstrap from source becomes worthwhile (no double-Bootstrap problem). | | Compiler | Pure-JS `--bs-*` overlay emitter. No Sass dependency. | Optional `sass.compileString` against vendored Bootstrap source; falls back to the overlay on failure. | | Per-role | `headers` `only_if(req)` predicate. | Native `layout_by_role`. | | Blast radius | Low. Ships first. | Higher; gated behind `phase2Enabled`. | ### 1.3 The hybrid `bootstrap.build` + Craft.js model Two complementary tooling models are adopted: - **`bootstrap.build`-style token editor** — we adopt its *model* (a panel of color/font/spacing pickers that drive Bootstrap variables), implemented as our own React because `bootstrap.build` is not an embeddable npm library. Tokens map 1:1 onto Bootstrap 5 `--bs-*` custom properties, so the common case needs **no Sass compiler**. - **Craft.js canvas** — `@craftjs/core` is already vendored in `saltcorn-builder`. Phase 2 grafts a structural canvas that reuses saltcorn-builder's `Editor`/`Frame`/resolver patterns and the `craftToSaltcorn`/`layoutToNodes` serialization round-trip, persisting a `layoutTree` on the theme row. ### 1.4 The cardinal rule: load ≠ activate The single most load-bearing semantic in this design: > **`load` (open in editor) is strictly separated from `activate` (publish).** Every editor/library operation (list/load/create/duplicate/save/rename/delete/export/import) only reads or writes the library Table. **`activate` is the ONLY operation that mutates the live site** — it recompiles tokens → CSS into the active-CSS cache and flips the active pointer in plugin config. This is enforced *structurally*: editor handler modules never `require` the activate engine. --- ## 2. Design principles ### 2.1 Single source of truth - A theme's **`tokens` object is authoritative.** Compiled CSS is a **derived cache**, recomputed on activate/save, never the source and never stored per-theme. - Craft.js layout nodes (Phase 2) reference `var(--bs-*)` / Bootstrap classes, **never literal hex**. Token values live only in `tokens`. The serialization bridge never inlines token values into the layout tree, so the token panel and the canvas panel both derive from the one `tokens` object. - The token→CSS **mapping** has one chokepoint each direction: the server `TOKEN_SCHEMA` table (compiler) and the SPA `TokenManifest` (editor) are kept structurally parallel so the live preview and production CSS can never drift (§7, §8). - The **active pointer** has one home: `plugin.configuration` (`activeThemeId`, optional `activeByRole`). It is read at request time from the live merged cfg; it is written only by `activate`. - **Identity has one form:** the stable `theme_id` (uuid). The `name` is display-only. ### 2.2 Backup-safety via a real Table (the audit lesson) The earlier backup audit (`plugin_db_backup_compat`) found that raw-DDL `_dd_*`/`_idp_*` tables created with a bare `CREATE TABLE` are **invisible** to Saltcorn backup/restore/snapshots (because backup enumerates `_sc_tables`), and that a raw `_dd_row_uuid` column could ROLLBACK a user-table restore — a data-loss bug. This design **never issues raw DDL.** The theme library is created via the **Table model** (`Table.create` writes `_sc_tables`; `Field.create` writes `_sc_fields`), so: - The table and all its rows ride backup/restore/snapshots **automatically**. - It is **per-tenant for free** (schema-qualified by the Table model from async-local storage). - The active pointer in plugin config rides `plugin_pack`/`install_pack` automatically. Export/import is the **per-theme cross-instance complement** to backup (a portable file for one theme between unrelated instances), deliberately *not* a workaround for an invisibility gap. ### 2.3 Admin-trust import model Per the final, locked import-security decision: **there is NO security sanitization of imported themes.** An admin can already inject arbitrary site-wide CSS/JS via `page_custom_css`, `page_custom_html`, or by installing plugins, so a theme import (admin-only) crosses no boundary the admin role does not already own. We keep **robustness-only** validation, justified by correctness/UX/operational hygiene, never by security: (a) format + version check with **upcasting** of old exports; (b) mint a **fresh id** on import + **de-dup name**, so an import can never overwrite an existing theme; (c) a **light valid-CSS / scope-a-bad-token** check so a malformed token cannot white-screen every page; (d) a **generous size cap** (operational hygiene). **Documented contingency:** if a non-admin role is ever permitted to import or manage themes, real sanitization (CSS/selector/`@import`/`url()`/expression filtering, layoutTree resolver allow-listing) **MUST** be reintroduced before that capability ships. This is recorded as an inline comment in `lib/portability.js` and in the plugin README security section. --- ## 3. Saltcorn integration points The single switch that makes the plugin config-driven is the **presence of `configuration_workflow`.** Because it is present, `State.registerPlugin`'s `withCfg` resolver treats **every facility** (`layout`, `headers`, `routes`, …) as a factory `(cfg) => value` and calls it with the merged config (state.ts:1019-1024). Therefore our `headers`, `routes`, and (Phase 2) `layout` **must** be functions; the stored config drives all three. ### 3.1 The hooks we use (with file:line) | Hook | How we use it | Grounding | |---|---|---| | `configuration_workflow()` | Returns a `Workflow` mounting the phase toggle + a link/iframe to the builder SPA + the active-pointer step. Its presence is the config-driven switch. | plugins.js:843-854,920-957; workflow.ts:28-54,212-322 | | `routes(cfg)` | Returns the full route array (public `theme.css` + admin editor/API). Mounted at **app root** on the per-tenant router; `routesChangedCb` fires only when `routes.length>0`. Callbacks run positionally `(req,res,next)` via `error_catcher`. | state.ts:1136-1138; plugin_routes_handler.js:26-53; utils.js:298-306 | | `headers(cfg)` | Phase 1: returns `{ css: ".../theme.css?v=", only_if }` entries. Emitted **after** the theme's Bootstrap CSS so the overlay wins. The `Header` type key is `css`/`script`/`headerTag` — **there is no `style` key** in the type (though `headersInHead` emits `h.style` at runtime, we never rely on it). | state.ts:1123-1135; base_types.ts:69-76; sbadmin2/index.js:400-427; layout_utils.ts:646-720; wrapper.js:132-190 | | `layout(cfg)` | Phase 2 only: returns `{ wrap, authWrap?, renderBody?, pluginName }`. Registered into `state.layouts["theme-builder"]` only when truthy (`if (layout) this.layouts[name] = layout`). Phase 1 returns `undefined` so we are never a layout. | state.ts:1113-1117; base_types.ts:159-175 | | `onLoad(cfg)` | Idempotent per-tenant bootstrap (table ensure, builtins are code-only so nothing to seed into DB, **active-CSS cache rehydration**). Runs **outside** `withCfg` with the **raw** stored config; errors are swallowed+logged, so every step must be idempotent and self-guarded. | plugin.ts:499-505 | | static `public/` | Serves the compiled SPA bundle at `/plugins/public/theme-builder/` with zero extra route. | plugins.js:1246-1276 | | `exposed_configs` | **Omitted** (see §5.3) — the factories read the full merged cfg directly, and declaring it triggers a misleading "users must relogin" flash on every config save. | state.ts:1007-1012; plugins.js:962-963 | ### 3.2 How `configuration_workflow` makes facilities config-driven ``` Plugin.loadPlugin(plugin) -> registerPlugin(plugin_name||name, module, cfg, location, modname) // state.ts:993 withCfg(key, def) = plugin.configuration_workflow ? (plugin[key] ? plugin[key](cfg||{}) : def) : (plugin[key] || def) // state.ts:1019 this.layouts[name] = withCfg("layout") // only set if truthy state.ts:1113 this.headers[name].push( ...withCfg("headers", []) ) state.ts:1129 this.plugin_routes[name] = withCfg("routes", []) state.ts:1136 -> module.onLoad(plugin.configuration) // RAW cfg, not merged plugin.ts:499 ``` `cfg` passed to factories is **merged**: `{ ...savedConfig, ...fixed_plugin_configuration[name] }` (state.ts:1002-1006). `onLoad` receives the **raw** `plugin.configuration`. We keep `activeThemeId`/`activeByRole`/`phase2Enabled`/`activeHash` as plugin-internal keys that an admin would not set in `fixed_plugin_configuration` (or they would be un-editable AND stripped from packs). ### 3.3 Layout selection (Phase 2) `getLayout(user)` precedence is **role > per-user > last-installed** (state.ts:378-426), with the twist that when `layout_by_role` names a plugin **and** the user has a per-user layout for that **same** plugin, the per-user entry wins (state.ts:388-392). Critically, only the `layout_by_role` branch and the per-user `userLayouts` branch stamp `pluginName` onto the returned layout; the **last-installed fallback branch (state.ts:419-425) returns the layout object WITHOUT `pluginName`** — a fact §7.6 and §5.7 must never rely on (see the suppression fix there). `activeByRole` is intentionally shaped like core's `layout_by_role` (`{ [role_id:number]: value }`) so Phase 2 can project it mechanically. The canonical key everywhere (`state.layouts`, `plugin_cfgs`, `layout_by_role` values) is `plugin_name` = `"theme-builder"`, **not** the npm package name (plugin.ts:474). --- ## 4. Data model & storage This is owned by `lib/themeStore.js` (the only module that touches the Table or `plugin.configuration`), with pure helpers in `lib/themeSchema.js`, `lib/portability.js`, and `lib/builtins/`. ### 4.1 The theme library — a real Saltcorn Table **Table name:** `_themebuilder_themes` (leading underscore + unique prefix marks it plugin-internal; there is no flag to hide a registered table from the admin Tables UI, so **naming + `min_role`** are the only levers — table.ts:770 gotcha). `min_role_read = 1`, `min_role_write = 1`, `versioned = false`, `has_sync_info = false`. **Identity column resolution (folded review fix):** `Table.create` *always* creates an integer `serial` pk literally named `id` (table.ts:750-797). The **stable theme identity must NOT live in a column named `id`** or it collides with that pk and the locked IDENTITY decision breaks (route `:id` would resolve to the integer pk). Therefore the stable uuid lives in a **separate** `theme_id` String column; the integer `id` is internal housekeeping only and is never a theme reference. | Column | Saltcorn type | SQL | required | unique | Purpose | |---|---|---|---|---|---| | `id` | (auto pk) | serial | yes | yes | Row housekeeping only. **NOT** a theme reference. | | `theme_id` | String | text | yes | **yes** (`is_unique`) | **Stable theme identity** (uuid v4). All pointers/role-map refs use this. | | `name` | String | text | yes | no (app-enforced) | Display name, freely renamable. Uniqueness is UX-only (no DB constraint, so import can de-dup freely). | | `engine` | String | text | yes | no | Token dialect: `"bootstrap5"`. Forward-compat. | | `tokens` | String | text | yes | no | `JSON.stringify(tokensObject)` — the SINGLE SOURCE OF TRUTH. | | `layout_tree` | String | text | no | no | `JSON.stringify(craftTree)` — Phase 2 only; null in Phase 1. | | `version` | Integer | int | yes | no | Optimistic-lock counter, starts at 1. Bumped only by `save`. | | `created_at` | Date | timestamptz | yes | no | Row creation. | | `updated_at` | Date | timestamptz | yes | no | Last save. | **Why String for JSON:** the base plugin registers only `String, Integer, Bool, Date, Float, Color` (base-plugin/index.ts:68); there is **no core JSON type**. `String` maps to unbounded SQL `text` (types.ts:782-785), so `tokens`/`layout_tree` are `String` and the store does `JSON.stringify`/`JSON.parse` itself. This also avoids the SQLite-only jsonb auto-parse asymmetry. **`name` uniqueness is NOT a DB constraint.** It is enforced in app code at create/rename/import via `dedupName` (so import can de-dup freely and never 409 on a clash). Identity (`theme_id`) carries the only real unique constraint. ### 4.2 Built-in / starter themes (in code, merged at read time) Built-ins ship as frozen JS/JSON files under `lib/builtins/` (Bootswatch-derived token maps). They are **never inserted** into the Table — they live in code so upgrades ship new/updated starters with no DB migration and a user can never delete or rename them. ```js // lib/builtins/flatly.js module.exports = Object.freeze({ id: "builtin:flatly", // "builtin:" namespace prevents collision with row uuids name: "Flatly", engine: "bootstrap5", tokens: { $tokensVersion: 1, colors: { primary: "#2c3e50", success: "#18bc9c" /* ... */ } }, layoutTree: null, // Phase-2 builtins may ship a tree }); ``` ```js // lib/builtins/index.js const themes = [require("./flatly"), require("./darkly"), require("./cosmo") /* ... */]; const byId = Object.freeze(Object.fromEntries(themes.map((t) => [t.id, t]))); function listBuiltins() { return themes; } function getBuiltin(id) { return byId[id] || null; } module.exports = { listBuiltins, getBuiltin }; ``` Merge semantics: - Row `theme_id`s are uuid v4; builtin ids are `builtin:` — collision is impossible by namespacing. `ensureTable` asserts no stored `theme_id` starts with `builtin:`. - Built-ins are **not deletable, not renamable, not save-able**: any mutating op whose id `startsWith("builtin:")` is rejected before touching the Table. - **"Load" of a builtin = duplicate-to-edit:** opening a builtin returns its definition read-only; the first save duplicates it to a fresh uuid row. - **Activating a builtin directly is allowed** (a builtin id is a valid active-pointer value); duplicating is only required to *edit*. - Built-ins appear in `list()` even on a fresh, empty install. ### 4.3 Token schema, shape & defaults `tokens` mirrors the `bootstrap.build` model and maps 1:1 onto Bootstrap 5 `--bs-*` custom properties. ```ts type Tokens = { $tokensVersion: 1; // token-shape version (for future upcasting) colors: { primary?, secondary?, success?, info?, warning?, danger?, light?, dark?, bodyBg?, bodyColor? }; // all string CSS values typography: { fontSansSerif?, fontMonospace?, rootFontSize?, bodyFontSize?, bodyFontWeight?, bodyLineHeight?, headingsFontFamily? }; borders: { borderRadius?, borderWidth?, borderColor? }; components: { navbarBg?, cardBg?, linkColor?, linkHoverColor? }; custom?: { [cssVarName: string]: string }; // raw "--my-var": "value" escape hatch sass?: string; // Phase-2 only: extra admin-authored Sass before recompile }; ``` Every leaf is a **string** (CSS value text). All keys are optional except `$tokensVersion`. Missing keys fall back to `DEFAULT_TOKENS` via deep-merge-on-read, so a sparse theme never white-screens (an empty `{}` compiles to a Bootstrap no-op overlay). ```js const DEFAULT_TOKENS = Object.freeze({ $tokensVersion: 1, colors: { primary: "#0d6efd", secondary: "#6c757d", success: "#198754", info: "#0dcaf0", warning: "#ffc107", danger: "#dc3545", light: "#f8f9fa", dark: "#212529", bodyBg: "#ffffff", bodyColor: "#212529" }, typography: { rootFontSize: "16px", bodyFontSize: "1rem", bodyFontWeight: "400", bodyLineHeight: "1.5" }, borders: { borderRadius: "0.375rem", borderWidth: "1px" }, components: {}, custom: {}, }); ``` > **Token-shape upcasting (folded review fix).** The token axis is currently a **no-op placeholder at v1**: `normalizeTokens` validates and deep-merges over `DEFAULT_TOKENS`; it does not transform. The `$tokensVersion` gate exists so a real `TOKEN_UPCASTERS` chain can be added later mirroring the envelope upcaster. We do **not** claim a transform that is not built. ```js // themeSchema.js function normalizeTokens(raw) { // v1: validate + deep-merge over DEFAULT_TOKENS (no transform yet). // Future: while ($tokensVersion < CURRENT) raw = TOKEN_UPCASTERS[v](raw) const merged = deepMerge(DEFAULT_TOKENS, raw || {}); merged.$tokensVersion = 1; return merged; } function emptyTokens() { return structuredClone(DEFAULT_TOKENS); } function tokensAreValid(tokens) { /* object; no non-string leaf; key-count cap -> {ok, errors[]} */ } ``` `normalizeTokens` is the single chokepoint every read passes through (`rowToTheme` and `portability.parseEnvelope` both call it), so the compiler always receives a fully-populated, current-shape object. ### 4.4 The active pointer (plugin config) ```js // in plugin.configuration: { activeThemeId: "ab12-...uuid...", // global active theme id (never name) activeByRole: { "4": "cd34-...", "8": "ef56-..." }, // optional {role_id: themeId} activeHash: "ab12cd34", // COMPOSITE content hash over the default CSS // AND every activeByRole entry's CSS (cache-buster) phase2Enabled: false, } ``` The pointer lives in config (not the Table) so it rides `plugin_pack` (pack.ts:118-139) and `install_pack` (pack.ts:558-578). It is read at request time from the live merged cfg (`getState().plugin_cfgs["theme-builder"]`, no DB hit); it is written only by `activate`/`delete-auto-switch`, via the **persist → re-register → propagate** sequence (§5.5). > **`activeHash` is a first-class, COMPOSITE config key (folded review fix resolving an inter-subsystem contradiction).** The compiler subsystem relies on a stable cross-worker cache-buster in `headers(cfg)`. `activate` recomputes `activeHash` as a composite over **every** pointer-implied CSS (the default theme's hash AND each `activeByRole` entry's hash) and **writes it into `plugin.configuration`** alongside the pointer, *before* `upsert`, on **every** activation (default and role). Because it folds in the role-override hashes, re-activating a theme for a specific role changes `activeHash`, so every `?v=` link the headers facet emits (default and per-role) changes too. This resolves the earlier "activeHash referenced but never written" contradiction AND the "role-override `?v` never changes → stale immutable role CSS" seam (§7.6, §7.8). ### 4.5 Backup interaction | Concern | Mechanism | Rides backup? | |---|---|---| | Whole library (all DB themes) | real Table `_themebuilder_themes` in `_sc_tables`/`_sc_fields` | **Yes** — catalog-registered (table.ts:786, field.ts:1390). Per-tenant for free. | | Active pointer + role map + `activeHash` + `phase2Enabled` | `plugin.configuration` | **Yes** — `plugin_pack` copies config minus fixed keys (pack.ts:118-139); `install_pack` round-trips it (pack.ts:558-578). | | Built-in starters | plugin **code** | N/A — travel with the plugin install, never in DB/backup. | | Compiled CSS | in-memory derived cache | **No** — never stored, regenerated on activate / lazily on miss. | | Single-theme cross-instance portability | export/import envelope | The additive complement (one theme as a file between unrelated instances). | **Restore-over-existing caveat (documented, not a bug):** `install_pack` *skips* a plugin whose name already exists (pack.ts:569), so restoring a pack onto an instance that already has `theme-builder` installed will **not** overwrite the live `activeThemeId`. To migrate the pointer between live instances, use export/import + an explicit activate. **onLoad-vs-pack double-definition is safe:** on restore, `install_pack` runs the plugin (→ `onLoad` → `ensureTable` creates the empty table) *before* its table-pack loop, and that loop finds the table existing and *updates* rather than re-creating; `Field.create` is gated on field existence (pack.ts:600-630). Both `ensureTable` and the pack loop are existence-checked, so there is no duplicate-create on restore. ### 4.6 Multi-tenant behavior - The Table is created **once per tenant** in `ensureTable`, iterated via the canonical `eachTenant` helper (which establishes per-tenant async-local schema). All CRUD is tenant-implicit (schema-qualified by the Table model from async-local storage — table.ts:761). - Plugin config (`_sc_plugins` / `plugin_cfgs`) is per-State/per-tenant, so `activeThemeId`/`activeByRole` differ independently per tenant. - Built-ins are global code, identical across tenants, merged at read time — no per-tenant storage cost. --- ## 5. Plugin module contract & lifecycle ### 5.1 The export object (`index.js`) ```js // index.js const { API_VERSION, PLUGIN_NAME } = require("./lib/constants"); const configuration_workflow = require("./lib/configWorkflow"); const { headers } = require("./lib/headers"); // (cfg)=>Header[] const { routes } = require("./lib/routes"); // (cfg)=>PluginRoute[] const { layout } = require("./lib/layout"); // (cfg)=>PluginLayout|undefined const { onLoad } = require("./lib/onLoad"); module.exports = { sc_plugin_api_version: API_VERSION, // 1, gates compat (sbadmin2/index.js:575) plugin_name: PLUGIN_NAME, // "theme-builder" — canonical key everywhere (plugin.ts:474) configuration_workflow, // PRESENCE => every facility is a (cfg)=>value factory // NOTE: exposed_configs intentionally OMITTED (see 5.3) headers, // Phase-1 overlay links; per-request only_if gated (see 5.4) routes, // public theme.css + admin editor/API layout, // (cfg)=> undefined in Phase 1; {wrap,...} in Phase 2 onLoad, // raw cfg; idempotent; per-tenant; rehydrates active CSS }; ``` **Why `layout` returns `undefined` in Phase 1 (load-bearing):** `registerPlugin` does `const layout = withCfg("layout"); if (layout) this.layouts[name] = layout` (state.ts:1113-1117). A falsy return means we are never inserted into `state.layouts`, never appear in `getLayout`'s last-installed fallback, and are never a `layout_by_role` choice — exactly the Phase-1 "skin overlay, not a layout" contract. Returning `{}` would wrongly register us; we return `undefined`. ### 5.2 Constants (`lib/constants.js`) ```js const PLUGIN_NAME = "theme-builder"; // canonical plugin_name const API_VERSION = 1; const THEME_TABLE = "_themebuilder_themes"; const URL_PREFIX = "/theme-builder"; // top-level, NOT under core /admin (see 6.1) const API_BASE = `${URL_PREFIX}/api`; const CSS_ROUTE = `${URL_PREFIX}/theme.css`; const FORMAT_VERSION = 1; // export envelope formatVersion const SCHEMA_ID = "saltcorn-theme"; const ENGINE = "bootstrap5"; const MAX_IMPORT_BYTES= 2 * 1024 * 1024; // 2 MiB const MAX_TOKENS = 2000; const ROLE_ADMIN = 1; const ROLE_PUBLIC = 100; // config keys: const CFG = { ACTIVE: "activeThemeId", BY_ROLE: "activeByRole", HASH: "activeHash", PHASE2: "phase2Enabled", SCHEMA_VER: "schemaVersion" }; module.exports = { PLUGIN_NAME, API_VERSION, THEME_TABLE, URL_PREFIX, API_BASE, CSS_ROUTE, FORMAT_VERSION, SCHEMA_ID, ENGINE, MAX_IMPORT_BYTES, MAX_TOKENS, ROLE_ADMIN, ROLE_PUBLIC, CFG }; ``` ### 5.3 `exposed_configs` is omitted (folded review fix) The factories read the full merged cfg directly; `plugins_cfg_context` (the only thing `exposed_configs` populates — state.ts:1007-1012) is never read. Worse, declaring `exposed_configs.length>0` makes the native configure route flash a misleading "users need to relogin" warning on every config save (plugins.js:962-963). We therefore **drop `exposed_configs` entirely.** ### 5.4 Config readers — one resolution rule (`lib/cfgReaders.js`) All facilities and the CSS route resolve the active theme through these readers, so there is exactly one rule. ```js function getActiveThemeId(cfg) { return cfg?.activeThemeId || null; } function getActiveByRole(cfg) { return cfg?.activeByRole || {}; } function activeThemeIdForRole(cfg, roleId) { // role override then global const byRole = getActiveByRole(cfg); return byRole[roleId] != null ? byRole[roleId] : getActiveThemeId(cfg); } function isPhase2(cfg) { return !!cfg?.phase2Enabled; } function activeHashHint(cfg, roleId){ return (cfg && cfg.activeHash) || "0"; } // Replicate getLayout's fallback precedence WITHOUT reading the pluginName field the // last-installed fallback omits (state.ts:419-425). A request renders via theme-builder when // layout_by_role[role] names us OR (no layout_by_role entry for that role AND theme-builder is // the last-installed/only registered layout). (folded review fix — see 5.7/7.6) function requestRendersViaThemeBuilder(req) { const state = getState(); if (!state.layouts[PLUGIN_NAME]) return false; // not registered as a layout (Phase 1) const role = req.user?.role_id ?? ROLE_PUBLIC; const byRole = state.getConfig("layout_by_role", {}) || {}; const mapped = byRole[role] ?? byRole[String(role)]; if (mapped != null) return mapped === PLUGIN_NAME; // role branch is authoritative // no layout_by_role entry for this role -> falls through to per-user / last-installed. // theme-builder is selected by the fallback iff it is the last-installed layout key. const keys = Object.keys(state.layouts); return keys.length > 0 && keys[keys.length - 1] === PLUGIN_NAME; } ``` > **role-key coercion:** `activeByRole` (and `layout_by_role`) keys are JSON strings but `req.user.role_id` is numeric. Readers and writers coerce consistently (lookups use the numeric value as a property key, which JS stringifies; all comparisons normalize to `Number`/`String` at the boundary). ### 5.5 Active-pointer write: persist → re-register → propagate → **refresh assets** (folded review fixes) The pointer is written by exactly one routine. The earlier subsystem designs were correct that the sequence must be `upsert → loadPlugin → processSend` (mirroring the core configure route, plugins.js:956-967), but **two review fixes are mandatory**: **Fix 1 — `assets_by_role` must be recomputed.** `get_headers` reads the precomputed `state.assets_by_role[role]` bucket FIRST and only falls back to raw `state.headers` when that bucket is empty (wrapper.js:133-145). `assets_by_role` is rebuilt **only** by `computeAssetsByRole()`, which is called **only** from `refresh_views()` (state.ts:718). Neither `registerPlugin` nor the `refresh_plugin_cfg` cluster handler triggers it. **Without an explicit refresh, an activated theme's new `?v=` header is never served** and the Phase-1 "activate is the only live-changer" contract silently fails. So the writer **must** call `getState().refresh_views()` locally after re-register, and the cluster receiver must do the same. **Fix 2 — `loadPlugin` is heavy; document and accept.** `Plugin.loadPlugin` constructs a `PluginInstaller` and runs `loader.install()` (a full reinstall) before `registerPlugin` (plugin.ts:460-505), and re-runs `onLoad`. This is correct (parity with the configure route) but **not cheap**; it runs synchronously inside the activate request and on every peer worker. We accept it for correctness and keep `onLoad` fast and idempotent (its hot path short-circuits — §5.6). ```js // lib/activePointer.js const Plugin = require("@saltcorn/data/models/plugin"); const { getState } = require("@saltcorn/data/db/state"); const db = require("@saltcorn/data/db"); const { PLUGIN_NAME } = require("./constants"); // Resolve the install-name row defensively: _sc_plugins.name is the INSTALL name, which // is NOT guaranteed to equal plugin_name. Try the canonical name, then fall back to scanning // for the row whose module resolves to plugin_name. (folded review fix: read/write name-space split) async function findPluginRow() { let p = await Plugin.findOne({ name: PLUGIN_NAME }); if (p) return p; const all = await Plugin.find({}); return all.find((r) => { const mod = getState().plugins[PLUGIN_NAME]; return mod && (getState().plugin_module_names[r.name] === PLUGIN_NAME || r.name === PLUGIN_NAME); }) || null; } async function setActivePointer(patch /* {activeThemeId?|activeByRole?|activeHash?} */) { const plugin = await findPluginRow(); if (!plugin) throw new Error("theme-builder plugin row not found"); plugin.configuration = { ...(plugin.configuration || {}), ...patch }; await plugin.upsert(); // _sc_plugins (plugin.ts:104-119) await Plugin.loadPlugin(plugin); // re-register -> re-run headers(cfg) await getState().refresh_views(); // REBUILD assets_by_role (Fix 1) getState().processSend({ refresh_plugin_cfg: PLUGIN_NAME, tenant: db.getTenantSchema() }); return plugin.configuration; } // READ: live merged cfg, no DB hit. function getActivePointer() { const cfg = getState().plugin_cfgs[PLUGIN_NAME] || {}; return { activeThemeId: cfg.activeThemeId || null, activeByRole: cfg.activeByRole || {}, activeHash: cfg.activeHash || "0" }; } module.exports = { setActivePointer, getActivePointer, findPluginRow }; ``` **Cluster receiver requirement (cross-seam contract):** the worker that handles `refresh_plugin_cfg` for `theme-builder` must, after re-loading the plugin, also `computeAssetsByRole()` (via `refresh_views()`) and `bustAll()` its local CSS cache, then lazily recompile on first request. This is a required step the lifecycle owner wires into the refresh handler; `registerPlugin` alone is insufficient. ### 5.6 `onLoad` — idempotent per-tenant bootstrap + **active-CSS rehydration** (folded review fixes) ```js // lib/onLoad.js const db = require("@saltcorn/data/db"); const { getState } = require("@saltcorn/data/db/state"); const { eachTenant } = require("@saltcorn/admin-models/models/tenant"); // canonical (folded fix) const themeStore = require("./themeStore"); const cssCache = require("./cssCache"); const { getActivePointer } = require("./activePointer"); const { PLUGIN_NAME } = require("./constants"); async function bootstrapTenant() { // 1) HOT-PATH SHORT-CIRCUIT: if the table already exists for THIS tenant, skip all DDL // work cheaply (loadPlugin re-runs onLoad on every activate). (folded review fix) await themeStore.ensureTableForCurrentTenant(); // Table.findOne first; Table.create only if null // 2) Rehydrate the active-CSS cache from the persisted pointer so the live site is themed // immediately after a restart/new worker (folded review fix: rehydration was unowned). try { const { activeThemeId, activeByRole } = getActivePointer(); const keys = new Set(); if (activeThemeId) keys.add(null); // default key for (const r of Object.keys(activeByRole)) keys.add(Number(r)); for (const roleKey of keys) { const id = roleKey == null ? activeThemeId : activeByRole[roleKey]; if (id) await cssCache.warm(id, roleKey); // recompile -> cache } } catch (e) { getState().log(3, `theme-builder rehydrate failed: ${e.message}`); } } // eachTenant self-selects single vs multi: it runs default_schema FIRST then iterates // DB-backed tenants only in MT mode. No hand-rolled is_it_multi_tenant branch. (folded fix) async function onLoad(/* rawCfg */) { await eachTenant(bootstrapTenant); } module.exports = { onLoad, bootstrapTenant }; ``` Folded review fixes embodied above: - **Canonical tenant iteration.** Use `eachTenant` from `@saltcorn/admin-models/models/tenant`, which runs `default_schema` first and then iterates the **DB-backed** tenant list. This fixes three bugs from the subsystem drafts: (a) `for...of` over the plain-object `getAllTenants()` (which throws `TypeError`), (b) the in-memory `db/state.getAllTenants` being best-effort (only loaded tenants), and (c) the admin `getAllTenants()` *excluding* the default schema (which would leave the root site with no table). - **Errors are swallowed** (plugin.ts:502-503), so every step is wrapped and idempotent; a throw won't fail load but would leave the table/cache unbuilt — hence the explicit try/catch on rehydration. - **Hot-path cheapness.** `ensureTableForCurrentTenant` does `Table.findOne(THEME_TABLE)` first and returns immediately if present, so re-running on every activate is cheap. ### 5.7 Phase 1 → Phase 2 toggle | Aspect | Phase 1 | Phase 2 | |---|---|---| | config flag | `phase2Enabled: false` | `phase2Enabled: true` | | `layout(cfg)` returns | `undefined` → not in `state.layouts` | `{ wrap, … }` → registered (state.ts:1116) | | token CSS delivery | `headers` overlay link, after Bootstrap | owned by `wrap()`; overlay suppressed **per-request** (§7.6) | | per-role | `only_if` overlay headers | native `layout_by_role` | | Sass | not needed | optional full dart-sass recompile | The toggle is a pure config change. The config workflow persists `phase2Enabled`; the writer's `upsert → loadPlugin → refresh_views → processSend` re-runs the factories cluster-wide. > **Phase-2 suppression must be per-request, must NOT read `getLayout().pluginName`, and is gated on full coverage (folded review fix).** A blanket `headers(cfg) → []` when `isPhase2` un-themes any role whose layout does **not** resolve to `theme-builder` (e.g. a role still on sbadmin2 because `layout_by_role` maps only some roles). So Phase-2 suppression is gated by an `only_if(req)` that suppresses the overlay **only** for requests that actually render via theme-builder. Crucially, that decision **must not** be made by reading `getState().getLayout(req.user)?.pluginName === PLUGIN_NAME`: the grounded `getLayout` (state.ts:378-426) stamps `pluginName` only in the `layout_by_role` branch and the per-user `userLayouts` branch — the **last-installed fallback branch (state.ts:419-425) returns the layout WITHOUT `pluginName`**. In Phase 2 theme-builder is typically the only registered layout, so a role with no `layout_by_role` entry resolves to theme-builder via exactly that fallback; there `pluginName` is `undefined`, `undefined === "theme-builder"` is false, the overlay is NOT suppressed, and that role's pages get tokens applied TWICE (overlay header + `wrap()`) — the precise "uncovered role" case the check was meant to handle, double-themed instead. We therefore (a) detect theme-builder-served requests with `cfgReaders.requestRendersViaThemeBuilder(req)`, which **replicates `getLayout`'s fallback precedence** (role branch, else last-installed/only-layout) rather than reading the omitted field; AND (b) the config workflow **hard-blocks** saving `phase2Enabled: true` until `layout_by_role` covers **every** live role, so the last-installed fallback is never the selector for a live role. (Details and code in §7.6; coverage hard-block in §12.3 resolved to a hard block.) ### 5.8 `package.json` Phase 1 needs no Sass/Bootstrap runtime deps. ```jsonc { "name": "theme-builder", "version": "0.0.1", "description": "Saltcorn plugin: visual theme builder (Phase 1 Bootstrap-5 skin overlay; Phase 2 Craft.js layout + optional dart-sass).", "main": "index.js", "scripts": { "test": "node --test test/", "build:ui": "webpack --config builder/webpack.config.js --mode production", "watch:ui": "webpack --config builder/webpack.config.js --watch --mode development" }, "engines": { "node": ">=20" }, "dependencies": { "@saltcorn/data": "*", "@saltcorn/markup": "*" // Phase 2 adds: "sass": "^1.x", "bootstrap": "^5.x" (vendored SCSS under scss/) }, "devDependencies": { // webpack, webpack-cli, babel-*, @craftjs/core (bundled into builderApp.js for Phase 2) }, "author": "Scott Duensing", "license": "MIT" } ``` --- ## 6. HTTP API surface ### 6.1 Mounting model & namespace (folded review fix) Routes are returned from the `routes(cfg)` factory and stored in `state.plugin_routes["theme-builder"]`; `routesChangedCb` fires only when `routes.length>0` (state.ts:1138). We always return a non-empty array (the public CSS route is always present), so the tenant router always mounts. Routes mount at the **app root** (no `/plugins` prefix) on the per-tenant router after `mountRoutes` (app.js:483-502). Callbacks run positionally `(req,res,next)` via `error_catcher` (utils.js:298-306). **Top-level namespace `/theme-builder` (NOT `/admin/theme-builder`).** An earlier draft namespaced under the core-owned `/admin` prefix (mounted at app.js:478 *before* the plugin router), which works today only because core's admin sub-router has no matching route and falls through — fragile if core ever adds `/admin/theme-builder*` or an `/admin` catch-all. We use a **unique top-level prefix** per the grounded guidance. ### 6.2 Route table `A` = admin-guarded; `P` = public. Plugin routes support only `get`/`post` (plugin_routes_handler.js:26-53) — there is **no PUT/DELETE verb**, so all mutations are POST sub-paths. | # | method | url | guard | body / query | success | errors | |---|---|---|---|---|---|---| | 1 | get | `/theme-builder/theme.css` | **P** | `?v=`, `?role=` | 200 `text/css` | never 5xx → fallback CSS | | 2 | get | `/theme-builder/editor` | A | `?id=` | 200 `text/html` SPA shell | 302/401 | | 3 | get | `/theme-builder/api/state` | A | — | 200 `StateEnvelope` | 401/403 | | 4 | get | `/theme-builder/api/themes/:id` | A | — | 200 `{theme}` | 404 | | 5 | post | `/theme-builder/api/themes` | A | `{name?, engine?, fromTemplate?}` | 200 `{theme}` | 422 bad engine | | 6 | post | `/theme-builder/api/themes/:id/duplicate` | A | `{name?}` | 200 `{theme}` | 404 | | 7 | post | `/theme-builder/api/themes/:id/save` | A | `{tokens, layoutTree?, baseVersion, force?}` | 200 `{theme}` | 400/403/404/409 | | 8 | post | `/theme-builder/api/themes/:id/rename` | A | `{name}` | 200 `{theme}` | 403/404/409 | | 9 | post | `/theme-builder/api/themes/:id/delete` | A | `{autoSwitch?}` | 200 `{ok, switchedActiveTo?}` | 403/404/409 | | 10 | post | `/theme-builder/api/themes/:id/activate` | A | `{role?}` | 200 `{ok, activeThemeId, activeByRole, cssHash}` | 404/500 | | 11 | get | `/theme-builder/api/themes/:id/export` | A | — | 200 JSON attachment | 404 | | 12 | post | `/theme-builder/api/import` | A | multipart `file` OR `{envelope}` | 200 `{theme}` | 400/413/422 | **CSRF:** GET routes are inherently exempt. `theme.css` is fully public. All POSTs are admin-only and the SPA carries the CSRF token (read from the shell bootstrap, sent as `CSRF-Token` header — mirrors core builder `Library.js:102-129`). We add **no** route to `noCsrf`/`disable_csrf_routes`. ### 6.3 `routes(cfg)` factory ```js // lib/routes.js const { URL_PREFIX, API_BASE, CSS_ROUTE } = require("./constants"); const h = require("./apiHandlers"); function routes(cfg) { return [ { url: CSS_ROUTE, method: "get", callback: (req,res)=>h.getThemeCss(req,res,cfg) }, // PUBLIC { url: `${URL_PREFIX}/editor`, method: "get", callback: h.getEditor }, { url: `${API_BASE}/state`, method: "get", callback: h.getState_ }, { url: `${API_BASE}/themes/:id`, method: "get", callback: h.loadTheme }, { url: `${API_BASE}/themes`, method: "post", callback: h.createTheme }, { url: `${API_BASE}/themes/:id/duplicate`, method: "post", callback: h.duplicateTheme }, { url: `${API_BASE}/themes/:id/save`, method: "post", callback: h.saveTheme }, { url: `${API_BASE}/themes/:id/rename`, method: "post", callback: h.renameTheme }, { url: `${API_BASE}/themes/:id/delete`, method: "post", callback: h.deleteTheme }, { url: `${API_BASE}/themes/:id/activate`, method: "post", callback: h.activateTheme }, { url: `${API_BASE}/themes/:id/export`, method: "get", callback: h.exportTheme }, { url: `${API_BASE}/import`, method: "post", callback: h.importTheme }, ]; } module.exports = { routes }; ``` `:id` carries the `theme_id` (slug/uuid), not the integer pk. `error_catcher` escapes `req.params`/`req.query` values (utils.js:299-304) — fine for slug ids; bodies are read raw. ### 6.4 Admin guard (`lib/httpUtils.js`) — JSON-aware (folded review fix) ```js const db = require("@saltcorn/data/db"); const { ROLE_ADMIN, API_BASE } = require("./constants"); function isAdminReq(req) { // mirror core isAdmin (utils.js:85-100) return !!(req.user && req.user.role_id === ROLE_ADMIN && req.user.tenant === db.getTenantSchema()); } function wantsJson(req) { return req.xhr || (req.path || "").startsWith(API_BASE) // API routes ALWAYS answer JSON (fixes multipart-import misclassify) || (req.get("accept") || "").includes("application/json") || (req.get("content-type") || "").includes("application/json"); } function guardAdmin(req, res) { if (isAdminReq(req)) return true; if (wantsJson(req)) res.status(req.user ? 403 : 401).json({ error: { code: "forbidden", message: "Must be admin" } }); else res.redirect(req.user ? "/" : `/auth/login?dest=${encodeURIComponent(req.originalUrl)}`); return false; } function jsonError(res, status, code, message, extra) { res.status(status).json({ error: { code, message, ...(extra||{}) } }); } module.exports = { isAdminReq, wantsJson, guardAdmin, jsonError }; ``` Uniform error envelope: `{ error: { code, message, ...context } }`, where `code ∈ { forbidden, not_found, version_conflict, name_taken, active_theme, builtin_immutable, bad_format, too_large, uncompilable, bad_request, compile_failed, activation_failed, initializing }`. The `wantsJson` `API_BASE` check (folded fix) ensures a non-admin **multipart** import POST (which has neither JSON content-type nor `req.xhr`) still receives the JSON `{error:{…}}` envelope rather than an opaque redirect. ### 6.5 The public route — `GET /theme.css` (folded cache-poisoning fix) ```js async function getThemeCss(req, res, cfg) { const role = req.query.role ? Number(req.query.role) : (req.user?.role_id ?? 100); const themeId = activeThemeIdForRole(cfg, role); res.set("Content-Type", "text/css; charset=utf-8"); if (!themeId) { res.set("Cache-Control","no-store"); res.send("/* theme-builder: no active theme */"); return; } const roleKey = (cfg.activeByRole && cfg.activeByRole[role]) ? role : null; let entry = cssCache.getCached(themeId, roleKey); let isReal = !!entry; if (!entry) { // lazy compile-on-miss const theme = await themeStore.getById(themeId); if (theme) { const compiled = compileTheme(theme.tokens, { engine: theme.engine }); entry = { ...compiled, themeId, role: roleKey, builtAt: Date.now() }; cssCache.setCached(entry); isReal = true; } } if (!entry) { // theme missing mid-activate, transient res.set("Cache-Control", "no-store"); // FOLDED FIX: never memoize fallback under live hash res.send(builtins.fallbackCss()); return; } // ETag always reflects the per-entry CONTENT hash, so the If-None-Match/304 path can correct // stale CSS even when a ?v cache-buster failed to change. We keep `immutable` ONLY when the // emitted link's ?v is guaranteed to change on re-activation. With the COMPOSITE activeHash // (4.4/7.8) that guarantee now holds for the per-role link too, so `immutable` is safe here; // were that guarantee ever weakened, drop `immutable` (keep ETag + must-revalidate) so the // 304/If-None-Match path can still correct stale role CSS. (folded review fix) res.set("ETag", `"${entry.hash}"`); res.set("Cache-Control", isReal ? "public, max-age=31536000, immutable" : "no-store"); if (req.headers["if-none-match"] === `"${entry.hash}"`) { res.status(304).end(); return; } res.send(entry.css); } ``` **Folded fixes:** - **No 5xx on a public page.** A missing table, missing theme, or compile miss degrades to valid (often empty) CSS — the active theme renders unchanged, never a white-screen. (This also subsumes the "503 initializing" status: a not-yet-created table on a freshly-spawned tenant degrades to fallback CSS on the *public* path rather than throwing; the 503 status is reserved for admin mutation paths where a hard error is appropriate.) - **No cache poisoning.** The `immutable, max-age=1y` header is set **only** when serving real compiled CSS under its true content hash. Fallback / transient-miss responses use `Cache-Control: no-store`, so a later successful compile is picked up. - **Role CSS staleness is correctable.** The `ETag` is the per-entry content hash, so the 304/`If-None-Match` path corrects stale CSS independently of the `?v` query; and because `activeHash` is now composite (§4.4, §7.8), every role link's `?v` actually changes on re-activation, so the `immutable` long-cache is safe rather than pinning old role CSS forever. ### 6.6 Selected handler semantics **save — optimistic CAS on `version` (row-level concurrency):** ```js async function saveTheme(req, res) { if (!guardAdmin(req, res)) return; const id = req.params.id; if (builtins.isBuiltinId(id)) return jsonError(res, 403, "builtin_immutable", "Built-in themes are read-only"); const { tokens, layoutTree = null, baseVersion, force } = req.body || {}; if (tokens == null) return jsonError(res, 400, "bad_request", "Missing tokens"); if (baseVersion == null && !force) return jsonError(res, 400, "bad_request", "Missing baseVersion"); const updated = force ? await themeStore.forceUpdate(id, { tokens, layoutTree }) : await themeStore.casUpdate(id, baseVersion, { tokens, layoutTree }); // UPDATE ... WHERE theme_id=$ AND version=$base, version=version+1 if (!updated) { const cur = await themeStore.getById(id); if (!cur) return jsonError(res, 404, "not_found", "Unknown theme"); return jsonError(res, 409, "version_conflict", "Theme was modified by another session", { currentVersion: cur.version }); } res.json({ theme: updated }); // SAVE does NOT touch the live site } ``` Two concurrent saves: first wins (`version → base+1`); second matches 0 rows → 409 with `currentVersion` so the SPA offers reload-or-overwrite (`force:true`). The pointer (config) is written only by `activate` (single-writer per tenant), so pointer writes never race row writes. **delete — block if active unless `autoSwitch`; builtins never deletable:** ```js async function deleteTheme(req, res) { if (!guardAdmin(req, res)) return; const id = req.params.id; if (builtins.isBuiltinId(id)) return jsonError(res, 403, "builtin_immutable", "Cannot delete built-in"); const r = await themeStore.getById(id); if (!r) return jsonError(res, 404, "not_found", "Unknown theme"); const { activeThemeId, activeByRole } = activePointer.getActivePointer(); const isActive = activeThemeId === id || Object.values(activeByRole).includes(id); if (isActive) { if (!req.body?.autoSwitch) return jsonError(res, 409, "active_theme", "Theme is active; pass autoSwitch to reassign first"); const successor = (await themeStore.firstOther(id))?.theme_id || builtins.defaultId(); await activateEngine.activateTheme(successor); // recompile + flip to successor FIRST await activePointer.dropRoleRefs(id); // scrub activeByRole entries == id } await themeStore.remove(id); res.json({ ok: true, switchedActiveTo: isActive ? undefined : null }); } ``` > **Delete-of-active policy is unified to auto-switch** (the API and SPA layers offer the explicit `autoSwitch` confirmation; the engine recompiles to the successor *before* the row is deleted, so the live site is never pointed at a deleted theme). The SPA blocks delete of a role-assigned theme with a clear message and offers `autoSwitch`. **activate — the only live-mutating op; staged compile vs pointer failures (folded review fix):** ```js async function activateTheme(req, res) { if (!guardAdmin(req, res)) return; const id = req.params.id; const exists = builtins.isBuiltinId(id) ? !!builtins.get(id) : !!(await themeStore.getById(id)); if (!exists) return jsonError(res, 404, "not_found", "Unknown theme"); try { const out = await activateEngine.activateTheme(id, req.body?.role); res.json({ ok: true, ...out }); } catch (e) { if (e.stage === "compile") return jsonError(res, 500, "compile_failed", e.message); // pointer untouched return jsonError(res, 500, "activation_failed", e.message); // pointer write/propagate failed } } ``` ### 6.7 Concurrency model summary - **Library rows:** row-level, with optimistic CAS on `version` for `save`. `rename` touches `name` only and does **not** bump `version`. - **Active pointer:** single-writer per tenant via `activePointer.setActivePointer` (`upsert` + `loadPlugin` + `refresh_views` + `processSend`). - **CSS cache:** per-tenant in-memory map; busted on activate, lazily refilled on miss. --- ## 7. Compile & render pipeline Owns tokens → CSS, the `theme.css` route, the compile/cache/cache-bust lifecycle, the robustness (no-white-screen) guard, and the load-order proof. It consumes `tokens`/`layoutTree` and produces the bytes the browser loads. ### 7.1 Module layout & shared helpers (folded export fix) ``` lib/ compile.js # compileTheme(), emitOverlayCss(), robustnessGuard(), contentHash(), deriveRgb() sanitize.js # sanitizeValue(), sanitizeSelector() <-- shared by compile.js AND sassCompile.js sassCompile.js # compileSass() (Phase 2, lazy require('sass')) cssCache.js # per-tenant cache + hashing + busting activate.js # activateTheme(): recompile + cache + persist pointer+composite-hash + re-register + refresh tokenSchema.js # TOKEN_SCHEMA mapping table scss/ # Phase 2: vendored Bootstrap SCSS + _themeVars template ``` > **Folded fix:** the value/selector guards (`sanitizeValue`, `sanitizeSelector`) live in their own `sanitize.js`, imported by **both** `compile.js` and `sassCompile.js`. They are not internal-only helpers of `compile.js` (an earlier draft `require('./compile').sanitizeValue`'d a non-exported symbol, silently degrading every Sass theme to the overlay fallback). ### 7.2 The token mapping table (`tokenSchema.js`) The single server-side source of truth for **how a token maps to CSS**, kept structurally parallel to the SPA `TokenManifest` (§8.6). ```js const TOKEN_SCHEMA = { primary: { kind: "bsvar", cssVar: "primary", derive: ["primary-rgb"] }, secondary: { kind: "bsvar", cssVar: "secondary", derive: ["secondary-rgb"] }, success: { kind: "bsvar", cssVar: "success", derive: ["success-rgb"] }, "body-bg": { kind: "bsvar", cssVar: "body-bg", derive: ["body-bg-rgb"] }, "body-color": { kind: "bsvar", cssVar: "body-color",derive: ["body-color-rgb"] }, "font-sans-serif": { kind: "bsvar", cssVar: "font-sans-serif" }, "border-radius":{ kind: "bsvar", cssVar: "border-radius" }, "link-color": { kind: "bsvar", cssVar: "link-color", derive: ["link-color-rgb"] }, "navbar-bg": { kind: "rule", selector: ".navbar", prop: "--bs-navbar-bg" }, "sidebar-bg": { kind: "rule", selector: ".sidebar, #accordionSidebar", prop: "background-color" }, // kind:"ignore" -> known-but-not-emitted (forward-compat) }; const TOKEN_KEY_RE = /^[-a-zA-Z0-9_]{1,64}$/; const MAX_VALUE_LEN = 2048, MAX_CSS_BYTES = 512 * 1024; ``` `kind:"bsvar"` → `:root { --bs-: ; }` (+ auto-derived `--bs--rgb` companion for `rgba()` mixes). `kind:"rule"` → a targeted `selector{prop:value;}` for things `--bs-*` alone can't reach. > The compiler walks a *flattened* view of the `tokens` object (colors/typography/borders/components/custom merged into `key→value` pairs by a small adapter), so `normalizeTokens`'s nested shape and `TOKEN_SCHEMA`'s flat keys stay reconciled in one place. ### 7.3 Phase 1 overlay compiler (`compile.js`) ```js function compileTheme(tokens, opts = {}) { // pure; NEVER throws const engine = opts.engine === "sass" ? "sass" : "overlay"; const out = engine === "sass" ? require("./sassCompile").compileSass(tokens, opts) // Phase 2 : emitOverlayCss(tokens); const guarded = robustnessGuard(out.css, out.warnings || []); return { css: guarded.css, hash: contentHash(guarded.css), warnings: guarded.warnings, engine }; } function contentHash(css) { return crypto.createHash("sha1").update(css).digest("hex").slice(0, 8); } ``` `emitOverlayCss` iterates the flattened tokens, drops keys failing `TOKEN_KEY_RE`, passes each value through `sanitize.sanitizeValue` (dropping it from output on failure), and emits `:root{ --bs-*: v; … }` plus per-token `selector{prop:v;}` rule blocks and validated `custom`/`_rules` entries. Unknown-but-valid tokens are emitted as harmless `--tb-` passthrough custom properties. ### 7.4 Robustness guard — "scope a bad token so it can't white-screen" (folded truncation fix) Three layered, non-security defenses: 1. **Per-value scoping (`sanitize.sanitizeValue`)** — the primary mechanism. A value containing `{`, `}`, `;`, ` MAX_CSS_BYTES) { warnings.push("css exceeds cap"); const rootLine = css.split("\n").find((l) => l.startsWith(":root{")) || ""; // FOLDED FIX: re-check the selected :root line; if STILL over cap, fall through to empty. css = (rootLine && Buffer.byteLength(rootLine, "utf8") <= MAX_CSS_BYTES) ? rootLine : "/* theme-builder: empty (over cap) */"; } let depth = 0, ok = true; for (const ch of css) { if (ch === "{") depth++; else if (ch === "}") { if (--depth < 0) { ok = false; break; } } } if (!ok || depth !== 0) { warnings.push("unbalanced braces; emitting empty"); css = "/* theme-builder: empty (invalid) */"; } return { css, warnings }; } ``` Empty-but-valid CSS = active theme renders unchanged = no white-screen. (Folded fix: the truncation path re-checks the selected `:root` line and falls through to the empty sentinel if it is itself over cap, so size is actually bounded; even better, `emitOverlayCss` caps `rootDecls` length during emit.) `compileTheme` doubles as the import robustness check (§9): the importer calls it, and a hard fallback flags the import. ### 7.5 Phase 2 dart-sass recompile (`sassCompile.js`) (folded path/@use fix) ```js const path = require("path"); const { sanitizeValue } = require("./sanitize"); // shared guard (folded fix) function compileSass(tokens, opts = {}) { const sass = require("sass"); // lazy const warnings = []; const flat = flattenTokens(tokens); const varBlock = Object.entries(flat) .filter(([k]) => /^[-a-zA-Z0-9_]{1,64}$/.test(k)) .map(([k, v]) => { const sv = sanitizeValue(v, warnings); return sv == null ? "" : `$${k}: ${sv};`; }) .filter(Boolean).join("\n"); const scssDir = path.join(__dirname, "..", "scss"); // FOLDED FIX: use a relative module id resolved via loadPaths; no absolute @import string. const entry = `@use "bootstrap/bootstrap" with (\n${varBlock}\n);\n`; try { const result = sass.compileString(entry, { loadPaths: [scssDir], style: "compressed", logger: { warn: (m) => warnings.push(m), debug: () => {} } }); return { css: result.css, warnings }; } catch (e) { warnings.push("sass compile failed: " + e.message); return require("./compile").emitOverlayCss(tokens); // fall back to overlay; never white-screen } } ``` Notes: - The plugin **ships** Bootstrap SCSS under `theme-builder/scss/bootstrap/` (not vendored in saltcorn). Pin a Bootstrap 5 version matching the Phase-1 `--bs-*` names. - Uses `@use … with (…)` (modern dart-sass) and a **relative** module id resolved by `loadPaths` (folded fix — an absolute interpolated `@import` path breaks on Windows backslashes and is deprecated in modern dart-sass). - Runs synchronously, only on activate/save — amortized by the cache. ### 7.6 `headers(cfg)` injection + per-role / Phase-2 suppression (folded fixes) ```js const { requestRendersViaThemeBuilder } = require("./cfgReaders"); function headers(cfg) { const def = getActiveThemeId(cfg); const byRole = getActiveByRole(cfg); const list = []; if (!def) return list; // ONE header; only_if decides per-request whether this role gets the overlay. list.push({ css: `${CSS_ROUTE}?v=${activeHashHint(cfg)}`, only_if: (req) => { const role = req.user?.role_id ?? 100; // PHASE 2: suppress ONLY for requests that actually render via theme-builder. // Detected by replicating getLayout's fallback precedence (cfgReaders), NOT by reading // getLayout().pluginName -- the last-installed fallback omits pluginName, so an uncovered // role would otherwise be double-themed (overlay + wrap()). (folded review fix) if (isPhase2(cfg) && requestRendersViaThemeBuilder(req)) return false; // role with an explicit override gets its own header below, not this one return byRole[role] === undefined || byRole[role] === def; }, }); // per-role overlays for explicit overrides (Phase 1) for (const [roleStr, tid] of Object.entries(byRole)) { if (tid === def) continue; const role = Number(roleStr); list.push({ css: `${CSS_ROUTE}?v=${activeHashHint(cfg)}&theme=${encodeURIComponent(tid)}&role=${role}`, only_if: (req) => { if (isPhase2(cfg) && requestRendersViaThemeBuilder(req)) return false; return (req.user?.role_id ?? 100) === role; // MUST strictly return true (wrapper.js:137) }, }); } return list; } ``` Folded fixes embodied: - **`only_if` must strictly return `true`** (wrapper.js:137) — every predicate returns an explicit boolean. - **Phase-2 suppression is per-request and does NOT read `getLayout().pluginName`.** The overlay is suppressed only for requests `requestRendersViaThemeBuilder(req)` deems theme-builder-served (those get tokens via `wrap()`), and is still emitted for roles left on a Bootstrap theme. `requestRendersViaThemeBuilder` (§5.4) replicates `getLayout`'s fallback precedence — role branch, else last-installed/only-layout — rather than reading the field the fallback branch omits (state.ts:419-425); this fixes the prior `undefined === "theme-builder"` miss that double-themed every uncovered role. The config workflow additionally **hard-blocks** saving `phase2Enabled` unless `layout_by_role` covers all live roles (§5.7, §12.3), so the last-installed fallback is never the live selector and suppression is deterministic. - **Cross-worker cache-buster** comes from the **composite** `cfg.activeHash` (written by `activate` before `upsert`, folding in the default AND every role override's CSS hash — §4.4/§7.8), so all workers emit a consistent `?v=` even on a cold worker, AND every per-role link's `?v` actually changes when that role is re-activated. The route lazily compiles to match. - `css` headers are not de-duped (only `script` URLs are — state.ts:1123-1135), so role-specific links coexist; distinct `?v`/`role` queries keep them distinct. ### 7.7 Cache + cache-busting (`cssCache.js`) ```js const cache = new Map(); // tenantSchema -> Map<`${themeId}:${role??"_"}`, CompiledEntry> function tkey() { return db.getTenantSchema(); } function ck(themeId, role) { return `${themeId}:${role ?? "_"}`; } function getCached(themeId, role) { return cache.get(tkey())?.get(ck(themeId, role)); } function setCached(e) { let t = cache.get(tkey()); if (!t) cache.set(tkey(), (t = new Map())); t.set(ck(e.themeId, e.role), e); } function bustTheme(themeId) { const t = cache.get(tkey()); if (t) for (const k of [...t.keys()]) if (k.startsWith(themeId + ":")) t.delete(k); } function bustAll() { cache.delete(tkey()); } async function warm(themeId, role) { // used by onLoad rehydration + activate const theme = await themeStore.getById(themeId); if (!theme) return null; const compiled = compileTheme(theme.tokens, { engine: theme.engine }); const entry = { ...compiled, themeId, role: role ?? null, builtAt: Date.now() }; setCached(entry); return entry; } ``` Per-tenant for free (outer key = `getTenantSchema()`). Derived; never persisted; rebuilt lazily on restart (plus eager rehydration in `onLoad`, §5.6). The cache key space is pinned: numeric `role` (or `null`/`"_"` for default). `activate` writes **all** keys implied by the current pointer (default + every `activeByRole` role), so the headers facet never reads a key activate didn't populate (folded fix). ### 7.8 `activateTheme` (`activate.js`) — staged, persists COMPOSITE hash, refreshes assets (folded fixes) ```js const crypto = require("crypto"); function compositeHash(entries /* CompiledEntry[] */) { // Fold the default + every role-override entry's content hash into ONE cache-buster so that // re-activating ANY single role changes activeHash (and thus every emitted ?v). (folded fix) const h = crypto.createHash("sha1"); for (const e of entries.filter(Boolean)) { h.update(`${e.role ?? "_"}:${e.hash};`); } return h.digest("hex").slice(0, 8); } async function activateTheme(id, role) { const theme = builtins.isBuiltinId(id) ? builtins.get(id) : await themeStore.getById(id); if (!theme) { const e = new Error("theme not found"); e.stage = "compile"; throw e; } // STAGE 1: compile (distinct failure stage -> compile_failed). Pointer untouched on throw. let compiled; try { compiled = compileTheme(theme.tokens, { engine: theme.engine }); } catch (e) { e.stage = "compile"; throw e; } // STAGE 2: prime cache for ALL pointer-implied keys (default + every activeByRole role). const { activeThemeId, activeByRole } = activePointer.getActivePointer(); const nextByRole = role == null ? activeByRole : { ...activeByRole, [role]: id }; const nextDefault = role == null ? id : activeThemeId; cssCache.bustAll(); const entries = []; if (nextDefault) entries.push(await cssCache.warm(nextDefault, null)); for (const r of Object.keys(nextByRole)) entries.push(await cssCache.warm(nextByRole[r], Number(r))); // STAGE 3: persist pointer + COMPOSITE hash, re-register, refresh assets, propagate // (distinct failure -> activation_failed). // activeHash folds in the default AND every role-override entry's content hash, so re-activating // a single role CHANGES activeHash -> the per-role ?v link changes -> immutable role CSS is busted. // (folded review fix: role-override cache-buster never changed -> stale immutable CSS) const nextHash = compositeHash(entries); try { const patch = role == null ? { activeThemeId: id, activeByRole: nextByRole, activeHash: nextHash } : { activeByRole: nextByRole, activeHash: nextHash }; // ALWAYS update activeHash, incl. role path await activePointer.setActivePointer(patch); // upsert + loadPlugin + refresh_views + processSend } catch (e) { cssCache.bustTheme(id); // drop orphan cache entry (folded fix) e.stage = "activation"; throw e; } return { activeThemeId: nextDefault, activeByRole: nextByRole, cssHash: nextHash }; } ``` Folded fixes: staged compile-vs-pointer failures (`compile_failed` vs `activation_failed`) with orphan-cache cleanup on pointer-write failure; `setActivePointer` performs the mandatory `refresh_views()` so the new `?v=hash` header actually reaches pages; and `activeHash` is now a **composite** persisted into config on **every** activation (default AND role), folding in the default plus every `activeByRole` entry's content hash. This closes the prior seam where the role-override path wrote `{ activeByRole }` only — leaving `activeHash` unchanged — so a re-activated role's per-role `?v` link was byte-identical and, under the `immutable, max-age=1y` header (§6.5), browsers served stale role CSS forever. Now any role re-activation changes `activeHash`, hence changes every emitted `?v`, busting the immutable cache. > `save` of the **currently-active** theme routes through `activateTheme(sameId)` to recompile-in-place (locked: recompile on activate/save). Non-active saves never touch the live cache or pointer. ### 7.9 Load-order proof (Phase 1) 1. `sbadmin2.wrapIt` emits, in order in ``: fontawesome, nunito, **`sb-admin-2.min.css`** (its Bootstrap-derived stylesheet), optional `bootstrap.rtl`, and **only then** `headersInHead(headers)` (sbadmin2/index.js:408-414). 2. `headersInHead` emits **all `css` links first** (layout_utils.ts:660-666), so our overlay `css` is in that first group. 3. `get_headers` places `state_headers` (where our plugin headers land) **after** `stdHeaders` (which includes core `saltcorn.css`) (wrapper.js:183-190). Result: `sb-admin-2.min.css → … → [overlay theme.css]`. The later `:root{--bs-*}` wins by cascade order, overriding the theme on equal specificity. The overlay **composes** with the active theme (it is a `css` header, not a layout) — the Phase-1 "skin not bones" requirement. For Phase 2 the plugin *is* the layout; its `wrap()` controls head order, emits the single (Sass-compiled) Bootstrap link and then `headersInHead(headers)` — no second Bootstrap, no overlay needed. ### 7.10 Phase 2 `layout(cfg).wrap()` ```js function layout(cfg) { if (!isPhase2(cfg)) return undefined; // falsy => not registered as a layout (Phase 1) return { pluginName: PLUGIN_NAME, wrap: ({ title, body, brand, menu, alerts, headers, bodyClass, role, req }) => { const themeLink = ``; const skeleton = renderTreeToHtml(activeLayoutTree(cfg, role), { title, body, brand, menu, alerts }); return `${themeLink}${headersInHead(headers)}${title}` + `${skeleton}${headersInBody(headers)}`; }, authWrap: /* analogous */, renderBody: /* passthrough */, }; } ``` Registered into `state.layouts["theme-builder"]`; selectable via `layout_by_role[role]="theme-builder"` or `user._attributes.layout`. `renderTreeToHtml` is the Phase-2 builder facet's tree→HTML emitter; the tree references `var(--bs-*)`/Bootstrap classes only. Because `wrap()` emits the token `` itself (with the composite-hash `?v`), Phase-2 token delivery never needs the Phase-1 overlay header — which is exactly why the §7.6 `only_if` suppresses it for theme-builder-served requests. ### 7.11 Data-flow diagram ``` ┌──────────────────────────── theme-builder plugin ───────────────────────────┐ EDIT (no compile) │ │ ┌─────────────┐ PUT/POST │ ┌────────────┐ Table model ┌───────────────────────────┐ │ │ Builder SPA │──────────────▶│ themeStore │───────────────▶│ _themebuilder_themes Table│ (in _sc_tables│ │ token panel │ /save │ │ (CRUD) │ JSON.stringify│ theme_id,name,tokens,... │ => backup-safe│ │ +preview │◀────────── │ └────────────┘ getById └───────────────────────────┘ │ └─────┬───────┘ GET load │ ▲ │ │ applyTokens(--bs-*)│ │ getById(activeId).tokens │ │ (live, in iframe) │ ┌──────┴─────┐ compileTheme(tokens) ┌──────────────┐ │ │ │ │ activate │───────────────────────▶│ compile.js │ tokens->CSS+hash │ ACTIVATE (only live op) │ │ engine │ │ (robustness │ (DERIVED cache only)│ ┌─────────────┐ POST │ │ │ setCached(all keys) │ guard) │ │ │ Activate btn│───────────────▶│ 1 compile │───────────────────────▶│ │ │ └─────────────┘ /activate │ │ 2 cache │ ┌──────────┐ └──────────────┘ │ │ │ 3 persist │──▶│ cssCache │ Map> │ │ │ pointer │ └────┬─────┘ │ │ │ +compos │ │ getCached / warm-on-miss │ │ │ -ite │ │ │ │ │ hash │ │ │ │ │ 4 reload │ │ │ │ │ 5 refresh │ │ │ │ │ _views │ ┌─────▼──────┐ {css:".../theme.css?v=hash", only_if(req)} │ │ │ 6 procSend │ │ headers(cfg)│──────────────────┐ │ │ └────────────┘ └────────────┘ │ │ └─────────────────────────────────────────────────── │ ─────────────────────────┘ ▼ BROWSER page render: sbadmin2 wrap emits Bootstrap CSS ─────▶ then headersInHead(headers) emits overlay link GET /theme-builder/theme.css?v=hash ─────▶ cssCache hit -> 200 text/css (304 if If-None-Match) :root{--bs-*} applies AFTER Bootstrap ─────▶ theme visible, no compile at request time ``` --- ## 8. Builder SPA The in-browser React editor, mounted on an admin-only route and built as one static bundle served from the plugin's `public/` dir. ### 8.1 Serving the bundle The SPA compiles to `public/builderApp.js` + `public/builderApp.css`, served by Saltcorn's built-in static route at `/plugins/public/theme-builder/builderApp.js` with **zero extra route** (plugins.js:1246-1276). The webpack `output.library` global is `themeBuilder`. The compiled artifacts are committed (so an installed plugin works without a build step) and rebuilt via `build:ui`. We do **not** reuse core's `/static_assets//builder_bundle.js` (core-owned, version_tag-gated — builder.ts:67-69). For Phase 2 we **bundle our own `@craftjs/core`** into `builderApp.js` (and **vendor/copy** the saltcorn-builder element/storage source files), because `@saltcorn/builder` exposes no importable `craftToSaltcorn`/`layoutToNodes` (its functions are ESM source bundled only into `builder_bundle.js`). This keeps the SPA self-contained and decoupled from a specific core builder version. ### 8.2 The shell page (`lib/page.js`) ```js async function getEditor(req, res) { if (!guardAdmin(req, res)) return; // GET -> redirect to login on failure const boot = await apiState.buildState(req); // same payload as GET /api/state const csrf = req.csrfToken ? req.csrfToken() : ""; const html = `
`; res.sendWrap({ title: "Theme Builder", no_menu: false }, html); } ``` The SPA reads CSRF from the bootstrap blob and sends it as `CSRF-Token` on every POST. The shell embeds the initial `/state` payload to avoid a flash GET. ### 8.3 Top-level state machine (`app.jsx`) ```ts AppState = { view: "manager" | "editor", panel: "tokens" | "canvas", // canvas only when caps.phase === 2 themes: ThemeListItem[], activeThemeId: string|null, manifest: TokenManifest, caps: {...}, open: { id, name, builtin, baseTokens, draftTokens, layoutTree, version, dirty } | null } ``` Transitions: - `openTheme(id)` → GET `/api/themes/:id`; if `builtin`, set `open.builtin=true` (read-only); switch `view:"editor"`. - `editToken(path,value)` → mutate `draftTokens`, set `dirty`, push to preview iframe (**no network**). - `save()` → if `open.builtin`, first POST `/dup` and swap `open.id`; then POST `/save` with `{tokens, layoutTree, baseVersion:open.version}`; on 409 offer reload-or-overwrite; on success `baseTokens=draftTokens, version++ , dirty=false`. - `activate()` → POST `/activate`; on success update `activeThemeId` + re-fetch `/state`. **The only transition that publishes.** - `rename/delete/create/duplicate/import/export` → library mutations + re-fetch `/state`. `save()` and `activate()` are distinct buttons; editing/saving never implies publishing. A "Save & Apply" affordance = save then activate. ### 8.4 REST adapter (`api.js`) ```js function makeApi(base, csrf) { const j = async (method, path, body) => { const r = await fetch(base + path, { method, headers: { "Content-Type":"application/json", "X-Requested-With":"XMLHttpRequest", "Accept":"application/json", "CSRF-Token": csrf }, body: body ? JSON.stringify(body) : undefined, }); if (!r.ok) throw new Error((await r.json().catch(()=>({}))).error?.message || r.statusText); return (r.headers.get("content-type")||"").includes("json") ? r.json() : r.text(); }; return { state: () => j("GET", "/api/state"), load: (id) => j("GET", `/api/themes/${id}`), create: (b) => j("POST", "/api/themes", b), duplicate: (id,b)=> j("POST", `/api/themes/${id}/duplicate`, b), save: (id,b)=> j("POST", `/api/themes/${id}/save`, b), rename: (id,n)=> j("POST", `/api/themes/${id}/rename`, { name:n }), remove: (id,autoSwitch)=> j("POST", `/api/themes/${id}/delete`, { autoSwitch }), activate: (id,role)=> j("POST", `/api/themes/${id}/activate`, { role }), exportUrl: (id)=> `${base}/api/themes/${id}/export`, // plain }; } ``` The `X-Requested-With`/`Accept` headers ensure `wantsJson` is true even for multipart import (folded fix). CSRF comes from the shell blob (core-builder pattern, Library.js:102-129). ### 8.5 `GET /api/state` payload ```ts StateEnvelope = { themes: ThemeListItem[], // metadata only (NOT full token payloads) activeThemeId: string|null, activeByRole?: { [roleId:number]: string }, manifest: TokenManifest, caps: { phase: 1|2, canImport: true, engine: "bootstrap5", formatVersion, sizeCap }, } ThemeListItem = { id, name, builtin, active, engine, updatedAt, hasLayoutTree } ``` `buildState` merges builtins (mapped `builtin:true`) + library rows (`builtin:false`), annotates `active` from the live pointer (read via `getState().plugin_cfgs`, no DB hit), and includes the manifest. `caps.phase` toggles the canvas panel. ### 8.6 Phase 1 — token panel + live preview The token panel renders **from the manifest** (parallel to the server `TOKEN_SCHEMA`), grouping descriptors into sections (colors/fonts/spacing) and naming the picker + `--bs-*` var(s) each token maps to: ```ts TokenManifest = { sections: [ { id:"colors", label:"Colors", tokens:[ { key:"primary", label:"Primary", picker:"color", cssVars:["--bs-primary","--bs-primary-rgb"], default:"#0d6efd" } ]}, { id:"fonts", label:"Typography", tokens:[ { key:"fontBase", label:"Base font", picker:"font", cssVars:["--bs-body-font-family"], default:"system-ui" } ]}, { id:"spacing", label:"Spacing", tokens:[ { key:"spacer", label:"Spacer", picker:"spacing", cssVars:["--bs-body-line-height"], default:"1rem", unit:"rem" } ]}, ]} ``` **Live preview (no compile while editing).** The preview is a sandboxed `