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

100 KiB
Raw Permalink Blame History

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
  2. Design principles
  3. Saltcorn integration points
  4. Data model & storage
  5. Plugin module contract & lifecycle
  6. HTTP API surface
  7. Compile & render pipeline
  8. Builder SPA
  9. Export / import
  10. Package / file layout
  11. Phase plan & milestones
  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/headerTagthere 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.

// 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
});
// 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_ids 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.

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).

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.

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

// 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 Yesplugin_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 (→ onLoadensureTable 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)

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

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.

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).

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

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

{
  "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

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

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)

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

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:

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

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).

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)

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

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)

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)

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)

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()

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)

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)

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)

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

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:

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.

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).

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

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)

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)

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

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)

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.