diff --git a/craft/package.json b/craft/package.json
index 72ca3ce..5789f0c 100644
--- a/craft/package.json
+++ b/craft/package.json
@@ -12,6 +12,7 @@
"test:e2e:sitesmith": "playwright test tests/sitesmith.spec.ts --reporter=list",
"test:unit": "vitest run",
"test:browser:bg": "node scripts/e2e-background.mjs",
+ "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.