commit cb1cbcc80b8df8ec87e32395b61ca7433d0b4aec Author: Scott Duensing Date: Wed Jul 1 20:07:28 2026 -0500 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..33287e5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules +.test-state/ +*.log diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..6b6e840 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,1425 @@ +# 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 `