Mobile guard: real-browser overflow suite, card-grid + HtmlBlock mobile hooks, wide custom HTML publish warning

- scripts/e2e-mobile-guard.mjs (npm run test:browser:mobile-guard): Chromium + Firefox at 360/390/768/1280 over
  exporter-built pages (navbar, columns, FeaturesGrid/Testimonials/NumberCounter/Gallery/Pricing/Reviews, big
  image, long word/URL, wide customer HtmlBlocks). Asserts body.scrollWidth and every visible box/text right edge
  (html,body{overflow-x:clip} hides overflow from documentElement.scrollWidth), stacking on phones, columns kept
  on tablet, navbar wrap, intentional overflow-x:auto still scrolls, and 1280 box + pixel identity with the hooks
  removed (optional cross-commit baseline).
- OLS home overflow was FeaturesGrid (inline repeat(3,1fr)), not an HtmlBlock. FeaturesGrid, Testimonials and
  NumberCounter grids carry data-wsb-grid; the <=767px head block makes them one column.
- HtmlBlock output is exported inside <div data-wsb-html style="display:contents"> (applied after attr
  injection, toHtml().html unchanged); on phones it becomes a max-width:100% sideways-scrolling box, media
  shrink, long words break. body{overflow-wrap:break-word} on phones. Navbar link wrap now up to 991px
  (8 links + long logo overflowed at 768).
- Head parity pins updated (mirror in WHP generateCompiledHTML); editor.css mirrors for the mobile frame.
- publish-checks: warn when an HtmlBlock's inline/<style> CSS sets widths, flex sizes or grids over 390px or a
  3+ column grid with no phone @media rule; says what is not checked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
shadowdaoandClaude Opus 5.5 committed 2026-11-01 14:58:39 -08:00
1 parent 9f9be5f41a
commit bb4fb7548b
15 files changed
+647 -27

No files matched your search

