sc-theme-builder/ARCHITECTURE.md
2026-07-01 20:07:28 -05:00

1425 lines
100 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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=<hash>" }` 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=<hash>", 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/<file>` 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:<slug>` — 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=<hash>` 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=<hash>`, `?role=` | 200 `text/css` | never 5xx → fallback CSS |
| 2 | get | `/theme-builder/editor` | A | `?id=<theme_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-<cssVar>: <value>; }` (+ auto-derived `--bs-<x>-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-<key>` 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 `{`, `}`, `;`, `</`, `/*`, `*/`, or exceeding `MAX_VALUE_LEN`, is **dropped from output** (not escaped). Because a CSS declaration value containing `}` could close `:root{}` early and leak following declarations globally, dropping it confines every token to its own declaration. A dropped token degrades to "that one variable falls back to Bootstrap's default," not "the whole block is lost."
2. **Block-isolation emit shape** — each `--bs-x:v;` is its own declaration and each targeted rule is its own `{}` block, so the CSS parser's standard error recovery drops a single bad declaration/rule without discarding siblings.
3. **Final structural validate (`robustnessGuard`)** — checks balanced braces and `MAX_CSS_BYTES`; on failure, degrade safely:
```js
function robustnessGuard(css, warnings) {
if (Buffer.byteLength(css, "utf8") > 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 `<head>`: 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 = `<link rel="stylesheet" href="${CSS_ROUTE}?v=${activeHashHint(cfg, role)}">`;
const skeleton = renderTreeToHtml(activeLayoutTree(cfg, role), { title, body, brand, menu, alerts });
return `<!doctype html><html><head>${themeLink}${headersInHead(headers)}<title>${title}</title></head>`
+ `<body class="${bodyClass||""}">${skeleton}${headersInBody(headers)}</body></html>`;
},
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 `<link>` 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<tenant, Map<themeId:role, {css,hash}>> │
│ │ 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/<tag>/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 = `
<div id="theme-builder-root" data-base="${URL_PREFIX}"></div>
<link rel="stylesheet" href="/plugins/public/theme-builder/builderApp.css">
<script>
window.__THEME_BUILDER__ = { boot:${JSON.stringify(boot)}, csrf:${JSON.stringify(csrf)},
apiBase:${JSON.stringify(API_BASE)}, base:${JSON.stringify(URL_PREFIX)},
openThemeId:${JSON.stringify(req.query.id || null)} };
</script>
<script src="/plugins/public/theme-builder/builderApp.js"></script>
<script>window.themeBuilder.mount("theme-builder-root", window.__THEME_BUILDER__);</script>`;
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 <a download>
};
}
```
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 `<iframe>` whose document loads the same Bootstrap/active-theme stylesheet the live site uses plus a representative sample page, with an empty `<style id="tb-overlay">` we write into. On every token change we set `--bs-*` custom properties — **the exact same overlay mechanism production uses on activate** — so WYSIWYG with zero compile.
```jsx
function PreviewFrame({ tokens, manifest }) {
const ref = useRef();
useEffect(() => { ref.current.srcdoc = buildSrcdoc(); }, []);
// FOLDED FIX: apply tokens on iframe load (srcdoc parses async) AND on tokens change.
const apply = () => { const d = ref.current?.contentDocument; if (d) applyTokens(d, tokens, manifest); };
useEffect(apply, [tokens, manifest]);
return <iframe ref={ref} onLoad={apply} sandbox="allow-same-origin" title="Theme preview" className="tb-preview"/>;
}
function applyTokens(doc, tokens, manifest) {
const decls = [];
for (const s of manifest.sections) for (const tk of s.tokens) {
const v = tokens[tk.key]; if (v == null) continue;
for (const cssVar of tk.cssVars) decls.push(`${cssVar}: ${formatVar(cssVar, v, tk)};`);
}
const style = doc.getElementById("tb-overlay");
if (style) style.textContent = `:root, [data-bs-theme]{${decls.join("")}}`;
}
```
The folded fix applies tokens on the iframe `load` event (the `srcdoc` document parses asynchronously, so the first `[tokens]` effect would otherwise no-op on a not-yet-parsed doc and show defaults). `sandbox="allow-same-origin"` only (no `allow-scripts`) — the preview cannot run arbitrary code or navigate.
**Parity guarantee:** editing → preview = `tokens``--bs-*` overlay (client, live); activate → production = `tokens``--bs-*` overlay served by `headers(cfg)` as cached CSS via the route (server). Same mapping; no drift. Production emits the overlay as a `css` link to the hash-busted `theme.css` (a real `Header` key) — **never** the untyped `style` Header field (folded fix).
### 8.7 Phase 2 — Craft.js canvas
A third panel (`panel:"canvas"`) reuses saltcorn-builder's patterns: `<Editor resolver onRender handlers>` + `<Frame><Element canvas is={Container}/></Frame>` (Builder.js:834-873,967-973) and the `craftToSaltcorn`/`layoutToNodes` round-trip (storage.js:97-679).
```jsx
function CanvasPanel({ open, onChange, options }) {
return (
<Editor onRender={RenderNode} resolver={RESOLVER} handlers={s=>new DefaultEventHandlers({store:s})}>
<Frame><Element canvas is={Container}/></Frame>
{/* on mount: layoutToNodes(open.layoutTree, query, actions.history.ignore(), "ROOT", options) */}
{/* on change (debounced): craftToSaltcorn(JSON.parse(query.serialize()),"ROOT",options).layout -> onChange(layout) */}
</Editor>
);
}
```
- **Load:** `layoutToNodes(..., actions.history.ignore(), ...)` so the seed render is not undoable (Builder.js:762-764).
- **Edit/serialize:** `craftToSaltcorn(...).layout` → store as `layoutTree`; mark dirty. **No network, no compile.**
- **Save:** the one `/save` POST carries `{ tokens, layoutTree }` together (both panels feed it).
- **Activate (Phase 2):** the engine does a full Sass recompile (viable because the plugin owns the stylesheet). The SPA is unchanged — still just POST `/activate`.
- **Resolver completeness:** every node type appearing in a serialized tree MUST be in the Editor resolver or `deserialize` throws (Builder.js:845-873). The canvas resolver mirrors saltcorn-builder's structural subset; new node types must also be registered in the storage `allElements` list.
- Craft nodes reference `var(--bs-*)`/Bootstrap classes only — never literal hex — keeping `tokens` the single source of truth across both panels.
### 8.8 Manager panel buttons
| Button | Enabled when |
|---|---|
| Activate | always (builtin or not) |
| Load/Edit | always; builtin opens read-only, first save duplicates |
| Duplicate | always |
| Rename | not builtin |
| Delete | not builtin AND (not active OR `autoSwitch` confirmed) AND not role-assigned-without-confirm |
| Export | always (`<a href={exportUrl} download>`) |
Top toolbar: New, Import (file input → reads text → POST `/import`).
---
## 9. Export / import
### 9.1 Envelope
```ts
ThemeEnvelope = {
$schema: "saltcorn-theme", // SCHEMA_ID discriminator
formatVersion: number, // FORMAT_VERSION (1) at export; import upcasts older
name: string, // import de-dups; never an id
engine: "bootstrap5",
tokens: object, // authoritative
layoutTree?: object, // present only if the theme has one
}
```
The envelope carries **no id**, **no version**, **no timestamps**, **no compiled CSS** — identity is instance-local (import mints fresh), so import is structurally non-destructive.
### 9.2 Build (export)
```js
function buildEnvelope(theme) {
const env = { $schema: SCHEMA_ID, formatVersion: FORMAT_VERSION,
name: theme.name, engine: theme.engine || ENGINE, tokens: theme.tokens };
if (theme.layoutTree) env.layoutTree = theme.layoutTree;
return env;
}
```
Export works for builtins too. The route sets `Content-Disposition: attachment; filename="<slug>.theme.json"` (ASCII slug, round-trips across OSes).
### 9.3 Upcaster chain (two axes)
```js
const FORMAT_UPCASTERS = {
0: (e) => ({ $schema: SCHEMA_ID, formatVersion: 1, name: e.name || "Imported",
engine: e.engine || ENGINE, tokens: e.vars || e.tokens || {}, layoutTree: e.layoutTree || null }),
// future: 1 -> 2 here
};
function upcastEnvelope(e) {
let cur = e, v = typeof e.formatVersion === "number" ? e.formatVersion : 0;
while (v < FORMAT_VERSION) {
if (!FORMAT_UPCASTERS[v]) throw new ImportError(`no upcaster for formatVersion ${v}`);
cur = FORMAT_UPCASTERS[v](cur); v = cur.formatVersion;
}
if (v > FORMAT_VERSION) throw new ImportError(`formatVersion ${v} newer than supported ${FORMAT_VERSION}`);
return cur;
}
```
The **envelope** axis (`formatVersion`) is handled here; the **token-shape** axis (`tokens.$tokensVersion`) is handled by `normalizeTokens` (§4.3), currently a v1 no-op.
### 9.4 Parse + robustness validation — NO security sanitization
```js
async function parseEnvelope(rawText) {
// (d) size cap — operational hygiene, BEFORE parse
if (Buffer.byteLength(rawText, "utf8") > MAX_IMPORT_BYTES) return { ok: false, error: "Theme file exceeds size limit" };
let parsed; try { parsed = JSON.parse(rawText); } catch { return { ok:false, error:"Not valid JSON" }; }
// (a) format + discriminator + upcast
if (!parsed || parsed.$schema !== SCHEMA_ID) return { ok:false, error:"Not a saltcorn-theme file" };
let env; try { env = upcastEnvelope(parsed); } catch (e) { return { ok:false, error:e.message }; }
if (env.engine && env.engine !== ENGINE) return { ok:false, error:`Unsupported engine: ${env.engine}` };
const tokens = normalizeTokens(env.tokens);
if (Object.keys(tokens).length > MAX_TOKENS) return { ok:false, error:"Too many tokens" };
const tv = tokensAreValid(tokens); if (!tv.ok) return { ok:false, error:tv.errors.join("; ") };
// (c) light "won't white-screen" check: actually compile + verify balanced braces.
const compiled = compileTheme(tokens, { engine: env.engine || ENGINE });
if (compiled.warnings.some((w) => /unbalanced|empty \(invalid\)/.test(w)))
return { ok:false, error:"Theme tokens do not compile to valid CSS" };
return { ok:true, draft: { name: (env.name||"Imported Theme").trim(), engine: env.engine||ENGINE,
tokens, layoutTree: env.layoutTree ?? null } };
}
```
### 9.5 Import handler — multipart source fix + fresh-id (folded fixes)
```js
async function importTheme(req, res) {
if (!guardAdmin(req, res)) return;
let raw;
if (req.files && req.files.file) {
const f = req.files.file;
if (f.size > MAX_IMPORT_BYTES) return jsonError(res, 413, "too_large", "Theme file too large");
// FOLDED FIX: with useTempFiles, f.data is an EMPTY (truthy) Buffer -> branch on LENGTH, read tempFilePath.
raw = (f.data && f.data.length) ? f.data.toString("utf8") : require("fs").readFileSync(f.tempFilePath, "utf8");
} else if (req.body && (req.body.$schema || req.body.envelope)) {
raw = typeof req.body.envelope === "string" ? req.body.envelope : JSON.stringify(req.body.envelope || req.body);
if (Buffer.byteLength(raw) > MAX_IMPORT_BYTES) return jsonError(res, 413, "too_large", "Theme too large");
} else return jsonError(res, 400, "bad_request", "No theme file provided");
const parsed = await portability.parseEnvelope(raw);
if (!parsed.ok) return jsonError(res, parsed.error.includes("compile") ? 422 : 400,
parsed.error.includes("compile") ? "uncompilable" : "bad_format", parsed.error);
// (b) FRESH id + de-dup name via create() -> import NEVER overwrites
const theme = await themeStore.create({ name: parsed.draft.name, engine: parsed.draft.engine,
tokens: parsed.draft.tokens, layoutTree: parsed.draft.layoutTree });
res.json({ theme });
}
```
Folded fixes: branch on `f.data.length` (with `useTempFiles`, `f.data` is an empty *truthy* `Buffer`, so a truthiness branch always took the empty path → `JSON.parse("")` → every multipart import 400'd); size-check before reading the temp file.
Import is admin-only; **no sanitization** (admin already owns site CSS/JS); only the four robustness checks run. Re-importing the same file yields a second copy (fresh uuid + de-dup name), never a clobber. **Contingency** (re-add sanitization if non-admin import is ever enabled) is documented inline.
---
## 10. Package / file layout
```
/home/scott/claude/saltcorn/theme-builder/
index.js # export object: api_version, plugin_name, configuration_workflow,
# headers, routes, layout, onLoad
package.json
README.md # incl. SECURITY section (no-sanitization rationale + contingency)
lib/
constants.js # PLUGIN_NAME, THEME_TABLE, URL_PREFIX, CSS_ROUTE, CFG keys, caps
configWorkflow.js # Workflow: phase2 toggle (+coverage HARD-BLOCK), builder link, active step
themeSchema.js # DEFAULT_TOKENS, normalizeTokens, tokensAreValid, emptyTokens
themeStore.js # ONLY module touching the Table: ensureTable, CRUD, casUpdate, dedupName
builtins/
index.js # listBuiltins(), getBuiltin(id), isBuiltinId(id), defaultId(), fallbackCss()
flatly.js darkly.js cosmo.js ... # Bootswatch-derived starters (frozen)
activePointer.js # getActivePointer (read live cfg), setActivePointer (persist+reload+refresh)
cfgReaders.js # getActiveThemeId, activeThemeIdForRole, isPhase2, activeHashHint, requestRendersViaThemeBuilder
portability.js # buildEnvelope, parseEnvelope, upcastEnvelope (robustness-only)
sanitize.js # sanitizeValue, sanitizeSelector (shared by compile + sassCompile)
compile.js # compileTheme, emitOverlayCss, robustnessGuard, contentHash, deriveRgb
sassCompile.js # compileSass (Phase 2, lazy require('sass'))
cssCache.js # getCached/setCached/bustTheme/bustAll/warm (per-tenant)
activate.js # activateTheme (staged compile->cache->persist composite hash->reload->refresh)
tokenSchema.js # TOKEN_SCHEMA mapping table
tokenManifest.js # getTokenManifest() (parallel to TOKEN_SCHEMA), validateCompiles()
headers.js # headers(cfg) factory
layout.js # layout(cfg) factory (Phase 2)
routes.js # routes(cfg) factory
onLoad.js # eachTenant bootstrap + active-CSS rehydration
httpUtils.js # isAdminReq, wantsJson, guardAdmin, jsonError
apiHandlers.js # getThemeCss, getEditor, getState_, loadTheme, createTheme, ...
page.js # SPA shell HTML
apiState.js # buildState
scss/ # Phase 2 only: vendored Bootstrap SCSS source
bootstrap/ ...
builder/ # React SPA source (compiled -> public/)
webpack.config.js babel.config.js
src/
index.jsx # window.themeBuilder.mount(elId, boot)
app.jsx api.js store.js
panels/ managerPanel.jsx tokenPanel.jsx canvasPanel.jsx
preview/ previewFrame.jsx previewSrcdoc.js
pickers/ colorPicker.jsx fontPicker.jsx spacingPicker.jsx
craft/ resolver.js craftBridge.js # Phase 2 (vendored saltcorn-builder source)
public/ # served at /plugins/public/theme-builder/<file>
builderApp.js builderApp.css # committed compiled artifacts
test/
contract.test.js compile.test.js portability.test.js themeStore.test.js e2e.js
```
Filenames are camelCase throughout, per the repo convention.
---
## 11. Phase plan & milestones
### 11.1 Phase 1 ships
- `themeStore` + real Table `_themebuilder_themes` (backup-safe, per-tenant) + built-ins in code.
- Plugin export with `configuration_workflow`, `headers(cfg)`, `routes(cfg)`, `onLoad` (table ensure + active-CSS rehydration). `layout(cfg)` present but returns `undefined`.
- Full HTTP API (list/load/create/duplicate/save[CAS]/rename/delete[auto-switch]/activate/export/import) + public `theme.css`.
- Pure-JS overlay compiler + robustness guard + per-tenant cache + composite `?v=hash` busting + `refresh_views` on activate.
- Builder SPA: manager panel + token panel + live-preview iframe (no compile while editing).
- Export/import envelope + upcaster + robustness-only validation.
### 11.2 Phase 2 ships (behind `phase2Enabled`)
- `layout(cfg)` returns a real `{wrap,...}` registered into `state.layouts`; selectable via `layout_by_role`/per-user attr.
- Per-request Phase-2 overlay suppression (only for requests that render via `theme-builder`, detected by replicating `getLayout`'s fallback precedence — never by reading the fallback-omitted `pluginName`), with a config-workflow **hard-block** requiring full `layout_by_role` coverage before `phase2Enabled` can be saved.
- Craft.js canvas panel (vendored saltcorn-builder source) persisting `layoutTree`; `wrap()` renders the tree.
- Optional full dart-sass recompile (`@use … with`), overlay fallback on failure.
- Vendored Bootstrap SCSS under `scss/`; `sass`/`bootstrap` added to `package.json`.
### 11.3 Testing strategy
- **Pure-unit:** `compile.test.js` (overlay emit, robustness guard, brace-balance, size-cap truncation, bad-token scoping), `portability.test.js` (upcast chain, fresh-id, de-dup, multipart vs JSON source, size cap), `themeSchema` (normalize/merge/version).
- **Store/contract:** `themeStore.test.js` against both SQLite and Postgres (per the Postgres-preferred directive; SQLite as the simple/mixed-topology peer) — `ensureTable` idempotency, CAS save, `dropRoleRefs`, dedupName bound.
- **e2e (`test/e2e.js`):** drive both instances via the existing sqlite + curl + `sc-exec.js` shim harness pattern. Cover: load≠activate (save of active theme does not change live CSS unless re-activated), activate flips `?v=hash` AND the served header actually changes (assert `assets_by_role` refresh), per-role overlay `only_if`, **re-activating a single role override changes `activeHash` so its per-role `?v` link changes (no stale immutable role CSS)**, delete-of-active auto-switch, import never overwrites, restore-over-existing pointer caveat, multi-tenant isolation, cold-worker rehydration, public `theme.css` never 5xx and never poisons cache on transient miss.
- **Phase-2 gates:** Craft round-trip equality (`craftToSaltcorn(layoutToNodes(x)) === x`), resolver completeness, Sass-failure → overlay fallback, per-request suppression coverage — including the **uncovered-role-via-last-installed-fallback** case (a role with no `layout_by_role` entry that resolves to theme-builder via the fallback is suppressed correctly and NOT double-themed), and the coverage hard-block rejecting `phase2Enabled` when any live role is uncovered.
- **Security-first per-phase gate (Scott's directive):** enumerate abuse cases per module and write the adversarial tests before coding (e.g. token value with `}` cannot escape `:root{}`; oversized import → 413; non-admin → 401/403 JSON on every API route; the import contingency is asserted to be a no-op only while import is admin-only).
### 11.4 Risks & mitigations
| Risk | Mitigation |
|---|---|
| Activate doesn't visibly update browsers (stale `assets_by_role`) | `setActivePointer` calls `refresh_views()` locally; cluster receiver recomputes assets + busts cache. e2e asserts the served `?v` changes. |
| Stale immutable role CSS after re-activating a role override | `activeHash` is a COMPOSITE over default + every role-override hash, updated on EVERY activation, so each role link's `?v` changes; ETag (per-entry content hash) + the 304 path provide a second correction lever. |
| Cache poisoning under the live hash on transient miss | Fallback/miss responses use `Cache-Control: no-store`; immutable only for real hashed CSS. |
| Uncovered role double-themed under Phase 2 (overlay + wrap) | Suppression detects theme-builder-served requests by replicating `getLayout`'s fallback precedence (not the fallback-omitted `pluginName`); config workflow hard-blocks `phase2Enabled` until `layout_by_role` covers every live role. |
| `loadPlugin` reinstall cost on every activate | Accepted for parity; `onLoad` hot-path short-circuits; document the cost; activate is admin-triggered, not per-request. |
| Theme lost after restart | `onLoad` eager rehydration of all pointer-implied cache keys. |
| Cross-tenant table miss for late-created tenant | `eachTenant` (default-first, DB-backed); public route degrades to fallback CSS, never 5xx, if a tenant's table is missing. |
| Bootstrap version drift between Phase-1 `--bs-*` names and Phase-2 SCSS | Pin a single Bootstrap 5 version for both; CI check that vendored SCSS var names match the manifest/schema. |
| `install name ≠ plugin_name` breaks pointer writes | `findPluginRow` resolves the `_sc_plugins` row defensively (canonical name, then scan), never assumes `name === plugin_name`. |
---
## 12. Open questions
1. **Per-role compile granularity at scale.** Phase 1 ships a single global active theme plus an optional `activeByRole` map; activate recompiles all pointer-implied keys eagerly. If `activeByRole` grows large (many roles × distinct themes), eager recompile-on-activate may warrant a lazy-only strategy for non-default roles. The cache keying and lazy-compile-on-miss already support either; the policy choice (eager vs lazy for role overrides) is deferred until real role counts are known.
2. **Tenant created after the last plugin load.** A tenant added to `_sc_tenants` after the most recent plugin load gets its table only on the next plugin load / tenant-create hook. The public path degrades gracefully (fallback CSS, no 5xx) and the first admin action triggers `ensureTable`, but whether to subscribe to a tenant-creation event for eager table creation is an open operational choice.
3. **Phase-2 `layout_by_role` coverage UX — RESOLVED to a hard block.** Enabling `phase2Enabled` without `layout_by_role` covering all live roles would leave some roles selected by the last-installed fallback (which `getLayout` returns *without* `pluginName`), making per-request overlay suppression undecidable from `getLayout().pluginName` and double-theming those roles. To keep suppression deterministic, the config workflow **hard-blocks** saving `phase2Enabled: true` until `layout_by_role` covers every live role (rather than merely warning). Remaining open question: the UX of presenting and auto-filling that coverage requirement (e.g. offering to map all uncovered roles to theme-builder in one click).
4. **`@use … with` vs runtime `--bs-*` fidelity (Phase 2).** Full Sass recompile gives higher fidelity for Bootstrap's build-time-computed values (button shades, etc.) than `--bs-*` overrides. Whether Phase-2 themes default to Sass or keep the overlay engine unless the admin opts in is deferred to Phase-2 implementation, when compile-time benchmarks are available.