From cfeeef37ce60df81d5bbc413574fe63395813ff8 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 1 Nov 2026 15:09:22 -0800 Subject: [PATCH] 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
 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 
---
 craft/CLAUDE.md                               | 13 ++-
 craft/scripts/e2e-mobile-guard-entry.ts       | 61 +++++++-----
 craft/scripts/e2e-mobile-guard.mjs            | 98 ++++++++++++++++---
 craft/src/components/basic/HtmlBlock.tsx      |  9 +-
 craft/src/styles/editor.css                   | 13 ++-
 .../src/utils/html-export.head-parity.test.ts | 21 ++--
 craft/src/utils/html-export.test.ts           |  2 +-
 craft/src/utils/html-export.ts                | 28 +++---
 craft/src/utils/publish-checks.test.ts        |  6 +-
 craft/src/utils/publish-checks.ts             | 13 ++-
 10 files changed, 175 insertions(+), 89 deletions(-)

diff --git a/craft/CLAUDE.md b/craft/CLAUDE.md
index 1fa715f..ed3c391 100644
--- a/craft/CLAUDE.md
+++ b/craft/CLAUDE.md
@@ -465,16 +465,19 @@ Every component has a static `toHtml(props, childrenHtml)` method. The `html-exp
 - **Mobile guard (real browsers):** `npm run test:browser:mobile-guard` (Chromium + Firefox, headless; jsdom has no
   layout). Exports representative pages (navbar, columns, card grids, images, long words, wide customer HtmlBlocks)
   and asserts at 360/390/768/1280: no horizontal overflow (body.scrollWidth AND the right edge of every visible box
-  and text line -- the head's `html,body{overflow-x:clip}` hides overflow from documentElement.scrollWidth), columns
-  and card grids stack on phones and keep their columns on tablet, navbar links wrap, an intentional
-  `overflow-x:auto` table still scrolls, and desktop is box- and pixel-identical with the mobile hooks removed.
+  and text line -- the head's `html,body{overflow-x:clip}` hides overflow from documentElement.scrollWidth) and no
+  sideways page scroll, columns and card grids stack on phones and keep their columns on tablet, navbar links wrap,
+  tables scroll on their own, sticky/dropdowns/multi-root flex+grid items inside HtmlBlocks still work, deliberately
+  wide customer HTML stays clipped (not reflowed), and desktop is box- and pixel-identical with the hooks removed.
   Set `CHROMIUM_PATH` / `FIREFOX_PATH` if the Playwright-pinned browsers are not installed; record/compare a desktop
   baseline across commits with `MOBILE_GUARD_RECORD=` (on the base commit) / `MOBILE_GUARD_BASELINE=`.
   Run it whenever a component's `toHtml`, the exporter, or the head CSS changes.
 - **Mobile hooks:** the head's `<=767px` block (RESPONSIVE_CSS in `utils/html-export.ts`, mirrored byte-for-byte by
   WHP `generateCompiledHTML`) targets `[data-wsb-grid]` (component card grids -> one column), `body` (long words
-  wrap) and `[data-wsb-html]` (the inline `display:contents` wrapper the exporter puts around HtmlBlock output; on
-  phones a sideways-scrolling box). A new component with an inline multi-column grid should carry `data-wsb-grid`.
+  wrap) and descendants of `[data-wsb-html]` (the inline `display:contents` wrapper the exporter puts around
+  HtmlBlock output: media shrink, tables/`
` scroll on their own). Never give that wrapper a box or overflow: it
+  clipped dropdowns, broke sticky and merged flex/grid items. A new component with an inline multi-column grid
+  should carry `data-wsb-grid`.
 - **WHP integration:** Deploy to staging, verify save/load, verify PHP wrapper injection
 
 ## Development Notes
diff --git a/craft/scripts/e2e-mobile-guard-entry.ts b/craft/scripts/e2e-mobile-guard-entry.ts
index 00ea258..da56229 100644
--- a/craft/scripts/e2e-mobile-guard-entry.ts
+++ b/craft/scripts/e2e-mobile-guard-entry.ts
@@ -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 = {
+/** Customer raw HTML the phone net makes fit: media shrink, tables and 
 scroll on their own, long words wrap. */
+export const HTML_FITS: Record = {
+  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
', + // Phone probes: none of these may be clipped, unstuck or merged by the net. + hbSticky: '', + hbDropdown: '
', +}; + +/** 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 = { hbFixed: '
A fixed 1100px banner
', hbGridPx: '
One
Two
Three
', hbGridFr: '

Multi-Brand Portfolio

Hosting

StreamerTools

Consultancy

', hbStyleFlex: '
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 { +function htmlState(blocks: Record, 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: '
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'); + 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: '
root one
root two
' }, [], { 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: '
cell one
cell two
' }, [], { 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: '

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); @@ -106,5 +118,6 @@ function compose(state: string, title: string, showMobileMenu: boolean): string (window as any).wsbMobileGuardDocs = (): Record => ({ 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), }); diff --git a/craft/scripts/e2e-mobile-guard.mjs b/craft/scripts/e2e-mobile-guard.mjs index 345d9f8..19cdded 100644 --- a/craft/scripts/e2e-mobile-guard.mjs +++ b/craft/scripts/e2e-mobile-guard.mjs @@ -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, \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', () => { @@ -277,7 +277,10 @@ describe('mobile stacking + navbar wrap head rules', () => { 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(mobile).toContain('[data-wsb-html] :is(table,pre):not([style*="overflow"] *){display:block;max-width:100%;overflow-x:auto}'); + // The wrapper itself never gets a box or a scroll container (that clipped dropdowns and broke sticky). + expect(RESPONSIVE_CSS_MINIFIED).not.toMatch(/\[data-wsb-html\]\{/); + expect(RESPONSIVE_CSS_MINIFIED).not.toContain('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 e39807a..2850670 100644 --- a/craft/src/utils/html-export.test.ts +++ b/craft/src/utils/html-export.test.ts @@ -578,7 +578,7 @@ describe('export head unchanged by the TOKEN_VARS move (W0-tokens-core)', () => 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}'; + const guard = '[data-wsb-grid]{grid-template-columns:minmax(0,1fr)!important}body{overflow-wrap:break-word}[data-wsb-html] :is(img,video,iframe,embed,object,svg,canvas){max-width:100%}[data-wsb-html] :is(img,video){height:auto}[data-wsb-html] :is(table,pre):not([style*="overflow"] *){display:block;max-width:100%;overflow-x: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*='; diff --git a/craft/src/utils/html-export.ts b/craft/src/utils/html-export.ts index da6a22a..ea4d780 100644 --- a/craft/src/utils/html-export.ts +++ b/craft/src/utils/html-export.ts @@ -407,11 +407,15 @@ const { mobileMax, tabletMin, tabletMax, desktopMin } = BREAKPOINTS; // 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. +// that already fits is untouched. break-word, not anywhere: anywhere would also shrink min-content sizes and so +// squeeze table columns and flex items. It inherits into HtmlBlock content through the display:contents wrapper. +// `[data-wsb-html]`: the wrapper HtmlBlock exports around customer HTML. It is inline display:contents on EVERY +// viewport and no rule here gives it a box: a box would clip dropdowns/shadows (overflow-x:auto forces +// overflow-y:auto), unstick position:sticky inside it and merge a multi-root block that is a direct flex/grid +// item into one item (all measured, scripts/e2e-mobile-guard.mjs). It only scopes these non-layout phone +// protections: media shrink to fit, and a table or
 scrolls sideways on its own (unless it already sits
+// in an inline-styled overflow wrapper the author made). Customer markup made wider than a phone with fixed
+// widths/grids stays clipped by html,body{overflow-x:clip} as before; publish-checks warns about it.
 const RESPONSIVE_CSS = `
 @media (max-width: ${mobileMax}px) {
   [style*="display: flex"][style*="flex-direction: row"],
@@ -428,19 +432,17 @@ const RESPONSIVE_CSS = `
   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;
   }
+  [data-wsb-html] :is(table, pre):not([style*="overflow"] *) {
+    display: block;
+    max-width: 100%;
+    overflow-x: auto;
+  }
 }
 @media (max-width: ${tabletMax}px) {
   nav[class^="nav_"], nav[class^="nav_"] .navbar-links {
@@ -448,7 +450,7 @@ const RESPONSIVE_CSS = `
   }
 }`;
 
-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}}`;
+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] :is(img,video,iframe,embed,object,svg,canvas){max-width:100%}[data-wsb-html] :is(img,video){height:auto}[data-wsb-html] :is(table,pre):not([style*="overflow"] *){display:block;max-width:100%;overflow-x: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 1968939..b691f4c 100644
--- a/craft/src/utils/publish-checks.test.ts
+++ b/craft/src/utils/publish-checks.test.ts
@@ -219,12 +219,11 @@ 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', () => {
+  test('flags fixed widths, min-widths and flex sizes 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', () => { @@ -241,6 +240,7 @@ describe('custom HTML wider than a phone (mobile guard)', () => { '
x
', // exactly a phone '', // media shrink to fit (net + reset) '', + '
x
', // tables scroll on their own on phones '
', // reflows on its own '
', // two columns: cramped at worst '', @@ -256,7 +256,7 @@ describe('custom HTML wider than a phone (mobile guard)', () => { 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('the part past the screen edge is cut off'); expect(cs[0].message).toContain('Not checked: head code, scripts, external stylesheets, em/rem/% sizes and content widths'); }); diff --git a/craft/src/utils/publish-checks.ts b/craft/src/utils/publish-checks.ts index 4690fdf..d19290c 100644 --- a/craft/src/utils/publish-checks.ts +++ b/craft/src/utils/publish-checks.ts @@ -225,10 +225,11 @@ const NOT_CHECKED = 'Not checked: head code, scripts, external stylesheets, em/r /** `css` with every @media block removed (balanced braces): those rules are the author's own breakpoints. */ function stripMediaBlocks(css: string): string { + const lower = css.toLowerCase(); let out = ''; let i = 0; for (;;) { - const at = css.toLowerCase().indexOf('@media', i); + const at = lower.indexOf('@media', i); if (at === -1) return out + css.slice(i); out += css.slice(i, at); const open = css.indexOf('{', at); @@ -280,8 +281,9 @@ function gridMin(value: string): { min: number; cols: number } { /** * 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 `