From 542f5efc78c3fc1f77758559b4cd731694e53a9b Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 1 Nov 2026 15:16:53 -0800 Subject: [PATCH] Mobile guard: drop the HtmlBlock table rule; colour e2e baseline = first 2026.11.4-head commit - Tables inside HtmlBlocks are left alone on phones: display:block (the only way to make one scroll) shrank a fitting width:100% table's rows from 349px to 77px at 390. Wide tables stay clipped as before. The
 rule (max-width:100%;overflow-x:auto, no display change) and the media rules stay.
- Browser suite: width:100% table still spans the screen (fails on cfeeef3's rule: first row 78px); the
  10-column bare table moved to htmlwide (clipped, page never scrolls sideways).
- e2e-color-system: default COLOR_BASE_COMMIT ab7a100 -> 69fd34b, the first commit with the 2026.11.4
  published head (ColumnLayout stacking + Navbar wrap) and none of the colour feature. ab7a100 predates
  that head, so check 5 failed on main itself. This branch's own head change (mobile guard) is folded back
  by exact string, asserted present once, before comparing, so the check still proves the colour feature
  changed nothing else. Passes on 69fd34b; fails (as it should) against ab7a100.

Co-Authored-By: Claude Opus 5.5 
---
 craft/CLAUDE.md                               |  6 ++---
 craft/scripts/e2e-color-system.mjs            | 24 +++++++++++++++----
 craft/scripts/e2e-mobile-guard-entry.ts       |  7 ++++--
 craft/scripts/e2e-mobile-guard.mjs            | 24 ++++++++++++-------
 craft/src/styles/editor.css                   |  5 ++--
 .../src/utils/html-export.head-parity.test.ts | 20 +++++++++-------
 craft/src/utils/html-export.test.ts           |  2 +-
 craft/src/utils/html-export.ts                | 12 +++++-----
 8 files changed, 63 insertions(+), 37 deletions(-)

diff --git a/craft/CLAUDE.md b/craft/CLAUDE.md
index ed3c391..9cf0ca2 100644
--- a/craft/CLAUDE.md
+++ b/craft/CLAUDE.md
@@ -467,7 +467,7 @@ Every component has a static `toHtml(props, childrenHtml)` method. The `html-exp
   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) 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
+  full-width tables still span the screen, 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=`.
@@ -475,8 +475,8 @@ Every component has a static `toHtml(props, childrenHtml)` method. The `html-exp
 - **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 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
+  HtmlBlock output: media shrink, `
` scrolls on its own; tables are left alone). 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
 
diff --git a/craft/scripts/e2e-color-system.mjs b/craft/scripts/e2e-color-system.mjs
index 3fd880b..beea4ec 100644
--- a/craft/scripts/e2e-color-system.mjs
+++ b/craft/scripts/e2e-color-system.mjs
@@ -25,10 +25,23 @@ import { buildProject } from './e2e-color-system-fixture.mjs';
 
 const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
 const BACKEND_DIR = process.env.WHP_BACKEND_DIR || '/workspace/whp-wt-color-system';
-// Pre-colour-feature baseline. On a branch that also carries other head changes (e.g. the release
-// branch, where mobile-parity's <=767px stacking rules land first) point this at the commit just
-// before the colour merge: COLOR_BASE_COMMIT=.
-const BASE_COMMIT = process.env.COLOR_BASE_COMMIT || 'ab7a100';
+// Pre-colour-feature baseline. 69fd34b = the first commit carrying the 2026.11.4 published head
+// (ColumnLayout stacking + Navbar wrap, fix/mobile-parity) and none of the colour feature, so "no brand
+// colours => published document unchanged" compares against the head that actually shipped before it.
+// (ab7a100, the old default, predates the 2026.11.4 head rules, so this check failed on main since then.)
+// Override with COLOR_BASE_COMMIT= on a branch with other deliberate head changes.
+const BASE_COMMIT = process.env.COLOR_BASE_COMMIT || '69fd34b';
+// Deliberate head changes made AFTER the base, folded back before the comparison so it still proves the
+// colour feature changed nothing else. Each `from` must occur exactly once (checked).
+const HEAD_FOLDS = [
+  {
+    why: 'mobile-guard: card-grid / long-word / HtmlBlock media rules in the <=767px block; Navbar wrap moved to <=991px',
+    from: '[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] pre{max-width:100%;overflow-x:auto}}@media(max-width:991px){nav[class^="nav_"],nav[class^="nav_"] .navbar-links{flex-wrap:wrap}}',
+    to: '[class^="cols_"]{flex-direction:column!important;align-items:stretch!important}nav[class^="nav_"],nav[class^="nav_"] .navbar-links{flex-wrap:wrap}}',
+  },
+];
+const foldHead = (html) => HEAD_FOLDS.reduce((h, f) => (h.split(f.from).length === 2 ? h.replace(f.from, () => f.to) : h), html);
+const foldsApplied = (html) => HEAD_FOLDS.map((f) => html.split(f.from).length - 1);
 const SHOTS = process.env.COLOR_SHOTS || '';
 if (SHOTS) fs.mkdirSync(SHOTS, { recursive: true });
 
@@ -350,7 +363,8 @@ async function run(name, launcher, exe) {
   eq('5 non-brand swatches change nothing in the published document', pplain, pa);
   if (haveBackend) eq('5 no-brand site: frontend :root == backend :root', rootDecl(pa), rootDecl(backendHtml(noDesign, 0)));
   if (baseExporter) {
-    eq(`5 no-brand site: published document identical to the pre-feature baseline (${BASE_COMMIT})`, pa === baseExporter.renderPublished(noDesign, 0), true);
+    eq(`5 no-brand site: each later deliberate head change found exactly once (${HEAD_FOLDS.map((f) => f.why.split(':')[0]).join(', ')})`, foldsApplied(pa), HEAD_FOLDS.map(() => 1));
+    eq(`5 no-brand site: published document identical to the pre-feature baseline (${BASE_COMMIT}, later head changes folded back)`, foldHead(pa) === baseExporter.renderPublished(noDesign, 0), true);
   } else console.log(`[${name}] SKIP  baseline comparison`);
   await pub.close();
 
diff --git a/craft/scripts/e2e-mobile-guard-entry.ts b/craft/scripts/e2e-mobile-guard-entry.ts
index da56229..afcb424 100644
--- a/craft/scripts/e2e-mobile-guard-entry.ts
+++ b/craft/scripts/e2e-mobile-guard-entry.ts
@@ -60,11 +60,12 @@ export function componentsState(): string {
   return JSON.stringify(nodes);
 }
 
-/** Customer raw HTML the phone net makes fit: media shrink, tables and 
 scroll on their own, long words wrap. */
+/** Customer raw HTML that fits a phone with the net: media shrink, 
 scrolls on its own, long words wrap,
+ *  a full-width table keeps spanning the screen. */
 export const HTML_FITS: Record = {
   hbIframe: '',
   hbImg: '',
-  hbTableBare: '' + Array.from({ length: 10 }, (_, i) => ``).join('') + '
Column_heading_${i}
', + hbTableFull: '
PlanPrice
Basic$5
', 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). @@ -81,6 +82,8 @@ export const HTML_WIDE: Record = { hbGridFr: '

Multi-Brand Portfolio

Hosting

StreamerTools

Consultancy

', hbStyleFlex: '
A
B
C
', hbMinWidth: '
min-width 900px content
', + // Tables are deliberately left alone by the net (scrolling one would shrink full-width tables): clipped as before. + hbTableBare: '' + Array.from({ length: 10 }, (_, i) => ``).join('') + '
Column_heading_${i}
', }; function htmlState(blocks: Record, probes: boolean): string { diff --git a/craft/scripts/e2e-mobile-guard.mjs b/craft/scripts/e2e-mobile-guard.mjs index 19cdded..2b786a6 100644 --- a/craft/scripts/e2e-mobile-guard.mjs +++ b/craft/scripts/e2e-mobile-guard.mjs @@ -8,11 +8,11 @@ * 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 the phone net makes fit (iframe, img, bare wide table, pre, URL), an + * htmlblocks - customer HtmlBlocks that fit a phone with the net (iframe, img, width:100% 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, \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,10 +277,12 @@ 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] :is(table,pre):not([style*="overflow"] *){display:block;max-width:100%;overflow-x:auto}'); + expect(mobile).toContain('[data-wsb-html] pre{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'); + // Tables are left alone: display:block (the only way to scroll one) shrinks a fitting width:100% table. + expect(RESPONSIVE_CSS_MINIFIED).not.toContain('table'); 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 2850670..be5c26d 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] :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}'; + 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] pre{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 ea4d780..8900d70 100644 --- a/craft/src/utils/html-export.ts +++ b/craft/src/utils/html-export.ts @@ -413,9 +413,10 @@ const { mobileMax, tabletMin, tabletMax, desktopMin } = BREAKPOINTS; // 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.
+// protections: media shrink to fit, and a 
 scrolls sideways on its own. Tables are deliberately left
+// alone: the only CSS way to make a table scroll (display:block) shrinks a fitting width:100% table to its
+// content width (measured 349px -> 77px at 390), so a wide table stays clipped as before, like customer
+// markup made wider than a phone with fixed widths/grids (html,body{overflow-x:clip}; publish-checks warns).
 const RESPONSIVE_CSS = `
 @media (max-width: ${mobileMax}px) {
   [style*="display: flex"][style*="flex-direction: row"],
@@ -438,8 +439,7 @@ const RESPONSIVE_CSS = `
   [data-wsb-html] :is(img, video) {
     height: auto;
   }
-  [data-wsb-html] :is(table, pre):not([style*="overflow"] *) {
-    display: block;
+  [data-wsb-html] pre {
     max-width: 100%;
     overflow-x: auto;
   }
@@ -450,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] :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}}`;
+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] pre{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; } }