From dbf3dda3e4df094d7f3b600db23ac526d952dc53 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 1 Nov 2026 07:52:34 -0800 Subject: [PATCH 1/8] 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'], From dc415db2d3d7043635df1653d4004ca2a60eff46 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 1 Nov 2026 08:23:19 -0800 Subject: [PATCH 2/8] fix(site-builder): editor canvas renders content with the published base reset Published pages/Preview ship a head reset (*{margin:0;padding:0}, img block, a inherit, body line-height 1.6 / #1f2937 / 16px); the editor canvas had none, so h1-h6/p margins, line-height and inherited size/colour differed. - editor.css: one commented block scoping the reset to .canvas-device-frame, every selector zero-specificity (:where) so inline styles / class rules win exactly as when published; editor chrome untouched (snapshot identical). - tokens.ts: canvas heading/link token rules made zero-specificity too. - utils/css-reset.ts: single source for the reset declarations (html-export imports it; test asserts byte-identity with the backend head and that the editor.css block carries the same declarations). - scripts/e2e-canvas-parity.mjs (npm run test:browser:canvas): real editor vs published HTML in Firefox + Chromium, boxes within 1px + computed styles. Co-Authored-By: Claude Sonnet 5.5 --- craft/package.json | 1 + craft/scripts/e2e-canvas-export-entry.ts | 15 + craft/scripts/e2e-canvas-parity.mjs | 323 ++++++++++++++++++ craft/src/styles/editor.css | 44 +++ craft/src/utils/css-reset.test.ts | 60 ++++ craft/src/utils/css-reset.ts | 21 ++ craft/src/utils/html-export.ts | 3 +- craft/src/utils/tokens.test.ts | 5 +- craft/src/utils/tokens.ts | 10 +- .../fixtures/canvas-parity/ols.about.html | 46 +++ .../fixtures/canvas-parity/ols.brands.html | 54 +++ .../fixtures/canvas-parity/ols.contact.html | 40 +++ .../fixtures/canvas-parity/ols.index.html | 64 ++++ .../fixtures/canvas-parity/ols.project.json | 88 +++++ .../fixtures/canvas-parity/ols.spec.json | 6 + 15 files changed, 774 insertions(+), 6 deletions(-) create mode 100644 craft/scripts/e2e-canvas-export-entry.ts create mode 100644 craft/scripts/e2e-canvas-parity.mjs create mode 100644 craft/src/utils/css-reset.test.ts create mode 100644 craft/src/utils/css-reset.ts create mode 100644 craft/tests/fixtures/canvas-parity/ols.about.html create mode 100644 craft/tests/fixtures/canvas-parity/ols.brands.html create mode 100644 craft/tests/fixtures/canvas-parity/ols.contact.html create mode 100644 craft/tests/fixtures/canvas-parity/ols.index.html create mode 100644 craft/tests/fixtures/canvas-parity/ols.project.json create mode 100644 craft/tests/fixtures/canvas-parity/ols.spec.json diff --git a/craft/package.json b/craft/package.json index f73194d..8572cf9 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:canvas": "node scripts/e2e-canvas-parity.mjs", "test:unit:watch": "vitest" }, "dependencies": { diff --git a/craft/scripts/e2e-canvas-export-entry.ts b/craft/scripts/e2e-canvas-export-entry.ts new file mode 100644 index 0000000..bd2e65f --- /dev/null +++ b/craft/scripts/e2e-canvas-export-entry.ts @@ -0,0 +1,15 @@ +// Bundled (esbuild) by scripts/e2e-canvas-parity.mjs and run in Node: renders what a publish +// of the CURRENT stored state would emit, composed like TopBar's Preview +// (header + body + footer under the real document head). Used for specs whose +// checked-in published HTML is stale relative to the stored state. +import { exportBodyHtml, exportComposedPreviewHtml } from '../src/utils/html-export'; + +export function renderPublished(project: any, pageIdx: number): string { + const page = project.pages_craft_state[pageIdx]; + const hdr = project.header_craft_state ? exportBodyHtml(project.header_craft_state, 'header').html : ''; + const ftr = project.footer_craft_state ? exportBodyHtml(project.footer_craft_state, 'footer').html : ''; + const body = exportBodyHtml(page.craftState).html; + return exportComposedPreviewHtml(page.craftState, hdr + body + ftr, { + title: page.name, includeFonts: true, headCode: project.design?.headCode, design: project.design, + }); +} diff --git a/craft/scripts/e2e-canvas-parity.mjs b/craft/scripts/e2e-canvas-parity.mjs new file mode 100644 index 0000000..9cb7194 --- /dev/null +++ b/craft/scripts/e2e-canvas-parity.mjs @@ -0,0 +1,323 @@ +#!/usr/bin/env node +/** + * Real-browser regression test: the editor CANVAS must lay page content out + * exactly like the PUBLISHED page ("the site looks different in the builder + * than published"). jsdom has no layout, so this mounts the REAL editor (a + * vite build served by a tiny mock API) in headless Firefox AND Chromium, + * loads a stored project, and compares -- element by element -- against the + * same page's published HTML rendered in a plain page whose viewport equals + * the canvas content width. + * + * Compared per matched element: bounding box (x/y/w/h, within 1px) and the + * computed font-size / line-height / margin / padding / colour / font-family + * / font-weight / display. Editor-only chrome (the dashed header/footer zone + * separators) is compensated for, not ignored. + * + * npm run test:browser:canvas + * + * Env: + * CHROMIUM_PATH / FIREFOX_PATH override the Playwright-pinned binaries + * WHP_CANVAS_SPECS extra comma-separated spec.json paths + * (e.g. real customer sites kept out of git) + * CANVAS_SHOTS= write canvas + published PNGs there + * CANVAS_CHROME_DUMP= write a computed-style snapshot of the + * editor chrome (everything OUTSIDE the + * canvas frame) to diff before/after a change + * CANVAS_ONLY=chromium|firefox run a single browser + * + * A spec is {name, project: , published: {: }} + * with paths relative to the spec file. Only pages that have a published + * file are compared. + */ +import { chromium, firefox } from '@playwright/test'; +import { build as esbuild } from 'esbuild'; +import { execFileSync } from 'node:child_process'; +import http from 'node:http'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const VIEW_H = 1000; +const EDITOR_W = 1500; +const TOL = 1; // px + +// ---------------------------------------------------------------- specs +const specPaths = [ + path.join(root, 'tests/fixtures/canvas-parity/ols.spec.json'), + ...(process.env.WHP_CANVAS_SPECS || '').split(',').map((s) => s.trim()).filter(Boolean), +]; +// `published` is either {slug: html file} or "generate" = render what a publish of the stored +// state emits (scripts/e2e-canvas-export-entry.ts) -- for sites whose saved HTML is stale. +let renderPublished = null; +async function loadExporter() { + if (renderPublished) return renderPublished; + const out = await esbuild({ + entryPoints: [path.join(root, 'scripts/e2e-canvas-export-entry.ts')], + bundle: true, write: false, format: 'esm', platform: 'node', jsx: 'automatic', logLevel: 'error', + define: { 'process.env.NODE_ENV': '"production"', __EDITOR_BUILD__: '"test"' }, + banner: { js: "import { createRequire } from 'node:module'; const require = createRequire(import.meta.url);" }, + }); + const f = path.join(os.tmpdir(), `wsb-export-${process.pid}.mjs`); + fs.writeFileSync(f, out.outputFiles[0].text); + renderPublished = (await import(f)).renderPublished; + fs.rmSync(f, { force: true }); + return renderPublished; +} +const specs = []; +for (const p of specPaths) { + const dir = path.dirname(p); + const s = JSON.parse(fs.readFileSync(p, 'utf8')); + const project = JSON.parse(fs.readFileSync(path.resolve(dir, s.project), 'utf8')); + let published; + if (s.published === 'generate') { + const render = await loadExporter(); + const only = s.pages || project.pages_craft_state.map((pg) => pg.slug); + published = {}; + project.pages_craft_state.forEach((pg, i) => { if (only.includes(pg.slug)) published[pg.slug] = render(project, i); }); + } else { + published = Object.fromEntries(Object.entries(s.published).map(([k, v]) => [k, fs.readFileSync(path.resolve(dir, v), 'utf8')])); + } + specs.push({ name: s.name, project, published }); +} + +// A stored `style` often carries a shorthand plus BLANK longhands +// ({padding:'20px', paddingTop:''}). The editor renders those through React, which +// assigns '' to the longhand and thereby wipes the shorthand in the canvas (the +// export drops blanks, so published keeps it). That is a SEPARATE bug from the +// base-reset parity this test guards (same class as fix/bg-shorthand-longhand), so by +// default the blanks are stripped from the loaded state to isolate the reset. +// CANVAS_RAW=1 keeps the stored state verbatim (shows that bug's residue). +function stripBlankStyles(project) { + if (process.env.CANVAS_RAW) return project; + const clean = (json) => { + const nodes = JSON.parse(json); + for (const n of Object.values(nodes)) { + const st = n && n.props && n.props.style; + if (st && typeof st === 'object') for (const k of Object.keys(st)) if (st[k] === '') delete st[k]; + } + return JSON.stringify(nodes); + }; + const out = { ...project }; + for (const k of ['craft_state', 'header_craft_state', 'footer_craft_state']) if (typeof out[k] === 'string' && out[k]) out[k] = clean(out[k]); + out.pages_craft_state = (project.pages_craft_state || []).map((pg) => ({ ...pg, craftState: typeof pg.craftState === 'string' ? clean(pg.craftState) : pg.craftState })); + return out; +} + +// ---------------------------------------------------------------- build + serve the real editor +const buildDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wsb-canvas-')); +execFileSync(path.join(root, 'node_modules/.bin/vite'), ['build', '--outDir', buildDir, '--emptyOutDir', '--logLevel', 'error'], { cwd: root, stdio: 'inherit' }); + +let currentProject = null; +const mime = { '.js': 'text/javascript', '.css': 'text/css', '.html': 'text/html', '.png': 'image/png', '.svg': 'image/svg+xml' }; +const server = http.createServer((req, res) => { + const u = new URL(req.url, 'http://x'); + if (u.pathname === '/api/site-builder.php') { + res.setHeader('content-type', 'application/json'); + if (u.searchParams.get('action') === 'load') { + return res.end(JSON.stringify({ success: true, rev: 'r1', limits: {}, project: currentProject })); + } + return res.end(JSON.stringify({ success: true })); + } + const p = u.pathname === '/' ? '/index.html' : u.pathname; + if (p === '/index.html') { + const cfg = { user: 't', apiUrl: '/api/site-builder.php', csrfToken: 'x', siteId: 1, siteDomain: 'example.test', siteName: 'Parity', backUrl: '/', isRoot: false }; + const h = fs.readFileSync(path.join(buildDir, 'index.html'), 'utf8') + .replace('', () => ``); + res.setHeader('content-type', 'text/html'); + return res.end(h); + } + const f = path.join(buildDir, p); + if (f.startsWith(buildDir) && fs.existsSync(f) && fs.statSync(f).isFile()) { + res.setHeader('content-type', mime[path.extname(f)] || 'application/octet-stream'); + return res.end(fs.readFileSync(f)); + } + res.statusCode = 404; + res.end('nf'); +}); +await new Promise((r) => server.listen(0, '127.0.0.1', r)); +const base = `http://127.0.0.1:${server.address().port}`; + +// ---------------------------------------------------------------- in-page measurer (runs in BOTH documents) +function measure({ rootSel, isCanvas, skipNav }) { + const rootEl = document.querySelector(rootSel); + const rr = rootEl.getBoundingClientRect(); + const bl = isCanvas ? parseFloat(getComputedStyle(rootEl).borderLeftWidth) || 0 : 0; + const bt = isCanvas ? parseFloat(getComputedStyle(rootEl).borderTopWidth) || 0 : 0; + const ox = rr.left + bl; + const oy = rr.top + bt; + // Editor-only dashed zone separators shift everything after them by their width. + const zones = isCanvas ? [...rootEl.querySelectorAll('[data-zone-preview]')] : []; + const adjustY = (el) => { + let adj = 0; + for (const z of zones) { + const cs = getComputedStyle(z); + const zone = z.getAttribute('data-zone-preview'); + const bw = parseFloat(zone === 'header' ? cs.borderBottomWidth : cs.borderTopWidth) || 0; + if (zone === 'header' && !z.contains(el) && (z.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_FOLLOWING)) adj += bw; + if (zone === 'footer' && (z === el || z.contains(el))) adj += bw; + } + return adj; + }; + const SKIP = new Set(['STYLE', 'SCRIPT', 'NOSCRIPT', 'LINK', 'META', 'BR', 'HEAD', 'TITLE', 'PATH', 'CIRCLE', 'RECT', 'LINE', 'POLYLINE', 'POLYGON', 'G', 'USE', 'DEFS']); + const norm = (s) => s.replace(/\s+/g, ''); + // Visible text only (never + + + + +
+ +
+

About OLS Digital

+

Who we are and what we believe in.

+
+
+

Our Story

OLS Digital was founded to bring structure, resources, and long-term support to promising digital brands. Rather than absorbing the businesses we work with, we help them keep their independent identity while benefiting from shared operational support.

Our Mission

We believe in honesty, quality, and putting people first. Every brand under the OLS Digital name is expected to uphold these same values in how they serve their customers.

+
+

Have Questions?

+

We'd love to hear from you.

+ +
+
+
© 2026 OLS Digital. All rights reserved. Questions? Email info@olsdigital.com
+ + \ No newline at end of file diff --git a/craft/tests/fixtures/canvas-parity/ols.brands.html b/craft/tests/fixtures/canvas-parity/ols.brands.html new file mode 100644 index 0000000..8ff1d64 --- /dev/null +++ b/craft/tests/fixtures/canvas-parity/ols.brands.html @@ -0,0 +1,54 @@ + + + + + + Brands + + + + + + + + + + + +
+ +
+

