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>
124 lines
9.4 KiB
TypeScript
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),
|
|
});
|