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 <noreply@anthropic.com>
This commit is contained in:
1 parent
76f0ba9ca1
commit
dc415db2d3
15 files changed
+774
-6
No files matched your search
@@ -391,6 +391,50 @@ body {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
CANVAS = PUBLISHED BASE RESET
|
||||
Published pages and Preview ship a reset in the document head
|
||||
(utils/css-reset.ts CSS_RESET: `*{margin:0;padding:0}`, img{display:block},
|
||||
a{color:inherit}, body line-height 1.6 / colour #1f2937 / 16px). The editor
|
||||
canvas lives inside the editor document, whose own body is 13px editor
|
||||
chrome with no such reset, so content looked different (default h1-h6/p/ul
|
||||
margins, `normal` line-height, 13px inherited text). This block gives the
|
||||
canvas FRAME -- the <body> stand-in -- and everything inside it the same
|
||||
defaults.
|
||||
Rules:
|
||||
- Every selector is ZERO specificity (:where). The published reset is `*`
|
||||
(specificity 0), so component inline styles and class rules beat it; a
|
||||
plain `.canvas-device-frame *` (0,1,0) would instead beat component
|
||||
<style> class rules and editor chrome rules. Do NOT remove the :where().
|
||||
- Scoped to the frame, never body/:root, so panels, toolbars and Craft
|
||||
overlays (all rendered outside the frame) are untouched. Editor-only
|
||||
elements inside the frame style themselves with classes / inline styles,
|
||||
which outrank this block.
|
||||
- Declarations come from utils/css-reset.ts; css-reset.test.ts fails if
|
||||
this block and that module drift apart. The font-family / colour tokens
|
||||
are layered on top by CanvasTokenStyle exactly as the published head
|
||||
layers them over the reset.
|
||||
========================================================================== */
|
||||
.canvas-device-frame {
|
||||
font-size: 16px; /* UA default a published <body> gets; editor body is 13px */
|
||||
font-family: 'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
|
||||
line-height:1.6;
|
||||
color:#1f2937;
|
||||
-webkit-font-smoothing:antialiased;
|
||||
}
|
||||
:where(.canvas-device-frame) :where(*),
|
||||
:where(.canvas-device-frame) ::before,
|
||||
:where(.canvas-device-frame) ::after {
|
||||
box-sizing:border-box;margin:0;padding:0;
|
||||
}
|
||||
:where(.canvas-device-frame) :where(img) {
|
||||
max-width:100%;height:auto;display:block;
|
||||
}
|
||||
:where(.canvas-device-frame) :where(a) {
|
||||
color:inherit;
|
||||
}
|
||||
/* END CANVAS = PUBLISHED BASE RESET */
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
No Selection Message
|
||||
-------------------------------------------------------------------------- */
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
// @ts-ignore -- node builtin (vitest runs in node); the project has no @types/node
|
||||
import fs from 'node:fs';
|
||||
import {
|
||||
CSS_RESET, RESET_ALL_DECLS, BODY_BASE_DECLS, IMG_DECLS, LINK_DECLS,
|
||||
} from './css-reset';
|
||||
import { buildCanvasTokenCss } from './tokens';
|
||||
import { DEFAULT_SITE_DESIGN } from '../state/SiteDesignContext';
|
||||
|
||||
// (not `?raw`: vitest's css pipeline strips/empties .css imports, and the block markers are comments)
|
||||
const css: string = fs.readFileSync('src/styles/editor.css' /* vitest cwd = craft/ */, 'utf8');
|
||||
const compact = (s: string) => s.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\s+/g, '').replace(/;}/g, '}');
|
||||
const blockStart = css.lastIndexOf('/*', css.indexOf('CANVAS = PUBLISHED BASE RESET'));
|
||||
const block = compact(css.slice(blockStart, css.indexOf('END CANVAS = PUBLISHED BASE RESET')));
|
||||
|
||||
describe('published reset text', () => {
|
||||
test('CSS_RESET is byte-identical to the backend head text (site-builder.php $reset)', () => {
|
||||
expect(CSS_RESET).toBe(
|
||||
"*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}html,body{overflow-x:clip}" +
|
||||
"body{font-family:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;line-height:1.6;color:#1f2937;-webkit-font-smoothing:antialiased}" +
|
||||
"img{max-width:100%;height:auto;display:block}a{color:inherit}",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('editor.css canvas reset block', () => {
|
||||
test('block exists', () => expect(blockStart).toBeGreaterThan(-1));
|
||||
test.each([
|
||||
['reset-all', RESET_ALL_DECLS],
|
||||
['body base', BODY_BASE_DECLS],
|
||||
['img', IMG_DECLS],
|
||||
['a', LINK_DECLS],
|
||||
])('carries the shared %s declarations', (_n, decls) => {
|
||||
expect(block).toContain(compact(decls));
|
||||
});
|
||||
test('base font-size matches the default 16px a published body gets', () => {
|
||||
expect(block).toContain('font-size:16px');
|
||||
});
|
||||
test('every selector in the block is zero-specificity (:where) below the frame', () => {
|
||||
const rules = css.slice(blockStart, css.indexOf('/* END CANVAS = PUBLISHED BASE RESET'))
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '');
|
||||
const selectors = [...rules.matchAll(/([^{}]+)\{/g)].map((m) => m[1].trim());
|
||||
expect(selectors.length).toBeGreaterThan(3);
|
||||
for (const sel of selectors) for (const part of sel.split(',')) {
|
||||
const p = part.trim();
|
||||
if (p === '.canvas-device-frame') continue; // the frame itself is the <body> stand-in
|
||||
expect(p.startsWith(':where(.canvas-device-frame)')).toBe(true);
|
||||
// nothing after the :where() group may add specificity (classes, ids, attrs, :not...)
|
||||
expect(p.replace(/:where\([^)]*\)/g, '').replace(/::(before|after)/g, '')).not.toMatch(/[.#[:]/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('canvas token base css keeps published (zero) specificity', () => {
|
||||
test('link/heading rules are :where-wrapped, only the frame rule carries a class', () => {
|
||||
const out = buildCanvasTokenCss(DEFAULT_SITE_DESIGN);
|
||||
expect(out).toContain(':where(.canvas-device-frame) :where(a){color:var(--wsb-link)}');
|
||||
expect(out).toContain(':where(.canvas-device-frame) :where(h1,h2,h3,h4,h5,h6){font-family:var(--wsb-heading-font)}');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* The page-level base reset shared by the PUBLISHED/Preview document head
|
||||
* (html-export.ts `wrapInDocument`, mirrored by the backend's
|
||||
* `generateCompiledHTML` in web-files/api/site-builder.php) and the editor
|
||||
* CANVAS (the "canvas = published reset" block in styles/editor.css).
|
||||
*
|
||||
* The declaration strings live here once so the two cannot drift;
|
||||
* css-reset.test.ts asserts html-export's CSS_RESET is byte-identical to the
|
||||
* published head text and that the editor.css block carries every one of
|
||||
* these declarations.
|
||||
*/
|
||||
export const RESET_ALL_DECLS = 'box-sizing:border-box;margin:0;padding:0';
|
||||
export const BODY_BASE_DECLS =
|
||||
"font-family:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;line-height:1.6;color:#1f2937;-webkit-font-smoothing:antialiased";
|
||||
export const IMG_DECLS = 'max-width:100%;height:auto;display:block';
|
||||
export const LINK_DECLS = 'color:inherit';
|
||||
|
||||
/** Minified published reset (document head). Must stay byte-identical to the backend's `$reset`. */
|
||||
export const CSS_RESET =
|
||||
`*,*::before,*::after{${RESET_ALL_DECLS}}html,body{overflow-x:clip}` +
|
||||
`body{${BODY_BASE_DECLS}}img{${IMG_DECLS}}a{${LINK_DECLS}}`;
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CSS_RESET } from './css-reset';
|
||||
import { componentResolver } from '../components/resolver';
|
||||
import type { CSSProperties } from 'react';
|
||||
import { cssPropsToString } from './style-helpers';
|
||||
@@ -214,7 +215,7 @@ function renderNode(
|
||||
return { html: allChildrenHtml };
|
||||
}
|
||||
|
||||
const CSS_RESET = `*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}html,body{overflow-x:clip}body{font-family:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;line-height:1.6;color:#1f2937;-webkit-font-smoothing:antialiased}img{max-width:100%;height:auto;display:block}a{color:inherit}`;
|
||||
// CSS_RESET (minified published reset) lives in ./css-reset, shared with the editor canvas.
|
||||
|
||||
const CSS_RESET_PRETTY = `*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
|
||||
@@ -110,8 +110,9 @@ describe('buildCanvasTokenCss', () => {
|
||||
const css = buildCanvasTokenCss(DEFAULT_SITE_DESIGN);
|
||||
expect(css).toContain(
|
||||
'.canvas-device-frame{font-family:var(--wsb-body-font);color:var(--wsb-text);background:var(--wsb-bg)}' +
|
||||
'.canvas-device-frame h1,.canvas-device-frame h2,.canvas-device-frame h3,.canvas-device-frame h4,.canvas-device-frame h5,.canvas-device-frame h6{font-family:var(--wsb-heading-font)}' +
|
||||
'.canvas-device-frame a{color:var(--wsb-link)}',
|
||||
// descendant rules are zero-specificity (:where) like the published `h1..h6{}` / `a{}`
|
||||
':where(.canvas-device-frame) :where(h1,h2,h3,h4,h5,h6){font-family:var(--wsb-heading-font)}' +
|
||||
':where(.canvas-device-frame) :where(a){color:var(--wsb-link)}',
|
||||
);
|
||||
// No unscoped selectors leak into the editor chrome.
|
||||
expect(css).not.toMatch(/(^|\})(body|h1|a)\{/);
|
||||
|
||||
@@ -99,11 +99,15 @@ export function resolveTokenValue(v: string, design: SiteDesign): string {
|
||||
// device frame (not :root) so the editor chrome never inherits site
|
||||
// fonts/colours.
|
||||
const CANVAS_SCOPE = '.canvas-device-frame';
|
||||
// Descendant rules are `:where()`-wrapped (zero specificity) to match the
|
||||
// published `h1..h6{}` / `a{}` rules: with a plain `.canvas-device-frame a`
|
||||
// (0,1,1) a component's own class rule (0,1,0) would lose in the editor but
|
||||
// win when published.
|
||||
const CANVAS_ZERO = `:where(${CANVAS_SCOPE})`;
|
||||
const CANVAS_TOKEN_BASE_CSS =
|
||||
`${CANVAS_SCOPE}{font-family:var(--wsb-body-font);color:var(--wsb-text);background:var(--wsb-bg)}` +
|
||||
['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].map((h) => `${CANVAS_SCOPE} ${h}`).join(',') +
|
||||
`{font-family:var(--wsb-heading-font)}` +
|
||||
`${CANVAS_SCOPE} a{color:var(--wsb-link)}`;
|
||||
`${CANVAS_ZERO} :where(h1,h2,h3,h4,h5,h6){font-family:var(--wsb-heading-font)}` +
|
||||
`${CANVAS_ZERO} :where(a){color:var(--wsb-link)}`;
|
||||
|
||||
/**
|
||||
* Defines the 16 `--wsb-*` variables on the editor canvas frame, plus the
|
||||
|
||||
Reference in new issue
Block a user