Our Brands

+

A closer look at the companies under the OLS Digital umbrella.

+
+
+
+

AnHonestHost

AnHonestHost provides transparent, no-nonsense web hosting for individuals and businesses. Straightforward pricing, honest support, and reliable infrastructure — no surprises.


+

StreamerTools

StreamerTools builds practical tools and resources for streamers and content creators, helping them grow their channels and streamline their workflow.

+
+

Interested in One of Our Brands?

+

Reach out and we'll point you in the right direction.

+ +
+
+
© 2026 OLS Digital. All rights reserved. Questions? Email info@olsdigital.com
+ + \ No newline at end of file diff --git a/craft/tests/fixtures/canvas-parity/ols.contact.html b/craft/tests/fixtures/canvas-parity/ols.contact.html new file mode 100644 index 0000000..fd32666 --- /dev/null +++ b/craft/tests/fixtures/canvas-parity/ols.contact.html @@ -0,0 +1,40 @@ + + + + + + Contact + + + + + + + + + + + +
+ +
+

Contact Us

+

Have a question about OLS Digital or one of our brands? We're happy to help.

+
+
+

Get In Touch

For all inquiries regarding OLS Digital, AnHonestHost, StreamerTools, or any of our other brands, please reach out via email and our team will respond as soon as possible.

info@olsdigital.com
+
© 2026 OLS Digital. All rights reserved. Questions? Email info@olsdigital.com
+ + \ No newline at end of file diff --git a/craft/tests/fixtures/canvas-parity/ols.index.html b/craft/tests/fixtures/canvas-parity/ols.index.html new file mode 100644 index 0000000..208d405 --- /dev/null +++ b/craft/tests/fixtures/canvas-parity/ols.index.html @@ -0,0 +1,64 @@ + + + + + + Home + + + + + + + + + + + +
+ +
+

OLS Digital

+

The parent company behind a growing family of trusted online brands, including AnHonestHost and StreamerTools.

+ +
+
+
+
+
🏢
+

Multi-Brand Portfolio

+

We own and support a growing collection of independent, high-quality brands.

+
+
+
🌐
+

AnHonestHost

+

Transparent, reliable web hosting built on trust.

+
+
+
🎮
+

StreamerTools

+

Tools and resources built for content creators and streamers.

+
+
+

Building Brands That Matter

OLS Digital is a privately held parent company focused on acquiring and growing digital-first businesses. Each brand under our umbrella operates independently, guided by a shared commitment to honesty, quality, and great customer experiences.

+
+

Want to Learn More?

+

For inquiries about OLS Digital or any of our brands, reach out to our team directly.

