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; } }