Files
site-builder/craft/scripts/e2e-mobile-guard-entry.ts
T
shadowdaoandClaude Opus 5.5 cfeeef37ce Mobile guard: HtmlBlock wrapper never gets a box; phone net is media + per-table/pre scroll only
Review of bb4fb75: overflow-x:auto on [data-wsb-html] forced overflow-y:auto (clipped dropdowns/shadows),
broke position:sticky inside customer HTML, and display:block!important merged multi-root blocks that are
direct flex/grid items. Now the wrapper stays display:contents on every viewport and no rule targets it;
at <=767px only its descendants are touched: media max-width:100% (img/video height:auto), and a table or
<pre> becomes its own sideways scroller (not when already inside an inline overflow wrapper). No
overflow-wrap:anywhere (it shrank min-content); body{overflow-wrap:break-word} inherits through the wrapper.
Deliberately wide customer markup (fixed widths, px grids) is clipped as before; the publish warning is
its guard, and its wording now says the excess is cut off. width= attributes are no longer flagged (they
size only media and tables, which the net handles). stripMediaBlocks lowercases once.

Browser suite: htmlblocks probes at every width (wrapper display:contents, sticky sticks, absolutely
positioned dropdown visible on top, multi-root block = separate flex items / grid cells, bare table
scrolls on its own); new htmlwide doc asserts wide blocks stay clipped and the page never scrolls sideways.
All probes fail on bb4fb75's net and pass now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 15:09:22 -08:00

124 lines
9.4 KiB
TypeScript

// 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 the phone net makes fit: media shrink, tables and <pre> scroll on their own, long words wrap. */
export const HTML_FITS: Record<string, string> = {
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 id="bare-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>',
// Phone probes: none of these may be clipped, unstuck or merged by the net.
hbSticky: '<div id="sticky-box" style="height:1200px;background:#f5f5f5"><div id="sticky-probe" style="position:sticky;top:0;background:#ffeb3b;padding:8px">Sticky bar</div></div>',
hbDropdown: '<div style="position:relative"><button type="button">Menu</button><ul id="dropdown-probe" style="position:absolute;top:100%;left:0;z-index:10;list-style:none;margin:0;padding:8px;background:#fff;box-shadow:0 4px 12px #0004"><li>One</li><li>Two</li><li>Three</li><li>Four</li><li id="dropdown-last">Last item</li></ul></div>',
};
/** Customer raw HTML wider than a phone by design (fixed widths / px grids): clipped as before, never a sideways page. */
export const HTML_WIDE: 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>',
};
function htmlState(blocks: Record<string, string>, probes: boolean): 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(blocks)) { nodes[id] = node('HtmlBlock', { code }, [], { parent: 'ROOT' }); kids.push(id); }
if (probes) {
// A multi-root HtmlBlock that is a direct flex / grid item: its roots must stay separate items/cells, at every
// width (and a boxed wrapper would also move the margin and inline probes at desktop).
nodes.flexRow = node('Container', { tag: 'div', style: { display: 'flex', flexDirection: 'row', gap: '12px' } }, ['hbRoots', 'rowText'], { parent: 'ROOT' });
nodes.hbRoots = node('HtmlBlock', { code: '<div class="root-probe" style="flex:1;background:#eef">root one</div><div class="root-probe" style="flex:2;background:#fee">root two</div>' }, [], { parent: 'flexRow' });
nodes.rowText = node('TextBlock', { text: 'flex sibling' }, [], { parent: 'flexRow' });
kids.push('flexRow');
nodes.gridBox = node('Container', { tag: 'div', style: { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px' } }, ['hbCells'], { parent: 'ROOT' });
nodes.hbCells = node('HtmlBlock', { code: '<div class="cell-probe" style="background:#efe">cell one</div><div class="cell-probe" style="background:#fef">cell two</div>' }, [], { parent: 'gridBox' });
kids.push('gridBox');
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(htmlState(HTML_FITS, true), 'Custom HTML', true),
htmlwide: compose(htmlState(HTML_WIDE, false), 'Wide custom HTML', true),
});