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>
This commit is contained in:
1 parent
bb4fb7548b
commit
cfeeef37ce
10 files changed
+175
-89
No files matched your search
@@ -60,39 +60,51 @@ export function componentsState(): string {
|
||||
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> = {
|
||||
/** 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>',
|
||||
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 {
|
||||
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(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');
|
||||
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);
|
||||
@@ -106,5 +118,6 @@ function compose(state: string, title: string, showMobileMenu: boolean): string
|
||||
|
||||
(window as any).wsbMobileGuardDocs = (): Record<string, string> => ({
|
||||
components: compose(componentsState(), 'Components', false),
|
||||
htmlblocks: compose(htmlBlocksState(), 'Custom HTML', true),
|
||||
htmlblocks: compose(htmlState(HTML_FITS, true), 'Custom HTML', true),
|
||||
htmlwide: compose(htmlState(HTML_WIDE, false), 'Wide custom HTML', true),
|
||||
});
|
||||
@@ -5,26 +5,30 @@
|
||||
*
|
||||
* npm run test:browser:mobile-guard
|
||||
*
|
||||
* Builds two representative documents with the REAL exporter (scripts/e2e-mobile-guard-entry.ts):
|
||||
* Builds three 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
|
||||
* htmlblocks - customer HtmlBlocks the phone net makes fit (iframe, img, bare wide table, pre, URL), an
|
||||
* intentional overflow-x:auto table, and probes: sticky bar, absolutely positioned dropdown,
|
||||
* multi-root blocks as direct flex items / grid cells, collapsing margins, an inline run
|
||||
* htmlwide - customer HtmlBlocks wider than a phone BY DESIGN (fixed width, px grid, 4x1fr grid, <style>
|
||||
* flex cards, min-width): clipped as before, the publish warning is their guard
|
||||
* 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).
|
||||
* - documentElement.scrollWidth <= viewport and the page cannot be scrolled sideways (every doc, every width)
|
||||
* - body.scrollWidth <= viewport and the right edge of every visible element box and text line <= viewport,
|
||||
* unless it sits inside a scroll/clip container that itself fits (reachable / intentionally clipped):
|
||||
* components at every width, htmlblocks at <=767px (the HtmlBlock net is phone-only). htmlwide: the wide
|
||||
* content is still clipped (not reflowed) at <=767px.
|
||||
* - 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.
|
||||
* - the intentional overflow-x:auto table still scrolls; the bare wide table scrolls on its own on phones.
|
||||
* - htmlblocks probes, every width: the HtmlBlock wrapper is display:contents, sticky still sticks, the
|
||||
* dropdown is visible on top, multi-root blocks stay separate flex items / grid cells.
|
||||
* - 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).
|
||||
* identical after removing them in the page (unwrap [data-wsb-html], drop data-wsb-grid).
|
||||
* 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
|
||||
@@ -116,6 +120,54 @@ function measure() {
|
||||
};
|
||||
}
|
||||
|
||||
/** Runs in the page: can the page be scrolled sideways? (scrollX after asking for 5000px.) */
|
||||
function sidewaysScroll() {
|
||||
const y = window.scrollY;
|
||||
window.scrollTo(5000, y);
|
||||
const x = window.scrollX;
|
||||
window.scrollTo(0, y);
|
||||
return x;
|
||||
}
|
||||
|
||||
/** Runs in the page (htmlblocks doc): the net must not box, clip, unstick or merge customer HTML. */
|
||||
function probes() {
|
||||
const out = {};
|
||||
out.wrapperDisplays = [...new Set([...document.querySelectorAll('[data-wsb-html]')].map((w) => getComputedStyle(w).display))];
|
||||
// Multi-root block as direct grid items: two separate cells side by side.
|
||||
const cells = [...document.querySelectorAll('.cell-probe')].map((c) => c.getBoundingClientRect());
|
||||
out.cellsSeparate = cells.length === 2 && Math.abs(cells[0].top - cells[1].top) < 1 && cells[1].left > cells[0].right;
|
||||
// Multi-root block as direct flex items: no box between the roots and the flex container.
|
||||
const roots = [...document.querySelectorAll('.root-probe')];
|
||||
const flexBox = roots[0]?.closest('[style*="display:flex"]');
|
||||
out.rootsAreFlexItems = roots.length === 2 && roots.every((r) => {
|
||||
let p = r.parentElement;
|
||||
while (p && getComputedStyle(p).display === 'contents') p = p.parentElement;
|
||||
return p === flexBox;
|
||||
});
|
||||
// Sticky: scroll 300px into its tall container; the bar must stay pinned at the top of the viewport.
|
||||
const box = document.getElementById('sticky-box');
|
||||
const bar = document.getElementById('sticky-probe');
|
||||
if (box && bar) {
|
||||
window.scrollTo(0, window.scrollY + box.getBoundingClientRect().top + 300);
|
||||
out.stickyTop = Math.round(bar.getBoundingClientRect().top * 10) / 10;
|
||||
window.scrollTo(0, 0);
|
||||
}
|
||||
// Absolutely positioned dropdown hanging below its block: its last item must be visible and on top.
|
||||
const last = document.getElementById('dropdown-last');
|
||||
if (last) {
|
||||
// window.scrollTo, not scrollIntoView: scrollIntoView would also scroll a clipping ancestor to reveal it.
|
||||
window.scrollTo(0, window.scrollY + last.getBoundingClientRect().top - 200);
|
||||
const r = last.getBoundingClientRect();
|
||||
const hit = document.elementFromPoint(r.left + Math.min(10, r.width / 2), r.top + r.height / 2);
|
||||
out.dropdownVisible = !!hit && (hit === last || last.contains(hit));
|
||||
window.scrollTo(0, 0);
|
||||
}
|
||||
// Wide bare table: it scrolls by itself, so its last cell is reachable.
|
||||
const t = document.getElementById('bare-table');
|
||||
if (t) { t.scrollLeft = 10000; out.tableScrolls = t.scrollWidth > t.clientWidth && t.scrollLeft > 0; t.scrollLeft = 0; }
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Runs in the page. Element boxes, ignoring display:contents hook wrappers (they have no box). */
|
||||
function boxes() {
|
||||
return [...document.body.querySelectorAll('*')]
|
||||
@@ -152,12 +204,18 @@ async function run(name, launcher, exe) {
|
||||
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}`);
|
||||
summary.push(`${name.padEnd(8)} ${String(w).padEnd(4)} ${docName.padEnd(11)} 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) {
|
||||
const sx = await page.evaluate(sidewaysScroll);
|
||||
ok(sx === 0, `${tag}: the page scrolls sideways (scrollX=${sx})`);
|
||||
if (docName === 'htmlwide') {
|
||||
// Wider than a phone by design (fixed widths, px grids): the net does not reflow or box it, so it stays
|
||||
// clipped by html,body{overflow-x:clip} exactly as before (the publish warning is the guard for these),
|
||||
// and the page never scrolls sideways (asserted above).
|
||||
if (mobile) ok(m.maxRight > w + 0.5, `${tag}: the wide fixed block was expected to stay clipped (content ends at x=${m.maxRight})`);
|
||||
} else if (docName === 'components' || mobile) {
|
||||
// Customer HTML the net makes fit is held to the viewport only where the net applies (<=767px).
|
||||
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}`);
|
||||
}
|
||||
@@ -166,6 +224,16 @@ async function run(name, launcher, exe) {
|
||||
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 === 'htmlblocks') {
|
||||
const pr = await page.evaluate(probes);
|
||||
summary.push(`${' '.repeat(26)}probes ${JSON.stringify(pr)}`);
|
||||
ok(pr.wrapperDisplays.length === 1 && pr.wrapperDisplays[0] === 'contents', `${tag}: HtmlBlock wrapper has a box (display ${pr.wrapperDisplays})`);
|
||||
ok(pr.cellsSeparate, `${tag}: a multi-root HtmlBlock in a grid no longer fills separate cells`);
|
||||
ok(pr.rootsAreFlexItems, `${tag}: a multi-root HtmlBlock in a flex row is no longer separate flex items`);
|
||||
ok(pr.stickyTop !== undefined && Math.abs(pr.stickyTop) <= 1, `${tag}: position:sticky inside an HtmlBlock stopped sticking (top=${pr.stickyTop})`);
|
||||
ok(pr.dropdownVisible, `${tag}: an absolutely positioned dropdown inside an HtmlBlock is clipped or covered`);
|
||||
if (mobile) ok(pr.tableScrolls, `${tag}: the wide bare table does not scroll on its own`);
|
||||
}
|
||||
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) {
|
||||
|
||||
Reference in new issue
Block a user