From bb4fb7548b96dd8b790a6430ccecc9e50edfb8c4 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 1 Nov 2026 14:58:39 -0800 Subject: [PATCH] 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
(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/
A
B
C
', + hbMinWidth: '
min-width 900px content
', + hbIframe: '', + hbImg: '', + hbTableBare: '' + Array.from({ length: 10 }, (_, i) => ``).join('') + '
Column_heading_${i}
', + hbPre: '
const reallyLongLine = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
', + hbUrl: '

See https://example.com/a/really/long/path/that/never/breaks/because/it/has/no/spaces/at/all/ok

', + // Deliberate horizontal scroller: must STILL scroll (the net must not flatten or clip it). + hbScrollTable: '
abc
', +}; + +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: '
root one
root two
' }, [], { 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: '

Collapsing margins

' }, [], { parent: 'ROOT' }); kids.push('hbMargins'); + nodes.inlineRun = node('Container', { tag: 'div', style: {} }, ['hbInline', 'inlineBtn'], { parent: 'ROOT' }); + nodes.hbInline = node('HtmlBlock', { code: 'inline custom' }, [], { 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 => ({ + components: compose(componentsState(), 'Components', false), + htmlblocks: compose(htmlBlocksState(), 'Custom HTML', true), +}); diff --git a/craft/scripts/e2e-mobile-guard.mjs b/craft/scripts/e2e-mobile-guard.mjs new file mode 100644 index 0000000..345d9f8 --- /dev/null +++ b/craft/scripts/e2e-mobile-guard.mjs @@ -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, \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", + 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", }; -PINNED_HEAD.E_brand_swatches = "\n\n\n \n \n Brand\n \n \n \n \n \n \n \n"; +PINNED_HEAD.E_brand_swatches = "\n\n\n \n \n Brand\n \n \n \n \n \n \n \n"; PINNED_HEAD.F_swatches_no_brand = PINNED_HEAD.A_defaults; const PINNED_BODY: Record = { @@ -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 = { - 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', () => { @@ -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); }); }); diff --git a/craft/src/utils/html-export.test.ts b/craft/src/utils/html-export.test.ts index ed728ed..e39807a 100644 --- a/craft/src/utils/html-export.test.ts +++ b/craft/src/utils/html-export.test.ts @@ -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('')); const body = html.slice(html.indexOf('')); - 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( '', ); @@ -881,3 +889,32 @@ describe('injectAttrs skips leading comments / style blocks / whitespace (findin expect(injectAttrs('', ' data-z')).toBe(''); }); }); + +describe('mobile guard hooks in the exported body', () => { + const one = (type: string, props: Record) => 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 = '

a

b

'; + expect(exportBodyHtml(one('HtmlBlock', { code })).html).toBe(`
${code}
`); + }); + + test('visibility/animation attrs still land on the customer\'s first element, not on the wrapper', () => { + const { html } = exportBodyHtml(one('HtmlBlock', { code: '

a

b

', hideOnMobile: true, animation: 'fade-in' })); + expect(html).toMatch(/^

]*data-animation="fade-in"[^>]*>a<\/p>

b<\/p><\/div><\/div>$/); + }); + + test('an empty HtmlBlock exports nothing (no empty wrapper)', () => { + expect(exportBodyHtml(one('HtmlBlock', { code: '' })).html).toBe('

'); + }); + + 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(/
]*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'); + }); +}); diff --git a/craft/src/utils/html-export.ts b/craft/src/utils/html-export.ts index 5a7b897..da6a22a 100644 --- a/craft/src/utils/html-export.ts +++ b/craft/src/utils/html-export.ts @@ -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; } } diff --git a/craft/src/utils/publish-checks.test.ts b/craft/src/utils/publish-checks.test.ts index 2c84534..1968939 100644 --- a/craft/src/utils/publish-checks.test.ts +++ b/craft/src/utils/publish-checks.test.ts @@ -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('
x
')).toEqual(['a fixed width of 1100px']); + expect(htmlBlockOverflowReasons('
x
')).toEqual(['a minimum width of 900px']); + expect(htmlBlockOverflowReasons('
')).toEqual(['a fixed flex size of 420px']); + expect(htmlBlockOverflowReasons('')).toEqual(['a fixed flex size of 500px']); + expect(htmlBlockOverflowReasons('
x
')).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('
')).toEqual(['a grid at least 900px wide']); + expect(htmlBlockOverflowReasons('
')).toEqual(['a grid at least 450px wide']); + expect(htmlBlockOverflowReasons('
')).toEqual(['a grid at least 420px wide']); + expect(htmlBlockOverflowReasons('
')).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 [ + '
x
', // max-width is not a fixed width + '
x
', + '
x
', // exactly a phone + '', // media shrink to fit (net + reset) + '', + '
', // reflows on its own + '
', // two columns: cramped at worst + '', + '', // desktop-only rule + '

Only text, not a style: width: 1100px; grid-template-columns: repeat(4, 1fr)

', + '', + ]) 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('
'); + 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('

Hello

')).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('
nav
') }) }]; + 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"'); + }); +}); diff --git a/craft/src/utils/publish-checks.ts b/craft/src/utils/publish-checks.ts index 55f6ecb..4690fdf 100644 --- a/craft/src/utils/publish-checks.ts +++ b/craft/src/utils/publish-checks.ts @@ -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 `