fix(tokens): canvas token CSS falls back to defaults for blank tokens

Export/published :root token CSS replaced a blank/whitespace token with its
default; the editor canvas emitted an empty custom property, invalidating
every var() that read it. Share one buildTokenVarDecls between the canvas
and the export so both apply the same fallback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
shadowdaoandClaude Opus 5.5 committed 2026-10-07 13:40:54 -07:00
1 parent faf129c509
commit 579ea5370c
3 files changed
+32 -12

No files matched your search

+4 -9
View File
@@ -1,10 +1,10 @@
import { componentResolver } from '../components/resolver';
import type { CSSProperties } from 'react';
import { cssPropsToString } from './style-helpers';
import { escapeHtml, escapeAttr, cssValue, safeImageUrl } from './escape';
import { escapeHtml, escapeAttr, safeImageUrl } from './escape';
import { sanitizeContainerTag } from '../components/layout/Container';
import { SiteDesign, DEFAULT_SITE_DESIGN } from '../state/SiteDesignContext';
import { TOKEN_VARS } from './tokens';
import { buildTokenVarDecls } from './tokens';
import { BREAKPOINTS } from './breakpoints';
import { responsiveCssForNode, responsiveScopeId } from './responsive-style';
@@ -322,13 +322,8 @@ const A11Y_PERF_CSS = `a:focus-visible,button:focus-visible,input:focus-visible,
* element itself.
*/
export function buildTokenCss(design: SiteDesign): string {
// A blank/whitespace token falls back to its default: an empty custom property
// would invalidate every var() that reads it (mirrors the backend sb_token_css).
const vars = TOKEN_VARS.map(([varName, key]) => {
const raw = String(design[key] ?? '').trim();
return `${varName}:${cssValue(raw !== '' ? raw : (DEFAULT_SITE_DESIGN[key] as string))}`;
}).join(';');
return `:root{${vars}}${TOKEN_BASE_CSS}${A11Y_PERF_CSS}`;
// Blank tokens fall back to defaults (shared with the canvas, see buildTokenVarDecls).
return `:root{${buildTokenVarDecls(design)}}${TOKEN_BASE_CSS}${A11Y_PERF_CSS}`;
}
// ---------- <head> SEO/meta block (contract §4) ----------
+13
View File
@@ -135,3 +135,16 @@ describe('buildCanvasTokenCss', () => {
expect(css.split('}').length - 1).toBe(4);
});
});
/** Minor finding: canvas token CSS had no blank-token fallback (export does). */
describe('buildCanvasTokenCss blank-token fallback (parity with export)', () => {
test('blank/whitespace/missing tokens fall back to the default, same vars as the export :root block', () => {
const design = { ...DEFAULT_SITE_DESIGN, primaryColor: '', textColor: ' ', borderRadius: undefined } as unknown as SiteDesign;
const css = buildCanvasTokenCss(design);
expect(css).toContain(`--wsb-primary:${DEFAULT_SITE_DESIGN.primaryColor}`);
expect(css).toContain(`--wsb-text:${DEFAULT_SITE_DESIGN.textColor}`);
expect(css).toContain(`--wsb-radius:${DEFAULT_SITE_DESIGN.borderRadius}`);
const exportVars = /^:root\{([^}]*)\}/.exec(buildTokenCss(design))![1];
expect(css).toContain(`.canvas-device-frame{${exportVars}}`);
});
});
+15 -3
View File
@@ -1,4 +1,4 @@
import type { SiteDesign } from '../state/SiteDesignContext';
import { DEFAULT_SITE_DESIGN, type SiteDesign } from '../state/SiteDesignContext';
import { cssValue } from './escape';
/**
@@ -112,6 +112,18 @@ const CANVAS_TOKEN_BASE_CSS =
* or `<` in a design value could otherwise break out of the rule / `<style>`.
*/
export function buildCanvasTokenCss(design: SiteDesign): string {
const vars = TOKEN_VARS.map(([varName, key]) => `${varName}:${cssValue(design[key] as string)}`).join(';');
return `${CANVAS_SCOPE}{${vars}}${CANVAS_TOKEN_BASE_CSS}`;
return `${CANVAS_SCOPE}{${buildTokenVarDecls(design)}}${CANVAS_TOKEN_BASE_CSS}`;
}
/**
* The 16 `--wsb-*` declarations (`;`-joined), shared by the canvas and the
* export `:root` block. A blank/whitespace token falls back to its default: an
* empty custom property would invalidate every var() that reads it (mirrors the
* backend sb_token_css). Values are sanitised through `cssValue()`.
*/
export function buildTokenVarDecls(design: SiteDesign): string {
return TOKEN_VARS.map(([varName, key]) => {
const raw = String(design[key] ?? '').trim();
return `${varName}:${cssValue(raw !== '' ? raw : (DEFAULT_SITE_DESIGN[key] as string))}`;
}).join(';');
}