+ +
+
+
© 2026 OLS Digital. All rights reserved. Questions? Email info@olsdigital.com
+ + \ No newline at end of file diff --git a/craft/tests/fixtures/canvas-parity/ols.project.json b/craft/tests/fixtures/canvas-parity/ols.project.json new file mode 100644 index 0000000..4715075 --- /dev/null +++ b/craft/tests/fixtures/canvas-parity/ols.project.json @@ -0,0 +1,88 @@ +{ + "site_id": 100, + "name": "OneLastShot Digital", + "html": "
\n\n
\n

OLS Digital

\n

The parent company behind a growing family of trusted online brands, including AnHonestHost and StreamerTools.

\n \n
\n
\n
\n
\n
\ud83c\udfe2
\n

Multi-Brand Portfolio

\n

We own and support a growing collection of independent, high-quality brands.

\n
\n
\n
\ud83c\udf10
\n

AnHonestHost

\n

Transparent, reliable web hosting built on trust.

\n
\n
\n
\ud83c\udfae
\n

StreamerTools

\n

Tools and resources built for content creators and streamers.

\n
\n
\n

Building Brands That Matter

OLS Digital is a privately held parent company focused on acquiring and growing digital-first businesses. Each brand under our umbrella operates independently, guided by a shared commitment to honesty, quality, and great customer experiences.

\n
\n

Want to Learn More?

\n

For inquiries about OLS Digital or any of our brands, reach out to our team directly.

\n \n
\n
", + "css": "", + "pages": [ + { + "filename": "index.html", + "title": "Home", + "html": "
\n\n
\n

OLS Digital

\n

The parent company behind a growing family of trusted online brands, including AnHonestHost and StreamerTools.

\n \n
\n
\n
\n
\n
\ud83c\udfe2
\n

Multi-Brand Portfolio

\n

We own and support a growing collection of independent, high-quality brands.

\n
\n
\n
\ud83c\udf10
\n

AnHonestHost

\n

Transparent, reliable web hosting built on trust.

\n
\n
\n
\ud83c\udfae
\n

StreamerTools

\n

Tools and resources built for content creators and streamers.

\n
\n
\n

Building Brands That Matter

OLS Digital is a privately held parent company focused on acquiring and growing digital-first businesses. Each brand under our umbrella operates independently, guided by a shared commitment to honesty, quality, and great customer experiences.

\n
\n

Want to Learn More?

\n

For inquiries about OLS Digital or any of our brands, reach out to our team directly.

\n \n
\n
" + }, + { + "filename": "brands.html", + "title": "Brands", + "html": "
\n\n
\n

Our Brands

\n

A closer look at the companies under the OLS Digital umbrella.

\n
\n
\n
\n

AnHonestHost

AnHonestHost provides transparent, no-nonsense web hosting for individuals and businesses. Straightforward pricing, honest support, and reliable infrastructure \u2014 no surprises.


\n

StreamerTools

StreamerTools builds practical tools and resources for streamers and content creators, helping them grow their channels and streamline their workflow.

\n
\n

Interested in One of Our Brands?

\n

Reach out and we'll point you in the right direction.

\n \n
\n
" + }, + { + "filename": "about.html", + "title": "About", + "html": "
\n\n
\n

About OLS Digital

\n

Who we are and what we believe in.

\n
\n
\n

Our Story

OLS Digital was founded to bring structure, resources, and long-term support to promising digital brands. Rather than absorbing the businesses we work with, we help them keep their independent identity while benefiting from shared operational support.

Our Mission

We believe in honesty, quality, and putting people first. Every brand under the OLS Digital name is expected to uphold these same values in how they serve their customers.

\n
\n

Have Questions?

\n

We'd love to hear from you.

\n \n
\n
" + }, + { + "filename": "contact.html", + "title": "Contact", + "html": "
\n\n
\n

Contact Us

\n

Have a question about OLS Digital or one of our brands? We're happy to help.

\n
\n
\n

Get In Touch

For all inquiries regarding OLS Digital, AnHonestHost, StreamerTools, or any of our other brands, please reach out via email and our team will respond as soon as possible.

info@olsdigital.com
" + } + ], + "header_html": "\n", + "footer_html": "
\u00a9 2026 OLS Digital. All rights reserved. Questions? Email info@olsdigital.com
", + "craft_state": "{\"ai-hero-1\":{\"type\":{\"resolvedName\":\"HeroSimple\"},\"isCanvas\":false,\"props\":{\"heading\":\"OLS Digital\",\"subtitle\":\"The parent company behind a growing family of trusted online brands, including AnHonestHost and StreamerTools.\",\"ctas\":[{\"text\":\"Explore Our Brands\",\"href\":\"/brands\",\"variant\":\"primary\"},{\"text\":\"Contact Us\",\"href\":\"/contact\",\"variant\":\"outline\"}],\"bgType\":\"gradient\",\"bgColor\":\"#1e293b\",\"bgGradientFrom\":\"#0f172a\",\"bgGradientTo\":\"#334155\",\"bgGradientAngle\":135,\"bgImage\":\"\",\"bgPosition\":\"\",\"bgVideo\":\"\",\"overlayColor\":\"#000000\",\"overlayOpacity\":0,\"textColor\":\"#ffffff\",\"buttonBgColor\":\"#3b82f6\",\"buttonTextColor\":\"#ffffff\",\"minHeight\":\"60vh\",\"verticalAlign\":\"center\",\"textAlign\":\"center\",\"anchorId\":\"home\",\"style\":{},\"aiName\":\"Hero\",\"node_id\":\"ai-hero-1\"},\"displayName\":\"Hero\",\"custom\":{},\"parent\":\"ROOT\",\"hidden\":false,\"nodes\":[],\"linkedNodes\":{}},\"ai-features-1\":{\"type\":{\"resolvedName\":\"FeaturesGrid\"},\"isCanvas\":false,\"props\":{\"features\":[{\"icon\":\"\ud83c\udfe2\",\"title\":\"Multi-Brand Portfolio\",\"description\":\"We own and support a growing collection of independent, high-quality brands.\"},{\"icon\":\"\ud83c\udf10\",\"title\":\"AnHonestHost\",\"description\":\"Transparent, reliable web hosting built on trust.\"},{\"icon\":\"\ud83c\udfae\",\"title\":\"StreamerTools\",\"description\":\"Tools and resources built for content creators and streamers.\"}],\"style\":{\"backgroundColor\":\"#ffffff\"},\"anchorId\":\"what-we-do\",\"animation\":\"\",\"animationDelay\":\"\",\"hideOnDesktop\":false,\"hideOnTablet\":false,\"hideOnMobile\":false,\"aiName\":\"What We Do\",\"node_id\":\"ai-features-1\"},\"displayName\":\"Features Grid\",\"custom\":{},\"parent\":\"ROOT\",\"hidden\":false,\"nodes\":[],\"linkedNodes\":{}},\"ai-section-intro-1\":{\"type\":{\"resolvedName\":\"Section\"},\"isCanvas\":false,\"props\":{\"style\":{\"paddingTop\":\"60px\",\"paddingBottom\":\"60px\"},\"innerMaxWidth\":\"1200px\",\"topDivider\":\"none\",\"topDividerColor\":\"#ffffff\",\"topDividerHeight\":\"50px\",\"bottomDivider\":\"none\",\"bottomDividerColor\":\"#ffffff\",\"bottomDividerHeight\":\"50px\",\"anchorId\":\"about-preview\",\"animation\":\"\",\"animationDelay\":\"0\",\"hideOnDesktop\":false,\"hideOnTablet\":false,\"hideOnMobile\":false,\"aiName\":\"Intro Section\",\"node_id\":\"ai-section-intro-1\"},\"displayName\":\"Section\",\"custom\":{},\"parent\":\"ROOT\",\"hidden\":false,\"nodes\":[],\"linkedNodes\":{\"section-inner\":\"ai-section-intro-1__section-inner\"}},\"ai-heading-intro-1\":{\"type\":{\"resolvedName\":\"Heading\"},\"isCanvas\":false,\"props\":{\"text\":\"Building Brands That Matter\",\"html\":\"\",\"level\":\"h2\",\"style\":{\"fontSize\":\"36px\",\"fontWeight\":\"700\",\"fontFamily\":\"Inter, sans-serif\",\"color\":\"#1f2937\",\"marginBottom\":\"16px\",\"lineHeight\":\"\",\"letterSpacing\":\"\",\"textTransform\":\"\",\"fontStyle\":\"\",\"textDecoration\":\"\",\"marginTop\":\"\",\"marginRight\":\"\",\"marginLeft\":\"\",\"paddingTop\":\"\",\"paddingRight\":\"\",\"paddingBottom\":\"\",\"paddingLeft\":\"\",\"border\":\"none\",\"boxShadow\":\"none\",\"opacity\":\"1\"},\"animation\":\"\",\"animationDelay\":\"0\",\"hideOnDesktop\":false,\"hideOnTablet\":false,\"hideOnMobile\":false,\"aiName\":\"Intro Heading\",\"node_id\":\"ai-heading-intro-1\"},\"displayName\":\"Heading\",\"custom\":{},\"parent\":\"ai-section-intro-1__section-inner\",\"hidden\":false,\"nodes\":[],\"linkedNodes\":{}},\"ai-text-intro-1\":{\"type\":{\"resolvedName\":\"TextBlock\"},\"isCanvas\":false,\"props\":{\"text\":\"OLS Digital is a privately held parent company focused on acquiring and growing digital-first businesses. Each brand under our umbrella operates independently, guided by a shared commitment to honesty, quality, and great customer experiences.\",\"html\":\"\",\"style\":{\"fontSize\":\"16px\",\"lineHeight\":\"1.6\",\"color\":\"#3f3f46\",\"letterSpacing\":\"\",\"textTransform\":\"\",\"fontStyle\":\"\",\"textDecoration\":\"\",\"marginTop\":\"\",\"marginRight\":\"\",\"marginBottom\":\"\",\"marginLeft\":\"\",\"paddingTop\":\"\",\"paddingRight\":\"\",\"paddingBottom\":\"\",\"paddingLeft\":\"\",\"border\":\"none\",\"boxShadow\":\"none\",\"opacity\":\"1\"},\"animation\":\"\",\"animationDelay\":\"0\",\"hideOnDesktop\":false,\"hideOnTablet\":false,\"hideOnMobile\":false,\"aiName\":\"Intro Text\",\"node_id\":\"ai-text-intro-1\"},\"displayName\":\"Text\",\"custom\":{},\"parent\":\"ai-section-intro-1__section-inner\",\"hidden\":false,\"nodes\":[],\"linkedNodes\":{}},\"ai-section-intro-1__section-inner\":{\"type\":{\"resolvedName\":\"Container\"},\"isCanvas\":true,\"props\":{\"style\":{\"padding\":\"20px\",\"minHeight\":\"100px\",\"justifyContent\":\"\",\"marginTop\":\"\",\"marginRight\":\"\",\"marginBottom\":\"\",\"marginLeft\":\"\",\"paddingTop\":\"\",\"paddingRight\":\"\",\"paddingBottom\":\"\",\"paddingLeft\":\"\",\"border\":\"none\",\"boxShadow\":\"none\",\"opacity\":\"1\"},\"tag\":\"div\",\"fullWidth\":false,\"contentWidth\":\"full\",\"anchorId\":\"\",\"cssId\":\"\",\"cssClass\":\"\",\"animation\":\"\",\"animationDelay\":\"0\",\"hideOnDesktop\":false,\"hideOnTablet\":false,\"hideOnMobile\":false},\"displayName\":\"Container\",\"custom\":{},\"parent\":\"ai-section-intro-1\",\"hidden\":false,\"nodes\":[\"ai-heading-intro-1\",\"ai-text-intro-1\"],\"linkedNodes\":{}},\"ai-cta-1\":{\"type\":{\"resolvedName\":\"CTASection\"},\"isCanvas\":false,\"props\":{\"heading\":\"Want to Learn More?\",\"description\":\"For inquiries about OLS Digital or any of our brands, reach out to our team directly.\",\"ctas\":[{\"text\":\"Email info@olsdigital.com\",\"href\":\"mailto:info@olsdigital.com\",\"variant\":\"primary\"}],\"gradient\":\"linear-gradient(135deg, #2563eb 0%, #7c3aed 100%)\",\"anchorId\":\"contact-cta\",\"style\":{},\"animation\":\"\",\"animationDelay\":\"\",\"hideOnDesktop\":false,\"hideOnTablet\":false,\"hideOnMobile\":false,\"aiName\":\"Contact CTA\",\"node_id\":\"ai-cta-1\"},\"displayName\":\"CTA Section\",\"custom\":{},\"parent\":\"ROOT\",\"hidden\":false,\"nodes\":[],\"linkedNodes\":{}},\"ROOT\":{\"type\":{\"resolvedName\":\"Container\"},\"isCanvas\":true,\"props\":{\"style\":{\"padding\":\"20px\",\"minHeight\":\"100px\",\"justifyContent\":\"\",\"marginTop\":\"\",\"marginRight\":\"\",\"marginBottom\":\"\",\"marginLeft\":\"\",\"paddingTop\":\"\",\"paddingRight\":\"\",\"paddingBottom\":\"\",\"paddingLeft\":\"\",\"border\":\"none\",\"boxShadow\":\"none\",\"opacity\":\"1\"},\"tag\":\"div\",\"fullWidth\":false,\"contentWidth\":\"full\",\"anchorId\":\"\",\"cssId\":\"\",\"cssClass\":\"\",\"animation\":\"\",\"animationDelay\":\"0\",\"hideOnDesktop\":false,\"hideOnTablet\":false,\"hideOnMobile\":false,\"aiName\":\"Root\",\"node_id\":\"ai-root-home\"},\"displayName\":\"Container\",\"custom\":{},\"parent\":null,\"hidden\":false,\"nodes\":[\"ai-hero-1\",\"ai-features-1\",\"ai-section-intro-1\",\"ai-cta-1\"],\"linkedNodes\":{}}}", + "header_craft_state": "{\"ROOT\":{\"type\":{\"resolvedName\":\"Navbar\"},\"isCanvas\":true,\"props\":{\"logoType\":\"image\",\"logoText\":\"\",\"logoImage\":\"/api/assets-proxy/?site_id=100&filename=1789158005_2592c5cf_Minimalist_OLS_Digital_Logo_with_Rounded_Elements__400_x_135_px_.webp\",\"logoWidth\":\"120px\",\"logoUrl\":\"/\",\"logoFontFamily\":\"Inter, sans-serif\",\"logoFontSize\":\"20px\",\"links\":[{\"text\":\"Home\",\"href\":\"/\",\"source\":\"page\",\"pageId\":\"home\"},{\"text\":\"Our Brands\",\"href\":\"/brands\",\"source\":\"page\",\"pageId\":\"page_mtxec41h_1\"},{\"text\":\"About\",\"href\":\"/about\",\"source\":\"page\",\"pageId\":\"page_mtxec41h_2\"},{\"text\":\"Contact\",\"href\":\"/contact\",\"source\":\"page\",\"pageId\":\"page_mtxec41h_3\"},{\"text\":\"Contact\",\"href\":\"/contact\",\"isCta\":true}],\"followPages\":true,\"backgroundColor\":\"#0f172a\",\"textColor\":\"#ffffff\",\"hoverColor\":\"#3b82f6\",\"ctaColor\":\"#3b82f6\",\"ctaTextColor\":\"#ffffff\",\"padding\":\"16px 24px\",\"navAlignment\":\"space-between\",\"isSticky\":false,\"showMobileMenu\":false,\"style\":{\"borderBottom\":\"1px solid #e4e4e7\"},\"hideOnDesktop\":false,\"hideOnTablet\":false,\"hideOnMobile\":false,\"animation\":\"none\",\"animationDelay\":\"0\",\"aiName\":\"Site Nav\",\"node_id\":\"ai-nav-1\"},\"displayName\":\"Navbar\",\"custom\":{},\"parent\":null,\"hidden\":false,\"nodes\":[],\"linkedNodes\":{}}}", + "footer_craft_state": "{\"ROOT\":{\"type\":{\"resolvedName\":\"Footer\"},\"isCanvas\":true,\"props\":{\"aiName\":\"Site Footer\",\"node_id\":\"ai-footer-1\",\"text\":\"\u00a9 2026 OLS Digital. All rights reserved. Questions? Email info@olsdigital.com\"},\"displayName\":\"Footer\",\"custom\":{},\"hidden\":false,\"parent\":null,\"nodes\":[],\"linkedNodes\":{}}}", + "pages_craft_state": [ + { + "id": "home", + "name": "Home", + "slug": "index", + "craftState": "{\"ai-hero-1\":{\"type\":{\"resolvedName\":\"HeroSimple\"},\"isCanvas\":false,\"props\":{\"heading\":\"OLS Digital\",\"subtitle\":\"The parent company behind a growing family of trusted online brands, including AnHonestHost and StreamerTools.\",\"ctas\":[{\"text\":\"Explore Our Brands\",\"href\":\"/brands\",\"variant\":\"primary\"},{\"text\":\"Contact Us\",\"href\":\"/contact\",\"variant\":\"outline\"}],\"bgType\":\"gradient\",\"bgColor\":\"#1e293b\",\"bgGradientFrom\":\"#0f172a\",\"bgGradientTo\":\"#334155\",\"bgGradientAngle\":135,\"bgImage\":\"\",\"bgPosition\":\"\",\"bgVideo\":\"\",\"overlayColor\":\"#000000\",\"overlayOpacity\":0,\"textColor\":\"#ffffff\",\"buttonBgColor\":\"#3b82f6\",\"buttonTextColor\":\"#ffffff\",\"minHeight\":\"60vh\",\"verticalAlign\":\"center\",\"textAlign\":\"center\",\"anchorId\":\"home\",\"style\":{},\"aiName\":\"Hero\",\"node_id\":\"ai-hero-1\"},\"displayName\":\"Hero\",\"custom\":{},\"parent\":\"ROOT\",\"hidden\":false,\"nodes\":[],\"linkedNodes\":{}},\"ai-features-1\":{\"type\":{\"resolvedName\":\"FeaturesGrid\"},\"isCanvas\":false,\"props\":{\"features\":[{\"icon\":\"\ud83c\udfe2\",\"title\":\"Multi-Brand Portfolio\",\"description\":\"We own and support a growing collection of independent, high-quality brands.\"},{\"icon\":\"\ud83c\udf10\",\"title\":\"AnHonestHost\",\"description\":\"Transparent, reliable web hosting built on trust.\"},{\"icon\":\"\ud83c\udfae\",\"title\":\"StreamerTools\",\"description\":\"Tools and resources built for content creators and streamers.\"}],\"style\":{\"backgroundColor\":\"#ffffff\"},\"anchorId\":\"what-we-do\",\"animation\":\"\",\"animationDelay\":\"\",\"hideOnDesktop\":false,\"hideOnTablet\":false,\"hideOnMobile\":false,\"aiName\":\"What We Do\",\"node_id\":\"ai-features-1\"},\"displayName\":\"Features Grid\",\"custom\":{},\"parent\":\"ROOT\",\"hidden\":false,\"nodes\":[],\"linkedNodes\":{}},\"ai-section-intro-1\":{\"type\":{\"resolvedName\":\"Section\"},\"isCanvas\":false,\"props\":{\"style\":{\"paddingTop\":\"60px\",\"paddingBottom\":\"60px\"},\"innerMaxWidth\":\"1200px\",\"topDivider\":\"none\",\"topDividerColor\":\"#ffffff\",\"topDividerHeight\":\"50px\",\"bottomDivider\":\"none\",\"bottomDividerColor\":\"#ffffff\",\"bottomDividerHeight\":\"50px\",\"anchorId\":\"about-preview\",\"animation\":\"\",\"animationDelay\":\"0\",\"hideOnDesktop\":false,\"hideOnTablet\":false,\"hideOnMobile\":false,\"aiName\":\"Intro Section\",\"node_id\":\"ai-section-intro-1\"},\"displayName\":\"Section\",\"custom\":{},\"parent\":\"ROOT\",\"hidden\":false,\"nodes\":[],\"linkedNodes\":{\"section-inner\":\"ai-section-intro-1__section-inner\"}},\"ai-heading-intro-1\":{\"type\":{\"resolvedName\":\"Heading\"},\"isCanvas\":false,\"props\":{\"text\":\"Building Brands That Matter\",\"html\":\"\",\"level\":\"h2\",\"style\":{\"fontSize\":\"36px\",\"fontWeight\":\"700\",\"fontFamily\":\"Inter, sans-serif\",\"color\":\"#1f2937\",\"marginBottom\":\"16px\",\"lineHeight\":\"\",\"letterSpacing\":\"\",\"textTransform\":\"\",\"fontStyle\":\"\",\"textDecoration\":\"\",\"marginTop\":\"\",\"marginRight\":\"\",\"marginLeft\":\"\",\"paddingTop\":\"\",\"paddingRight\":\"\",\"paddingBottom\":\"\",\"paddingLeft\":\"\",\"border\":\"none\",\"boxShadow\":\"none\",\"opacity\":\"1\"},\"animation\":\"\",\"animationDelay\":\"0\",\"hideOnDesktop\":false,\"hideOnTablet\":false,\"hideOnMobile\":false,\"aiName\":\"Intro Heading\",\"node_id\":\"ai-heading-intro-1\"},\"displayName\":\"Heading\",\"custom\":{},\"parent\":\"ai-section-intro-1__section-inner\",\"hidden\":false,\"nodes\":[],\"linkedNodes\":{}},\"ai-text-intro-1\":{\"type\":{\"resolvedName\":\"TextBlock\"},\"isCanvas\":false,\"props\":{\"text\":\"OLS Digital is a privately held parent company focused on acquiring and growing digital-first businesses. Each brand under our umbrella operates independently, guided by a shared commitment to honesty, quality, and great customer experiences.\",\"html\":\"\",\"style\":{\"fontSize\":\"16px\",\"lineHeight\":\"1.6\",\"color\":\"#3f3f46\",\"letterSpacing\":\"\",\"textTransform\":\"\",\"fontStyle\":\"\",\"textDecoration\":\"\",\"marginTop\":\"\",\"marginRight\":\"\",\"marginBottom\":\"\",\"marginLeft\":\"\",\"paddingTop\":\"\",\"paddingRight\":\"\",\"paddingBottom\":\"\",\"paddingLeft\":\"\",\"border\":\"none\",\"boxShadow\":\"none\",\"opacity\":\"1\"},\"animation\":\"\",\"animationDelay\":\"0\",\"hideOnDesktop\":false,\"hideOnTablet\":false,\"hideOnMobile\":false,\"aiName\":\"Intro Text\",\"node_id\":\"ai-text-intro-1\"},\"displayName\":\"Text\",\"custom\":{},\"parent\":\"ai-section-intro-1__section-inner\",\"hidden\":false,\"nodes\":[],\"linkedNodes\":{}},\"ai-section-intro-1__section-inner\":{\"type\":{\"resolvedName\":\"Container\"},\"isCanvas\":true,\"props\":{\"style\":{\"padding\":\"20px\",\"minHeight\":\"100px\",\"justifyContent\":\"\",\"marginTop\":\"\",\"marginRight\":\"\",\"marginBottom\":\"\",\"marginLeft\":\"\",\"paddingTop\":\"\",\"paddingRight\":\"\",\"paddingBottom\":\"\",\"paddingLeft\":\"\",\"border\":\"none\",\"boxShadow\":\"none\",\"opacity\":\"1\"},\"tag\":\"div\",\"fullWidth\":false,\"contentWidth\":\"full\",\"anchorId\":\"\",\"cssId\":\"\",\"cssClass\":\"\",\"animation\":\"\",\"animationDelay\":\"0\",\"hideOnDesktop\":false,\"hideOnTablet\":false,\"hideOnMobile\":false},\"displayName\":\"Container\",\"custom\":{},\"parent\":\"ai-section-intro-1\",\"hidden\":false,\"nodes\":[\"ai-heading-intro-1\",\"ai-text-intro-1\"],\"linkedNodes\":{}},\"ai-cta-1\":{\"type\":{\"resolvedName\":\"CTASection\"},\"isCanvas\":false,\"props\":{\"heading\":\"Want to Learn More?\",\"description\":\"For inquiries about OLS Digital or any of our brands, reach out to our team directly.\",\"ctas\":[{\"text\":\"Email info@olsdigital.com\",\"href\":\"mailto:info@olsdigital.com\",\"variant\":\"primary\"}],\"gradient\":\"linear-gradient(135deg, #2563eb 0%, #7c3aed 100%)\",\"anchorId\":\"contact-cta\",\"style\":{},\"animation\":\"\",\"animationDelay\":\"\",\"hideOnDesktop\":false,\"hideOnTablet\":false,\"hideOnMobile\":false,\"aiName\":\"Contact CTA\",\"node_id\":\"ai-cta-1\"},\"displayName\":\"CTA Section\",\"custom\":{},\"parent\":\"ROOT\",\"hidden\":false,\"nodes\":[],\"linkedNodes\":{}},\"ROOT\":{\"type\":{\"resolvedName\":\"Container\"},\"isCanvas\":true,\"props\":{\"style\":{\"padding\":\"20px\",\"minHeight\":\"100px\",\"justifyContent\":\"\",\"marginTop\":\"\",\"marginRight\":\"\",\"marginBottom\":\"\",\"marginLeft\":\"\",\"paddingTop\":\"\",\"paddingRight\":\"\",\"paddingBottom\":\"\",\"paddingLeft\":\"\",\"border\":\"none\",\"boxShadow\":\"none\",\"opacity\":\"1\"},\"tag\":\"div\",\"fullWidth\":false,\"contentWidth\":\"full\",\"anchorId\":\"\",\"cssId\":\"\",\"cssClass\":\"\",\"animation\":\"\",\"animationDelay\":\"0\",\"hideOnDesktop\":false,\"hideOnTablet\":false,\"hideOnMobile\":false,\"aiName\":\"Root\",\"node_id\":\"ai-root-home\"},\"displayName\":\"Container\",\"custom\":{},\"parent\":null,\"hidden\":false,\"nodes\":[\"ai-hero-1\",\"ai-features-1\",\"ai-section-intro-1\",\"ai-cta-1\"],\"linkedNodes\":{}}}" + }, + { + "id": "page_mtxec41h_1", + "name": "Brands", + "slug": "brands", + "craftState": "{\"ai-hero-brands-1\":{\"type\":{\"resolvedName\":\"HeroSimple\"},\"isCanvas\":false,\"props\":{\"heading\":\"Our Brands\",\"subtitle\":\"A closer look at the companies under the OLS Digital umbrella.\",\"ctas\":[],\"bgType\":\"color\",\"bgColor\":\"#1e293b\",\"bgGradientFrom\":\"#667eea\",\"bgGradientTo\":\"#764ba2\",\"bgGradientAngle\":135,\"bgImage\":\"\",\"bgPosition\":\"\",\"bgVideo\":\"\",\"overlayColor\":\"#000000\",\"overlayOpacity\":0,\"textColor\":\"#ffffff\",\"buttonBgColor\":\"#3b82f6\",\"buttonTextColor\":\"#ffffff\",\"minHeight\":\"35vh\",\"verticalAlign\":\"center\",\"textAlign\":\"center\",\"anchorId\":\"brands\",\"style\":{},\"aiName\":\"Brands Hero\",\"node_id\":\"ai-hero-brands-1\"},\"displayName\":\"Hero\",\"custom\":{},\"parent\":\"ROOT\",\"hidden\":false,\"nodes\":[],\"linkedNodes\":{}},\"ai-columns-anhonesthost-1\":{\"type\":{\"resolvedName\":\"ColumnLayout\"},\"isCanvas\":false,\"props\":{\"columns\":2,\"split\":\"40-60\",\"gap\":\"40px\",\"style\":{\"paddingTop\":\"60px\",\"paddingBottom\":\"20px\"},\"anchorId\":\"anhonesthost\",\"animation\":\"\",\"animationDelay\":\"0\",\"hideOnDesktop\":false,\"hideOnTablet\":false,\"hideOnMobile\":false,\"aiName\":\"Brand: AnHonestHost\",\"node_id\":\"ai-columns-anhonesthost-1\"},\"displayName\":\"Columns\",\"custom\":{},\"parent\":\"ROOT\",\"hidden\":false,\"nodes\":[],\"linkedNodes\":{\"col-0\":\"K8SJDt59Ct\",\"col-1\":\"ai-container-anhonesthost-1\"}},\"ai-container-anhonesthost-1\":{\"type\":{\"resolvedName\":\"Container\"},\"isCanvas\":true,\"props\":{\"style\":{\"padding\":\"20px\",\"minHeight\":\"100px\",\"justifyContent\":\"\",\"marginTop\":\"\",\"marginRight\":\"\",\"marginBottom\":\"\",\"marginLeft\":\"\",\"paddingTop\":\"\",\"paddingRight\":\"\",\"paddingBottom\":\"\",\"paddingLeft\":\"\",\"border\":\"none\",\"boxShadow\":\"none\",\"opacity\":\"1\"},\"tag\":\"div\",\"fullWidth\":false,\"contentWidth\":\"full\",\"anchorId\":\"\",\"cssId\":\"\",\"cssClass\":\"\",\"animation\":\"\",\"animationDelay\":\"0\",\"hideOnDesktop\":false,\"hideOnTablet\":false,\"hideOnMobile\":false,\"aiName\":\"AnHonestHost Text\",\"node_id\":\"ai-container-anhonesthost-1\"},\"displayName\":\"Container\",\"custom\":{},\"parent\":\"ai-columns-anhonesthost-1\",\"hidden\":false,\"nodes\":[\"ai-heading-anhonesthost-1\",\"ai-text-anhonesthost-1\"],\"linkedNodes\":{}},\"ai-heading-anhonesthost-1\":{\"type\":{\"resolvedName\":\"Heading\"},\"isCanvas\":false,\"props\":{\"text\":\"AnHonestHost\",\"html\":\"\",\"level\":\"h2\",\"style\":{\"fontSize\":\"36px\",\"fontWeight\":\"700\",\"fontFamily\":\"Inter, sans-serif\",\"color\":\"#1f2937\",\"marginBottom\":\"16px\",\"lineHeight\":\"\",\"letterSpacing\":\"\",\"textTransform\":\"\",\"fontStyle\":\"\",\"textDecoration\":\"\",\"marginTop\":\"\",\"marginRight\":\"\",\"marginLeft\":\"\",\"paddingTop\":\"\",\"paddingRight\":\"\",\"paddingBottom\":\"\",\"paddingLeft\":\"\",\"border\":\"none\",\"boxShadow\":\"none\",\"opacity\":\"1\"},\"animation\":\"\",\"animationDelay\":\"0\",\"hideOnDesktop\":false,\"hideOnTablet\":false,\"hideOnMobile\":false,\"aiName\":\"AnHonestHost Heading\",\"node_id\":\"ai-heading-anhonesthost-1\"},\"displayName\":\"Heading\",\"custom\":{},\"parent\":\"ai-container-anhonesthost-1\",\"hidden\":false,\"nodes\":[],\"linkedNodes\":{}},\"ai-text-anhonesthost-1\":{\"type\":{\"resolvedName\":\"TextBlock\"},\"isCanvas\":false,\"props\":{\"text\":\"AnHonestHost provides transparent, no-nonsense web hosting for individuals and businesses. Straightforward pricing, honest support, and reliable infrastructure \u2014 no surprises.\",\"html\":\"\",\"style\":{\"fontSize\":\"16px\",\"lineHeight\":\"1.6\",\"color\":\"#3f3f46\",\"letterSpacing\":\"\",\"textTransform\":\"\",\"fontStyle\":\"\",\"textDecoration\":\"\",\"marginTop\":\"\",\"marginRight\":\"\",\"marginBottom\":\"\",\"marginLeft\":\"\",\"paddingTop\":\"\",\"paddingRight\":\"\",\"paddingBottom\":\"\",\"paddingLeft\":\"\",\"border\":\"none\",\"boxShadow\":\"none\",\"opacity\":\"1\"},\"animation\":\"\",\"animationDelay\":\"0\",\"hideOnDesktop\":false,\"hideOnTablet\":false,\"hideOnMobile\":false,\"aiName\":\"AnHonestHost Description\",\"node_id\":\"ai-text-anhonesthost-1\"},\"displayName\":\"Text\",\"custom\":{},\"parent\":\"ai-container-anhonesthost-1\",\"hidden\":false,\"nodes\":[],\"linkedNodes\":{}},\"ai-divider-1\":{\"type\":{\"resolvedName\":\"Divider\"},\"isCanvas\":false,\"props\":{\"color\":\"#e2e8f0\",\"thickness\":\"1px\",\"style\":{},\"aiName\":\"Divider 1\",\"node_id\":\"ai-divider-1\"},\"displayName\":\"Divider\",\"custom\":{},\"parent\":\"ROOT\",\"hidden\":false,\"nodes\":[],\"linkedNodes\":{}},\"ai-columns-streamertools-1\":{\"type\":{\"resolvedName\":\"ColumnLayout\"},\"isCanvas\":false,\"props\":{\"columns\":2,\"split\":\"60-40\",\"gap\":\"40px\",\"style\":{\"paddingTop\":\"60px\",\"paddingBottom\":\"60px\"},\"anchorId\":\"streamertools\",\"animation\":\"\",\"animationDelay\":\"0\",\"hideOnDesktop\":false,\"hideOnTablet\":false,\"hideOnMobile\":false,\"aiName\":\"Brand: StreamerTools\",\"node_id\":\"ai-columns-streamertools-1\"},\"displayName\":\"Columns\",\"custom\":{},\"parent\":\"ROOT\",\"hidden\":false,\"nodes\":[],\"linkedNodes\":{\"col-0\":\"ai-container-streamertools-1\",\"col-1\":\"BoOVT1AGGy\"}},\"ai-container-streamertools-1\":{\"type\":{\"resolvedName\":\"Container\"},\"isCanvas\":true,\"props\":{\"style\":{\"padding\":\"20px\",\"minHeight\":\"100px\",\"justifyContent\":\"\",\"marginTop\":\"\",\"marginRight\":\"\",\"marginBottom\":\"\",\"marginLeft\":\"\",\"paddingTop\":\"\",\"paddingRight\":\"\",\"paddingBottom\":\"\",\"paddingLeft\":\"\",\"border\":\"none\",\"boxShadow\":\"none\",\"opacity\":\"1\"},\"tag\":\"div\",\"fullWidth\":false,\"contentWidth\":\"full\",\"anchorId\":\"\",\"cssId\":\"\",\"cssClass\":\"\",\"animation\":\"\",\"animationDelay\":\"0\",\"hideOnDesktop\":false,\"hideOnTablet\":false,\"hideOnMobile\":false,\"aiName\":\"StreamerTools Text\",\"node_id\":\"ai-container-streamertools-1\"},\"displayName\":\"Container\",\"custom\":{},\"parent\":\"ai-columns-streamertools-1\",\"hidden\":false,\"nodes\":[\"ai-heading-streamertools-1\",\"ai-text-streamertools-1\"],\"linkedNodes\":{}},\"ai-heading-streamertools-1\":{\"type\":{\"resolvedName\":\"Heading\"},\"isCanvas\":false,\"props\":{\"text\":\"StreamerTools\",\"html\":\"\",\"level\":\"h2\",\"style\":{\"fontSize\":\"36px\",\"fontWeight\":\"700\",\"fontFamily\":\"Inter, sans-serif\",\"color\":\"#1f2937\",\"marginBottom\":\"16px\",\"lineHeight\":\"\",\"letterSpacing\":\"\",\"textTransform\":\"\",\"fontStyle\":\"\",\"textDecoration\":\"\",\"marginTop\":\"\",\"marginRight\":\"\",\"marginLeft\":\"\",\"paddingTop\":\"\",\"paddingRight\":\"\",\"paddingBottom\":\"\",\"paddingLeft\":\"\",\"border\":\"none\",\"boxShadow\":\"none\",\"opacity\":\"1\"},\"animation\":\"\",\"animationDelay\":\"0\",\"hideOnDesktop\":false,\"hideOnTablet\":false,\"hideOnMobile\":false,\"aiName\":\"StreamerTools Heading\",\"node_id\":\"ai-heading-streamertools-1\"},\"displayName\":\"Heading\",\"custom\":{},\"parent\":\"ai-container-streamertools-1\",\"hidden\":false,\"nodes\":[],\"linkedNodes\":{}},\"ai-text-streamertools-1\":{\"type\":{\"resolvedName\":\"TextBlock\"},\"isCanvas\":false,\"props\":{\"text\":\"StreamerTools builds practical tools and resources for streamers and content creators, helping them grow their channels and streamline their workflow.\",\"html\":\"\",\"style\":{\"fontSize\":\"16px\",\"lineHeight\":\"1.6\",\"color\":\"#3f3f46\",\"letterSpacing\":\"\",\"textTransform\":\"\",\"fontStyle\":\"\",\"textDecoration\":\"\",\"marginTop\":\"\",\"marginRight\":\"\",\"marginBottom\":\"\",\"marginLeft\":\"\",\"paddingTop\":\"\",\"paddingRight\":\"\",\"paddingBottom\":\"\",\"paddingLeft\":\"\",\"border\":\"none\",\"boxShadow\":\"none\",\"opacity\":\"1\"},\"animation\":\"\",\"animationDelay\":\"0\",\"hideOnDesktop\":false,\"hideOnTablet\":false,\"hideOnMobile\":false,\"aiName\":\"StreamerTools Description\",\"node_id\":\"ai-text-streamertools-1\"},\"displayName\":\"Text\",\"custom\":{},\"parent\":\"ai-container-streamertools-1\",\"hidden\":false,\"nodes\":[],\"linkedNodes\":{}},\"ai-cta-brands-1\":{\"type\":{\"resolvedName\":\"CTASection\"},\"isCanvas\":false,\"props\":{\"heading\":\"Interested in One of Our Brands?\",\"description\":\"Reach out and we'll point you in the right direction.\",\"ctas\":[{\"text\":\"Email info@olsdigital.com\",\"href\":\"mailto:info@olsdigital.com\",\"variant\":\"primary\"}],\"gradient\":\"linear-gradient(135deg, #2563eb 0%, #7c3aed 100%)\",\"anchorId\":\"brands-contact\",\"style\":{},\"animation\":\"\",\"animationDelay\":\"\",\"hideOnDesktop\":false,\"hideOnTablet\":false,\"hideOnMobile\":false,\"aiName\":\"Brands CTA\",\"node_id\":\"ai-cta-brands-1\"},\"displayName\":\"CTA Section\",\"custom\":{},\"parent\":\"ROOT\",\"hidden\":false,\"nodes\":[],\"linkedNodes\":{}},\"ROOT\":{\"type\":{\"resolvedName\":\"Container\"},\"isCanvas\":true,\"props\":{\"style\":{\"padding\":\"20px\",\"minHeight\":\"100px\",\"justifyContent\":\"\",\"marginTop\":\"\",\"marginRight\":\"\",\"marginBottom\":\"\",\"marginLeft\":\"\",\"paddingTop\":\"\",\"paddingRight\":\"\",\"paddingBottom\":\"\",\"paddingLeft\":\"\",\"border\":\"none\",\"boxShadow\":\"none\",\"opacity\":\"1\"},\"tag\":\"div\",\"fullWidth\":false,\"contentWidth\":\"full\",\"anchorId\":\"\",\"cssId\":\"\",\"cssClass\":\"\",\"animation\":\"\",\"animationDelay\":\"0\",\"hideOnDesktop\":false,\"hideOnTablet\":false,\"hideOnMobile\":false,\"aiName\":\"Root\",\"node_id\":\"ai-root-brands\"},\"displayName\":\"Container\",\"custom\":{},\"parent\":null,\"hidden\":false,\"nodes\":[\"ai-hero-brands-1\",\"ai-columns-anhonesthost-1\",\"ai-divider-1\",\"ai-columns-streamertools-1\",\"ai-cta-brands-1\"],\"linkedNodes\":{}},\"K8SJDt59Ct\":{\"type\":{\"resolvedName\":\"Container\"},\"isCanvas\":true,\"props\":{\"style\":{\"flex\":\"0 0 calc(40% - 40px)\",\"minHeight\":\"60px\",\"padding\":\"8px\"},\"tag\":\"div\",\"fullWidth\":false,\"contentWidth\":\"full\",\"anchorId\":\"\",\"cssId\":\"\",\"cssClass\":\"\",\"animation\":\"\",\"animationDelay\":\"0\",\"hideOnDesktop\":false,\"hideOnTablet\":false,\"hideOnMobile\":false},\"displayName\":\"Container\",\"custom\":{\"className\":\"craft-column\"},\"parent\":\"ai-columns-anhonesthost-1\",\"hidden\":false,\"nodes\":[],\"linkedNodes\":{}},\"BoOVT1AGGy\":{\"type\":{\"resolvedName\":\"Container\"},\"isCanvas\":true,\"props\":{\"style\":{\"flex\":\"0 0 calc(40% - 40px)\",\"minHeight\":\"60px\",\"padding\":\"8px\"},\"tag\":\"div\",\"fullWidth\":false,\"contentWidth\":\"full\",\"anchorId\":\"\",\"cssId\":\"\",\"cssClass\":\"\",\"animation\":\"\",\"animationDelay\":\"0\",\"hideOnDesktop\":false,\"hideOnTablet\":false,\"hideOnMobile\":false},\"displayName\":\"Container\",\"custom\":{\"className\":\"craft-column\"},\"parent\":\"ai-columns-streamertools-1\",\"hidden\":false,\"nodes\":[],\"linkedNodes\":{}}}", + "nav": { + "label": "Our Brands" + } + }, + { + "id": "page_mtxec41h_2", + "name": "About", + "slug": "about", + "craftState": "{\"ai-hero-about-1\":{\"type\":{\"resolvedName\":\"HeroSimple\"},\"isCanvas\":false,\"props\":{\"heading\":\"About OLS Digital\",\"subtitle\":\"Who we are and what we believe in.\",\"ctas\":[],\"bgType\":\"color\",\"bgColor\":\"#1e293b\",\"bgGradientFrom\":\"#667eea\",\"bgGradientTo\":\"#764ba2\",\"bgGradientAngle\":135,\"bgImage\":\"\",\"bgVideo\":\"\",\"overlayColor\":\"#000000\",\"overlayOpacity\":0,\"textColor\":\"#ffffff\",\"buttonBgColor\":\"#3b82f6\",\"buttonTextColor\":\"#ffffff\",\"minHeight\":\"35vh\",\"verticalAlign\":\"center\",\"textAlign\":\"center\",\"anchorId\":\"about\",\"style\":{},\"aiName\":\"About Hero\",\"node_id\":\"ai-hero-about-1\"},\"displayName\":\"Hero\",\"custom\":{},\"parent\":\"ROOT\",\"hidden\":false,\"nodes\":[],\"linkedNodes\":{}},\"ai-section-about-1\":{\"type\":{\"resolvedName\":\"Section\"},\"isCanvas\":false,\"props\":{\"style\":{\"paddingTop\":\"60px\",\"paddingBottom\":\"20px\"},\"innerMaxWidth\":\"1200px\",\"topDivider\":\"none\",\"topDividerColor\":\"#ffffff\",\"topDividerHeight\":\"50px\",\"bottomDivider\":\"none\",\"bottomDividerColor\":\"#ffffff\",\"bottomDividerHeight\":\"50px\",\"anchorId\":\"our-story\",\"animation\":\"\",\"animationDelay\":\"0\",\"hideOnDesktop\":false,\"hideOnTablet\":false,\"hideOnMobile\":false,\"aiName\":\"About Content\",\"node_id\":\"ai-section-about-1\"},\"displayName\":\"Section\",\"custom\":{},\"parent\":\"ROOT\",\"hidden\":false,\"nodes\":[],\"linkedNodes\":{\"section-inner\":\"ai-section-about-1__section-inner\"}},\"ai-heading-about-1\":{\"type\":{\"resolvedName\":\"Heading\"},\"isCanvas\":false,\"props\":{\"text\":\"Our Story\",\"level\":\"h2\",\"style\":{\"fontSize\":\"36px\",\"fontWeight\":\"700\",\"fontFamily\":\"Inter, sans-serif\",\"color\":\"#1f2937\",\"marginBottom\":\"16px\",\"lineHeight\":\"\",\"letterSpacing\":\"\",\"textTransform\":\"\",\"fontStyle\":\"\",\"textDecoration\":\"\",\"marginTop\":\"\",\"marginRight\":\"\",\"marginLeft\":\"\",\"paddingTop\":\"\",\"paddingRight\":\"\",\"paddingBottom\":\"\",\"paddingLeft\":\"\",\"border\":\"none\",\"boxShadow\":\"none\",\"opacity\":\"1\"},\"animation\":\"\",\"animationDelay\":\"0\",\"hideOnDesktop\":false,\"hideOnTablet\":false,\"hideOnMobile\":false,\"aiName\":\"Our Story Heading\",\"node_id\":\"ai-heading-about-1\"},\"displayName\":\"Heading\",\"custom\":{},\"parent\":\"ai-section-about-1__section-inner\",\"hidden\":false,\"nodes\":[],\"linkedNodes\":{}},\"ai-text-about-1\":{\"type\":{\"resolvedName\":\"TextBlock\"},\"isCanvas\":false,\"props\":{\"text\":\"OLS Digital was founded to bring structure, resources, and long-term support to promising digital brands. Rather than absorbing the businesses we work with, we help them keep their independent identity while benefiting from shared operational support.\",\"style\":{\"fontSize\":\"16px\",\"lineHeight\":\"1.6\",\"color\":\"#3f3f46\",\"letterSpacing\":\"\",\"textTransform\":\"\",\"fontStyle\":\"\",\"textDecoration\":\"\",\"marginTop\":\"\",\"marginRight\":\"\",\"marginBottom\":\"\",\"marginLeft\":\"\",\"paddingTop\":\"\",\"paddingRight\":\"\",\"paddingBottom\":\"\",\"paddingLeft\":\"\",\"border\":\"none\",\"boxShadow\":\"none\",\"opacity\":\"1\"},\"animation\":\"\",\"animationDelay\":\"0\",\"hideOnDesktop\":false,\"hideOnTablet\":false,\"hideOnMobile\":false,\"aiName\":\"Our Story Text\",\"node_id\":\"ai-text-about-1\"},\"displayName\":\"Text\",\"custom\":{},\"parent\":\"ai-section-about-1__section-inner\",\"hidden\":false,\"nodes\":[],\"linkedNodes\":{}},\"ai-section-about-1__section-inner\":{\"type\":{\"resolvedName\":\"Container\"},\"isCanvas\":true,\"props\":{\"style\":{\"padding\":\"20px\",\"minHeight\":\"100px\",\"justifyContent\":\"\",\"marginTop\":\"\",\"marginRight\":\"\",\"marginBottom\":\"\",\"marginLeft\":\"\",\"paddingTop\":\"\",\"paddingRight\":\"\",\"paddingBottom\":\"\",\"paddingLeft\":\"\",\"border\":\"none\",\"boxShadow\":\"none\",\"opacity\":\"1\"},\"tag\":\"div\",\"fullWidth\":false,\"contentWidth\":\"full\",\"anchorId\":\"\",\"cssId\":\"\",\"cssClass\":\"\",\"animation\":\"\",\"animationDelay\":\"0\",\"hideOnDesktop\":false,\"hideOnTablet\":false,\"hideOnMobile\":false},\"displayName\":\"Container\",\"custom\":{},\"parent\":\"ai-section-about-1\",\"hidden\":false,\"nodes\":[\"ai-heading-about-1\",\"ai-text-about-1\"],\"linkedNodes\":{}},\"ai-section-mission-1\":{\"type\":{\"resolvedName\":\"Section\"},\"isCanvas\":false,\"props\":{\"style\":{\"paddingTop\":\"20px\",\"paddingBottom\":\"60px\"},\"innerMaxWidth\":\"1200px\",\"topDivider\":\"none\",\"topDividerColor\":\"#ffffff\",\"topDividerHeight\":\"50px\",\"bottomDivider\":\"none\",\"bottomDividerColor\":\"#ffffff\",\"bottomDividerHeight\":\"50px\",\"anchorId\":\"our-mission\",\"animation\":\"\",\"animationDelay\":\"0\",\"hideOnDesktop\":false,\"hideOnTablet\":false,\"hideOnMobile\":false,\"aiName\":\"Our Mission\",\"node_id\":\"ai-section-mission-1\"},\"displayName\":\"Section\",\"custom\":{},\"parent\":\"ROOT\",\"hidden\":false,\"nodes\":[],\"linkedNodes\":{\"section-inner\":\"ai-section-mission-1__section-inner\"}},\"ai-heading-mission-1\":{\"type\":{\"resolvedName\":\"Heading\"},\"isCanvas\":false,\"props\":{\"text\":\"Our Mission\",\"level\":\"h2\",\"style\":{\"fontSize\":\"36px\",\"fontWeight\":\"700\",\"fontFamily\":\"Inter, sans-serif\",\"color\":\"#1f2937\",\"marginBottom\":\"16px\",\"lineHeight\":\"\",\"letterSpacing\":\"\",\"textTransform\":\"\",\"fontStyle\":\"\",\"textDecoration\":\"\",\"marginTop\":\"\",\"marginRight\":\"\",\"marginLeft\":\"\",\"paddingTop\":\"\",\"paddingRight\":\"\",\"paddingBottom\":\"\",\"paddingLeft\":\"\",\"border\":\"none\",\"boxShadow\":\"none\",\"opacity\":\"1\"},\"animation\":\"\",\"animationDelay\":\"0\",\"hideOnDesktop\":false,\"hideOnTablet\":false,\"hideOnMobile\":false,\"aiName\":\"Mission Heading\",\"node_id\":\"ai-heading-mission-1\"},\"displayName\":\"Heading\",\"custom\":{},\"parent\":\"ai-section-mission-1__section-inner\",\"hidden\":false,\"nodes\":[],\"linkedNodes\":{}},\"ai-text-mission-1\":{\"type\":{\"resolvedName\":\"TextBlock\"},\"isCanvas\":false,\"props\":{\"text\":\"We believe in honesty, quality, and putting people first. Every brand under the OLS Digital name is expected to uphold these same values in how they serve their customers.\",\"style\":{\"fontSize\":\"16px\",\"lineHeight\":\"1.6\",\"color\":\"#3f3f46\",\"letterSpacing\":\"\",\"textTransform\":\"\",\"fontStyle\":\"\",\"textDecoration\":\"\",\"marginTop\":\"\",\"marginRight\":\"\",\"marginBottom\":\"\",\"marginLeft\":\"\",\"paddingTop\":\"\",\"paddingRight\":\"\",\"paddingBottom\":\"\",\"paddingLeft\":\"\",\"border\":\"none\",\"boxShadow\":\"none\",\"opacity\":\"1\"},\"animation\":\"\",\"animationDelay\":\"0\",\"hideOnDesktop\":false,\"hideOnTablet\":false,\"hideOnMobile\":false,\"aiName\":\"Mission Text\",\"node_id\":\"ai-text-mission-1\"},\"displayName\":\"Text\",\"custom\":{},\"parent\":\"ai-section-mission-1__section-inner\",\"hidden\":false,\"nodes\":[],\"linkedNodes\":{}},\"ai-section-mission-1__section-inner\":{\"type\":{\"resolvedName\":\"Container\"},\"isCanvas\":true,\"props\":{\"style\":{\"padding\":\"20px\",\"minHeight\":\"100px\",\"justifyContent\":\"\",\"marginTop\":\"\",\"marginRight\":\"\",\"marginBottom\":\"\",\"marginLeft\":\"\",\"paddingTop\":\"\",\"paddingRight\":\"\",\"paddingBottom\":\"\",\"paddingLeft\":\"\",\"border\":\"none\",\"boxShadow\":\"none\",\"opacity\":\"1\"},\"tag\":\"div\",\"fullWidth\":false,\"contentWidth\":\"full\",\"anchorId\":\"\",\"cssId\":\"\",\"cssClass\":\"\",\"animation\":\"\",\"animationDelay\":\"0\",\"hideOnDesktop\":false,\"hideOnTablet\":false,\"hideOnMobile\":false},\"displayName\":\"Container\",\"custom\":{},\"parent\":\"ai-section-mission-1\",\"hidden\":false,\"nodes\":[\"ai-heading-mission-1\",\"ai-text-mission-1\"],\"linkedNodes\":{}},\"ai-cta-about-1\":{\"type\":{\"resolvedName\":\"CTASection\"},\"isCanvas\":false,\"props\":{\"heading\":\"Have Questions?\",\"description\":\"We'd love to hear from you.\",\"ctas\":[{\"text\":\"Email info@olsdigital.com\",\"href\":\"mailto:info@olsdigital.com\",\"variant\":\"primary\"}],\"gradient\":\"linear-gradient(135deg, #2563eb 0%, #7c3aed 100%)\",\"anchorId\":\"about-contact\",\"style\":{},\"animation\":\"\",\"animationDelay\":\"\",\"hideOnDesktop\":false,\"hideOnTablet\":false,\"hideOnMobile\":false,\"aiName\":\"About CTA\",\"node_id\":\"ai-cta-about-1\"},\"displayName\":\"CTA Section\",\"custom\":{},\"parent\":\"ROOT\",\"hidden\":false,\"nodes\":[],\"linkedNodes\":{}},\"ROOT\":{\"type\":{\"resolvedName\":\"Container\"},\"isCanvas\":true,\"props\":{\"style\":{\"padding\":\"20px\",\"minHeight\":\"100px\",\"justifyContent\":\"\",\"marginTop\":\"\",\"marginRight\":\"\",\"marginBottom\":\"\",\"marginLeft\":\"\",\"paddingTop\":\"\",\"paddingRight\":\"\",\"paddingBottom\":\"\",\"paddingLeft\":\"\",\"border\":\"none\",\"boxShadow\":\"none\",\"opacity\":\"1\"},\"tag\":\"div\",\"fullWidth\":false,\"contentWidth\":\"full\",\"anchorId\":\"\",\"cssId\":\"\",\"cssClass\":\"\",\"animation\":\"\",\"animationDelay\":\"0\",\"hideOnDesktop\":false,\"hideOnTablet\":false,\"hideOnMobile\":false,\"aiName\":\"Root\",\"node_id\":\"ai-root-about\"},\"displayName\":\"Container\",\"custom\":{},\"parent\":null,\"hidden\":false,\"nodes\":[\"ai-hero-about-1\",\"ai-section-about-1\",\"ai-section-mission-1\",\"ai-cta-about-1\"],\"linkedNodes\":{}}}" + }, + { + "id": "page_mtxec41h_3", + "name": "Contact", + "slug": "contact", + "craftState": "{\"ai-hero-contact-1\":{\"type\":{\"resolvedName\":\"HeroSimple\"},\"isCanvas\":false,\"props\":{\"heading\":\"Contact Us\",\"subtitle\":\"Have a question about OLS Digital or one of our brands? We're happy to help.\",\"ctas\":[],\"bgType\":\"gradient\",\"bgColor\":\"#1e293b\",\"bgGradientFrom\":\"#0f172a\",\"bgGradientTo\":\"#334155\",\"bgGradientAngle\":135,\"bgImage\":\"\",\"bgVideo\":\"\",\"overlayColor\":\"#000000\",\"overlayOpacity\":0,\"textColor\":\"#ffffff\",\"buttonBgColor\":\"#3b82f6\",\"buttonTextColor\":\"#ffffff\",\"minHeight\":\"40vh\",\"verticalAlign\":\"center\",\"textAlign\":\"center\",\"anchorId\":\"contact\",\"style\":{},\"aiName\":\"Contact Hero\",\"node_id\":\"ai-hero-contact-1\"},\"displayName\":\"Hero\",\"custom\":{},\"parent\":\"ROOT\",\"hidden\":false,\"nodes\":[],\"linkedNodes\":{}},\"ai-section-contact-1\":{\"type\":{\"resolvedName\":\"Section\"},\"isCanvas\":false,\"props\":{\"style\":{\"paddingTop\":\"60px\",\"paddingBottom\":\"60px\",\"textAlign\":\"center\"},\"innerMaxWidth\":\"1200px\",\"topDivider\":\"none\",\"topDividerColor\":\"#ffffff\",\"topDividerHeight\":\"50px\",\"bottomDivider\":\"none\",\"bottomDividerColor\":\"#ffffff\",\"bottomDividerHeight\":\"50px\",\"anchorId\":\"contact-info\",\"animation\":\"\",\"animationDelay\":\"0\",\"hideOnDesktop\":false,\"hideOnTablet\":false,\"hideOnMobile\":false,\"aiName\":\"Contact Info\",\"node_id\":\"ai-section-contact-1\"},\"displayName\":\"Section\",\"custom\":{},\"parent\":\"ROOT\",\"hidden\":false,\"nodes\":[],\"linkedNodes\":{\"section-inner\":\"ai-section-contact-1__section-inner\"}},\"ai-heading-contact-1\":{\"type\":{\"resolvedName\":\"Heading\"},\"isCanvas\":false,\"props\":{\"text\":\"Get In Touch\",\"level\":\"h2\",\"style\":{\"fontSize\":\"36px\",\"fontWeight\":\"700\",\"fontFamily\":\"Inter, sans-serif\",\"color\":\"#1f2937\",\"marginBottom\":\"16px\",\"lineHeight\":\"\",\"letterSpacing\":\"\",\"textTransform\":\"\",\"fontStyle\":\"\",\"textDecoration\":\"\",\"marginTop\":\"\",\"marginRight\":\"\",\"marginLeft\":\"\",\"paddingTop\":\"\",\"paddingRight\":\"\",\"paddingBottom\":\"\",\"paddingLeft\":\"\",\"border\":\"none\",\"boxShadow\":\"none\",\"opacity\":\"1\"},\"animation\":\"\",\"animationDelay\":\"0\",\"hideOnDesktop\":false,\"hideOnTablet\":false,\"hideOnMobile\":false,\"aiName\":\"Contact Heading\",\"node_id\":\"ai-heading-contact-1\"},\"displayName\":\"Heading\",\"custom\":{},\"parent\":\"ai-section-contact-1__section-inner\",\"hidden\":false,\"nodes\":[],\"linkedNodes\":{}},\"ai-text-contact-1\":{\"type\":{\"resolvedName\":\"TextBlock\"},\"isCanvas\":false,\"props\":{\"text\":\"For all inquiries regarding OLS Digital, AnHonestHost, StreamerTools, or any of our other brands, please reach out via email and our team will respond as soon as possible.\",\"style\":{\"fontSize\":\"16px\",\"lineHeight\":\"1.6\",\"color\":\"#3f3f46\",\"letterSpacing\":\"\",\"textTransform\":\"\",\"fontStyle\":\"\",\"textDecoration\":\"\",\"marginTop\":\"\",\"marginRight\":\"\",\"marginBottom\":\"\",\"marginLeft\":\"\",\"paddingTop\":\"\",\"paddingRight\":\"\",\"paddingBottom\":\"\",\"paddingLeft\":\"\",\"border\":\"none\",\"boxShadow\":\"none\",\"opacity\":\"1\"},\"animation\":\"\",\"animationDelay\":\"0\",\"hideOnDesktop\":false,\"hideOnTablet\":false,\"hideOnMobile\":false,\"aiName\":\"Contact Text\",\"node_id\":\"ai-text-contact-1\"},\"displayName\":\"Text\",\"custom\":{},\"parent\":\"ai-section-contact-1__section-inner\",\"hidden\":false,\"nodes\":[],\"linkedNodes\":{}},\"ai-spacer-contact-1\":{\"type\":{\"resolvedName\":\"Spacer\"},\"isCanvas\":false,\"props\":{\"height\":\"20px\",\"style\":{},\"aiName\":\"Spacer\",\"node_id\":\"ai-spacer-contact-1\"},\"displayName\":\"Spacer\",\"custom\":{},\"parent\":\"ai-section-contact-1__section-inner\",\"hidden\":false,\"nodes\":[],\"linkedNodes\":{}},\"ai-button-contact-1\":{\"type\":{\"resolvedName\":\"ButtonLink\"},\"isCanvas\":false,\"props\":{\"text\":\"info@olsdigital.com\",\"href\":\"mailto:info@olsdigital.com\",\"target\":\"_self\",\"hoverBg\":\"\",\"hoverColor\":\"\",\"style\":{\"backgroundColor\":\"#3b82f6\",\"color\":\"#ffffff\",\"padding\":\"12px 24px\",\"borderRadius\":\"8px\",\"fontWeight\":\"600\",\"fontSize\":\"16px\",\"border\":\"none\",\"marginTop\":\"\",\"marginRight\":\"\",\"marginBottom\":\"\",\"marginLeft\":\"\",\"boxShadow\":\"none\",\"opacity\":\"1\"},\"animation\":\"\",\"animationDelay\":\"0\",\"hideOnDesktop\":false,\"hideOnTablet\":false,\"hideOnMobile\":false,\"aiName\":\"Email Button\",\"node_id\":\"ai-button-contact-1\"},\"displayName\":\"Button\",\"custom\":{},\"parent\":\"ai-section-contact-1__section-inner\",\"hidden\":false,\"nodes\":[],\"linkedNodes\":{}},\"ai-section-contact-1__section-inner\":{\"type\":{\"resolvedName\":\"Container\"},\"isCanvas\":true,\"props\":{\"style\":{\"padding\":\"20px\",\"minHeight\":\"100px\",\"justifyContent\":\"\",\"marginTop\":\"\",\"marginRight\":\"\",\"marginBottom\":\"\",\"marginLeft\":\"\",\"paddingTop\":\"\",\"paddingRight\":\"\",\"paddingBottom\":\"\",\"paddingLeft\":\"\",\"border\":\"none\",\"boxShadow\":\"none\",\"opacity\":\"1\"},\"tag\":\"div\",\"fullWidth\":false,\"contentWidth\":\"full\",\"anchorId\":\"\",\"cssId\":\"\",\"cssClass\":\"\",\"animation\":\"\",\"animationDelay\":\"0\",\"hideOnDesktop\":false,\"hideOnTablet\":false,\"hideOnMobile\":false},\"displayName\":\"Container\",\"custom\":{},\"parent\":\"ai-section-contact-1\",\"hidden\":false,\"nodes\":[\"ai-heading-contact-1\",\"ai-text-contact-1\",\"ai-spacer-contact-1\",\"ai-button-contact-1\"],\"linkedNodes\":{}},\"ROOT\":{\"type\":{\"resolvedName\":\"Container\"},\"isCanvas\":true,\"props\":{\"style\":{\"padding\":\"20px\",\"minHeight\":\"100px\",\"justifyContent\":\"\",\"marginTop\":\"\",\"marginRight\":\"\",\"marginBottom\":\"\",\"marginLeft\":\"\",\"paddingTop\":\"\",\"paddingRight\":\"\",\"paddingBottom\":\"\",\"paddingLeft\":\"\",\"border\":\"none\",\"boxShadow\":\"none\",\"opacity\":\"1\"},\"tag\":\"div\",\"fullWidth\":false,\"contentWidth\":\"full\",\"anchorId\":\"\",\"cssId\":\"\",\"cssClass\":\"\",\"animation\":\"\",\"animationDelay\":\"0\",\"hideOnDesktop\":false,\"hideOnTablet\":false,\"hideOnMobile\":false,\"aiName\":\"Root\",\"node_id\":\"ai-root-contact\"},\"displayName\":\"Container\",\"custom\":{},\"parent\":null,\"hidden\":false,\"nodes\":[\"ai-hero-contact-1\",\"ai-section-contact-1\"],\"linkedNodes\":{}}}" + } + ], + "head_code": "", + "design": { + "primaryColor": "#3b82f6", + "secondaryColor": "#8b5cf6", + "accentColor": "#10b981", + "headingFont": "Inter, sans-serif", + "bodyFont": "Inter, sans-serif", + "linkColor": "#3b82f6", + "successColor": "#10b981", + "warningColor": "#f59e0b", + "errorColor": "#ef4444", + "backgroundColor": "#ffffff", + "textColor": "#1f2937", + "mutedTextColor": "#6b7280", + "borderColor": "#e5e7eb", + "borderRadius": "8px", + "buttonFont": "Inter, sans-serif", + "buttonRadius": "8px", + "navStyle": "light", + "headCode": "", + "favicon": "" + }, + "extras": [], + "_rev": "0af66cc775393317", + "_base_rev": "3d18b0567d28ea29", + "_client_id": "e4633813-f672-4fbb-9618-d16fb97ff90f" +} \ No newline at end of file diff --git a/craft/tests/fixtures/canvas-parity/ols.spec.json b/craft/tests/fixtures/canvas-parity/ols.spec.json new file mode 100644 index 0000000..946fb6d --- /dev/null +++ b/craft/tests/fixtures/canvas-parity/ols.spec.json @@ -0,0 +1,6 @@ +{ + "name": "ols", + "project": "ols.project.json", + "published": {"index": "ols.index.html", "about": "ols.about.html", "contact": "ols.contact.html"}, + "_note": "brands is deliberately not listed: its ColumnLayout row renders differently in the canvas than the export (padding longhand / wrapping) -- a component bug unrelated to the base reset" +} From 64fc21866c17286ffb3e71d47cb1d7b6fc2c6672 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 1 Nov 2026 09:11:52 -0800 Subject: [PATCH 3/8] test(site-builder): canvas parity harness loads stored state verbatim (no blank-style stripping) Co-Authored-By: Claude Sonnet 5.5 --- craft/scripts/e2e-canvas-parity.mjs | 27 ++++----------------------- 1 file changed, 4 insertions(+), 23 deletions(-) diff --git a/craft/scripts/e2e-canvas-parity.mjs b/craft/scripts/e2e-canvas-parity.mjs index 9cb7194..b67ab4e 100644 --- a/craft/scripts/e2e-canvas-parity.mjs +++ b/craft/scripts/e2e-canvas-parity.mjs @@ -82,28 +82,9 @@ for (const p of specPaths) { specs.push({ name: s.name, project, published }); } -// A stored `style` often carries a shorthand plus BLANK longhands -// ({padding:'20px', paddingTop:''}). The editor renders those through React, which -// assigns '' to the longhand and thereby wipes the shorthand in the canvas (the -// export drops blanks, so published keeps it). That is a SEPARATE bug from the -// base-reset parity this test guards (same class as fix/bg-shorthand-longhand), so by -// default the blanks are stripped from the loaded state to isolate the reset. -// CANVAS_RAW=1 keeps the stored state verbatim (shows that bug's residue). -function stripBlankStyles(project) { - if (process.env.CANVAS_RAW) return project; - const clean = (json) => { - const nodes = JSON.parse(json); - for (const n of Object.values(nodes)) { - const st = n && n.props && n.props.style; - if (st && typeof st === 'object') for (const k of Object.keys(st)) if (st[k] === '') delete st[k]; - } - return JSON.stringify(nodes); - }; - const out = { ...project }; - for (const k of ['craft_state', 'header_craft_state', 'footer_craft_state']) if (typeof out[k] === 'string' && out[k]) out[k] = clean(out[k]); - out.pages_craft_state = (project.pages_craft_state || []).map((pg) => ({ ...pg, craftState: typeof pg.craftState === 'string' ? clean(pg.craftState) : pg.craftState })); - return out; -} +// NOTE: stored state is loaded VERBATIM, blank style values included (e.g. +// {padding:'20px', paddingTop:''} left by the style panel). The harness must test what +// the editor really renders; an earlier version stripped blanks and thereby hid a wipe bug. // ---------------------------------------------------------------- build + serve the real editor const buildDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wsb-canvas-')); @@ -248,7 +229,7 @@ async function run(name, launcher, exe) { if (!pubHtml) continue; const label = `${spec.name}/${pg.slug}`; // The editor opens pages_craft_state[0]; rotate so page `idx` is first. - currentProject = stripBlankStyles({ ...spec.project, pages_craft_state: [pg, ...pages.filter((_, j) => j !== idx)] }); + currentProject = { ...spec.project, pages_craft_state: [pg, ...pages.filter((_, j) => j !== idx)] }; const ctx = await browser.newContext({ viewport: { width: EDITOR_W, height: VIEW_H } }); await ctx.route(/^https?:\/\/(?!127\.0\.0\.1)/, offline); // no web fonts / icon CDN: identical fallbacks both sides From 7a1ab36e636fc4c325a324cf920f62f2a78ec901 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 1 Nov 2026 09:12:35 -0800 Subject: [PATCH 4/8] fix(site-builder): editor canvas drops blank style values like the exporter React assigns '' for a blank style value and a blank longhand after a shorthand ({padding:'20px', paddingTop:''}) wipes the shorthand in the canvas; the exporter drops blanks. ResolvedRender is the one place node style reaches React, so normalise there. Co-Authored-By: Claude Sonnet 5.5 --- .../src/editor/RenderNode.responsive.test.tsx | 33 +++++++++++++++++++ craft/src/editor/RenderNode.tsx | 10 ++++-- craft/src/utils/responsive-style.test.ts | 12 +++++++ craft/src/utils/responsive-style.ts | 13 ++++++++ 4 files changed, 65 insertions(+), 3 deletions(-) diff --git a/craft/src/editor/RenderNode.responsive.test.tsx b/craft/src/editor/RenderNode.responsive.test.tsx index 07951ac..fc2b70d 100644 --- a/craft/src/editor/RenderNode.responsive.test.tsx +++ b/craft/src/editor/RenderNode.responsive.test.tsx @@ -128,3 +128,36 @@ describe('RenderNode per-device style resolution (real editor)', () => { expect(h2().hasAttribute('data-wsb-overridden')).toBe(false); }); }); + +describe('RenderNode drops blank style values like the exporter (canvas/published parity)', () => { + test('{padding, paddingTop:""} keeps the shorthand (React would wipe it)', () => { + mount( + JSON.stringify({ + ROOT: node('Container', { tag: 'div', style: {} }, ['sec1']), + sec1: node('Container', { tag: 'section', style: { padding: '20px', paddingTop: '', paddingLeft: undefined, margin: null } }, [], { parent: 'ROOT' }), + }), + 'desktop', + ); + const sec = h!.container.querySelector('section') as HTMLElement; + expect(sec.style.padding).toBe('20px'); + expect(sec.style.paddingTop).toBe('20px'); + }); + + test('a real longhand after the shorthand still wins', () => { + mount( + JSON.stringify({ + ROOT: node('Container', { tag: 'div', style: {} }, ['sec1']), + sec1: node('Container', { tag: 'section', style: { padding: '20px', paddingTop: '5px' } }, [], { parent: 'ROOT' }), + }), + 'desktop', + ); + const sec = h!.container.querySelector('section') as HTMLElement; + expect(sec.style.paddingTop).toBe('5px'); + expect(sec.style.paddingBottom).toBe('20px'); + }); + + test('also holds on a non-desktop device with overrides', () => { + mount(state({ style: { margin: '8px', marginTop: '' }, styleMobile: { textAlign: 'center' } }), 'mobile'); + expect(h2().style.marginBottom).toBe('8px'); + }); +}); diff --git a/craft/src/editor/RenderNode.tsx b/craft/src/editor/RenderNode.tsx index bdfd6a5..8789b58 100644 --- a/craft/src/editor/RenderNode.tsx +++ b/craft/src/editor/RenderNode.tsx @@ -2,7 +2,7 @@ import React, { useCallback, useContext, useEffect, useMemo, useRef } from 'reac import { createPortal } from 'react-dom'; import { NodeElement, useEditor, useNode } from '@craftjs/core'; import { useDevice } from '../state/DeviceContext'; -import { baseStacksOnMobile, hasAnyOverride, hasActiveOverride, resolveStyleForDevice, type ResponsiveProps } from '../utils/responsive-style'; +import { dropBlankStyle, baseStacksOnMobile, hasAnyOverride, hasActiveOverride, resolveStyleForDevice, type ResponsiveProps } from '../utils/responsive-style'; import type { DeviceMode } from '../types'; import { GlobalInstanceContext } from '../state/GlobalInstanceContext'; @@ -87,8 +87,12 @@ const ResolvedRender: React.FC<{ device: DeviceMode }> = ({ device }) => { ) : ( props.children ); - const resolved = - styleDevice !== 'desktop' ? { ...props, style: resolveStyleForDevice(props as ResponsiveProps, styleDevice) } : props; + // The ONE place node style reaches React: drop blank values like the exporter does + // (see dropBlankStyle) so the canvas can't wipe a shorthand with a blank longhand. + const style = dropBlankStyle( + styleDevice !== 'desktop' ? resolveStyleForDevice(props as ResponsiveProps, styleDevice) : (props as ResponsiveProps).style, + ); + const resolved = styleDevice !== 'desktop' || style !== (props as ResponsiveProps).style ? { ...props, style } : props; return React.createElement(type as React.ElementType, resolved, children); }, [type, props, childIds, hydrationTimestamp, styleDevice]); }; diff --git a/craft/src/utils/responsive-style.test.ts b/craft/src/utils/responsive-style.test.ts index 8b1b3be..6b6d636 100644 --- a/craft/src/utils/responsive-style.test.ts +++ b/craft/src/utils/responsive-style.test.ts @@ -202,3 +202,15 @@ describe('responsiveCssForNode', () => { expect(responsiveCssForNode('"]', { styleMobile: { fontSize: '2px' } })).toBe(''); }); }); + +import { dropBlankStyle as _dropBlankStyle } from './responsive-style'; +describe('dropBlankStyle (canvas == exporter: blanks are not declarations)', () => { + it('drops "", null, undefined; keeps 0 and real values', () => { + expect(_dropBlankStyle({ padding: '20px', paddingTop: '', a: null, b: undefined, opacity: 0, zIndex: '0' } as any)).toEqual({ padding: '20px', opacity: 0, zIndex: '0' }); + }); + it('returns the same object when nothing is blank, and passes non-objects through', () => { + const s = { color: 'red' }; + expect(_dropBlankStyle(s)).toBe(s); + expect(_dropBlankStyle(undefined)).toBeUndefined(); + }); +}); diff --git a/craft/src/utils/responsive-style.ts b/craft/src/utils/responsive-style.ts index af04bc5..075f121 100644 --- a/craft/src/utils/responsive-style.ts +++ b/craft/src/utils/responsive-style.ts @@ -70,6 +70,19 @@ export function resolveStyleForDevice(props: ResponsiveProps, device: DeviceMode return { ...base, ...tablet, ...setEntries(props.styleMobile) } as CSSProperties; } +/** + * The style as the CANVAS must render it: blank ('' / null / undefined) entries + * dropped, exactly like the exporter (`cssPropsToString`). React assigns '' for a + * blank value, and a blank longhand after a shorthand ({padding:'20px', + * paddingTop:''}) wipes the shorthand in the browser -- published output keeps it. + * Returns the SAME object when nothing is blank (no needless re-render). + */ +export function dropBlankStyle(style: T): T { + if (!style || typeof style !== 'object' || Array.isArray(style)) return style; + const rec = style as unknown as StyleRecord; + return Object.values(rec).every(isSet) ? style : (setEntries(rec) as unknown as T); +} + /** The raw partial stored for `device` (`{}` for desktop or when absent). */ export function overrideFor(props: ResponsiveProps, device: DeviceMode): Partial { if (device === 'desktop') return {}; From 01d5ba5496b55d1deb4fa96b2b7c6570bd7cedf6 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 1 Nov 2026 09:13:54 -0800 Subject: [PATCH 5/8] fix(site-builder): HeroSimple canvas render uses the exporter's declaration order (style wins over minHeight prop) Co-Authored-By: Claude Sonnet 5.5 --- .../sections/HeroSimple.renderParity.test.tsx | 36 +++++++++++++++++++ craft/src/components/sections/HeroSimple.tsx | 7 ++-- 2 files changed, 41 insertions(+), 2 deletions(-) create mode 100644 craft/src/components/sections/HeroSimple.renderParity.test.tsx diff --git a/craft/src/components/sections/HeroSimple.renderParity.test.tsx b/craft/src/components/sections/HeroSimple.renderParity.test.tsx new file mode 100644 index 0000000..57f019c --- /dev/null +++ b/craft/src/components/sections/HeroSimple.renderParity.test.tsx @@ -0,0 +1,36 @@ +import { describe, test, expect, afterEach } from 'vitest'; +import { renderEditorHarness, EditorHarness } from '../../test-utils/editorHarness'; +import { HeroSimple } from './HeroSimple'; + +// The canvas render must lay the
out exactly like toHtml (published is the source of +// truth): same declarations, `style` LAST so style.minHeight / padding win over the minHeight prop. +const node = (type: string, props: Record, nodes: string[] = [], extra: Record = {}) => ({ + type: { resolvedName: type }, isCanvas: type === 'Container', props, displayName: type, custom: {}, hidden: false, nodes, linkedNodes: {}, ...extra, +}); +let h: EditorHarness | null = null; +afterEach(() => { h?.unmount(); h = null; }); + +const cases: Record[] = [ + { minHeight: '600px', style: { minHeight: '480px' } }, + { minHeight: '300px', style: {} }, + { minHeight: '600px', style: { padding: '10px 5px', display: 'block' } }, +]; + +describe('HeroSimple canvas render == export', () => { + test.each(cases)('%j', (props) => { + h = renderEditorHarness({ + initialState: JSON.stringify({ + ROOT: node('Container', { tag: 'div', style: {} }, ['hero']), + hero: node('HeroSimple', props, [], { parent: 'ROOT' }), + }), + }); + const sec = h.container.querySelector('section') as HTMLElement; + const html = (HeroSimple as any).toHtml(props, '').html.match(/]*>/)[0]; + const exp = document.createElement('div'); + exp.innerHTML = html + '
'; + const e = (exp.firstElementChild as HTMLElement).style; + for (const p of ['minHeight', 'padding', 'display', 'alignItems', 'overflow', 'position'] as const) { + expect(sec.style[p], p).toBe(e[p]); + } + }); +}); diff --git a/craft/src/components/sections/HeroSimple.tsx b/craft/src/components/sections/HeroSimple.tsx index 824a302..c7e3093 100644 --- a/craft/src/components/sections/HeroSimple.tsx +++ b/craft/src/components/sections/HeroSimple.tsx @@ -109,16 +109,19 @@ export const HeroSimple: UserComponent = ({
{ if (ref) connect(drag(ref)); }} id={anchorId || undefined} + // Same declaration ORDER as toHtml (published output is the source of truth): + // computed defaults first, the node's own `style` last so style.minHeight / + // padding / display ... win over the `minHeight` prop and the built-ins. style={{ - ...style, ...heroBackgroundStyle({ bgType, bgColor, bgGradientFrom, bgGradientTo, bgGradientAngle, bgImage, bgPosition } as HeroProps), - minHeight: minHeight === '100vh' ? '100vh' : minHeight, + minHeight: minHeight || '500px', display: 'flex', alignItems: justifyMap[verticalAlign] || 'center', justifyContent: 'center', position: 'relative', overflow: 'hidden', padding: '60px 20px', + ...style, }} > {/* Video background */} From 7d77201e5a430e491a4c7562cdc16b75cdfd0a2e Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 1 Nov 2026 09:14:28 -0800 Subject: [PATCH 6/8] fix(site-builder): Container 40px drop-zone min-height only when empty Co-Authored-By: Claude Sonnet 5.5 --- .../layout/Container.dropZone.test.tsx | 33 +++++++++++++++++++ craft/src/components/layout/Container.tsx | 5 ++- 2 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 craft/src/components/layout/Container.dropZone.test.tsx diff --git a/craft/src/components/layout/Container.dropZone.test.tsx b/craft/src/components/layout/Container.dropZone.test.tsx new file mode 100644 index 0000000..3621b4e --- /dev/null +++ b/craft/src/components/layout/Container.dropZone.test.tsx @@ -0,0 +1,33 @@ +import { describe, test, expect, afterEach } from 'vitest'; +import { renderEditorHarness, EditorHarness } from '../../test-utils/editorHarness'; + +// The 40px min-height is an editor-only DROP ZONE for EMPTY containers. A container that has +// content must lay out like the published page (no injected min-height). +const node = (type: string, props: Record, nodes: string[] = [], extra: Record = {}) => ({ + type: { resolvedName: type }, isCanvas: type === 'Container' || type === 'Text', props, displayName: type, custom: {}, hidden: false, nodes, linkedNodes: {}, ...extra, +}); +let h: EditorHarness | null = null; +afterEach(() => { h?.unmount(); h = null; }); +const mount = (inner: Record, withChild: boolean) => { + h = renderEditorHarness({ + initialState: JSON.stringify({ + ROOT: node('Container', { tag: 'div', style: {} }, ['c1']), + c1: node('Container', { tag: 'section', ...inner }, withChild ? ['t1'] : [], { parent: 'ROOT' }), + ...(withChild ? { t1: node('TextBlock', { text: 'hi', style: {} }, [], { parent: 'c1' }) } : {}), + }), + }); + return h.container.querySelector('section') as HTMLElement; +}; + +describe('Container empty-only drop zone', () => { + test('empty container keeps the 40px drop zone', () => { + expect(mount({ style: {} }, false).style.minHeight).toBe('40px'); + }); + test('non-empty container does NOT get an injected min-height', () => { + expect(mount({ style: {} }, true).style.minHeight).toBe(''); + }); + test('an explicit style.minHeight always wins', () => { + expect(mount({ style: { minHeight: '100px' } }, true).style.minHeight).toBe('100px'); + expect(mount({ style: { minHeight: '100px' } }, false).style.minHeight).toBe('100px'); + }); +}); diff --git a/craft/src/components/layout/Container.tsx b/craft/src/components/layout/Container.tsx index 44ec28d..4797f83 100644 --- a/craft/src/components/layout/Container.tsx +++ b/craft/src/components/layout/Container.tsx @@ -95,8 +95,11 @@ export const Container: UserComponent = ({ // follows the current device's Alignment / Vertical alignment. const layout = containerLayout(style, needsBoxedWrapper); + // 40px min-height = editor-only DROP ZONE, so only an EMPTY container gets it. A container with + // content must size like the published page (toHtml adds no min-height). + const isEmpty = React.Children.count(children) === 0; const outerStyle: CSSProperties = { - minHeight: '40px', + ...(isEmpty ? { minHeight: '40px' } : {}), ...style, ...(fullWidth ? { width: '100vw', marginLeft: 'calc(-50vw + 50%)' } : {}), ...layout.outer, From d75f9d90ca6616203e31724da5375ac486aa8dd2 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 1 Nov 2026 09:16:26 -0800 Subject: [PATCH 7/8] fix(site-builder): published Text/Heading keep plain-text newlines as
like the canvas Canvas renders plain text via textToInlineHtml (newline ->
); toHtml used escapeHtml, so line breaks typed in the editor vanished on publish (TTM about page: ~100px shorter). Co-Authored-By: Claude Sonnet 5.5 --- craft/src/components/basic/Heading.toHtml.test.ts | 7 +++++++ craft/src/components/basic/Heading.tsx | 5 ++--- craft/src/components/basic/TextBlock.toHtml.test.ts | 7 +++++++ craft/src/components/basic/TextBlock.tsx | 5 ++--- craft/src/utils/responsive-style.test.ts | 4 ++-- 5 files changed, 20 insertions(+), 8 deletions(-) diff --git a/craft/src/components/basic/Heading.toHtml.test.ts b/craft/src/components/basic/Heading.toHtml.test.ts index 1236510..298820b 100644 --- a/craft/src/components/basic/Heading.toHtml.test.ts +++ b/craft/src/components/basic/Heading.toHtml.test.ts @@ -154,3 +154,10 @@ describe('Heading.toHtml inline rich text (#46)', () => { test('Heading default colour is the text token', () => { expect((Heading as any).craft.props.style.color).toBe('var(--wsb-text)'); }); + +describe('Heading.toHtml newlines (canvas parity)', () => { + test('plain text newlines become
, like the editor canvas', () => { + const { html } = (Heading as any).toHtml({ text: 'a\nb', level: 'h2' }, ''); + expect(html).toContain('a
b'); + }); +}); diff --git a/craft/src/components/basic/Heading.tsx b/craft/src/components/basic/Heading.tsx index dab636f..8ebe900 100644 --- a/craft/src/components/basic/Heading.tsx +++ b/craft/src/components/basic/Heading.tsx @@ -1,8 +1,7 @@ import React, { CSSProperties } from 'react'; import { useNode, UserComponent } from '@craftjs/core'; import { cssPropsToString } from '../../utils/style-helpers'; -import { escapeHtml } from '../../utils/escape'; -import { resolveInlineHtml } from '../../utils/inline-html'; +import { resolveInlineHtml, textToInlineHtml } from '../../utils/inline-html'; import { tokenRef } from '../../utils/tokens'; import { useInlineEditable } from './useInlineEditable'; @@ -102,7 +101,7 @@ Heading.craft = { (Heading as any).toHtml = (props: HeadingProps, _childrenHtml: string) => { const tag = sanitizeHeadingLevel(props.level); - const safeText = resolveInlineHtml(props.text, props.html) ?? escapeHtml(props.text || ''); + const safeText = resolveInlineHtml(props.text, props.html) ?? textToInlineHtml(props.text || ''); const styleStr = cssPropsToString(props.style); return { html: `<${tag}${styleStr ? ` style="${styleStr}"` : ''}>${safeText}` }; }; diff --git a/craft/src/components/basic/TextBlock.toHtml.test.ts b/craft/src/components/basic/TextBlock.toHtml.test.ts index 564d1e5..be03998 100644 --- a/craft/src/components/basic/TextBlock.toHtml.test.ts +++ b/craft/src/components/basic/TextBlock.toHtml.test.ts @@ -117,3 +117,10 @@ describe('TextBlock.toHtml design tokens (#44)', () => { for (const hex of ["#3f3f46"]) expect(lower, `old ${hex}`).not.toContain(hex); }); }); + +describe('TextBlock.toHtml newlines (canvas parity)', () => { + test('plain text newlines become
, like the editor canvas (textToInlineHtml)', () => { + const { html } = toHtml({ text: 'a\n\nb ' }, ''); + expect(html).toBe('

a

b <i>

'); + }); +}); diff --git a/craft/src/components/basic/TextBlock.tsx b/craft/src/components/basic/TextBlock.tsx index 94ec5fb..16539b3 100644 --- a/craft/src/components/basic/TextBlock.tsx +++ b/craft/src/components/basic/TextBlock.tsx @@ -2,8 +2,7 @@ import React, { CSSProperties } from 'react'; import { tokenRef } from '../../utils/tokens'; import { useNode, UserComponent } from '@craftjs/core'; import { cssPropsToString } from '../../utils/style-helpers'; -import { escapeHtml } from '../../utils/escape'; -import { resolveInlineHtml } from '../../utils/inline-html'; +import { resolveInlineHtml, textToInlineHtml } from '../../utils/inline-html'; import { useInlineEditable } from './useInlineEditable'; interface TextBlockProps { @@ -94,6 +93,6 @@ TextBlock.craft = { (TextBlock as any).toHtml = (props: TextBlockProps, _childrenHtml: string) => { const styleStr = cssPropsToString(props.style); - const escapedText = resolveInlineHtml(props.text, props.html) ?? escapeHtml(props.text || ''); + const escapedText = resolveInlineHtml(props.text, props.html) ?? textToInlineHtml(props.text || ''); return { html: `${escapedText}

` }; }; diff --git a/craft/src/utils/responsive-style.test.ts b/craft/src/utils/responsive-style.test.ts index 6b6d636..e20622f 100644 --- a/craft/src/utils/responsive-style.test.ts +++ b/craft/src/utils/responsive-style.test.ts @@ -205,10 +205,10 @@ describe('responsiveCssForNode', () => { import { dropBlankStyle as _dropBlankStyle } from './responsive-style'; describe('dropBlankStyle (canvas == exporter: blanks are not declarations)', () => { - it('drops "", null, undefined; keeps 0 and real values', () => { + test('drops "", null, undefined; keeps 0 and real values', () => { expect(_dropBlankStyle({ padding: '20px', paddingTop: '', a: null, b: undefined, opacity: 0, zIndex: '0' } as any)).toEqual({ padding: '20px', opacity: 0, zIndex: '0' }); }); - it('returns the same object when nothing is blank, and passes non-objects through', () => { + test('returns the same object when nothing is blank, and passes non-objects through', () => { const s = { color: 'red' }; expect(_dropBlankStyle(s)).toBe(s); expect(_dropBlankStyle(undefined)).toBeUndefined(); From ae02eb7d0d323a82d8fbfdb87787ba7d4b9a9e3b Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 1 Nov 2026 09:18:29 -0800 Subject: [PATCH 8/8] test(site-builder): parity harness neutralises documented editor-only differences (empty drop zone, empty-image placeholder) Container marks the applied drop zone with data-wsb-dropzone. Co-Authored-By: Claude Sonnet 5.5 --- craft/scripts/e2e-canvas-parity.mjs | 13 +++++++++++++ .../components/layout/Container.dropZone.test.tsx | 8 ++++++++ craft/src/components/layout/Container.tsx | 1 + 3 files changed, 22 insertions(+) diff --git a/craft/scripts/e2e-canvas-parity.mjs b/craft/scripts/e2e-canvas-parity.mjs index b67ab4e..276d45c 100644 --- a/craft/scripts/e2e-canvas-parity.mjs +++ b/craft/scripts/e2e-canvas-parity.mjs @@ -8,6 +8,10 @@ * same page's published HTML rendered in a plain page whose viewport equals * the canvas content width. * + * Stored state is loaded VERBATIM (blank style values included). Allowed editor-only + * differences (empty-container drop zone, empty-image placeholder) are neutralised in the + * canvas before measuring; see the ALLOWED block in run(). + * * Compared per matched element: bounding box (x/y/w/h, within 1px) and the * computed font-size / line-height / margin / padding / colour / font-family * / font-weight / display. Editor-only chrome (the dashed header/footer zone @@ -240,6 +244,15 @@ async function run(name, launcher, exe) { await ed.waitForSelector('.canvas-device-frame [data-craft-container]', { timeout: 20000 }); await ed.waitForTimeout(1500); // header/footer zone previews + layout settle const canvasW = await ed.evaluate(() => document.querySelector('.canvas-device-frame').clientWidth); + // ALLOWED editor-only differences (deliberate UX with no published counterpart), neutralised + // before measuring so they don't mask real diffs: + // - the 40px drop zone on EMPTY containers (Container sets data-wsb-dropzone only then) + // - the placeholder shown for an image node with no src (toHtml emits nothing) + await ed.evaluate(() => { + const f = document.querySelector('.canvas-device-frame'); + f.querySelectorAll('[data-wsb-dropzone]').forEach((el) => el.style.setProperty('min-height', '0', 'important')); + f.querySelectorAll('img[src^="data:image/svg"]').forEach((el) => el.style.setProperty('display', 'none', 'important')); + }); const canvas = await ed.evaluate(measure, { rootSel: '.canvas-device-frame', isCanvas: true, skipNav: idx > 0 }); if (shotDir) await ed.screenshot({ path: path.join(shotDir, `${label.replace('/', '_')}.${name}.canvas.png`) }); if (process.env.CANVAS_CHROME_DUMP && idx === 0 && spec === specs[0]) { diff --git a/craft/src/components/layout/Container.dropZone.test.tsx b/craft/src/components/layout/Container.dropZone.test.tsx index 3621b4e..8d75e75 100644 --- a/craft/src/components/layout/Container.dropZone.test.tsx +++ b/craft/src/components/layout/Container.dropZone.test.tsx @@ -31,3 +31,11 @@ describe('Container empty-only drop zone', () => { expect(mount({ style: { minHeight: '100px' } }, false).style.minHeight).toBe('100px'); }); }); + +describe('data-wsb-dropzone marker (harness hook)', () => { + test('set only while the drop-zone min-height is the one applied', () => { + expect(mount({ style: {} }, false).hasAttribute('data-wsb-dropzone')).toBe(true); + expect(mount({ style: {} }, true).hasAttribute('data-wsb-dropzone')).toBe(false); + expect(mount({ style: { minHeight: '100px' } }, false).hasAttribute('data-wsb-dropzone')).toBe(false); + }); +}); diff --git a/craft/src/components/layout/Container.tsx b/craft/src/components/layout/Container.tsx index 4797f83..3c59b1d 100644 --- a/craft/src/components/layout/Container.tsx +++ b/craft/src/components/layout/Container.tsx @@ -117,6 +117,7 @@ export const Container: UserComponent = ({ style: outerStyle, 'data-craft-container': 'true', 'data-fullwidth': fullWidth ? '' : undefined, + 'data-wsb-dropzone': isEmpty && !style.minHeight ? '' : undefined, id: idValue, className: cssClass || undefined, },