fix(site-builder): hero gradient blank on editor canvas - shorthand + undefined longhands

HeroSimple emitted 'background' together with backgroundImage/Size/Position
that were undefined. React assigns '' for those on first render and Chromium
and Firefox then wipe the shorthand's gradient (background-image: none), so
the canvas hero rendered white. Build the style with only the relevant keys
(shared by render and toHtml); exported HTML is byte-identical.

Adds a real-browser (Chromium+Firefox) regression test: npm run test:browser:bg,
and toHtml byte-identity assertions.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
shadowdaoandClaude Sonnet 5.5 committed 2026-11-01 07:52:34 -08:00
1 parent 76f0ba9ca1
commit dbf3dda3e4
5 files changed
+129 -13

No files matched your search

+1
View File
@@ -11,6 +11,7 @@
"test:headed": "playwright test tests/site-builder.spec.ts --reporter=list --headed",
"test:e2e:sitesmith": "playwright test tests/sitesmith.spec.ts --reporter=list",
"test:unit": "vitest run",
"test:browser:bg": "node scripts/e2e-background.mjs",
"test:unit:watch": "vitest"
},
"dependencies": {
+24
View File
@@ -0,0 +1,24 @@
// Bundled by scripts/e2e-background.mjs and mounted in real Chromium.
import React from 'react';
import { createRoot } from 'react-dom/client';
import { Editor, Frame } from '@craftjs/core';
import { HeroSimple } from '../src/components/sections/HeroSimple';
import { BackgroundSection } from '../src/components/layout/BackgroundSection';
import { CTASection } from '../src/components/sections/CTASection';
import { CallToAction } from '../src/components/sections/CallToAction';
import { Container } from '../src/components/layout/Container';
const resolver = { HeroSimple, BackgroundSection, CTASection, CallToAction, Container };
(window as any).mountNode = (id: string, type: string, props: Record<string, unknown>) => {
const data = JSON.stringify({
ROOT: { type: { resolvedName: 'Container' }, isCanvas: true, props: {}, displayName: 'Container', custom: {}, hidden: false, nodes: ['n'], linkedNodes: {} },
n: { type: { resolvedName: type }, isCanvas: false, props, displayName: type, custom: {}, parent: 'ROOT', hidden: false, nodes: [], linkedNodes: {} },
});
const el = document.createElement('div');
el.id = id;
document.body.appendChild(el);
createRoot(el).render(
<Editor resolver={resolver} enabled={false}><Frame data={data} /></Editor>,
);
};
+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env node
/**
* Real-browser regression test: a `background` shorthand followed by an
* undefined/'' background longhand wipes the gradient in Chromium (React
* assigns '' for undefined style values). jsdom cannot reproduce this, so
* mount the real components via react-dom in headless Chromium and assert the
* COMPUTED style. Run: npm run test:browser:bg (needs a Playwright chromium)
*/
import { build } from 'esbuild';
import { chromium, firefox } from '@playwright/test';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const out = await build({
entryPoints: [path.join(root, 'scripts/e2e-background-entry.tsx')],
bundle: true, write: false, format: 'iife', platform: 'browser',
define: { 'process.env.NODE_ENV': '"production"' }, jsx: 'automatic', logLevel: 'error',
});
const js = out.outputFiles[0].text;
const cases = [
['hero gradient', 'HeroSimple', { bgType: 'gradient', bgGradientFrom: '#0f172a', bgGradientTo: '#334155' }, 'backgroundImage', /linear-gradient/],
['hero color', 'HeroSimple', { bgType: 'color', bgColor: '#123456' }, 'backgroundColor', /rgb\(18, 52, 86\)/],
['hero image', 'HeroSimple', { bgType: 'image', bgImage: 'https://example.invalid/a.jpg' }, 'backgroundImage', /url\(.*a\.jpg/],
['hero video (fallback color)', 'HeroSimple', { bgType: 'video', bgVideo: 'v.mp4', bgColor: '#123456' }, 'backgroundColor', /rgb\(18, 52, 86\)/],
['BackgroundSection image', 'BackgroundSection', { bgImage: 'https://example.invalid/b.jpg', bgColor: '#123456' }, 'backgroundImage', /url\(.*b\.jpg/],
['BackgroundSection color', 'BackgroundSection', { bgColor: '#123456' }, 'backgroundColor', /rgb\(18, 52, 86\)/],
['CTASection gradient', 'CTASection', { gradient: 'linear-gradient(135deg, #111111, #222222)' }, 'backgroundImage', /linear-gradient/],
['CallToAction gradient', 'CallToAction', { bgType: 'gradient', bgValue: 'linear-gradient(135deg, #111111, #222222)' }, 'backgroundImage', /linear-gradient/],
];
// Override binaries with CHROMIUM_PATH / FIREFOX_PATH when the Playwright-pinned builds are not installed.
async function run(name, launcher, exe) {
const browser = await launcher.launch({ executablePath: exe || undefined, ...(name === 'chromium' ? { args: ['--no-sandbox'] } : {}) });
const page = await browser.newPage();
const errors = [];
page.on('pageerror', (e) => errors.push(String(e)));
await page.setContent('<!doctype html><html><body></body></html>');
await page.addScriptTag({ content: js });
let failed = 0;
for (const [i, [label, type, props, prop, re]] of cases.entries()) {
const id = `c${i}`;
await page.evaluate(([id, type, props]) => window.mountNode(id, type, props), [id, type, props]);
await page.waitForSelector(`#${id} section`, { timeout: 5000 }).catch(() => {});
const value = await page.evaluate(([id, prop]) => {
const s = document.querySelector(`#${id} section`);
return s ? getComputedStyle(s)[prop] : '<<no section>>';
}, [id, prop]);
const ok = re.test(value);
if (!ok) failed++;
console.log(`[${name}] ${ok ? 'PASS' : 'FAIL'} ${label}: computed ${prop} = ${value}`);
}
if (errors.length) console.log('page errors:', errors.join('\n'));
await browser.close();
return failed;
}
let total = 0;
total += await run('chromium', chromium, process.env.CHROMIUM_PATH);
total += await run('firefox', firefox, process.env.FIREFOX_PATH);
process.exit(total ? 1 : 0);
@@ -0,0 +1,21 @@
import { describe, test, expect } from 'vitest';
import { HeroSimple } from './HeroSimple';
// Exported section tag captured BEFORE the shorthand/longhand fix; must stay byte-identical.
const toHtml = (HeroSimple as any).toHtml;
const cases: Array<[any, string]> = [
[{"bgType":"gradient"}, "<section style=\"background:linear-gradient(135deg, var(--wsb-primary), var(--wsb-secondary));min-height:500px;display:flex;align-items:center;justify-content:center;position:relative;overflow:hidden;padding:60px 20px\">"],
[{"bgType":"color","bgColor":"#123456"}, "<section style=\"background:#123456;min-height:500px;display:flex;align-items:center;justify-content:center;position:relative;overflow:hidden;padding:60px 20px\">"],
[{"bgType":"image","bgImage":"a.jpg"}, "<section style=\"background-image:url('a.jpg');background-size:cover;background-position:center;min-height:500px;display:flex;align-items:center;justify-content:center;position:relative;overflow:hidden;padding:60px 20px\">"],
[{"bgType":"image","bgImage":"a.jpg","bgPosition":"top"}, "<section style=\"background-image:url('a.jpg');background-size:cover;background-position:top;min-height:500px;display:flex;align-items:center;justify-content:center;position:relative;overflow:hidden;padding:60px 20px\">"],
[{"bgType":"image"}, "<section style=\"background-size:cover;background-position:center;min-height:500px;display:flex;align-items:center;justify-content:center;position:relative;overflow:hidden;padding:60px 20px\">"],
[{"bgType":"video","bgVideo":"v.mp4"}, "<section style=\"background:var(--wsb-text);min-height:500px;display:flex;align-items:center;justify-content:center;position:relative;overflow:hidden;padding:60px 20px\">"],
[{"bgType":"gradient","style":{"backgroundColor":"red"}}, "<section style=\"background:linear-gradient(135deg, var(--wsb-primary), var(--wsb-secondary));min-height:500px;display:flex;align-items:center;justify-content:center;position:relative;overflow:hidden;padding:60px 20px;background-color:red\">"],
];
describe('HeroSimple.toHtml background output is unchanged by the longhand fix', () => {
test.each(cases)('%j', (props, expected) => {
const tag = toHtml(props, '').html.match(/<section[^>]*>/)[0];
expect(tag).toBe(expected);
});
});
+20 -13
View File
@@ -49,6 +49,24 @@ function buildBackground(props: HeroProps): string {
}
}
/**
* Background style keys for the hero section. Emits EITHER the `background`
* shorthand (color/gradient/video) OR the image longhands -- never the
* shorthand together with a longhand that may be undefined. React assigns ''
* for undefined style values on the initial render, and in Chromium assigning
* a background longhand '' AFTER the `background` shorthand wipes the
* shorthand's gradient (background-image -> none), leaving the editor canvas
* hero blank. jsdom does not reproduce this; see scripts/e2e-background.mjs.
*/
function heroBackgroundStyle(props: HeroProps): React.CSSProperties {
if (props.bgType !== 'image') return { background: buildBackground(props) };
return {
...(props.bgImage ? { backgroundImage: `url('${props.bgImage}')` } : {}),
backgroundSize: 'cover',
backgroundPosition: cssValue(props.bgPosition) || 'center',
};
}
export const HeroSimple: UserComponent<HeroProps> = ({
heading = 'Build Something Amazing',
subtitle = 'Create beautiful websites without writing a single line of code.',
@@ -78,10 +96,6 @@ export const HeroSimple: UserComponent<HeroProps> = ({
}) => {
const { connectors: { connect, drag } } = useNode();
const bg = buildBackground({
bgType, bgColor, bgGradientFrom, bgGradientTo, bgGradientAngle, bgImage,
} as HeroProps);
const justifyMap = { top: 'flex-start', center: 'center', bottom: 'flex-end' };
const effectiveCtas = normalizeCtas({ ctas, buttonText, buttonHref, secondaryButtonText, secondaryButtonHref });
@@ -97,10 +111,7 @@ export const HeroSimple: UserComponent<HeroProps> = ({
id={anchorId || undefined}
style={{
...style,
background: bgType !== 'image' ? bg : undefined,
backgroundImage: bgType === 'image' && bgImage ? `url('${bgImage}')` : undefined,
backgroundSize: bgType === 'image' ? 'cover' : undefined,
backgroundPosition: bgType === 'image' ? (cssValue(bgPosition) || 'center') : undefined,
...heroBackgroundStyle({ bgType, bgColor, bgGradientFrom, bgGradientTo, bgGradientAngle, bgImage, bgPosition } as HeroProps),
minHeight: minHeight === '100vh' ? '100vh' : minHeight,
display: 'flex',
alignItems: justifyMap[verticalAlign] || 'center',
@@ -205,14 +216,10 @@ HeroSimple.craft = {
/* ---------- HTML export ---------- */
(HeroSimple as any).toHtml = (props: HeroProps, _childrenHtml: string) => {
const bg = buildBackground(props);
const justifyMap: Record<string, string> = { top: 'flex-start', center: 'center', bottom: 'flex-end' };
const sectionStyle = cssPropsToString({
background: props.bgType !== 'image' ? bg : undefined,
backgroundImage: props.bgType === 'image' && props.bgImage ? `url('${props.bgImage}')` : undefined,
backgroundSize: props.bgType === 'image' ? 'cover' : undefined,
backgroundPosition: props.bgType === 'image' ? (cssValue(props.bgPosition) || 'center') : undefined,
...heroBackgroundStyle(props),
minHeight: props.minHeight || '500px',
display: 'flex',
alignItems: justifyMap[props.verticalAlign || 'center'],