From dbf3dda3e4df094d7f3b600db23ac526d952dc53 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 1 Nov 2026 07:52:34 -0800 Subject: [PATCH] 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 --- craft/package.json | 1 + craft/scripts/e2e-background-entry.tsx | 24 +++++++ craft/scripts/e2e-background.mjs | 63 +++++++++++++++++++ .../HeroSimple.background.toHtml.test.ts | 21 +++++++ craft/src/components/sections/HeroSimple.tsx | 33 ++++++---- 5 files changed, 129 insertions(+), 13 deletions(-) create mode 100644 craft/scripts/e2e-background-entry.tsx create mode 100644 craft/scripts/e2e-background.mjs create mode 100644 craft/src/components/sections/HeroSimple.background.toHtml.test.ts diff --git a/craft/package.json b/craft/package.json index f73194d..72ca3ce 100644 --- a/craft/package.json +++ b/craft/package.json @@ -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": { diff --git a/craft/scripts/e2e-background-entry.tsx b/craft/scripts/e2e-background-entry.tsx new file mode 100644 index 0000000..441308d --- /dev/null +++ b/craft/scripts/e2e-background-entry.tsx @@ -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) => { + 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( + , + ); +}; diff --git a/craft/scripts/e2e-background.mjs b/craft/scripts/e2e-background.mjs new file mode 100644 index 0000000..8366083 --- /dev/null +++ b/craft/scripts/e2e-background.mjs @@ -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(''); + 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] : '<>'; + }, [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); diff --git a/craft/src/components/sections/HeroSimple.background.toHtml.test.ts b/craft/src/components/sections/HeroSimple.background.toHtml.test.ts new file mode 100644 index 0000000..a9a80f0 --- /dev/null +++ b/craft/src/components/sections/HeroSimple.background.toHtml.test.ts @@ -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"}, "
"], + [{"bgType":"color","bgColor":"#123456"}, "
"], + [{"bgType":"image","bgImage":"a.jpg"}, "
"], + [{"bgType":"image","bgImage":"a.jpg","bgPosition":"top"}, "
"], + [{"bgType":"image"}, "
"], + [{"bgType":"video","bgVideo":"v.mp4"}, "
"], + [{"bgType":"gradient","style":{"backgroundColor":"red"}}, "
"], +]; + +describe('HeroSimple.toHtml background output is unchanged by the longhand fix', () => { + test.each(cases)('%j', (props, expected) => { + const tag = toHtml(props, '').html.match(/]*>/)[0]; + expect(tag).toBe(expected); + }); +}); diff --git a/craft/src/components/sections/HeroSimple.tsx b/craft/src/components/sections/HeroSimple.tsx index b37a9ef..824a302 100644 --- a/craft/src/components/sections/HeroSimple.tsx +++ b/craft/src/components/sections/HeroSimple.tsx @@ -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 = ({ heading = 'Build Something Amazing', subtitle = 'Create beautiful websites without writing a single line of code.', @@ -78,10 +96,6 @@ export const HeroSimple: UserComponent = ({ }) => { 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 = ({ 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 = { 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'],