From 79cdd593d941e9bbf2bbae6da4889ee59e424cd0 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 1 Nov 2026 10:10:15 -0800 Subject: [PATCH 1/4] Navbar: showMobileMenu defaults to true for new Navbars and the seeded header Existing Navbars (prop absent/false) are untouched: render default and toHtml stay falsy. Co-Authored-By: Claude Sonnet 5.5 --- craft/src/components/basic/Navbar.toHtml.test.ts | 10 ++++++++++ craft/src/components/basic/Navbar.tsx | 4 +++- craft/src/state/PageContext.tsx | 2 +- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/craft/src/components/basic/Navbar.toHtml.test.ts b/craft/src/components/basic/Navbar.toHtml.test.ts index ea2c5bb..7179602 100644 --- a/craft/src/components/basic/Navbar.toHtml.test.ts +++ b/craft/src/components/basic/Navbar.toHtml.test.ts @@ -195,3 +195,13 @@ describe('Navbar.toHtml design tokens (#44)', () => { for (const hex of ['#3f3f46', '#e4e4e7']) expect(lower, `old ${hex}`).not.toContain(hex.toLowerCase()); }); }); + +describe('Navbar mobile menu default', () => { + test('NEW Navbars (craft defaults) get the hamburger; a stored Navbar without the prop keeps its output', async () => { + const { Navbar } = await import('./Navbar'); + expect((Navbar as any).craft.props.showMobileMenu).toBe(true); + const toHtml = (Navbar as any).toHtml; + expect(toHtml({}, '', 'n1').html).not.toContain('navbar-hamburger"'); + expect(toHtml({ showMobileMenu: true }, '', 'n1').html).toContain('class="navbar-hamburger"'); + }); +}); diff --git a/craft/src/components/basic/Navbar.tsx b/craft/src/components/basic/Navbar.tsx index 0be00d2..10cddc9 100644 --- a/craft/src/components/basic/Navbar.tsx +++ b/craft/src/components/basic/Navbar.tsx @@ -246,7 +246,9 @@ Navbar.craft = { padding: '16px 24px', navAlignment: 'space-between', isSticky: false, - showMobileMenu: false, + // On for NEW Navbars (links overflow a phone otherwise). The component's render default and toHtml + // stay falsy so a stored Navbar that never had the prop keeps its exact output. + showMobileMenu: true, style: { borderBottom: `1px solid ${tokenRef('border')}`, }, diff --git a/craft/src/state/PageContext.tsx b/craft/src/state/PageContext.tsx index abc9276..c7f7fea 100644 --- a/craft/src/state/PageContext.tsx +++ b/craft/src/state/PageContext.tsx @@ -252,7 +252,7 @@ export const DEFAULT_HEADER_STATE = JSON.stringify({ padding: '16px 24px', navAlignment: 'space-between', isSticky: false, - showMobileMenu: false, + showMobileMenu: true, style: { borderBottom: '1px solid #e4e4e7' }, }, displayName: 'Navbar', From 69fd34bd7c328b048366bc3bda3f37d71bbb2adf Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 1 Nov 2026 10:10:15 -0800 Subject: [PATCH 2/4] Preview/Published head: stack ColumnLayout on phones, wrap Navbar links, emit Font Awesome - <=767px: [class^=cols_] wrappers (every ColumnLayout split) stack; a mobile flexDirection:row override still opts out. - <=767px: navbars without a hamburger wrap instead of overflowing. - wrapInDocument emits the same Font Awesome links as the backend sb_font_awesome_link (Preview is a sandboxed srcdoc iframe). - head-parity pins updated (+ E_icons case). Co-Authored-By: Claude Sonnet 5.5 --- .../src/utils/html-export.head-parity.test.ts | 61 +++++++++++++++++-- craft/src/utils/html-export.test.ts | 5 +- craft/src/utils/html-export.ts | 40 +++++++++++- 3 files changed, 97 insertions(+), 9 deletions(-) diff --git a/craft/src/utils/html-export.head-parity.test.ts b/craft/src/utils/html-export.head-parity.test.ts index 1e2ec4a..c17baa0 100644 --- a/craft/src/utils/html-export.head-parity.test.ts +++ b/craft/src/utils/html-export.head-parity.test.ts @@ -14,10 +14,11 @@ import { exportToHtml, exportBodyHtml, buildFontsLink, type ExportOptions } from import { DEFAULT_SITE_DESIGN, type SiteDesign } from '../state/SiteDesignContext'; const PINNED_HEAD: Record = { - A_defaults: "\n\n\n \n \n Joe's Pizza & Grill\n \n \n \n \n \n \n \n", - B_full: "\n\n\n \n \n \n About "Us"\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n", - C_unknown_font: "\n\n\n \n \n Contact\n \n \n \n \n \n \n \n \n \n \n", - D_body_font_and_blank_tokens: "\n\n\n \n \n Services\n \n \n \n \n \n \n \n", + A_defaults: "\n\n\n \n \n Joe's Pizza & Grill\n \n \n \n \n \n \n \n", + B_full: "\n\n\n \n \n \n About "Us"\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n", + C_unknown_font: "\n\n\n \n \n Contact\n \n \n \n \n \n \n \n \n \n \n", + D_body_font_and_blank_tokens: "\n\n\n \n \n Services\n \n \n \n \n \n \n \n", + E_icons: "\n\n\n \n \n Icons\n \n \n \n \n \n \n \n \n \n", }; const PINNED_BODY: Record = { @@ -25,6 +26,7 @@ const PINNED_BODY: Record = { B_full: "
", C_unknown_font: "
", D_body_font_and_blank_tokens: "
", + E_icons: "", }; @@ -77,6 +79,10 @@ const CASES: Record = { design: merged({ accentColor: '', textColor: ' #111111 ' }), }, }, + E_icons: { + state: root({}), + opts: { title: 'Icons', includeFonts: true, canonicalUrl: U + 'icons', fontScanHtml: '' }, + }, }; const headOf = (doc: string) => doc.slice(0, doc.indexOf('') + ''.length); @@ -84,6 +90,8 @@ const headOf = (doc: string) => doc.slice(0, doc.indexOf('') + ''. describe('Preview head === pinned published head (WHP test-site-builder-head-parity.php)', () => { for (const [name, { state, opts }] of Object.entries(CASES)) { test(`${name}: body input matches the backend case`, () => { + // E_icons feeds the icon markup through fontScanHtml (the composed page), as the PHP case passes it as the body. + if (name === 'E_icons') return expect(opts.fontScanHtml).toBe(PINNED_BODY[name]); expect(exportBodyHtml(state).html).toBe(PINNED_BODY[name]); }); test(`${name}: COMPLETE head is byte-identical`, () => { @@ -159,8 +167,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 = { - E_headcode_font: "\n\n\n \n \n Notes\n \n \n \n \n \n \n \n \n", - F_header_animation: "\n\n\n \n \n Home\n \n \n \n \n \n \n \n \n", + E_headcode_font: "\n\n\n \n \n Notes\n \n \n \n \n \n \n \n \n", + F_header_animation: "\n\n\n \n \n Home\n \n \n \n \n \n \n \n \n", }; describe('Preview head === backend head for the head-code font scan and header/footer animation gate', () => { @@ -194,3 +202,44 @@ describe('Preview head === backend head for the head-code font scan and header/f expect(headOf(html)).toBe(BACKEND_PINNED_HEAD.F_header_animation); }); }); + +// Font Awesome links: same decisions as the backend's sb_font_awesome_link() (scripts/test-site-builder-head-parity.php "FA:" block). +describe('Font Awesome head links (Preview === Published)', () => { + const FA = 'font-awesome/7.3.1/css/all.min.css'; + const SHIM = 'font-awesome/7.3.1/css/v4-shims.min.css'; + const count = (s: string, needle: string) => s.split(needle).length - 1; + const head = (body: string, headCode = '') => headOf(exportToHtml(root({}), { title: 'T', includeFonts: true, fontScanHtml: body, headCode }).html); + + test('no icon => no link', () => { expect(head('

x

')).not.toContain('font-awesome'); }); + test('v7 markup => all.min.css once, no shims', () => { + const h = head(''); + expect([count(h, FA), count(h, SHIM)]).toEqual([1, 0]); + }); + test('legacy `fa fa-star` => all.min.css + v4-shims once each', () => { + const h = head(''); + expect([count(h, FA), count(h, SHIM)]).toEqual([1, 1]); + }); + test('order: fonts link, then Font Awesome, then ${animationNoscript}${headCode} From 2e5492600ef069f31514df68851a6a63910e235a Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 1 Nov 2026 10:10:15 -0800 Subject: [PATCH 3/4] Editor: mirror default mobile column stacking (canvas emulation + Stack toggle) Co-Authored-By: Claude Sonnet 5.5 --- .../layout/ColumnLayout.stack-parity.test.tsx | 24 +++++++++++++++++++ craft/src/components/layout/ColumnLayout.tsx | 1 + .../right/styles/ContainerStylePanel.tsx | 9 +++---- .../right/styles/responsive-panel.test.tsx | 22 +++++++++++++---- craft/src/styles/editor.css | 7 ++++++ 5 files changed, 55 insertions(+), 8 deletions(-) diff --git a/craft/src/components/layout/ColumnLayout.stack-parity.test.tsx b/craft/src/components/layout/ColumnLayout.stack-parity.test.tsx index 4eccbc9..e27e21c 100644 --- a/craft/src/components/layout/ColumnLayout.stack-parity.test.tsx +++ b/craft/src/components/layout/ColumnLayout.stack-parity.test.tsx @@ -86,3 +86,27 @@ describe('Columns mobile stacking: editor matches published', () => { expect(editorStacksOnMobile(state)).toBe(true); }); }); + +describe('Columns stack on phones BY DEFAULT (no flex-direction in the props)', () => { + test('published: wrapper carries the cols_ class the global head rule targets; mobile row override is the opt-out', () => { + const html = exportBodyHtml(colsState({ style: {} })).html; + expect(html).toMatch(/
{ + const stacksDefault = () => { + h = renderEditorHarness({ + initialState: colsState({ style: {} }), + onRender: RenderNode, + wrap: (tree) =>
{tree}
, + }); + const el = h.container.querySelector('#cl') as HTMLElement; + return el.matches(editorMobileStackSelectors()); + }; + expect(stacksDefault()).toBe(true); + h?.unmount(); h = null; + expect(editorStacksOnMobile(colsState({ styleMobile: { flexDirection: 'row' } }))).toBe(false); + }); +}); diff --git a/craft/src/components/layout/ColumnLayout.tsx b/craft/src/components/layout/ColumnLayout.tsx index 64904d5..ab7cfd1 100644 --- a/craft/src/components/layout/ColumnLayout.tsx +++ b/craft/src/components/layout/ColumnLayout.tsx @@ -75,6 +75,7 @@ export const ColumnLayout: UserComponent = ({
{ if (ref) connect(drag(ref)); }} id={anchorId || undefined} + data-wsb-cols="" style={{ display: 'flex', flexWrap: 'wrap', diff --git a/craft/src/panels/right/styles/ContainerStylePanel.tsx b/craft/src/panels/right/styles/ContainerStylePanel.tsx index 03fc37d..0e4790a 100644 --- a/craft/src/panels/right/styles/ContainerStylePanel.tsx +++ b/craft/src/panels/right/styles/ContainerStylePanel.tsx @@ -53,10 +53,11 @@ export const ContainerStylePanel: React.FC = ({ selectedId, nod // override. Turning it off removes this device's override; if the columns // would still inherit a stack (mobile under a stacked tablet), it writes // 'row' instead so the toggle really un-stacks this device. - const stacked = style.flexDirection === 'column'; - const inheritsStack = - device !== 'desktop' && - resolveStyleForDevice(withoutOverride(nodeProps, device, 'flexDirection'), device).flexDirection === 'column'; + // On mobile, Columns stack BY DEFAULT in the published head (`[class^="cols_"]` rule), so "no row override" = stacked. + const stacksByDefault = (dir: unknown) => isColumns && device === 'mobile' && !/^row/.test(String(dir ?? '')); + const stacked = style.flexDirection === 'column' || stacksByDefault(style.flexDirection); + const inheritedDir = resolveStyleForDevice(withoutOverride(nodeProps, device, 'flexDirection'), device).flexDirection; + const inheritsStack = device !== 'desktop' && (inheritedDir === 'column' || stacksByDefault(inheritedDir)); const toggleStack = () => setPropStyle('flexDirection', stacked ? (inheritsStack ? 'row' : '') : 'column'); return ( diff --git a/craft/src/panels/right/styles/responsive-panel.test.tsx b/craft/src/panels/right/styles/responsive-panel.test.tsx index ad6de5f..93afc15 100644 --- a/craft/src/panels/right/styles/responsive-panel.test.tsx +++ b/craft/src/panels/right/styles/responsive-panel.test.tsx @@ -282,16 +282,30 @@ describe('setter routing + undo bursts', () => { }); describe('Columns "Stack on this device"', () => { - test('hidden on desktop; on mobile it writes flexDirection:column to the mobile override only', () => { + test('hidden on desktop; on tablet it writes flexDirection:column to the tablet override only', () => { mountPanel('desktop', ContainerStylePanel, 'cols'); expect(h!.container.querySelector('[data-testid="stack-columns"]')).toBeNull(); - switchDevice('mobile'); + switchDevice('tablet'); const box = h!.container.querySelector('[data-testid="stack-columns"]') as HTMLInputElement; + expect(box.checked).toBe(false); click(box); - expect(props('cols').styleMobile).toEqual({ flexDirection: 'column' }); + expect(props('cols').styleTablet).toEqual({ flexDirection: 'column' }); expect(props('cols').style.flexDirection).toBeUndefined(); click(h!.container.querySelector('[data-testid="stack-columns"]')!); - expect(props('cols').styleMobile).toEqual({}); + expect(props('cols').styleTablet).toEqual({}); + }); + + test('mobile: Columns stack by default (box checked); unchecking writes the row opt-out, re-checking writes column', () => { + mountPanel('desktop', ContainerStylePanel, 'cols'); + switchDevice('mobile'); + const box = () => h!.container.querySelector('[data-testid="stack-columns"]') as HTMLInputElement; + expect(box().checked).toBe(true); + click(box()); + expect(props('cols').styleMobile).toEqual({ flexDirection: 'row' }); + expect(box().checked).toBe(false); + click(box()); + expect(props('cols').styleMobile).toEqual({ flexDirection: 'column' }); + expect(props('cols').style.flexDirection).toBeUndefined(); }); test('un-stacking mobile under a stacked tablet writes row', () => { diff --git a/craft/src/styles/editor.css b/craft/src/styles/editor.css index 270ddba..3577622 100644 --- a/craft/src/styles/editor.css +++ b/craft/src/styles/editor.css @@ -2366,6 +2366,13 @@ body { flex-direction: column !important; } +/* Columns stack on phones by default in the published head (`[class^="cols_"]`); a mobile `flexDirection:row` + override (resolved inline style) is the opt-out, exactly as published. */ +.canvas-device-frame[data-device="mobile"] [data-wsb-cols]:not([style*="flex-direction: row"]) { + flex-direction: column !important; + align-items: stretch !important; +} + .canvas-device-frame:not([data-device="desktop"]) [data-fullwidth] { width: 100% !important; margin-left: 0 !important; From c4b6802cab136af4456cf09103e909bb48b84077 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 1 Nov 2026 10:10:15 -0800 Subject: [PATCH 4/4] Add real-browser mobile parity test (Firefox + Chromium, 390/1280) npm run test:browser:mobile; MOBILE_PARITY_RECORD/BASELINE compare desktop boxes against a base-commit snapshot. Co-Authored-By: Claude Sonnet 5.5 --- craft/package.json | 1 + craft/scripts/e2e-mobile-parity-entry.ts | 73 +++++++++++++ craft/scripts/e2e-mobile-parity.mjs | 131 +++++++++++++++++++++++ 3 files changed, 205 insertions(+) create mode 100644 craft/scripts/e2e-mobile-parity-entry.ts create mode 100644 craft/scripts/e2e-mobile-parity.mjs diff --git a/craft/package.json b/craft/package.json index 5789f0c..f8d4921 100644 --- a/craft/package.json +++ b/craft/package.json @@ -13,6 +13,7 @@ "test:unit": "vitest run", "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:unit:watch": "vitest" }, "dependencies": { diff --git a/craft/scripts/e2e-mobile-parity-entry.ts b/craft/scripts/e2e-mobile-parity-entry.ts new file mode 100644 index 0000000..3ab00e3 --- /dev/null +++ b/craft/scripts/e2e-mobile-parity-entry.ts @@ -0,0 +1,73 @@ +// Bundled by scripts/e2e-mobile-parity.mjs. Builds the documents the real-browser mobile test measures. +// Uses only exports that exist before and after the mobile-parity fix so the SAME harness can record a baseline. +import { exportBodyHtml, exportComposedPreviewHtml } from '../src/utils/html-export'; + +const node = (type: string, props: Record, nodes: string[] = [], extra: Record = {}) => ({ + type: { resolvedName: type }, isCanvas: type === 'Container', 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.'; + +/** ColumnLayout with `n` Container columns each holding one TextBlock. */ +function addColumns(nodes: Record, id: string, props: Record, n: number) { + const linked: Record = {}; + 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', anchorId: id, ...props }, [], { parent: 'ROOT', linkedNodes: linked }); +} + +export const NAV_LINKS = ['Home', 'About us', 'Services', 'Our work', 'Pricing', 'Blog', 'Contact'].map((text, i, a) => ({ + text, href: '#' + text.toLowerCase().replace(/\s/g, '-'), ...(i === a.length - 1 ? { isCta: true } : {}), +})); + +export function syntheticState(navShowMobileMenu: boolean | undefined): string { + const nodes: Record = {}; + const kids: string[] = ['nav']; + const navProps: Record = { logoText: 'Acme Plumbing & Heating', links: NAV_LINKS, padding: '16px 24px' }; + if (navShowMobileMenu !== undefined) navProps.showMobileMenu = navShowMobileMenu; + nodes.nav = node('Navbar', navProps, [], { parent: 'ROOT' }); + const specs: Array<[string, Record, number]> = [ + ['c2', { split: '50-50' }, 2], + ['centered', { split: '50-50', gap: '48px', style: { alignItems: 'center' } }, 2], + ['c3', { split: '33-33-33' }, 3], + ['c4', { split: '25-25-25-25' }, 4], + ['c6', { split: '16-16-16-16-16-16' }, 6], + ['sideL', { split: '30-70' }, 2], + ['sideR', { split: '70-30' }, 2], + ['optout', { split: '50-50', styleMobile: { flexDirection: 'row' } }, 2], + ]; + for (const [id, p, n] of specs) { addColumns(nodes, id, p, n); kids.push(id); } + // An existing author-built flex row (already stacks via the global rule) must be unaffected. + nodes.row = node('Container', { tag: 'div', style: { display: 'flex', flexDirection: 'row', gap: '12px' } }, ['rt1', 'rt2'], { parent: 'ROOT' }); + nodes.rt1 = node('TextBlock', { text: 'Row item one', style: {} }, [], { parent: 'row' }); + nodes.rt2 = node('TextBlock', { text: 'Row item two', style: {} }, [], { parent: 'row' }); + kids.push('row'); + nodes.ROOT = node('Container', { tag: 'div', style: {} }, kids); + return JSON.stringify(nodes); +} + +export function renderSynthetic(navShowMobileMenu: boolean | undefined): string { + const state = syntheticState(navShowMobileMenu); + const body = exportBodyHtml(state).html; + return exportComposedPreviewHtml(state, body, { title: 'Synthetic', includeFonts: false }); +} + +/** Stored OLS page rendered by the CURRENT frontend: regenerated body + current head. */ +export function renderStored(project: any, pageIdx: number): string { + const page = project.pages_craft_state[pageIdx]; + const hdr = project.header_craft_state ? exportBodyHtml(project.header_craft_state, 'header').html : ''; + const ftr = project.footer_craft_state ? exportBodyHtml(project.footer_craft_state, 'footer').html : ''; + const body = exportBodyHtml(page.craftState).html; + return exportComposedPreviewHtml(page.craftState, hdr + body + ftr, { title: page.name, includeFonts: true, headCode: project.design?.headCode, design: project.design }); +} + +/** The checked-in (OLD) stored body under the CURRENT head: "existing site, next publish". */ +export function renderOldBodyNewHead(project: any, storedHtml: string): string { + const m = storedHtml.match(/]*>([\s\S]*)<\/body>/i); + const body = m ? m[1] : storedHtml; + return exportComposedPreviewHtml(project.pages_craft_state[0].craftState, body, { title: 'old', includeFonts: true, design: project.design }); +} diff --git a/craft/scripts/e2e-mobile-parity.mjs b/craft/scripts/e2e-mobile-parity.mjs new file mode 100644 index 0000000..898c06a --- /dev/null +++ b/craft/scripts/e2e-mobile-parity.mjs @@ -0,0 +1,131 @@ +#!/usr/bin/env node +/** + * Real-browser regression test (Firefox AND Chromium, headless): published/Preview documents must + * stack multi-column layouts at phone width, must not overflow horizontally at 390px, and must lay + * out EXACTLY as before at desktop width. + * + * npm run test:browser:mobile + * MOBILE_PARITY_RECORD= write a layout snapshot (run on the base commit to get a baseline) + * MOBILE_PARITY_BASELINE= additionally assert desktop (1280) boxes equal that snapshot + * + * Env: CHROMIUM_PATH / FIREFOX_PATH, MOBILE_ONLY=chromium|firefox. + */ +import { chromium, firefox } from '@playwright/test'; +import { build as esbuild } from 'esbuild'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const fixDir = path.join(root, 'tests/fixtures/canvas-parity'); + +const out = await esbuild({ + entryPoints: [path.join(root, 'scripts/e2e-mobile-parity-entry.ts')], + bundle: true, write: false, format: 'esm', platform: 'node', jsx: 'automatic', logLevel: 'error', + define: { 'process.env.NODE_ENV': '"production"', __EDITOR_BUILD__: '"test"' }, + banner: { js: "import { createRequire } from 'node:module'; const require = createRequire(import.meta.url);" }, +}); +const tmp = path.join(os.tmpdir(), `wsb-mobile-${process.pid}.mjs`); +fs.writeFileSync(tmp, out.outputFiles[0].text); +const ent = await import(tmp); +fs.rmSync(tmp, { force: true }); + +const project = JSON.parse(fs.readFileSync(path.join(fixDir, 'ols.project.json'), 'utf8')); +const docs = { + 'synthetic (navbar showMobileMenu unset = existing)': ent.renderSynthetic(undefined), + 'synthetic (navbar showMobileMenu false)': ent.renderSynthetic(false), + 'synthetic (navbar showMobileMenu true)': ent.renderSynthetic(true), +}; +project.pages_craft_state.forEach((pg, i) => { docs[`ols/${pg.slug} (regenerated)`] = ent.renderStored(project, i); }); +for (const slug of ['index', 'about', 'contact', 'brands']) { + docs[`ols/${slug} (stored body, current head)`] = ent.renderOldBodyNewHead(project, fs.readFileSync(path.join(fixDir, `ols.${slug}.html`), 'utf8')); +} + +function measure() { + const vis = (el) => { const r = el.getBoundingClientRect(); return getComputedStyle(el).display !== 'none' && (r.width > 0 || r.height > 0); }; + const els = [...document.body.querySelectorAll('*')].filter((e) => !['SCRIPT', 'STYLE', 'NOSCRIPT'].includes(e.tagName)); + const boxes = els.map((el, i) => { + const r = el.getBoundingClientRect(); + return [i, el.tagName, Math.round(r.x * 10) / 10, Math.round(r.y * 10) / 10, Math.round(r.width * 10) / 10, Math.round(r.height * 10) / 10, getComputedStyle(el).display]; + }); + let maxRight = 0; let worst = ''; + for (const el of els) { if (!vis(el)) continue; const r = el.getBoundingClientRect(); if (r.right > maxRight) { maxRight = r.right; worst = el.tagName + '.' + (el.className || '') + ':' + (el.textContent || '').slice(0, 20); } } + // Multi-column layouts: ColumnLayout wrappers (class cols_*) -> do their column children share a row? + const cols = [...document.querySelectorAll('[class^="cols_"]')].map((w) => { + const kids = [...w.children].filter((c) => !['STYLE', 'SCRIPT'].includes(c.tagName)); + const tops = new Set(kids.map((k) => Math.round(k.getBoundingClientRect().top))); + return { id: w.id, n: kids.length, rows: tops.size, dir: getComputedStyle(w).flexDirection, minW: Math.min(...kids.map((k) => k.getBoundingClientRect().width)) }; + }); + const rowEls = [...document.querySelectorAll('div')].filter((d) => /flex-direction:\s*row/.test(d.getAttribute('style') || '') && /display:\s*flex/.test(d.getAttribute('style') || '') && !/^cols_/.test(d.className)); + const rows = rowEls.map((d) => getComputedStyle(d).flexDirection); + const nav = document.querySelector('nav'); + let navRight = 0; + if (nav) for (const el of [nav, ...nav.querySelectorAll('*')]) { if (vis(el)) navRight = Math.max(navRight, el.getBoundingClientRect().right); } + const navInfo = nav ? { w: nav.getBoundingClientRect().width, h: Math.round(nav.getBoundingClientRect().height), scrollW: nav.scrollWidth, hamburger: !!nav.querySelector('.navbar-hamburger') && getComputedStyle(nav.querySelector('.navbar-hamburger')).display } : null; + return { innerW: window.innerWidth, maxRight: Math.round(maxRight * 10) / 10, navRight: Math.round(navRight * 10) / 10, worst, cols, rows, navInfo, boxes }; +} + +const results = {}; +let fail = 0; +const ok = (c, m) => { if (!c) { fail++; console.log(' FAIL', m); } }; +const baseline = process.env.MOBILE_PARITY_BASELINE ? JSON.parse(fs.readFileSync(process.env.MOBILE_PARITY_BASELINE, 'utf8')) : null; + +async function run(name, launcher, exe) { + const browser = await launcher.launch({ executablePath: exe || undefined, ...(name === 'chromium' ? { args: ['--no-sandbox'] } : {}) }); + results[name] = {}; + for (const [docName, html] of Object.entries(docs)) { + results[name][docName] = {}; + for (const w of [390, 1280]) { + const page = await browser.newPage({ viewport: { width: w, height: 900 } }); + // Deterministic: no network (web fonts / CDN icons would race the measurement). + await page.route(/^https?:/, (r) => r.abort()); + await page.setContent(html, { waitUntil: 'load' }); + await page.waitForTimeout(50); + const m = await page.evaluate(measure); + await page.close(); + 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. + 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})`); + 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)})`); + else ok(c.rows === c.n, `${tag}: columns not stacked (${JSON.stringify(c)})`); + } + } + if (w === 1280 && baseline) { + const b = baseline[name]?.[docName]?.[1280]; + if (b) { + const sameCount = b.boxes.length === m.boxes.length; + ok(sameCount, `${tag}: element count ${b.boxes.length} -> ${m.boxes.length}`); + let diffs = 0; + if (sameCount) for (let i = 0; i < b.boxes.length; i++) { + const bb = b.boxes[i]; const mm = m.boxes[i]; + if (bb.slice(2, 6).some((v, j) => Math.abs(v - mm[2 + j]) > 0.5) || bb[6] !== mm[6]) { if (diffs++ < 3) console.log(' diff', JSON.stringify(bb), JSON.stringify(mm)); } + } + ok(diffs === 0, `${tag}: ${diffs} element boxes changed at desktop`); + } + } + } + } + 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_PARITY_RECORD) fs.writeFileSync(process.env.MOBILE_PARITY_RECORD, JSON.stringify(results)); +// Human-readable summary at 390. +for (const [b, per] of Object.entries(results)) { + for (const [d, r] of Object.entries(per)) { + const m = r[390]; + console.log(`${b.padEnd(8)} 390 ${d.padEnd(55)} maxRight=${String(m.maxRight).padEnd(7)} cols(rows/n)=${m.cols.map((c) => c.rows + '/' + c.n).join(',') || '-'} nav=${m.navInfo ? m.navInfo.h + 'h/' + m.navInfo.hamburger + '/right=' + m.navRight : '-'}`); + } +} +console.log(fail ? `\n${fail} FAILED` : '\nall passed'); +process.exit(fail ? 1 : 0);