+13
View File
@@ -462,6 +462,19 @@ Every component has a static `toHtml(props, childrenHtml)` method. The `html-exp
- **Type checking:** `tsc --noEmit` (part of build step)
- **HTML export:** Verify `toHtml()` output matches expected HTML structure
- **Device preview:** Switch between desktop/tablet/mobile and verify responsive behavior
- **Mobile guard (real browsers):** `npm run test:browser:mobile-guard` (Chromium + Firefox, headless; jsdom has no
layout). Exports representative pages (navbar, columns, card grids, images, long words, wide customer HtmlBlocks)
and asserts at 360/390/768/1280: no horizontal overflow (body.scrollWidth AND the right edge of every visible box
and text line -- the head's `html,body{overflow-x:clip}` hides overflow from documentElement.scrollWidth), columns
and card grids stack on phones and keep their columns on tablet, navbar links wrap, an intentional
`overflow-x:auto` table still scrolls, and desktop is box- and pixel-identical with the mobile hooks removed.
Set `CHROMIUM_PATH` / `FIREFOX_PATH` if the Playwright-pinned browsers are not installed; record/compare a desktop
baseline across commits with `MOBILE_GUARD_RECORD=<file>` (on the base commit) / `MOBILE_GUARD_BASELINE=<file>`.
Run it whenever a component's `toHtml`, the exporter, or the head CSS changes.
- **Mobile hooks:** the head's `<=767px` block (RESPONSIVE_CSS in `utils/html-export.ts`, mirrored byte-for-byte by
WHP `generateCompiledHTML`) targets `[data-wsb-grid]` (component card grids -> one column), `body` (long words
wrap) and `[data-wsb-html]` (the inline `display:contents` wrapper the exporter puts around HtmlBlock output; on
phones a sideways-scrolling box). A new component with an inline multi-column grid should carry `data-wsb-grid`.
- **WHP integration:** Deploy to staging, verify save/load, verify PHP wrapper injection
## Development Notes
+1
View File
@@ -14,6 +14,7 @@
"test:browser:bg": "node scripts/e2e-background.mjs",
"test:browser:canvas": "node scripts/e2e-canvas-parity.mjs",
"test:browser:mobile": "node scripts/e2e-mobile-parity.mjs",
"test:browser:mobile-guard": "node scripts/e2e-mobile-guard.mjs",
"test:browser:colorpick": "node scripts/e2e-color-pick.mjs",
"test:browser:colors": "node scripts/e2e-color-system.mjs",
"test:browser:sitesmith": "node scripts/e2e-sitesmith.mjs",
+110
View File
@@ -0,0 +1,110 @@
// Bundled (browser IIFE) by scripts/e2e-mobile-guard.mjs. Builds representative published documents
// with the REAL exporter (exportBodyHtml + exportComposedPreviewHtml, the Preview/publish path) so the
// real-browser suite measures exactly what customers get. HtmlBlock sanitising needs a DOM, which is
// why this runs inside the browser rather than in node.
// Uses only exports that exist before and after the mobile-guard change, so the SAME harness can record
// a desktop baseline on the base commit (MOBILE_GUARD_RECORD).
import { exportBodyHtml, exportComposedPreviewHtml } from '../src/utils/html-export';
import { componentResolver } from '../src/components/resolver';
type N = Record<string, any>;
const defaults = (type: string): Record<string, unknown> => {
const c = (componentResolver as any)[type];
return c?.craft?.props ? JSON.parse(JSON.stringify(c.craft.props)) : {};
};
const node = (type: string, props: Record<string, unknown>, nodes: string[] = [], extra: N = {}) => ({
type: { resolvedName: type }, isCanvas: type === 'Container', props: { ...defaults(type), ...props }, displayName: type,
custom: {}, hidden: false, nodes, linkedNodes: {}, ...extra,
});
const LONG = 'Honest prices, straight answers and work that is finished when we say it will be, every time.';
function columns(nodes: N, id: string, split: string, n: number) {
const linked: Record<string, string> = {};
for (let i = 0; i < n; i++) {
const c = `${id}-c${i}`; const t = `${id}-t${i}`;
nodes[c] = node('Container', { tag: 'div', style: { padding: '8px' } }, [t], { parent: id });
nodes[t] = node('TextBlock', { text: `${LONG} (${id} col ${i + 1})`, style: { fontSize: '16px' } }, [], { parent: c });
linked[`col-${i}`] = c;
}
nodes[id] = node('ColumnLayout', { columns: n, gap: '16px', split }, [], { parent: 'ROOT', linkedNodes: linked });
}
const NAV_LINKS = ['Home', 'About us', 'Services', 'Our work', 'Pricing', 'Testimonials', 'Blog', 'Contact us'].map((text, i, a) => ({
text, href: '#' + text.toLowerCase().replace(/\s/g, '-'), ...(i === a.length - 1 ? { isCta: true } : {}),
}));
/** showMobileMenu false = every Navbar saved before the hamburger existed (links must wrap);
* true = the default for a newly dropped Navbar (hamburger). */
function header(showMobileMenu: boolean): string {
const nodes: N = { nav: node('Navbar', { logoText: 'Acme Plumbing & Heating Ltd', links: NAV_LINKS, padding: '16px 24px', showMobileMenu }, [], { parent: 'ROOT' }) };
nodes.ROOT = node('Container', { tag: 'div', style: {} }, ['nav']);
return JSON.stringify(nodes);
}
/** First-party components only: every one of them must fit every width. */
export function componentsState(): string {
const nodes: N = {};
const kids: string[] = [];
const add = (id: string, type: string, props: Record<string, unknown> = {}) => { nodes[id] = node(type, props, [], { parent: 'ROOT' }); kids.push(id); };
add('hero', 'HeroSimple', { title: 'Plumbing and heating you can actually rely on', subtitle: LONG });
for (const [id, split, n] of [['c2', '50-50', 2], ['c3', '33-33-33', 3], ['c4', '25-25-25-25', 4], ['side', '30-70', 2]] as const) { columns(nodes, id, split, n); kids.push(id); }
add('features', 'FeaturesGrid');
add('testimonials', 'Testimonials');
add('counter', 'NumberCounter');
add('gallery', 'Gallery');
add('pricing', 'PricingTable');
add('reviews', 'ReviewsStrip');
add('img', 'ImageBlock', { src: 'data:image/svg+xml,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="600"><rect width="1600" height="600" fill="#888"/></svg>') });
add('longword', 'TextBlock', { text: 'Supercalifragilisticexpialidociouslyunbreakablewordthatgoesonandonandonforeverandever and https://example.com/a/really/long/path/that/never/breaks/because/it/has/no/spaces/at/all?query=string&more=yes' });
nodes.ROOT = node('Container', { tag: 'div', style: {} }, kids);
return JSON.stringify(nodes);
}
/** Customer raw HTML that is wider than a phone. Keyed by node id so the runner can name offenders. */
export const HTML_BLOCKS: Record<string, string> = {
hbFixed: '<div style="width:1100px;background:#eee;padding:20px">A fixed 1100px banner</div>',
hbGridPx: '<div style="display:grid;grid-template-columns:repeat(3,300px);gap:20px"><div style="padding:16px;border:1px solid #ccc">One</div><div style="padding:16px;border:1px solid #ccc">Two</div><div style="padding:16px;border:1px solid #ccc">Three</div></div>',
hbGridFr: '<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:32px"><div style="padding:24px"><h3>Multi-Brand Portfolio</h3></div><div style="padding:24px"><h3>Hosting</h3></div><div style="padding:24px"><h3>StreamerTools</h3></div><div style="padding:24px"><h3>Consultancy</h3></div></div>',
hbStyleFlex: '<style>.cards{display:flex;gap:16px}.card{flex:0 0 360px;padding:16px;border:1px solid #ccc}</style><div class="cards"><div class="card">A</div><div class="card">B</div><div class="card">C</div></div>',
hbMinWidth: '<div style="min-width:900px">min-width 900px content</div>',
hbIframe: '<iframe width="800" height="450" src="about:blank" title="video"></iframe>',
hbImg: '<img src="data:image/gif;base64,R0lGODlhAQABAAAAACw=" width="1200" height="400" alt="">',
hbTableBare: '<table><tr>' + Array.from({ length: 10 }, (_, i) => `<td>Column_heading_${i}</td>`).join('') + '</tr></table>',
hbPre: '<pre>const reallyLongLine = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";</pre>',
hbUrl: '<p>See https://example.com/a/really/long/path/that/never/breaks/because/it/has/no/spaces/at/all/ok</p>',
// Deliberate horizontal scroller: must STILL scroll (the net must not flatten or clip it).
hbScrollTable: '<div id="intentional-scroll" style="overflow-x:auto"><table style="width:1000px"><tr><td>a</td><td>b</td><td>c</td></tr></table></div>',
};
export function htmlBlocksState(): string {
const nodes: N = {};
const kids: string[] = [];
nodes.intro = node('TextBlock', { text: 'Before the custom HTML' }, [], { parent: 'ROOT' }); kids.push('intro');
for (const [id, code] of Object.entries(HTML_BLOCKS)) { nodes[id] = node('HtmlBlock', { code }, [], { parent: 'ROOT' }); kids.push(id); }
// Desktop-identity probes: a boxed wrapper would merge these roots into ONE flex item, stop the root
// margins collapsing with their neighbours', and break the inline run. display:contents must not.
nodes.flexRow = node('Container', { tag: 'div', style: { display: 'flex', flexDirection: 'row', gap: '12px' } }, ['hbRoots', 'rowText'], { parent: 'ROOT' });
nodes.hbRoots = node('HtmlBlock', { code: '<div style="flex:1;background:#eef">root one</div><div style="flex:2;background:#fee">root two</div>' }, [], { parent: 'flexRow' });
nodes.rowText = node('TextBlock', { text: 'flex sibling' }, [], { parent: 'flexRow' });
kids.push('flexRow');
nodes.marginText = node('TextBlock', { text: 'margin neighbour', style: { marginBottom: '30px' } }, [], { parent: 'ROOT' }); kids.push('marginText');
nodes.hbMargins = node('HtmlBlock', { code: '<h2 style="margin:40px 0">Collapsing margins</h2>' }, [], { parent: 'ROOT' }); kids.push('hbMargins');
nodes.inlineRun = node('Container', { tag: 'div', style: {} }, ['hbInline', 'inlineBtn'], { parent: 'ROOT' });
nodes.hbInline = node('HtmlBlock', { code: '<span>inline custom</span>' }, [], { parent: 'inlineRun' });
nodes.inlineBtn = node('ButtonLink', { text: 'inline button' }, [], { parent: 'inlineRun' });
kids.push('inlineRun');
nodes.outro = node('TextBlock', { text: 'After the custom HTML' }, [], { parent: 'ROOT' }); kids.push('outro');
nodes.ROOT = node('Container', { tag: 'div', style: { padding: '20px' } }, kids);
return JSON.stringify(nodes);
}
function compose(state: string, title: string, showMobileMenu: boolean): string {
const hdr = exportBodyHtml(header(showMobileMenu), 'header').html;
const body = exportBodyHtml(state).html;
return exportComposedPreviewHtml(state, hdr + body, { title, includeFonts: false });
}
(window as any).wsbMobileGuardDocs = (): Record<string, string> => ({
components: compose(componentsState(), 'Components', false),
htmlblocks: compose(htmlBlocksState(), 'Custom HTML', true),
});
+207
View File
@@ -0,0 +1,207 @@
#!/usr/bin/env node
/**
* Mobile guard: real-browser (Chromium AND Firefox, headless) regression suite for published pages.
* jsdom has no layout engine, so none of this can be asserted in vitest.
*
* npm run test:browser:mobile-guard
*
* Builds two representative documents with the REAL exporter (scripts/e2e-mobile-guard-entry.ts):
* components - Navbar with 8 links, hero, ColumnLayout 2/3/4/30-70, FeaturesGrid, Testimonials,
* NumberCounter, Gallery, PricingTable, ReviewsStrip, a 1600px image, long word + URL
* htmlblocks - customer HtmlBlocks wider than a phone (fixed width, px grid, 1fr grid, <style> flex
* cards, min-width, iframe, img, bare table, pre, URL) + one intentional overflow-x:auto table
* and measures them at 360 / 390 / 768 / 1280.
*
* Asserted:
* - documentElement.scrollWidth AND body.scrollWidth <= viewport (the head sets html,body{overflow-x:clip},
* so these alone stay green while content is CLIPPED -- hence also:)
* - the right edge of every visible element box and text line <= viewport, unless it sits inside a
* scroll/clip container that itself fits (that content is reachable / intentionally clipped).
* components: at every width. htmlblocks: at <=767px (the HtmlBlock net is mobile-only).
* - ColumnLayout rows stack (one column per row) at <=767px; component card grids are one column at
* <=767px and keep more than one column at 768 (tablet).
* - the Navbar fits the viewport and wraps its links onto more than one row at 360/390.
* - the intentional overflow-x:auto table still scrolls at every width.
* - desktop (1280) is unchanged by the mobile hooks: every element box AND the full-page screenshot are
* identical after removing them in the page (unwrap [data-wsb-html], drop data-wsb-grid). The htmlblocks
* page carries probes a boxed wrapper would move (multi-root flex item, collapsing margins, inline run).
* Optional cross-commit proof of the same thing:
* MOBILE_GUARD_RECORD=<file> write the 1280 boxes (run this on the base commit)
* MOBILE_GUARD_BASELINE=<file> assert 1280 boxes equal that recording
*
* Env: CHROMIUM_PATH / FIREFOX_PATH (when the Playwright-pinned builds are not installed),
* MOBILE_ONLY=chromium|firefox.
*/
import { chromium, firefox } from '@playwright/test';
import { build as esbuild } from 'esbuild';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const out = await esbuild({
entryPoints: [path.join(root, 'scripts/e2e-mobile-guard-entry.ts')],
bundle: true, write: false, format: 'iife', platform: 'browser', jsx: 'automatic', logLevel: 'error',
define: { 'process.env.NODE_ENV': '"production"', __EDITOR_BUILD__: '"test"' },
});
const js = out.outputFiles[0].text;
const WIDTHS = [360, 390, 768, 1280];
const MOBILE_MAX = 767;
/** Runs in the page. */
function measure() {
const vw = window.innerWidth;
const skip = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE']);
// An ancestor (below body) that scrolls or clips horizontally and itself fits the viewport keeps its
// content reachable (auto/scroll) or deliberately hidden (hidden/clip): not page overflow.
const contained = (el) => {
for (let a = el.parentElement; a && a !== document.body && a !== document.documentElement; a = a.parentElement) {
if (getComputedStyle(a).overflowX !== 'visible' && a.getBoundingClientRect().right <= vw + 0.5) return true;
}
return false;
};
const label = (el) => {
const host = el.closest('[id]');
return `${el.tagName.toLowerCase()}${host ? ' in #' + host.id : ''} "${(el.textContent || '').trim().slice(0, 24)}"`;
};
let maxRight = 0; let worst = '';
const bump = (right, el) => { if (right > maxRight) { maxRight = right; worst = label(el); } };
for (const el of document.body.querySelectorAll('*')) {
if (skip.has(el.tagName)) continue;
const r = el.getBoundingClientRect();
if (!(r.width || r.height) || getComputedStyle(el).visibility === 'hidden' || contained(el)) continue;
bump(r.right, el);
}
const tw = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
for (let n = tw.nextNode(); n; n = tw.nextNode()) {
const el = n.parentElement;
if (!el || skip.has(el.tagName) || !n.textContent.trim() || contained(n)) continue;
const range = document.createRange(); range.selectNodeContents(n);
for (const r of range.getClientRects()) if (r.width) bump(r.right, el);
}
// Distinct rows among boxes: a box starts a new row when its top is below the previous row's bottom.
const rowsOf = (kids) => {
const rs = kids.map((k) => k.getBoundingClientRect()).filter((r) => r.height > 0).sort((a, b) => a.top - b.top);
let rows = 0; let bottom = -Infinity;
for (const r of rs) { if (r.top >= bottom - 1) { rows++; bottom = r.bottom; } else bottom = Math.max(bottom, r.bottom); }
return rows;
};
const realKids = (w) => [...w.children].filter((c) => !skip.has(c.tagName));
const cols = [...document.querySelectorAll('[class^="cols_"]')].map((w) => ({ n: realKids(w).length, rows: rowsOf(realKids(w)) }));
// Every CSS grid the first-party components emit (inline display:grid), keyed by its section.
const grids = [...document.querySelectorAll('div')].filter((d) => getComputedStyle(d).display === 'grid' && !d.closest('[data-wsb-html]') && !d.closest('[id^="hb"]')).map((g) => {
const kids = realKids(g).filter((k) => k.getBoundingClientRect().height > 0);
const firstTop = kids.length ? Math.round(kids[0].getBoundingClientRect().top) : 0;
return {
where: (g.closest('section')?.textContent || '').trim().slice(0, 30),
hook: g.hasAttribute('data-wsb-grid'),
// A grid of images (Gallery) shrinks its thumbnails instead of overflowing; it may keep its columns.
imagesOnly: kids.length > 0 && kids.every((k) => k.tagName === 'IMG' || k.querySelector('img')),
n: kids.length, perRow: kids.filter((k) => Math.round(k.getBoundingClientRect().top) === firstTop).length,
};
});
const nav = document.querySelector('nav');
let navRight = 0; let linkRows = null;
if (nav) {
for (const el of [nav, ...nav.querySelectorAll('*')]) { const r = el.getBoundingClientRect(); if (r.width || r.height) navRight = Math.max(navRight, r.right); }
const links = [...nav.querySelectorAll('.navbar-link, .navbar-cta')].filter((a) => a.getBoundingClientRect().height > 0);
linkRows = links.length ? rowsOf(links) : null; // null: links collapsed behind the hamburger
}
const sc = document.getElementById('intentional-scroll');
let scrolls = null;
if (sc) { sc.scrollLeft = 50; scrolls = sc.scrollWidth > sc.clientWidth && sc.scrollLeft > 0; sc.scrollLeft = 0; }
return {
vw, docSW: document.documentElement.scrollWidth, bodySW: document.body.scrollWidth,
maxRight: Math.round(maxRight * 10) / 10, worst, cols, grids, navRight: Math.round(navRight * 10) / 10, linkRows, scrolls,
};
}
/** Runs in the page. Element boxes, ignoring display:contents hook wrappers (they have no box). */
function boxes() {
return [...document.body.querySelectorAll('*')]
.filter((e) => !['SCRIPT', 'STYLE', 'NOSCRIPT'].includes(e.tagName) && !e.hasAttribute('data-wsb-html'))
.map((e) => { const r = e.getBoundingClientRect(); return [e.tagName, ...[r.x, r.y, r.width, r.height].map((v) => Math.round(v * 10) / 10)].join(','); });
}
/** Runs in the page: remove the mobile hooks (unwrap the HtmlBlock wrapper, drop the grid hook). */
function stripHooks() {
for (const w of [...document.querySelectorAll('[data-wsb-html]')]) w.replaceWith(...w.childNodes);
for (const g of document.querySelectorAll('[data-wsb-grid]')) g.removeAttribute('data-wsb-grid');
}
let fail = 0;
const ok = (c, m) => { if (!c) { fail++; console.log(' FAIL', m); } };
const baseline = process.env.MOBILE_GUARD_BASELINE ? JSON.parse(fs.readFileSync(process.env.MOBILE_GUARD_BASELINE, 'utf8')) : null;
const record = {};
const summary = [];
async function run(name, launcher, exe) {
const browser = await launcher.launch({ executablePath: exe || undefined, ...(name === 'chromium' ? { args: ['--no-sandbox'] } : {}) });
const gen = await browser.newPage();
await gen.setContent('<!doctype html><html><body></body></html>');
await gen.addScriptTag({ content: js });
const docs = await gen.evaluate(() => window.wsbMobileGuardDocs());
await gen.close();
record[name] = {};
for (const [docName, html] of Object.entries(docs)) {
for (const w of WIDTHS) {
const page = await browser.newPage({ viewport: { width: w, height: 900 } });
await page.route(/^https?:/, (r) => r.abort()); // deterministic: no fonts/CDN racing the layout
await page.setContent(html, { waitUntil: 'load' });
await page.waitForTimeout(50);
const m = await page.evaluate(measure);
const tag = `[${name} ${w}] ${docName}`;
const mobile = w <= MOBILE_MAX;
summary.push(`${name.padEnd(8)} ${String(w).padEnd(4)} ${docName.padEnd(10)} docSW=${m.docSW} bodySW=${m.bodySW} maxRight=${m.maxRight}${m.maxRight > w + 0.5 ? ' (' + m.worst + ')' : ''} cols=${m.cols.map((c) => c.rows + '/' + c.n).join(',') || '-'} grids/row=${m.grids.map((g) => g.perRow + '/' + g.n + (g.hook ? 'h' : '')).join(',') || '-'} nav=${m.navRight}/${m.linkRows}rows${m.scrolls === null ? '' : ' scroller=' + m.scrolls}`);
ok(m.docSW <= w, `${tag}: documentElement.scrollWidth ${m.docSW} > ${w}`);
// Customer HTML is held to the viewport only where the HtmlBlock net applies (<=767px): wider than
// that, a block the author made wider than the screen renders exactly as it always has.
if (docName === 'components' || mobile) {
ok(m.bodySW <= w, `${tag}: body.scrollWidth ${m.bodySW} > ${w}`);
ok(m.maxRight <= w + 0.5, `${tag}: content reaches x=${m.maxRight} (clipped on a real device): ${m.worst}`);
}
ok(m.navRight <= w + 0.5, `${tag}: navbar reaches x=${m.navRight}`);
if (docName === 'components' && w <= 390) ok(m.linkRows > 1, `${tag}: navbar links did not wrap (${m.linkRows} row)`);
if (docName === 'htmlblocks' && mobile) ok(m.linkRows === null, `${tag}: hamburger navbar shows its links on a phone`);
// The 1000px table only needs to scroll where it is wider than its box.
if (m.scrolls !== null && w <= 768) ok(m.scrolls, `${tag}: the intentional overflow-x:auto table no longer scrolls`);
if (docName === 'components') {
for (const c of m.cols) ok(mobile ? c.rows === c.n : c.rows === 1, `${tag}: ColumnLayout ${mobile ? 'not stacked' : 'stacked'} (${c.rows} rows / ${c.n} cols)`);
for (const g of m.grids) {
if (g.n < 2) continue;
if (mobile && !g.imagesOnly) ok(g.perRow === 1, `${tag}: grid "${g.where}" keeps ${g.perRow} columns on a phone`);
if (!mobile && g.hook) ok(g.perRow > 1, `${tag}: grid "${g.where}" lost its columns (${g.perRow} per row)`);
}
}
if (w === 1280) {
const before = await page.evaluate(boxes);
const shotBefore = await page.screenshot({ fullPage: true });
await page.evaluate(stripHooks);
await page.waitForTimeout(20);
const after = await page.evaluate(boxes);
const shotAfter = await page.screenshot({ fullPage: true });
ok(shotBefore.equals(shotAfter), `${tag}: full-page pixels differ with vs without the mobile hooks`);
const diffs = before.length !== after.length ? Infinity : before.filter((b, i) => b !== after[i]).length;
ok(diffs === 0, `${tag}: ${diffs} element boxes differ with vs without the mobile hooks (${before.length}/${after.length} elements)`);
record[name][docName] = after;
const b = baseline?.[name]?.[docName];
if (b) {
const d = b.length !== before.length ? Infinity : b.filter((x, i) => x !== before[i]).length;
if (d) for (let i = 0, shown = 0; i < Math.min(b.length, before.length) && shown < 3; i++) if (b[i] !== before[i]) { shown++; console.log(' diff', b[i], '->', before[i]); }
ok(d === 0, `${tag}: ${d} element boxes differ from the recorded baseline (${b.length} -> ${before.length} elements)`);
}
}
await page.close();
}
}
await browser.close();
}
const only = process.env.MOBILE_ONLY;
if (!only || only === 'chromium') await run('chromium', chromium, process.env.CHROMIUM_PATH);
if (!only || only === 'firefox') await run('firefox', firefox, process.env.FIREFOX_PATH);
if (process.env.MOBILE_GUARD_RECORD) fs.writeFileSync(process.env.MOBILE_GUARD_RECORD, JSON.stringify(record));
console.log('\n' + summary.join('\n'));
console.log(fail ? `\n${fail} FAILED` : '\nall passed');
process.exit(fail ? 1 : 0);
+3 -3
View File
@@ -87,10 +87,10 @@ async function run(name, launcher, exe) {
results[name][docName][w] = m;
const tag = `[${name} ${w}] ${docName}`;
if (w === 390) {
// ols/index carries a customer HtmlBlock card grid that overflows on its own (out of scope here): for it,
// only the navbar is held to the phone width.
// ols/index's FeaturesGrid stacks via its data-wsb-grid hook, which a body stored before the hook
// existed does not carry: only that stored body is exempt (it is fixed by republishing).
ok(m.navRight <= 390.5, `${tag}: navbar overflows, reaches x=${m.navRight}`);
if (!docName.startsWith('ols/index')) ok(m.maxRight <= 390.5, `${tag}: horizontal overflow, content reaches x=${m.maxRight} (${m.worst})`);
if (docName !== 'ols/index (stored body, current head)') ok(m.maxRight <= 390.5, `${tag}: horizontal overflow, content reaches x=${m.maxRight} (${m.worst})`);
for (const c of m.cols) {
const optOut = c.id === 'optout';
if (optOut) ok(c.rows === 1 || c.n === 1, `${tag}: opt-out columns should stay side by side (${JSON.stringify(c)})`);
+13 -1
View File
@@ -375,6 +375,8 @@ export const HtmlBlock: UserComponent<HtmlBlockProps> = ({ code = '' }) => {
// already-saved sites keep deserializing cleanly.
return React.createElement('div', {
ref: setRef,
// Hook for the editor's mobile-frame mirror of the published HtmlBlock net (editor.css).
'data-wsb-html': '',
style: {
minHeight: '40px',
outline: selected ? '2px solid #3b82f6' : 'none',
@@ -400,8 +402,18 @@ HtmlBlock.craft = {
/* ---------- HTML export ---------- */
/**
* Mobile safety net: the exporter wraps the block's output in this element
* (after injecting the node's data attrs, so `html` itself stays byte-identical
* to every earlier release). Inline `display:contents` = no box: desktop and
* tablet layout, and pages whose head predates the net, are unchanged. The head's
* <=767px rule turns it into a sideways-scrolling box no wider than its parent
* (RESPONSIVE_CSS in utils/html-export.ts).
*/
export const HTML_BLOCK_WRAP: [string, string] = ['<div data-wsb-html style="display:contents">', '</div>'];
(HtmlBlock as any).toHtml = (props: HtmlBlockProps, _childrenHtml: string) => {
// Run through the same DOMPurify config used for the live editor preview
// so exported pages can't carry <script>/on*= payloads either.
return { html: purifyHtml(props.code || '') };
return { html: purifyHtml(props.code || ''), wrap: HTML_BLOCK_WRAP };
};
@@ -57,7 +57,7 @@ export const FeaturesGrid: UserComponent<FeaturesGridProps> = ({
...style,
}}
>
<div style={{ maxWidth: '1100px', margin: '0 auto', display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '32px' }}>
<div data-wsb-grid="" style={{ maxWidth: '1100px', margin: '0 auto', display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '32px' }}>
{(Array.isArray(features) ? features : []).map((feat, i) => (
<div
key={i}
@@ -141,7 +141,7 @@ FeaturesGrid.craft = {
return {
html: `<section${idAttr}${sectionStyle ? ` style="${sectionStyle}"` : ''}>
<div style="max-width:1100px;margin:0 auto;display:grid;grid-template-columns:repeat(3,1fr);gap:32px">
<div data-wsb-grid style="max-width:1100px;margin:0 auto;display:grid;grid-template-columns:repeat(3,1fr);gap:32px">
${cards}
</div>
</section>`,
@@ -59,6 +59,7 @@ export const NumberCounter: UserComponent<NumberCounterProps> = ({
}}
>
<div
data-wsb-grid=""
style={{
maxWidth: '1100px',
margin: '0 auto',
@@ -171,7 +172,7 @@ NumberCounter.craft = {
return {
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}>
<div id="${uid}" style="max-width:1100px;margin:0 auto;display:grid;grid-template-columns:repeat(${columns},1fr);gap:32px;text-align:center">
<div id="${uid}" data-wsb-grid style="max-width:1100px;margin:0 auto;display:grid;grid-template-columns:repeat(${columns},1fr);gap:32px;text-align:center">
${countersHtml}
</div>
<script>
@@ -124,7 +124,7 @@ export const Testimonials: UserComponent<TestimonialsProps> = ({
>
<div style={{ maxWidth: '1100px', margin: '0 auto' }}>
{layout === 'grid' ? (
<div style={{ display: 'grid', gridTemplateColumns: `repeat(${columns}, 1fr)`, gap: '24px' }}>
<div data-wsb-grid="" style={{ display: 'grid', gridTemplateColumns: `repeat(${columns}, 1fr)`, gap: '24px' }}>
{items.map((t, i) => renderCard(t, i))}
</div>
) : (
@@ -218,7 +218,7 @@ Testimonials.craft = {
return {
html: `<section${idAttr}${sectionStyle ? ` style="${sectionStyle}"` : ''}>
<div style="max-width:1100px;margin:0 auto;display:grid;grid-template-columns:repeat(${columns},1fr);gap:24px">
<div data-wsb-grid style="max-width:1100px;margin:0 auto;display:grid;grid-template-columns:repeat(${columns},1fr);gap:24px">
${cards}
</div>
</section>`,
+21
View File
@@ -2373,6 +2373,27 @@ body {
align-items: stretch !important;
}
/* Mirrors of the published <=767px head rules (RESPONSIVE_CSS in utils/html-export.ts): component card
grids go to one column, long words wrap, customer HTML (HtmlBlock) scrolls sideways inside its own box. */
.canvas-device-frame[data-device="mobile"] [data-wsb-grid] {
grid-template-columns: minmax(0, 1fr) !important;
}
.canvas-device-frame[data-device="mobile"] {
overflow-wrap: break-word;
}
.canvas-device-frame[data-device="mobile"] [data-wsb-html] {
max-width: 100%;
min-width: 0;
overflow-x: auto;
overflow-wrap: anywhere;
}
.canvas-device-frame[data-device="mobile"] [data-wsb-html] :is(img, video, iframe, embed, object, svg, canvas) {
max-width: 100%;
}
.canvas-device-frame[data-device="mobile"] [data-wsb-html] :is(img, video) {
height: auto;
}
.canvas-device-frame:not([data-device="desktop"]) [data-fullwidth] {
width: 100% !important;
margin-left: 0 !important;
+20 -11
View File
@@ -14,14 +14,14 @@ import { exportToHtml, exportBodyHtml, buildFontsLink, type ExportOptions } from
import { DEFAULT_SITE_DESIGN, type SiteDesign } from '../state/SiteDesignContext';
const PINNED_HEAD: Record<string, string> = {
A_defaults: "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <title>Joe's Pizza &amp; Grill</title>\n <meta property=\"og:title\" content=\"Joe&#39;s Pizza &amp; Grill\">\n <meta property=\"og:type\" content=\"website\">\n <link rel=\"canonical\" href=\"https://example.com/\">\n <link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n <link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n <link href=\"https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap\" rel=\"stylesheet\">\n <style>*,*::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}@media(max-width:767px){[style*=\"display: flex\"][style*=\"flex-direction: row\"],[style*=\"display:flex\"][style*=\"flex-direction:row\"]{flex-direction:column!important}[class^=\"cols_\"]{flex-direction:column!important;align-items:stretch!important}nav[class^=\"nav_\"],nav[class^=\"nav_\"] .navbar-links{flex-wrap:wrap}}@media(min-width:992px){[data-hide-desktop]{display:none!important}}@media(min-width:768px) and (max-width:991px){[data-hide-tablet]{display:none!important}}@media(max-width:767px){[data-hide-mobile]{display:none!important}}:root{--wsb-primary:#3b82f6;--wsb-secondary:#8b5cf6;--wsb-accent:#10b981;--wsb-link:#3b82f6;--wsb-success:#10b981;--wsb-warning:#f59e0b;--wsb-error:#ef4444;--wsb-bg:#ffffff;--wsb-text:#1f2937;--wsb-muted:#6b7280;--wsb-border:#e5e7eb;--wsb-radius:8px;--wsb-heading-font:Inter, sans-serif;--wsb-body-font:Inter, sans-serif;--wsb-button-font:Inter, sans-serif;--wsb-button-radius:8px}body{font-family:var(--wsb-body-font);color:var(--wsb-text);background:var(--wsb-bg)}h1,h2,h3,h4,h5,h6{font-family:var(--wsb-heading-font)}a{color:var(--wsb-link)}a:focus-visible,button:focus-visible,input:focus-visible,textarea:focus-visible,select:focus-visible,[tabindex]:focus-visible{outline:2px solid var(--wsb-accent);outline-offset:2px}@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation-duration:.001ms!important;animation-iteration-count:1!important;transition-duration:.001ms!important;scroll-behavior:auto!important}}</style>\n</head>",
B_full: "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <meta name=\"robots\" content=\"noindex, nofollow\">\n <title>About &quot;Us&quot;</title>\n <meta name=\"description\" content=\"We&#39;re &lt;the&gt; best &amp; &quot;only&quot; bakery\">\n <meta property=\"og:title\" content=\"About Joe&#39;s\">\n <meta property=\"og:description\" content=\"We&#39;re &lt;the&gt; best &amp; &quot;only&quot; bakery\">\n <meta property=\"og:image\" content=\"https://cdn.example.com/og.png\">\n <meta property=\"og:type\" content=\"website\">\n <meta name=\"twitter:card\" content=\"summary\">\n <link rel=\"icon\" href=\"https://cdn.example.com/favicon.ico\">\n <link rel=\"canonical\" href=\"https://example.com/about\">\n <link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n <link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n <link href=\"https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;600;700&family=Roboto:wght@300;400;500;700&display=swap\" rel=\"stylesheet\">\n <style>*,*::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}@media(max-width:767px){[style*=\"display: flex\"][style*=\"flex-direction: row\"],[style*=\"display:flex\"][style*=\"flex-direction:row\"]{flex-direction:column!important}[class^=\"cols_\"]{flex-direction:column!important;align-items:stretch!important}nav[class^=\"nav_\"],nav[class^=\"nav_\"] .navbar-links{flex-wrap:wrap}}@media(min-width:992px){[data-hide-desktop]{display:none!important}}@media(min-width:768px) and (max-width:991px){[data-hide-tablet]{display:none!important}}@media(max-width:767px){[data-hide-mobile]{display:none!important}}@keyframes fadeIn{from{opacity:0}to{opacity:1}}@keyframes slideUp{from{opacity:0;transform:translateY(30px)}to{opacity:1;transform:translateY(0)}}@keyframes slideLeft{from{opacity:0;transform:translateX(-30px)}to{opacity:1;transform:translateX(0)}}@keyframes slideRight{from{opacity:0;transform:translateX(30px)}to{opacity:1;transform:translateX(0)}}@keyframes zoomIn{from{opacity:0;transform:scale(.9)}to{opacity:1;transform:scale(1)}}@keyframes bounce{0%{opacity:0;transform:translateY(40px)}40%{opacity:1;transform:translateY(-12px)}60%{transform:translateY(6px)}80%{transform:translateY(-3px)}100%{opacity:1;transform:translateY(0)}}[data-animation]{opacity:0}[data-animation].animated{animation-duration:.6s;animation-fill-mode:both}[data-animation=\"fade-in\"].animated{animation-name:fadeIn}[data-animation=\"slide-up\"].animated{animation-name:slideUp}[data-animation=\"slide-left\"].animated{animation-name:slideLeft}[data-animation=\"slide-right\"].animated{animation-name:slideRight}[data-animation=\"zoom-in\"].animated{animation-name:zoomIn}[data-animation=\"bounce\"].animated{animation-name:bounce}:root{--wsb-primary:#ff0066;--wsb-secondary:#8b5cf6;--wsb-accent:#10b981;--wsb-link:#3b82f6;--wsb-success:#10b981;--wsb-warning:#f59e0b;--wsb-error:#ef4444;--wsb-bg:#ffffff;--wsb-text:#1f2937;--wsb-muted:#6b7280;--wsb-border:#e5e7eb;--wsb-radius:12px;--wsb-heading-font:Playfair Display, serif;--wsb-body-font:Roboto, sans-serif;--wsb-button-font:Roboto, sans-serif;--wsb-button-radius:999px}body{font-family:var(--wsb-body-font);color:var(--wsb-text);background:var(--wsb-bg)}h1,h2,h3,h4,h5,h6{font-family:var(--wsb-heading-font)}a{color:var(--wsb-link)}a:focus-visible,button:focus-visible,input:focus-visible,textarea:focus-visible,select:focus-visible,[tabindex]:focus-visible{outline:2px solid var(--wsb-accent);outline-offset:2px}@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation-duration:.001ms!important;animation-iteration-count:1!important;transition-duration:.001ms!important;scroll-behavior:auto!important}}</style>\n <noscript><style>[data-animation]{opacity:1}</style></noscript>\n <script async src=\"https://www.googletagmanager.com/gtag/js?id=G-X\"></script>\n<meta name=\"google-site-verification\" content=\"abc123\">\n</head>",
C_unknown_font: "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <title>Contact</title>\n <meta name=\"description\" content=\"Get in touch\">\n <meta property=\"og:title\" content=\"Contact\">\n <meta property=\"og:description\" content=\"Get in touch\">\n <meta property=\"og:type\" content=\"website\">\n <meta name=\"twitter:card\" content=\"summary\">\n <link rel=\"canonical\" href=\"https://example.com/contact\">\n <link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n <link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n <link href=\"https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Roboto:wght@300;400;500;700&family=Open+Sans:wght@300;400;600;700&family=Poppins:wght@300;400;500;600;700&family=Montserrat:wght@300;400;500;600;700&family=Playfair+Display:wght@400;600;700&family=Merriweather:wght@300;400;700&family=Source+Code+Pro:wght@400;500;600&display=swap\" rel=\"stylesheet\">\n <style>*,*::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}@media(max-width:767px){[style*=\"display: flex\"][style*=\"flex-direction: row\"],[style*=\"display:flex\"][style*=\"flex-direction:row\"]{flex-direction:column!important}[class^=\"cols_\"]{flex-direction:column!important;align-items:stretch!important}nav[class^=\"nav_\"],nav[class^=\"nav_\"] .navbar-links{flex-wrap:wrap}}@media(min-width:992px){[data-hide-desktop]{display:none!important}}@media(min-width:768px) and (max-width:991px){[data-hide-tablet]{display:none!important}}@media(max-width:767px){[data-hide-mobile]{display:none!important}}:root{--wsb-primary:#3b82f6;--wsb-secondary:#8b5cf6;--wsb-accent:#10b981;--wsb-link:#3b82f6;--wsb-success:#10b981;--wsb-warning:#f59e0b;--wsb-error:#ef4444;--wsb-bg:#ffffff;--wsb-text:#1f2937;--wsb-muted:#6b7280;--wsb-border:#e5e7eb;--wsb-radius:8px;--wsb-heading-font:Comic Sans MS, cursive;--wsb-body-font:Inter, sans-serif;--wsb-button-font:Inter, sans-serif;--wsb-button-radius:8px}body{font-family:var(--wsb-body-font);color:var(--wsb-text);background:var(--wsb-bg)}h1,h2,h3,h4,h5,h6{font-family:var(--wsb-heading-font)}a{color:var(--wsb-link)}a:focus-visible,button:focus-visible,input:focus-visible,textarea:focus-visible,select:focus-visible,[tabindex]:focus-visible{outline:2px solid var(--wsb-accent);outline-offset:2px}@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation-duration:.001ms!important;animation-iteration-count:1!important;transition-duration:.001ms!important;scroll-behavior:auto!important}}</style>\n</head>",
D_body_font_and_blank_tokens: "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <title>Services</title>\n <meta property=\"og:title\" content=\"Services\">\n <meta property=\"og:type\" content=\"website\">\n <link rel=\"canonical\" href=\"https://example.com/services\">\n <link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n <link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n <link href=\"https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Montserrat:wght@300;400;500;600;700&display=swap\" rel=\"stylesheet\">\n <style>*,*::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}@media(max-width:767px){[style*=\"display: flex\"][style*=\"flex-direction: row\"],[style*=\"display:flex\"][style*=\"flex-direction:row\"]{flex-direction:column!important}[class^=\"cols_\"]{flex-direction:column!important;align-items:stretch!important}nav[class^=\"nav_\"],nav[class^=\"nav_\"] .navbar-links{flex-wrap:wrap}}@media(min-width:992px){[data-hide-desktop]{display:none!important}}@media(min-width:768px) and (max-width:991px){[data-hide-tablet]{display:none!important}}@media(max-width:767px){[data-hide-mobile]{display:none!important}}:root{--wsb-primary:#3b82f6;--wsb-secondary:#8b5cf6;--wsb-accent:#10b981;--wsb-link:#3b82f6;--wsb-success:#10b981;--wsb-warning:#f59e0b;--wsb-error:#ef4444;--wsb-bg:#ffffff;--wsb-text:#111111;--wsb-muted:#6b7280;--wsb-border:#e5e7eb;--wsb-radius:8px;--wsb-heading-font:Inter, sans-serif;--wsb-body-font:Inter, sans-serif;--wsb-button-font:Inter, sans-serif;--wsb-button-radius:8px}body{font-family:var(--wsb-body-font);color:var(--wsb-text);background:var(--wsb-bg)}h1,h2,h3,h4,h5,h6{font-family:var(--wsb-heading-font)}a{color:var(--wsb-link)}a:focus-visible,button:focus-visible,input:focus-visible,textarea:focus-visible,select:focus-visible,[tabindex]:focus-visible{outline:2px solid var(--wsb-accent);outline-offset:2px}@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation-duration:.001ms!important;animation-iteration-count:1!important;transition-duration:.001ms!important;scroll-behavior:auto!important}}</style>\n</head>",
E_icons: "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <title>Icons</title>\n <meta property=\"og:title\" content=\"Icons\">\n <meta property=\"og:type\" content=\"website\">\n <link rel=\"canonical\" href=\"https://example.com/icons\">\n <link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n <link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n <link href=\"https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap\" rel=\"stylesheet\">\n <link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.3.1/css/all.min.css\" integrity=\"sha384-qrALq7+6jBOZIQsNnT6xGkMDru64qD6uTlDra39xrt2SoXl4pO3FX6Roz/RpR/BS\" crossorigin=\"anonymous\" referrerpolicy=\"no-referrer\">\n <link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.3.1/css/v4-shims.min.css\" integrity=\"sha384-BOBgwI2GEcZ4l/UA/+WOVnLChe2S4c7UhUCtdmX1m0v7Wdyk0S/tDoJOBci2ywvl\" crossorigin=\"anonymous\" referrerpolicy=\"no-referrer\">\n <style>*,*::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}@media(max-width:767px){[style*=\"display: flex\"][style*=\"flex-direction: row\"],[style*=\"display:flex\"][style*=\"flex-direction:row\"]{flex-direction:column!important}[class^=\"cols_\"]{flex-direction:column!important;align-items:stretch!important}nav[class^=\"nav_\"],nav[class^=\"nav_\"] .navbar-links{flex-wrap:wrap}}@media(min-width:992px){[data-hide-desktop]{display:none!important}}@media(min-width:768px) and (max-width:991px){[data-hide-tablet]{display:none!important}}@media(max-width:767px){[data-hide-mobile]{display:none!important}}:root{--wsb-primary:#3b82f6;--wsb-secondary:#8b5cf6;--wsb-accent:#10b981;--wsb-link:#3b82f6;--wsb-success:#10b981;--wsb-warning:#f59e0b;--wsb-error:#ef4444;--wsb-bg:#ffffff;--wsb-text:#1f2937;--wsb-muted:#6b7280;--wsb-border:#e5e7eb;--wsb-radius:8px;--wsb-heading-font:Inter, sans-serif;--wsb-body-font:Inter, sans-serif;--wsb-button-font:Inter, sans-serif;--wsb-button-radius:8px}body{font-family:var(--wsb-body-font);color:var(--wsb-text);background:var(--wsb-bg)}h1,h2,h3,h4,h5,h6{font-family:var(--wsb-heading-font)}a{color:var(--wsb-link)}a:focus-visible,button:focus-visible,input:focus-visible,textarea:focus-visible,select:focus-visible,[tabindex]:focus-visible{outline:2px solid var(--wsb-accent);outline-offset:2px}@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation-duration:.001ms!important;animation-iteration-count:1!important;transition-duration:.001ms!important;scroll-behavior:auto!important}}</style>\n</head>",
A_defaults: "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <title>Joe's Pizza &amp; Grill</title>\n <meta property=\"og:title\" content=\"Joe&#39;s Pizza &amp; Grill\">\n <meta property=\"og:type\" content=\"website\">\n <link rel=\"canonical\" href=\"https://example.com/\">\n <link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n <link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n <link href=\"https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap\" rel=\"stylesheet\">\n <style>*,*::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}@media(max-width:767px){[style*=\"display: flex\"][style*=\"flex-direction: row\"],[style*=\"display:flex\"][style*=\"flex-direction:row\"]{flex-direction:column!important}[class^=\"cols_\"]{flex-direction:column!important;align-items:stretch!important}[data-wsb-grid]{grid-template-columns:minmax(0,1fr)!important}body{overflow-wrap:break-word}[data-wsb-html]{display:block!important;max-width:100%;min-width:0;overflow-x:auto;overflow-wrap:anywhere}[data-wsb-html] :is(img,video,iframe,embed,object,svg,canvas){max-width:100%}[data-wsb-html] :is(img,video){height:auto}}@media(max-width:991px){nav[class^=\"nav_\"],nav[class^=\"nav_\"] .navbar-links{flex-wrap:wrap}}@media(min-width:992px){[data-hide-desktop]{display:none!important}}@media(min-width:768px) and (max-width:991px){[data-hide-tablet]{display:none!important}}@media(max-width:767px){[data-hide-mobile]{display:none!important}}:root{--wsb-primary:#3b82f6;--wsb-secondary:#8b5cf6;--wsb-accent:#10b981;--wsb-link:#3b82f6;--wsb-success:#10b981;--wsb-warning:#f59e0b;--wsb-error:#ef4444;--wsb-bg:#ffffff;--wsb-text:#1f2937;--wsb-muted:#6b7280;--wsb-border:#e5e7eb;--wsb-radius:8px;--wsb-heading-font:Inter, sans-serif;--wsb-body-font:Inter, sans-serif;--wsb-button-font:Inter, sans-serif;--wsb-button-radius:8px}body{font-family:var(--wsb-body-font);color:var(--wsb-text);background:var(--wsb-bg)}h1,h2,h3,h4,h5,h6{font-family:var(--wsb-heading-font)}a{color:var(--wsb-link)}a:focus-visible,button:focus-visible,input:focus-visible,textarea:focus-visible,select:focus-visible,[tabindex]:focus-visible{outline:2px solid var(--wsb-accent);outline-offset:2px}@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation-duration:.001ms!important;animation-iteration-count:1!important;transition-duration:.001ms!important;scroll-behavior:auto!important}}</style>\n</head>",
B_full: "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <meta name=\"robots\" content=\"noindex, nofollow\">\n <title>About &quot;Us&quot;</title>\n <meta name=\"description\" content=\"We&#39;re &lt;the&gt; best &amp; &quot;only&quot; bakery\">\n <meta property=\"og:title\" content=\"About Joe&#39;s\">\n <meta property=\"og:description\" content=\"We&#39;re &lt;the&gt; best &amp; &quot;only&quot; bakery\">\n <meta property=\"og:image\" content=\"https://cdn.example.com/og.png\">\n <meta property=\"og:type\" content=\"website\">\n <meta name=\"twitter:card\" content=\"summary\">\n <link rel=\"icon\" href=\"https://cdn.example.com/favicon.ico\">\n <link rel=\"canonical\" href=\"https://example.com/about\">\n <link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n <link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n <link href=\"https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;600;700&family=Roboto:wght@300;400;500;700&display=swap\" rel=\"stylesheet\">\n <style>*,*::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}@media(max-width:767px){[style*=\"display: flex\"][style*=\"flex-direction: row\"],[style*=\"display:flex\"][style*=\"flex-direction:row\"]{flex-direction:column!important}[class^=\"cols_\"]{flex-direction:column!important;align-items:stretch!important}[data-wsb-grid]{grid-template-columns:minmax(0,1fr)!important}body{overflow-wrap:break-word}[data-wsb-html]{display:block!important;max-width:100%;min-width:0;overflow-x:auto;overflow-wrap:anywhere}[data-wsb-html] :is(img,video,iframe,embed,object,svg,canvas){max-width:100%}[data-wsb-html] :is(img,video){height:auto}}@media(max-width:991px){nav[class^=\"nav_\"],nav[class^=\"nav_\"] .navbar-links{flex-wrap:wrap}}@media(min-width:992px){[data-hide-desktop]{display:none!important}}@media(min-width:768px) and (max-width:991px){[data-hide-tablet]{display:none!important}}@media(max-width:767px){[data-hide-mobile]{display:none!important}}@keyframes fadeIn{from{opacity:0}to{opacity:1}}@keyframes slideUp{from{opacity:0;transform:translateY(30px)}to{opacity:1;transform:translateY(0)}}@keyframes slideLeft{from{opacity:0;transform:translateX(-30px)}to{opacity:1;transform:translateX(0)}}@keyframes slideRight{from{opacity:0;transform:translateX(30px)}to{opacity:1;transform:translateX(0)}}@keyframes zoomIn{from{opacity:0;transform:scale(.9)}to{opacity:1;transform:scale(1)}}@keyframes bounce{0%{opacity:0;transform:translateY(40px)}40%{opacity:1;transform:translateY(-12px)}60%{transform:translateY(6px)}80%{transform:translateY(-3px)}100%{opacity:1;transform:translateY(0)}}[data-animation]{opacity:0}[data-animation].animated{animation-duration:.6s;animation-fill-mode:both}[data-animation=\"fade-in\"].animated{animation-name:fadeIn}[data-animation=\"slide-up\"].animated{animation-name:slideUp}[data-animation=\"slide-left\"].animated{animation-name:slideLeft}[data-animation=\"slide-right\"].animated{animation-name:slideRight}[data-animation=\"zoom-in\"].animated{animation-name:zoomIn}[data-animation=\"bounce\"].animated{animation-name:bounce}:root{--wsb-primary:#ff0066;--wsb-secondary:#8b5cf6;--wsb-accent:#10b981;--wsb-link:#3b82f6;--wsb-success:#10b981;--wsb-warning:#f59e0b;--wsb-error:#ef4444;--wsb-bg:#ffffff;--wsb-text:#1f2937;--wsb-muted:#6b7280;--wsb-border:#e5e7eb;--wsb-radius:12px;--wsb-heading-font:Playfair Display, serif;--wsb-body-font:Roboto, sans-serif;--wsb-button-font:Roboto, sans-serif;--wsb-button-radius:999px}body{font-family:var(--wsb-body-font);color:var(--wsb-text);background:var(--wsb-bg)}h1,h2,h3,h4,h5,h6{font-family:var(--wsb-heading-font)}a{color:var(--wsb-link)}a:focus-visible,button:focus-visible,input:focus-visible,textarea:focus-visible,select:focus-visible,[tabindex]:focus-visible{outline:2px solid var(--wsb-accent);outline-offset:2px}@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation-duration:.001ms!important;animation-iteration-count:1!important;transition-duration:.001ms!important;scroll-behavior:auto!important}}</style>\n <noscript><style>[data-animation]{opacity:1}</style></noscript>\n <script async src=\"https://www.googletagmanager.com/gtag/js?id=G-X\"></script>\n<meta name=\"google-site-verification\" content=\"abc123\">\n</head>",
C_unknown_font: "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <title>Contact</title>\n <meta name=\"description\" content=\"Get in touch\">\n <meta property=\"og:title\" content=\"Contact\">\n <meta property=\"og:description\" content=\"Get in touch\">\n <meta property=\"og:type\" content=\"website\">\n <meta name=\"twitter:card\" content=\"summary\">\n <link rel=\"canonical\" href=\"https://example.com/contact\">\n <link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n <link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n <link href=\"https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Roboto:wght@300;400;500;700&family=Open+Sans:wght@300;400;600;700&family=Poppins:wght@300;400;500;600;700&family=Montserrat:wght@300;400;500;600;700&family=Playfair+Display:wght@400;600;700&family=Merriweather:wght@300;400;700&family=Source+Code+Pro:wght@400;500;600&display=swap\" rel=\"stylesheet\">\n <style>*,*::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}@media(max-width:767px){[style*=\"display: flex\"][style*=\"flex-direction: row\"],[style*=\"display:flex\"][style*=\"flex-direction:row\"]{flex-direction:column!important}[class^=\"cols_\"]{flex-direction:column!important;align-items:stretch!important}[data-wsb-grid]{grid-template-columns:minmax(0,1fr)!important}body{overflow-wrap:break-word}[data-wsb-html]{display:block!important;max-width:100%;min-width:0;overflow-x:auto;overflow-wrap:anywhere}[data-wsb-html] :is(img,video,iframe,embed,object,svg,canvas){max-width:100%}[data-wsb-html] :is(img,video){height:auto}}@media(max-width:991px){nav[class^=\"nav_\"],nav[class^=\"nav_\"] .navbar-links{flex-wrap:wrap}}@media(min-width:992px){[data-hide-desktop]{display:none!important}}@media(min-width:768px) and (max-width:991px){[data-hide-tablet]{display:none!important}}@media(max-width:767px){[data-hide-mobile]{display:none!important}}:root{--wsb-primary:#3b82f6;--wsb-secondary:#8b5cf6;--wsb-accent:#10b981;--wsb-link:#3b82f6;--wsb-success:#10b981;--wsb-warning:#f59e0b;--wsb-error:#ef4444;--wsb-bg:#ffffff;--wsb-text:#1f2937;--wsb-muted:#6b7280;--wsb-border:#e5e7eb;--wsb-radius:8px;--wsb-heading-font:Comic Sans MS, cursive;--wsb-body-font:Inter, sans-serif;--wsb-button-font:Inter, sans-serif;--wsb-button-radius:8px}body{font-family:var(--wsb-body-font);color:var(--wsb-text);background:var(--wsb-bg)}h1,h2,h3,h4,h5,h6{font-family:var(--wsb-heading-font)}a{color:var(--wsb-link)}a:focus-visible,button:focus-visible,input:focus-visible,textarea:focus-visible,select:focus-visible,[tabindex]:focus-visible{outline:2px solid var(--wsb-accent);outline-offset:2px}@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation-duration:.001ms!important;animation-iteration-count:1!important;transition-duration:.001ms!important;scroll-behavior:auto!important}}</style>\n</head>",
D_body_font_and_blank_tokens: "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <title>Services</title>\n <meta property=\"og:title\" content=\"Services\">\n <meta property=\"og:type\" content=\"website\">\n <link rel=\"canonical\" href=\"https://example.com/services\">\n <link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n <link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n <link href=\"https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Montserrat:wght@300;400;500;600;700&display=swap\" rel=\"stylesheet\">\n <style>*,*::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}@media(max-width:767px){[style*=\"display: flex\"][style*=\"flex-direction: row\"],[style*=\"display:flex\"][style*=\"flex-direction:row\"]{flex-direction:column!important}[class^=\"cols_\"]{flex-direction:column!important;align-items:stretch!important}[data-wsb-grid]{grid-template-columns:minmax(0,1fr)!important}body{overflow-wrap:break-word}[data-wsb-html]{display:block!important;max-width:100%;min-width:0;overflow-x:auto;overflow-wrap:anywhere}[data-wsb-html] :is(img,video,iframe,embed,object,svg,canvas){max-width:100%}[data-wsb-html] :is(img,video){height:auto}}@media(max-width:991px){nav[class^=\"nav_\"],nav[class^=\"nav_\"] .navbar-links{flex-wrap:wrap}}@media(min-width:992px){[data-hide-desktop]{display:none!important}}@media(min-width:768px) and (max-width:991px){[data-hide-tablet]{display:none!important}}@media(max-width:767px){[data-hide-mobile]{display:none!important}}:root{--wsb-primary:#3b82f6;--wsb-secondary:#8b5cf6;--wsb-accent:#10b981;--wsb-link:#3b82f6;--wsb-success:#10b981;--wsb-warning:#f59e0b;--wsb-error:#ef4444;--wsb-bg:#ffffff;--wsb-text:#111111;--wsb-muted:#6b7280;--wsb-border:#e5e7eb;--wsb-radius:8px;--wsb-heading-font:Inter, sans-serif;--wsb-body-font:Inter, sans-serif;--wsb-button-font:Inter, sans-serif;--wsb-button-radius:8px}body{font-family:var(--wsb-body-font);color:var(--wsb-text);background:var(--wsb-bg)}h1,h2,h3,h4,h5,h6{font-family:var(--wsb-heading-font)}a{color:var(--wsb-link)}a:focus-visible,button:focus-visible,input:focus-visible,textarea:focus-visible,select:focus-visible,[tabindex]:focus-visible{outline:2px solid var(--wsb-accent);outline-offset:2px}@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation-duration:.001ms!important;animation-iteration-count:1!important;transition-duration:.001ms!important;scroll-behavior:auto!important}}</style>\n</head>",
E_icons: "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <title>Icons</title>\n <meta property=\"og:title\" content=\"Icons\">\n <meta property=\"og:type\" content=\"website\">\n <link rel=\"canonical\" href=\"https://example.com/icons\">\n <link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n <link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n <link href=\"https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap\" rel=\"stylesheet\">\n <link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.3.1/css/all.min.css\" integrity=\"sha384-qrALq7+6jBOZIQsNnT6xGkMDru64qD6uTlDra39xrt2SoXl4pO3FX6Roz/RpR/BS\" crossorigin=\"anonymous\" referrerpolicy=\"no-referrer\">\n <link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.3.1/css/v4-shims.min.css\" integrity=\"sha384-BOBgwI2GEcZ4l/UA/+WOVnLChe2S4c7UhUCtdmX1m0v7Wdyk0S/tDoJOBci2ywvl\" crossorigin=\"anonymous\" referrerpolicy=\"no-referrer\">\n <style>*,*::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}@media(max-width:767px){[style*=\"display: flex\"][style*=\"flex-direction: row\"],[style*=\"display:flex\"][style*=\"flex-direction:row\"]{flex-direction:column!important}[class^=\"cols_\"]{flex-direction:column!important;align-items:stretch!important}[data-wsb-grid]{grid-template-columns:minmax(0,1fr)!important}body{overflow-wrap:break-word}[data-wsb-html]{display:block!important;max-width:100%;min-width:0;overflow-x:auto;overflow-wrap:anywhere}[data-wsb-html] :is(img,video,iframe,embed,object,svg,canvas){max-width:100%}[data-wsb-html] :is(img,video){height:auto}}@media(max-width:991px){nav[class^=\"nav_\"],nav[class^=\"nav_\"] .navbar-links{flex-wrap:wrap}}@media(min-width:992px){[data-hide-desktop]{display:none!important}}@media(min-width:768px) and (max-width:991px){[data-hide-tablet]{display:none!important}}@media(max-width:767px){[data-hide-mobile]{display:none!important}}:root{--wsb-primary:#3b82f6;--wsb-secondary:#8b5cf6;--wsb-accent:#10b981;--wsb-link:#3b82f6;--wsb-success:#10b981;--wsb-warning:#f59e0b;--wsb-error:#ef4444;--wsb-bg:#ffffff;--wsb-text:#1f2937;--wsb-muted:#6b7280;--wsb-border:#e5e7eb;--wsb-radius:8px;--wsb-heading-font:Inter, sans-serif;--wsb-body-font:Inter, sans-serif;--wsb-button-font:Inter, sans-serif;--wsb-button-radius:8px}body{font-family:var(--wsb-body-font);color:var(--wsb-text);background:var(--wsb-bg)}h1,h2,h3,h4,h5,h6{font-family:var(--wsb-heading-font)}a{color:var(--wsb-link)}a:focus-visible,button:focus-visible,input:focus-visible,textarea:focus-visible,select:focus-visible,[tabindex]:focus-visible{outline:2px solid var(--wsb-accent);outline-offset:2px}@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation-duration:.001ms!important;animation-iteration-count:1!important;transition-duration:.001ms!important;scroll-behavior:auto!important}}</style>\n</head>",
};
PINNED_HEAD.E_brand_swatches = "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <title>Brand</title>\n <meta property=\"og:title\" content=\"Brand\">\n <meta property=\"og:type\" content=\"website\">\n <link rel=\"canonical\" href=\"https://example.com/\">\n <link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n <link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n <link href=\"https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap\" rel=\"stylesheet\">\n <style>*,*::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}@media(max-width:767px){[style*=\"display: flex\"][style*=\"flex-direction: row\"],[style*=\"display:flex\"][style*=\"flex-direction:row\"]{flex-direction:column!important}[class^=\"cols_\"]{flex-direction:column!important;align-items:stretch!important}nav[class^=\"nav_\"],nav[class^=\"nav_\"] .navbar-links{flex-wrap:wrap}}@media(min-width:992px){[data-hide-desktop]{display:none!important}}@media(min-width:768px) and (max-width:991px){[data-hide-tablet]{display:none!important}}@media(max-width:767px){[data-hide-mobile]{display:none!important}}:root{--wsb-primary:#3b82f6;--wsb-secondary:#8b5cf6;--wsb-accent:#10b981;--wsb-link:#3b82f6;--wsb-success:#10b981;--wsb-warning:#f59e0b;--wsb-error:#ef4444;--wsb-bg:#ffffff;--wsb-text:#1f2937;--wsb-muted:#6b7280;--wsb-border:#e5e7eb;--wsb-radius:8px;--wsb-heading-font:Inter, sans-serif;--wsb-body-font:Inter, sans-serif;--wsb-button-font:Inter, sans-serif;--wsb-button-radius:8px;--wsb-brand-1:#FFcc00;--wsb-brand-3:#0A7}body{font-family:var(--wsb-body-font);color:var(--wsb-text);background:var(--wsb-bg)}h1,h2,h3,h4,h5,h6{font-family:var(--wsb-heading-font)}a{color:var(--wsb-link)}a:focus-visible,button:focus-visible,input:focus-visible,textarea:focus-visible,select:focus-visible,[tabindex]:focus-visible{outline:2px solid var(--wsb-accent);outline-offset:2px}@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation-duration:.001ms!important;animation-iteration-count:1!important;transition-duration:.001ms!important;scroll-behavior:auto!important}}</style>\n</head>";
PINNED_HEAD.E_brand_swatches = "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <title>Brand</title>\n <meta property=\"og:title\" content=\"Brand\">\n <meta property=\"og:type\" content=\"website\">\n <link rel=\"canonical\" href=\"https://example.com/\">\n <link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n <link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n <link href=\"https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap\" rel=\"stylesheet\">\n <style>*,*::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}@media(max-width:767px){[style*=\"display: flex\"][style*=\"flex-direction: row\"],[style*=\"display:flex\"][style*=\"flex-direction:row\"]{flex-direction:column!important}[class^=\"cols_\"]{flex-direction:column!important;align-items:stretch!important}[data-wsb-grid]{grid-template-columns:minmax(0,1fr)!important}body{overflow-wrap:break-word}[data-wsb-html]{display:block!important;max-width:100%;min-width:0;overflow-x:auto;overflow-wrap:anywhere}[data-wsb-html] :is(img,video,iframe,embed,object,svg,canvas){max-width:100%}[data-wsb-html] :is(img,video){height:auto}}@media(max-width:991px){nav[class^=\"nav_\"],nav[class^=\"nav_\"] .navbar-links{flex-wrap:wrap}}@media(min-width:992px){[data-hide-desktop]{display:none!important}}@media(min-width:768px) and (max-width:991px){[data-hide-tablet]{display:none!important}}@media(max-width:767px){[data-hide-mobile]{display:none!important}}:root{--wsb-primary:#3b82f6;--wsb-secondary:#8b5cf6;--wsb-accent:#10b981;--wsb-link:#3b82f6;--wsb-success:#10b981;--wsb-warning:#f59e0b;--wsb-error:#ef4444;--wsb-bg:#ffffff;--wsb-text:#1f2937;--wsb-muted:#6b7280;--wsb-border:#e5e7eb;--wsb-radius:8px;--wsb-heading-font:Inter, sans-serif;--wsb-body-font:Inter, sans-serif;--wsb-button-font:Inter, sans-serif;--wsb-button-radius:8px;--wsb-brand-1:#FFcc00;--wsb-brand-3:#0A7}body{font-family:var(--wsb-body-font);color:var(--wsb-text);background:var(--wsb-bg)}h1,h2,h3,h4,h5,h6{font-family:var(--wsb-heading-font)}a{color:var(--wsb-link)}a:focus-visible,button:focus-visible,input:focus-visible,textarea:focus-visible,select:focus-visible,[tabindex]:focus-visible{outline:2px solid var(--wsb-accent);outline-offset:2px}@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation-duration:.001ms!important;animation-iteration-count:1!important;transition-duration:.001ms!important;scroll-behavior:auto!important}}</style>\n</head>";
PINNED_HEAD.F_swatches_no_brand = PINNED_HEAD.A_defaults;
const PINNED_BODY: Record<string, string> = {
@@ -196,8 +196,8 @@ describe('composed preview: animation head gate covers header+body+footer', () =
* only in the header), default design, canonical https://example.com/
*/
const BACKEND_PINNED_HEAD: Record<string, string> = {
E_headcode_font: "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <title>Notes</title>\n <meta property=\"og:title\" content=\"Notes\">\n <meta property=\"og:type\" content=\"website\">\n <link rel=\"canonical\" href=\"https://example.com/notes\">\n <link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n <link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n <link href=\"https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Merriweather:wght@300;400;700&display=swap\" rel=\"stylesheet\">\n <style>*,*::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}@media(max-width:767px){[style*=\"display: flex\"][style*=\"flex-direction: row\"],[style*=\"display:flex\"][style*=\"flex-direction:row\"]{flex-direction:column!important}[class^=\"cols_\"]{flex-direction:column!important;align-items:stretch!important}nav[class^=\"nav_\"],nav[class^=\"nav_\"] .navbar-links{flex-wrap:wrap}}@media(min-width:992px){[data-hide-desktop]{display:none!important}}@media(min-width:768px) and (max-width:991px){[data-hide-tablet]{display:none!important}}@media(max-width:767px){[data-hide-mobile]{display:none!important}}:root{--wsb-primary:#3b82f6;--wsb-secondary:#8b5cf6;--wsb-accent:#10b981;--wsb-link:#3b82f6;--wsb-success:#10b981;--wsb-warning:#f59e0b;--wsb-error:#ef4444;--wsb-bg:#ffffff;--wsb-text:#1f2937;--wsb-muted:#6b7280;--wsb-border:#e5e7eb;--wsb-radius:8px;--wsb-heading-font:Inter, sans-serif;--wsb-body-font:Inter, sans-serif;--wsb-button-font:Inter, sans-serif;--wsb-button-radius:8px}body{font-family:var(--wsb-body-font);color:var(--wsb-text);background:var(--wsb-bg)}h1,h2,h3,h4,h5,h6{font-family:var(--wsb-heading-font)}a{color:var(--wsb-link)}a:focus-visible,button:focus-visible,input:focus-visible,textarea:focus-visible,select:focus-visible,[tabindex]:focus-visible{outline:2px solid var(--wsb-accent);outline-offset:2px}@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation-duration:.001ms!important;animation-iteration-count:1!important;transition-duration:.001ms!important;scroll-behavior:auto!important}}</style>\n <style>.x{font-family:Merriweather,serif}</style>\n</head>",
F_header_animation: "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <title>Home</title>\n <meta property=\"og:title\" content=\"Home\">\n <meta property=\"og:type\" content=\"website\">\n <link rel=\"canonical\" href=\"https://example.com/\">\n <link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n <link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n <link href=\"https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap\" rel=\"stylesheet\">\n <style>*,*::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}@media(max-width:767px){[style*=\"display: flex\"][style*=\"flex-direction: row\"],[style*=\"display:flex\"][style*=\"flex-direction:row\"]{flex-direction:column!important}[class^=\"cols_\"]{flex-direction:column!important;align-items:stretch!important}nav[class^=\"nav_\"],nav[class^=\"nav_\"] .navbar-links{flex-wrap:wrap}}@media(min-width:992px){[data-hide-desktop]{display:none!important}}@media(min-width:768px) and (max-width:991px){[data-hide-tablet]{display:none!important}}@media(max-width:767px){[data-hide-mobile]{display:none!important}}@keyframes fadeIn{from{opacity:0}to{opacity:1}}@keyframes slideUp{from{opacity:0;transform:translateY(30px)}to{opacity:1;transform:translateY(0)}}@keyframes slideLeft{from{opacity:0;transform:translateX(-30px)}to{opacity:1;transform:translateX(0)}}@keyframes slideRight{from{opacity:0;transform:translateX(30px)}to{opacity:1;transform:translateX(0)}}@keyframes zoomIn{from{opacity:0;transform:scale(.9)}to{opacity:1;transform:scale(1)}}@keyframes bounce{0%{opacity:0;transform:translateY(40px)}40%{opacity:1;transform:translateY(-12px)}60%{transform:translateY(6px)}80%{transform:translateY(-3px)}100%{opacity:1;transform:translateY(0)}}[data-animation]{opacity:0}[data-animation].animated{animation-duration:.6s;animation-fill-mode:both}[data-animation=\"fade-in\"].animated{animation-name:fadeIn}[data-animation=\"slide-up\"].animated{animation-name:slideUp}[data-animation=\"slide-left\"].animated{animation-name:slideLeft}[data-animation=\"slide-right\"].animated{animation-name:slideRight}[data-animation=\"zoom-in\"].animated{animation-name:zoomIn}[data-animation=\"bounce\"].animated{animation-name:bounce}:root{--wsb-primary:#3b82f6;--wsb-secondary:#8b5cf6;--wsb-accent:#10b981;--wsb-link:#3b82f6;--wsb-success:#10b981;--wsb-warning:#f59e0b;--wsb-error:#ef4444;--wsb-bg:#ffffff;--wsb-text:#1f2937;--wsb-muted:#6b7280;--wsb-border:#e5e7eb;--wsb-radius:8px;--wsb-heading-font:Inter, sans-serif;--wsb-body-font:Inter, sans-serif;--wsb-button-font:Inter, sans-serif;--wsb-button-radius:8px}body{font-family:var(--wsb-body-font);color:var(--wsb-text);background:var(--wsb-bg)}h1,h2,h3,h4,h5,h6{font-family:var(--wsb-heading-font)}a{color:var(--wsb-link)}a:focus-visible,button:focus-visible,input:focus-visible,textarea:focus-visible,select:focus-visible,[tabindex]:focus-visible{outline:2px solid var(--wsb-accent);outline-offset:2px}@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation-duration:.001ms!important;animation-iteration-count:1!important;transition-duration:.001ms!important;scroll-behavior:auto!important}}</style>\n <noscript><style>[data-animation]{opacity:1}</style></noscript>\n</head>",
E_headcode_font: "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <title>Notes</title>\n <meta property=\"og:title\" content=\"Notes\">\n <meta property=\"og:type\" content=\"website\">\n <link rel=\"canonical\" href=\"https://example.com/notes\">\n <link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n <link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n <link href=\"https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Merriweather:wght@300;400;700&display=swap\" rel=\"stylesheet\">\n <style>*,*::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}@media(max-width:767px){[style*=\"display: flex\"][style*=\"flex-direction: row\"],[style*=\"display:flex\"][style*=\"flex-direction:row\"]{flex-direction:column!important}[class^=\"cols_\"]{flex-direction:column!important;align-items:stretch!important}[data-wsb-grid]{grid-template-columns:minmax(0,1fr)!important}body{overflow-wrap:break-word}[data-wsb-html]{display:block!important;max-width:100%;min-width:0;overflow-x:auto;overflow-wrap:anywhere}[data-wsb-html] :is(img,video,iframe,embed,object,svg,canvas){max-width:100%}[data-wsb-html] :is(img,video){height:auto}}@media(max-width:991px){nav[class^=\"nav_\"],nav[class^=\"nav_\"] .navbar-links{flex-wrap:wrap}}@media(min-width:992px){[data-hide-desktop]{display:none!important}}@media(min-width:768px) and (max-width:991px){[data-hide-tablet]{display:none!important}}@media(max-width:767px){[data-hide-mobile]{display:none!important}}:root{--wsb-primary:#3b82f6;--wsb-secondary:#8b5cf6;--wsb-accent:#10b981;--wsb-link:#3b82f6;--wsb-success:#10b981;--wsb-warning:#f59e0b;--wsb-error:#ef4444;--wsb-bg:#ffffff;--wsb-text:#1f2937;--wsb-muted:#6b7280;--wsb-border:#e5e7eb;--wsb-radius:8px;--wsb-heading-font:Inter, sans-serif;--wsb-body-font:Inter, sans-serif;--wsb-button-font:Inter, sans-serif;--wsb-button-radius:8px}body{font-family:var(--wsb-body-font);color:var(--wsb-text);background:var(--wsb-bg)}h1,h2,h3,h4,h5,h6{font-family:var(--wsb-heading-font)}a{color:var(--wsb-link)}a:focus-visible,button:focus-visible,input:focus-visible,textarea:focus-visible,select:focus-visible,[tabindex]:focus-visible{outline:2px solid var(--wsb-accent);outline-offset:2px}@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation-duration:.001ms!important;animation-iteration-count:1!important;transition-duration:.001ms!important;scroll-behavior:auto!important}}</style>\n <style>.x{font-family:Merriweather,serif}</style>\n</head>",
F_header_animation: "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <title>Home</title>\n <meta property=\"og:title\" content=\"Home\">\n <meta property=\"og:type\" content=\"website\">\n <link rel=\"canonical\" href=\"https://example.com/\">\n <link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n <link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n <link href=\"https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap\" rel=\"stylesheet\">\n <style>*,*::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}@media(max-width:767px){[style*=\"display: flex\"][style*=\"flex-direction: row\"],[style*=\"display:flex\"][style*=\"flex-direction:row\"]{flex-direction:column!important}[class^=\"cols_\"]{flex-direction:column!important;align-items:stretch!important}[data-wsb-grid]{grid-template-columns:minmax(0,1fr)!important}body{overflow-wrap:break-word}[data-wsb-html]{display:block!important;max-width:100%;min-width:0;overflow-x:auto;overflow-wrap:anywhere}[data-wsb-html] :is(img,video,iframe,embed,object,svg,canvas){max-width:100%}[data-wsb-html] :is(img,video){height:auto}}@media(max-width:991px){nav[class^=\"nav_\"],nav[class^=\"nav_\"] .navbar-links{flex-wrap:wrap}}@media(min-width:992px){[data-hide-desktop]{display:none!important}}@media(min-width:768px) and (max-width:991px){[data-hide-tablet]{display:none!important}}@media(max-width:767px){[data-hide-mobile]{display:none!important}}@keyframes fadeIn{from{opacity:0}to{opacity:1}}@keyframes slideUp{from{opacity:0;transform:translateY(30px)}to{opacity:1;transform:translateY(0)}}@keyframes slideLeft{from{opacity:0;transform:translateX(-30px)}to{opacity:1;transform:translateX(0)}}@keyframes slideRight{from{opacity:0;transform:translateX(30px)}to{opacity:1;transform:translateX(0)}}@keyframes zoomIn{from{opacity:0;transform:scale(.9)}to{opacity:1;transform:scale(1)}}@keyframes bounce{0%{opacity:0;transform:translateY(40px)}40%{opacity:1;transform:translateY(-12px)}60%{transform:translateY(6px)}80%{transform:translateY(-3px)}100%{opacity:1;transform:translateY(0)}}[data-animation]{opacity:0}[data-animation].animated{animation-duration:.6s;animation-fill-mode:both}[data-animation=\"fade-in\"].animated{animation-name:fadeIn}[data-animation=\"slide-up\"].animated{animation-name:slideUp}[data-animation=\"slide-left\"].animated{animation-name:slideLeft}[data-animation=\"slide-right\"].animated{animation-name:slideRight}[data-animation=\"zoom-in\"].animated{animation-name:zoomIn}[data-animation=\"bounce\"].animated{animation-name:bounce}:root{--wsb-primary:#3b82f6;--wsb-secondary:#8b5cf6;--wsb-accent:#10b981;--wsb-link:#3b82f6;--wsb-success:#10b981;--wsb-warning:#f59e0b;--wsb-error:#ef4444;--wsb-bg:#ffffff;--wsb-text:#1f2937;--wsb-muted:#6b7280;--wsb-border:#e5e7eb;--wsb-radius:8px;--wsb-heading-font:Inter, sans-serif;--wsb-body-font:Inter, sans-serif;--wsb-button-font:Inter, sans-serif;--wsb-button-radius:8px}body{font-family:var(--wsb-body-font);color:var(--wsb-text);background:var(--wsb-bg)}h1,h2,h3,h4,h5,h6{font-family:var(--wsb-heading-font)}a{color:var(--wsb-link)}a:focus-visible,button:focus-visible,input:focus-visible,textarea:focus-visible,select:focus-visible,[tabindex]:focus-visible{outline:2px solid var(--wsb-accent);outline-offset:2px}@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation-duration:.001ms!important;animation-iteration-count:1!important;transition-duration:.001ms!important;scroll-behavior:auto!important}}</style>\n <noscript><style>[data-animation]{opacity:1}</style></noscript>\n</head>",
};
describe('Preview head === backend head for the head-code font scan and header/footer animation gate', () => {
@@ -265,10 +265,19 @@ describe('Font Awesome head links (Preview === Published)', () => {
});
describe('mobile stacking + navbar wrap head rules', () => {
test('RESPONSIVE_CSS_MINIFIED stacks ColumnLayout (cols_ wrapper) and wraps Navbar links, inside the 767px query only', async () => {
test('RESPONSIVE_CSS_MINIFIED stacks ColumnLayout (cols_ wrapper) inside the 767px query and wraps Navbar links up to tablet (991px)', async () => {
const { RESPONSIVE_CSS_MINIFIED } = await import('./html-export');
expect(RESPONSIVE_CSS_MINIFIED).toContain('[class^="cols_"]{flex-direction:column!important;align-items:stretch!important}');
expect(RESPONSIVE_CSS_MINIFIED).toContain('nav[class^="nav_"],nav[class^="nav_"] .navbar-links{flex-wrap:wrap}');
expect(RESPONSIVE_CSS_MINIFIED.match(/@media/g)).toHaveLength(1);
expect(RESPONSIVE_CSS_MINIFIED).toContain('@media(max-width:991px){nav[class^="nav_"],nav[class^="nav_"] .navbar-links{flex-wrap:wrap}}');
expect(RESPONSIVE_CSS_MINIFIED.match(/@media/g)).toHaveLength(2);
});
test('mobile guard: card-grid hook, long-word wrap and the HtmlBlock net sit inside the 767px query only', async () => {
const { RESPONSIVE_CSS_MINIFIED } = await import('./html-export');
const mobile = /^@media\(max-width:767px\)\{(.*)\}@media\(max-width:991px\)/.exec(RESPONSIVE_CSS_MINIFIED)![1];
expect(mobile).toContain('[data-wsb-grid]{grid-template-columns:minmax(0,1fr)!important}');
expect(mobile).toContain('body{overflow-wrap:break-word}');
expect(mobile).toContain('[data-wsb-html]{display:block!important;max-width:100%;min-width:0;overflow-x:auto;overflow-wrap:anywhere}');
expect(RESPONSIVE_CSS_MINIFIED.split('data-wsb-').length - 1).toBe(mobile.split('data-wsb-').length - 1);
});
});
+38 -1
View File
@@ -10,6 +10,7 @@ import {
VISIBILITY_CSS_MINIFIED,
} from './html-export';
import { DEFAULT_SITE_DESIGN, SiteDesign } from '../state/SiteDesignContext';
import { componentResolver } from '../components/resolver';
/**
* C2: buildDataAttrs (internal to html-export.ts) previously interpolated
@@ -575,6 +576,11 @@ describe('export head unchanged by the TOKEN_VARS move (W0-tokens-core)', () =>
// 767 mobile boundary. Undo exactly that one change before hashing so these
// baselines still prove nothing ELSE in the head moved.
const undo767 = (html: string) => {
// mobile-guard: grid/word/HtmlBlock rules joined the 767 block and the Navbar wrap moved to its own
// <=991px block. Fold that back to the mobile-parity form first.
const guard = '[data-wsb-grid]{grid-template-columns:minmax(0,1fr)!important}body{overflow-wrap:break-word}[data-wsb-html]{display:block!important;max-width:100%;min-width:0;overflow-x:auto;overflow-wrap:anywhere}[data-wsb-html] :is(img,video,iframe,embed,object,svg,canvas){max-width:100%}[data-wsb-html] :is(img,video){height:auto}}@media(max-width:991px){nav[class^="nav_"],nav[class^="nav_"] .navbar-links{flex-wrap:wrap}';
expect(html.split(guard).length).toBe(2);
html = html.replace(guard, 'nav[class^="nav_"],nav[class^="nav_"] .navbar-links{flex-wrap:wrap}');
const from = '@media(max-width:767px){[style*=';
expect(html.split(from).length).toBe(2);
// mobile-parity then appended the ColumnLayout-stack and Navbar-wrap rules to that same media block.
@@ -676,7 +682,9 @@ describe('per-breakpoint overrides + hidden nodes (W0-breakpoints)', () => {
const { html } = exportToHtml(makeState({ styleTablet: { textAlign: 'center' } }));
const head = html.slice(0, html.indexOf('</head>'));
const body = html.slice(html.indexOf('<body>'));
expect(head).not.toContain('data-wsb');
// The per-node override block is body-only; the head carries only the fixed global hooks.
expect(head).not.toContain('data-wsb-r');
expect(head).not.toContain('data-wsb-responsive');
expect(body).toContain(
'<style data-wsb-responsive>@media (max-width:991px){[data-wsb-r="b-head1"]{text-align:center!important}}</style>',
);
@@ -881,3 +889,32 @@ describe('injectAttrs skips leading comments / style blocks / whitespace (findin
expect(injectAttrs('<!--c--><img src="x" />', ' data-z')).toBe('<!--c--><img src="x" data-z/>');
});
});
describe('mobile guard hooks in the exported body', () => {
const one = (type: string, props: Record<string, unknown>) => JSON.stringify({
ROOT: { type: { resolvedName: 'Container' }, isCanvas: true, props: { tag: 'div', style: {} }, displayName: 'Container', custom: {}, hidden: false, nodes: ['n'], linkedNodes: {} },
n: { type: { resolvedName: type }, isCanvas: false, props, displayName: type, custom: {}, parent: 'ROOT', hidden: false, nodes: [], linkedNodes: {} },
});
test('HtmlBlock output is wrapped in the inert display:contents net element; the block html itself is unchanged', () => {
const code = '<p>a</p><p>b</p>';
expect(exportBodyHtml(one('HtmlBlock', { code })).html).toBe(`<div><div data-wsb-html style="display:contents">${code}</div></div>`);
});
test('visibility/animation attrs still land on the customer\'s first element, not on the wrapper', () => {
const { html } = exportBodyHtml(one('HtmlBlock', { code: '<p>a</p><p>b</p>', hideOnMobile: true, animation: 'fade-in' }));
expect(html).toMatch(/^<div><div data-wsb-html style="display:contents"><p data-hide-mobile[^>]*data-animation="fade-in"[^>]*>a<\/p><p>b<\/p><\/div><\/div>$/);
});
test('an empty HtmlBlock exports nothing (no empty wrapper)', () => {
expect(exportBodyHtml(one('HtmlBlock', { code: '' })).html).toBe('<div></div>');
});
test('FeaturesGrid, Testimonials and NumberCounter mark their card grid with data-wsb-grid; Gallery does not', () => {
for (const t of ['FeaturesGrid', 'Testimonials', 'NumberCounter']) {
const { html } = exportBodyHtml(one(t, { ...(componentResolver as any)[t].craft.props }));
expect([t, (html.match(/<div [^>]*data-wsb-grid[^>]*display:grid/g) || []).length]).toEqual([t, 1]);
}
expect(exportBodyHtml(one('Gallery', { ...(componentResolver as any).Gallery.craft.props })).html).not.toContain('data-wsb-grid');
});
});
+39 -4
View File
@@ -194,8 +194,11 @@ function renderNode(
// with existing 2-arg `toHtml(props, childrenHtml)` implementations/tests.
if (component && typeof component.toHtml === 'function') {
const result = component.toHtml(props, allChildrenHtml, nodeId);
const html = result.html || '';
return { html: injectAttrs(html, dataAttrs) };
const html = injectAttrs(result.html || '', dataAttrs);
// `wrap` (HtmlBlock): an element placed around the output AFTER the data attrs were injected, so
// visibility/animation attrs keep landing on the component's own first element, as they always have.
const wrap = result.wrap as [string, string] | undefined;
return { html: wrap && html ? wrap[0] + html + wrap[1] : html };
}
// Fallback: wrap children in a div with inline styles
@@ -397,7 +400,18 @@ const { mobileMax, tabletMin, tabletMax, desktopMin } = BREAKPOINTS;
// node's own `[data-wsb-r]` mobile `flexDirection:'row'` override (later in the document) still wins = the opt-out.
// Navbar fallback: a Navbar saved with showMobileMenu:false has no hamburger, so on a phone its links ran off the
// screen. Wrapping (plain, no !important, so a hamburger Navbar's own rules are untouched) keeps every link
// reachable without flipping anyone's saved setting. Same-class wrapper as `scopeId(..., 'nav')`.
// reachable without flipping anyone's saved setting. Same-class wrapper as `scopeId(..., 'nav')`. It applies up
// to tablet (<=991px): a long logo plus many links overflowed at 768 too (measured, scripts/e2e-mobile-guard.mjs).
// Wrapping only changes a navbar whose links did not fit on one line.
// `[data-wsb-grid]`: card grids the components emit with inline `repeat(N,1fr)` (FeaturesGrid, Testimonials,
// NumberCounter) go to one column on a phone; tablet and desktop keep their columns. minmax(0,1fr), not 1fr,
// so a long word cannot push the single track wider than the screen.
// `body{overflow-wrap:break-word}`: an unbreakable word/URL wraps instead of running off a phone screen; text
// that already fits is untouched.
// `[data-wsb-html]`: the wrapper HtmlBlock exports around customer HTML (inline display:contents, so it has no
// box and changes nothing above 767px). On a phone it becomes a box no wider than its parent that scrolls
// sideways, so customer markup wider than the screen (fixed widths, px grids, wide tables) stays reachable
// instead of being clipped by html,body{overflow-x:clip}; media shrink to fit and long words break.
const RESPONSIVE_CSS = `
@media (max-width: ${mobileMax}px) {
[style*="display: flex"][style*="flex-direction: row"],
@@ -408,12 +422,33 @@ const RESPONSIVE_CSS = `
flex-direction: column !important;
align-items: stretch !important;
}
[data-wsb-grid] {
grid-template-columns: minmax(0, 1fr) !important;
}
body {
overflow-wrap: break-word;
}
[data-wsb-html] {
display: block !important;
max-width: 100%;
min-width: 0;
overflow-x: auto;
overflow-wrap: anywhere;
}
[data-wsb-html] :is(img, video, iframe, embed, object, svg, canvas) {
max-width: 100%;
}
[data-wsb-html] :is(img, video) {
height: auto;
}
}
@media (max-width: ${tabletMax}px) {
nav[class^="nav_"], nav[class^="nav_"] .navbar-links {
flex-wrap: wrap;
}
}`;
export const RESPONSIVE_CSS_MINIFIED = `@media(max-width:${mobileMax}px){[style*="display: flex"][style*="flex-direction: row"],[style*="display:flex"][style*="flex-direction:row"]{flex-direction:column!important}[class^="cols_"]{flex-direction:column!important;align-items:stretch!important}nav[class^="nav_"],nav[class^="nav_"] .navbar-links{flex-wrap:wrap}}`;
export const RESPONSIVE_CSS_MINIFIED = `@media(max-width:${mobileMax}px){[style*="display: flex"][style*="flex-direction: row"],[style*="display:flex"][style*="flex-direction:row"]{flex-direction:column!important}[class^="cols_"]{flex-direction:column!important;align-items:stretch!important}[data-wsb-grid]{grid-template-columns:minmax(0,1fr)!important}body{overflow-wrap:break-word}[data-wsb-html]{display:block!important;max-width:100%;min-width:0;overflow-x:auto;overflow-wrap:anywhere}[data-wsb-html] :is(img,video,iframe,embed,object,svg,canvas){max-width:100%}[data-wsb-html] :is(img,video){height:auto}}@media(max-width:${tabletMax}px){nav[class^="nav_"],nav[class^="nav_"] .navbar-links{flex-wrap:wrap}}`;
const VISIBILITY_CSS = `
@media (min-width: ${desktopMin}px) { [data-hide-desktop] { display: none !important; } }
+59 -1
View File
@@ -1,5 +1,5 @@
import { describe, test, expect } from 'vitest';
import { runPublishChecks, parseColor, contrastRatio, MAX_CHECKS, type CheckPage } from './publish-checks';
import { runPublishChecks, parseColor, contrastRatio, MAX_CHECKS, htmlBlockOverflowReasons, type CheckPage } from './publish-checks';
import { DEFAULT_SITE_DESIGN } from '../state/SiteDesignContext';
import { PLACEHOLDER_SRC } from '../components/media/ImageBlock';
@@ -214,3 +214,61 @@ describe('runPublishChecks', () => {
});
});
});
describe('custom HTML wider than a phone (mobile guard)', () => {
const hb = (code: string): N => ({ type: 'HtmlBlock', props: { code, aiName: 'Cards' } });
const wide = (code: string) => run([page({ nodes: { h1, x: hb(code) } })]).filter((c) => c.id.startsWith('html-wide:'));
test('flags fixed widths, min-widths, flex-basis and HTML width attributes over 390px', () => {
expect(htmlBlockOverflowReasons('<div style="width:1100px">x</div>')).toEqual(['a fixed width of 1100px']);
expect(htmlBlockOverflowReasons('<div style="min-width: 900px !important">x</div>')).toEqual(['a minimum width of 900px']);
expect(htmlBlockOverflowReasons('<style>.c{flex:0 0 420px}</style><div class="c"></div>')).toEqual(['a fixed flex size of 420px']);
expect(htmlBlockOverflowReasons('<style>.c{flex-basis:500px}</style>')).toEqual(['a fixed flex size of 500px']);
expect(htmlBlockOverflowReasons('<table width="800"><tr><td>x</td></tr></table>')).toEqual(['a table/box width="800"']);
});
test('flags grids whose fixed tracks add up to more than 390px, and 3+ column grids with no phone rule', () => {
expect(htmlBlockOverflowReasons('<div style="display:grid;grid-template-columns:repeat(3,300px)">')).toEqual(['a grid at least 900px wide']);
expect(htmlBlockOverflowReasons('<div style="grid-template-columns: 200px minmax(250px, 1fr)">')).toEqual(['a grid at least 450px wide']);
expect(htmlBlockOverflowReasons('<div style="grid-template-columns:repeat(auto-fit,minmax(420px,1fr))">')).toEqual(['a grid at least 420px wide']);
expect(htmlBlockOverflowReasons('<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:32px">')).toEqual(['a 3-column grid with no phone layout']);
});
test('does not flag what fits, what the net already shrinks, or what the author made responsive', () => {
for (const code of [
'<div style="max-width:1100px;margin:0 auto">x</div>', // max-width is not a fixed width
'<div style="width:100%;min-width:0">x</div>',
'<div style="width:390px">x</div>', // exactly a phone
'<img src="a.jpg" width="1200" height="400">', // media shrink to fit (net + reset)
'<iframe width="800" height="450" src="https://example.com"></iframe>',
'<div style="grid-template-columns:repeat(auto-fit,minmax(240px,1fr))">', // reflows on its own
'<div style="grid-template-columns:1fr 1fr">', // two columns: cramped at worst
'<style>.g{display:grid;grid-template-columns:repeat(3,1fr)}@media (max-width:767px){.g{grid-template-columns:1fr}}</style>',
'<style>@media (min-width: 1200px){.w{width:1140px}}</style>', // desktop-only rule
'<p>Only text, not a style: <code>width: 1100px; grid-template-columns: repeat(4, 1fr)</code></p>',
'',
]) expect([code, htmlBlockOverflowReasons(code)]).toEqual([code, []]);
});
test('one warning per block, naming the block, what was found, and what was not checked', () => {
const cs = wide('<div style="width:1100px"><div style="display:grid;grid-template-columns:repeat(3,300px)"></div></div>');
expect(cs).toHaveLength(1);
expect(cs[0]).toMatchObject({ id: 'html-wide:p1:x', severity: 'warn', pageId: 'p1', nodeId: 'x', fix: 'select' });
expect(cs[0].message).toContain('Home: custom HTML "Cards" may be wider than a phone screen (390px)');
expect(cs[0].message).toContain('a fixed width of 1100px and a grid at least 900px wide');
expect(cs[0].message).toContain('scrolls sideways inside the block');
expect(cs[0].message).toContain('Not checked: head code, scripts, external stylesheets, em/rem/% sizes and content widths');
});
test('a fitting block and non-HTML components produce nothing', () => {
expect(wide('<p>Hello</p>')).toEqual([]);
expect(run([page({ nodes: { h1, t: { type: 'TextBlock', props: { text: 'width:1100px' } } } })]).filter((c) => c.id.startsWith('html-wide:'))).toEqual([]);
});
test('header/footer zones are checked too', () => {
const zones = [{ id: '__header__', name: 'Header', craftState: state({ x: hb('<div style="width:1000px">nav</div>') }) }];
const cs = run([page({ nodes: { h1 } })], zones).filter((c) => c.id.startsWith('html-wide:'));
expect(cs.map((c) => c.id)).toEqual(['html-wide:__header__:x']);
expect(cs[0].message).toContain('Header: custom HTML "Cards"');
});
});
+117 -1
View File
@@ -217,6 +217,119 @@ function h1Count(nodes: SNodes, ids: string[]): number {
return n;
}
// ---------- custom HTML wider than a phone ----------
/** The phone width the HtmlBlock check measures against (a common modern phone). */
export const PHONE_WIDTH = 390;
const NOT_CHECKED = 'Not checked: head code, scripts, external stylesheets, em/rem/% sizes and content widths (long words, wide images).';
/** `css` with every @media block removed (balanced braces): those rules are the author's own breakpoints. */
function stripMediaBlocks(css: string): string {
let out = '';
let i = 0;
for (;;) {
const at = css.toLowerCase().indexOf('@media', i);
if (at === -1) return out + css.slice(i);
out += css.slice(i, at);
const open = css.indexOf('{', at);
if (open === -1) return out;
let depth = 1;
let j = open + 1;
for (; j < css.length && depth > 0; j++) {
if (css[j] === '{') depth++;
else if (css[j] === '}') depth--;
}
i = j;
}
}
/** Split a track list on top-level whitespace (keeps `minmax(a, b)` / `repeat(...)` together). */
function topLevelTokens(v: string): string[] {
const out: string[] = [];
let cur = '';
let depth = 0;
for (const ch of v.trim()) {
if (ch === '(') depth++;
if (ch === ')') depth--;
if (/\s/.test(ch) && depth === 0) { if (cur) out.push(cur); cur = ''; } else cur += ch;
}
if (cur) out.push(cur);
return out;
}
const pxOf = (t: string): number => { const m = /^(\d+(?:\.\d+)?)px$/i.exec(t.trim()); return m ? parseFloat(m[1]) : 0; };
/** Minimum width (px) a `grid-template-columns` value forces, and its column count (-1 = auto-fit/fill). */
function gridMin(value: string): { min: number; cols: number } {
let min = 0;
let cols = 0;
for (const tok of topLevelTokens(value.replace(/!important/i, ''))) {
const rep = /^repeat\(\s*([^,]+),(.*)\)$/i.exec(tok);
const count = rep ? (/^\d+$/.test(rep[1].trim()) ? parseInt(rep[1], 10) : -1) : 1;
const tracks = rep ? topLevelTokens(rep[2]) : [tok];
let each = 0;
for (const t of tracks) {
const mm = /^minmax\(\s*([^,]+),/i.exec(t);
each += mm ? pxOf(mm[1]) : pxOf(t);
}
if (count === -1) { min += each; cols = -1; } else { min += each * count; if (cols !== -1) cols += count * tracks.length; }
}
return { min, cols };
}
/**
* Why a Custom HTML block's code is likely wider than a {@link PHONE_WIDTH}px phone, as short phrases
* (empty = nothing found). A heuristic over the block's own inline `style` attributes and `<style>`
* elements (rules inside `@media` are the author's breakpoints and are skipped), plus `width=` on
* non-media elements. Media (img/video/iframe...) are not flagged: the published mobile net shrinks them.
*/
export function htmlBlockOverflowReasons(code: string): string[] {
if (!code) return [];
const styles: string[] = [];
for (const m of code.matchAll(/\sstyle\s*=\s*("([^"]*)"|'([^']*)')/gi)) styles.push(m[2] ?? m[3] ?? '');
const sheets = [...code.matchAll(/<style\b[^>]*>([\s\S]*?)<\/style>/gi)].map((m) => m[1]);
const authorHasPhoneRule = sheets.some((c) => /@media[^{]*max-width/i.test(c));
const css = [...styles, ...sheets.map(stripMediaBlocks)].join(';');
const reasons: string[] = [];
const add = (r: string) => { if (!reasons.includes(r)) reasons.push(r); };
// `(^|[;{\s])` so `max-width` / `line-width` never match.
for (const m of css.matchAll(/(?:^|[;{\s])(min-width|width|flex-basis)\s*:\s*(\d+(?:\.\d+)?)px/gi)) {
const px = parseFloat(m[2]);
if (px <= PHONE_WIDTH) continue;
const prop = m[1].toLowerCase();
add(prop === 'width' ? `a fixed width of ${px}px` : prop === 'min-width' ? `a minimum width of ${px}px` : `a fixed flex size of ${px}px`);
}
for (const m of css.matchAll(/(?:^|[;{\s])flex\s*:\s*\d+(?:\.\d+)?\s+\d+(?:\.\d+)?\s+(\d+(?:\.\d+)?)px/gi)) {
if (parseFloat(m[1]) > PHONE_WIDTH) add(`a fixed flex size of ${parseFloat(m[1])}px`);
}
for (const m of css.matchAll(/grid-template-columns\s*:\s*([^;{}]+)/gi)) {
const { min, cols } = gridMin(m[1]);
if (min > PHONE_WIDTH) add(`a grid at least ${min}px wide`);
else if (cols >= 3 && !authorHasPhoneRule) add(`a ${cols}-column grid with no phone layout`);
}
for (const m of code.matchAll(/<(table|div|td|th|section|article|p|form)\b[^>]*?\swidth\s*=\s*["']?(\d+)(?!\d|%)/gi)) {
if (parseInt(m[2], 10) > PHONE_WIDTH) add(`a table/box width="${m[2]}"`);
}
return reasons;
}
function htmlBlockChecks(nodes: SNodes, ids: string[], ownerId: string, where: string, out: Check[]) {
for (const id of ids) {
const n = nodes[id];
if (n.type?.resolvedName !== 'HtmlBlock') continue;
const reasons = htmlBlockOverflowReasons(typeof n.props?.code === 'string' ? n.props.code : '');
if (!reasons.length) continue;
const name = typeof n.props?.aiName === 'string' && n.props.aiName.trim() ? ` "${n.props.aiName.trim()}"` : '';
const found = reasons.length === 1 ? reasons[0] : `${reasons.slice(0, -1).join(', ')} and ${reasons[reasons.length - 1]}`;
out.push({
id: `html-wide:${ownerId}:${id}`, severity: 'warn',
message: `${where}: custom HTML${name} may be wider than a phone screen (${PHONE_WIDTH}px): it sets ${found}. `
+ `On phones it scrolls sideways inside the block; use max-width or a phone @media rule to make it fit. ${NOT_CHECKED}`,
pageId: ownerId, nodeId: id, fix: 'select',
});
}
}
/** Runs every pre-publish check; capped at {@link MAX_CHECKS}, deduped by id. */
export function runPublishChecks(input: PublishChecksInput): Check[] {
const { pages, design, zones = [] } = input;
@@ -324,6 +437,7 @@ export function runPublishChecks(input: PublishChecksInput): Check[] {
}
linkChecks(nodes, ids, page.id, where, out);
htmlBlockChecks(nodes, ids, page.id, where, out);
});
// Site-level pairs that are always seen together (text / muted text on the site background).
@@ -341,7 +455,9 @@ export function runPublishChecks(input: PublishChecksInput): Check[] {
for (const zone of zones) {
const nodes = parseState(zone.craftState);
if (!nodes) continue;
linkChecks(nodes, walk(nodes), zone.id, zone.name, out);
const zoneIds = walk(nodes);
linkChecks(nodes, zoneIds, zone.id, zone.name, out);
htmlBlockChecks(nodes, zoneIds, zone.id, zone.name, out);
}
const seen = new Set<string>();