From 96670b94f8d48360932ef79ad7f2c89865afba1f Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 1 Nov 2026 16:26:11 -0800 Subject: [PATCH] Legacy grids: published head stacks pre-hook card grids on phones (no re-save) Stored pages (generated at editor-save time) from before release 2026.11.5 carry FeaturesGrid / Testimonials / NumberCounter grids without data-wsb-grid, so a republish kept repeat(N,1fr) on phones (OLS home: body 594px wide at 360). The <=767px block now matches the exact inline styles those emitters stored (whole-attribute signatures; no spaces, as in git history and every stored OLS/TTM page). Gallery shares the prefix with a free gap and keeps its columns: the two colliding signatures carry :not(:has(> * > Gallery thumbnail)) in their own rule (a browser without :has drops only that rule). Customer grids never match. Mirrored byte-for-byte by WHP generateCompiledHTML (fix/legacy-grid-backend). Tests: vitest selector contract (legacy shapes match, == current emitters minus the hook, Gallery and customer grids never match, pretty == minified); head parity pins updated; new real-browser suite test:browser:legacy-grid (Chromium + Firefox, 360/390/768/1280) on the stored OLS home page + synthetic legacy grids; colors suite folds the new rules back like the mobile-guard ones. Co-Authored-By: Claude Opus 5.5 --- craft/CLAUDE.md | 11 +- craft/package.json | 1 + craft/scripts/e2e-color-system.mjs | 8 +- craft/scripts/e2e-legacy-grid-entry.ts | 51 +++++ craft/scripts/e2e-legacy-grid.mjs | 179 ++++++++++++++++++ .../src/utils/html-export.head-parity.test.ts | 18 +- .../src/utils/html-export.legacy-grid.test.ts | 113 +++++++++++ craft/src/utils/html-export.test.ts | 4 + craft/src/utils/html-export.ts | 19 +- .../fixtures/legacy-grid/ols.design.json | 21 ++ .../fixtures/legacy-grid/ols.footer.html | 1 + .../fixtures/legacy-grid/ols.header.html | 14 ++ .../tests/fixtures/legacy-grid/ols.index.html | 32 ++++ 13 files changed, 460 insertions(+), 12 deletions(-) create mode 100644 craft/scripts/e2e-legacy-grid-entry.ts create mode 100644 craft/scripts/e2e-legacy-grid.mjs create mode 100644 craft/src/utils/html-export.legacy-grid.test.ts create mode 100644 craft/tests/fixtures/legacy-grid/ols.design.json create mode 100644 craft/tests/fixtures/legacy-grid/ols.footer.html create mode 100644 craft/tests/fixtures/legacy-grid/ols.header.html create mode 100644 craft/tests/fixtures/legacy-grid/ols.index.html diff --git a/craft/CLAUDE.md b/craft/CLAUDE.md index 9cf0ca2..09209e0 100644 --- a/craft/CLAUDE.md +++ b/craft/CLAUDE.md @@ -472,12 +472,21 @@ Every component has a static `toHtml(props, childrenHtml)` method. The `html-exp 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. +- **Legacy grids (real browsers):** `npm run test:browser:legacy-grid` (Chromium + Firefox) renders STORED pre-hook + HTML (the OLS home page in `tests/fixtures/legacy-grid/`, plus every card-grid emitter with `data-wsb-grid` + stripped, colliding Galleries and an unwrapped customer grid) in the real head at 360/390/768/1280: no overflow, + legacy card grids stack on phones (and do NOT without the legacy rules), Galleries and customer grids keep their + columns, the legacy page is pixel-identical on phones to its re-saved form, and 768/1280 are box- and + pixel-identical with vs without the legacy rules. - **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, `
` 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`.
+  should carry `data-wsb-grid`. Pages stored before the hook are covered by whole-attribute `[style=...]` /
+  `[style^=...][style$=...]` signatures of the exact inline styles FeaturesGrid / Testimonials / NumberCounter stored
+  (Gallery excluded via `:not(:has(...))`), so republishing stacks them with no re-save. Those signatures are frozen: they
+  describe already-stored HTML, so keep them even if an emitter's inline style changes (new output carries the hook).
 - **WHP integration:** Deploy to staging, verify save/load, verify PHP wrapper injection
 
 ## Development Notes
diff --git a/craft/package.json b/craft/package.json
index 790e8a5..8179347 100644
--- a/craft/package.json
+++ b/craft/package.json
@@ -15,6 +15,7 @@
     "test:browser:canvas": "node scripts/e2e-canvas-parity.mjs",
     "test:browser:mobile": "node scripts/e2e-mobile-parity.mjs",
     "test:browser:mobile-guard": "node scripts/e2e-mobile-guard.mjs",
+    "test:browser:legacy-grid": "node scripts/e2e-legacy-grid.mjs",
     "test:browser:colorpick": "node scripts/e2e-color-pick.mjs",
     "test:browser:colors": "node scripts/e2e-color-system.mjs",
     "test:browser:sitesmith": "node scripts/e2e-sitesmith.mjs",
diff --git a/craft/scripts/e2e-color-system.mjs b/craft/scripts/e2e-color-system.mjs
index beea4ec..56c6c6b 100644
--- a/craft/scripts/e2e-color-system.mjs
+++ b/craft/scripts/e2e-color-system.mjs
@@ -34,6 +34,11 @@ 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: 'legacy-grid: stored (pre-hook) card-grid signatures right after [data-wsb-grid] in the <=767px block',
+    from: '[data-wsb-grid]{grid-template-columns:minmax(0,1fr)!important}[style^="max-width:1100px;margin:0 auto;display:grid;grid-template-columns:repeat("][style$=",1fr);gap:32px;text-align:center"]{grid-template-columns:minmax(0,1fr)!important}[style="max-width:1100px;margin:0 auto;display:grid;grid-template-columns:repeat(3,1fr);gap:32px"]:not(:has(>*>img[style^="width:100%;height:200px;object-fit:cover"])),[style^="max-width:1100px;margin:0 auto;display:grid;grid-template-columns:repeat("][style$=",1fr);gap:24px"]:not(:has(>*>img[style^="width:100%;height:200px;object-fit:cover"])){grid-template-columns:minmax(0,1fr)!important}',
+    to: '[data-wsb-grid]{grid-template-columns:minmax(0,1fr)!important}',
+  },
   {
     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}}',
@@ -41,7 +46,8 @@ const HEAD_FOLDS = [
   },
 ];
 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);
+// Counted in order on the progressively folded document (a later fold's `from` may need an earlier fold applied).
+const foldsApplied = (html) => HEAD_FOLDS.map((f) => { const n = html.split(f.from).length - 1; if (n === 1) html = html.replace(f.from, () => f.to); return n; });
 const SHOTS = process.env.COLOR_SHOTS || '';
 if (SHOTS) fs.mkdirSync(SHOTS, { recursive: true });
 
diff --git a/craft/scripts/e2e-legacy-grid-entry.ts b/craft/scripts/e2e-legacy-grid-entry.ts
new file mode 100644
index 0000000..fb69b6b
--- /dev/null
+++ b/craft/scripts/e2e-legacy-grid-entry.ts
@@ -0,0 +1,51 @@
+// Bundled (browser IIFE) by scripts/e2e-legacy-grid.mjs. Builds published documents from LEGACY stored HTML
+// (pages saved before the data-wsb-grid hook existed) wrapped in the REAL Preview/publish head
+// (exportComposedPreviewHtml: header + page + footer, as WHP handlePublish composes them; the head is byte-identical
+// to WHP generateCompiledHTML, pinned on both sides by the head-parity tests).
+import { exportBodyHtml, exportComposedPreviewHtml, RESPONSIVE_CSS_MINIFIED } from '../src/utils/html-export';
+import { componentResolver } from '../src/components/resolver';
+import olsHeader from '../tests/fixtures/legacy-grid/ols.header.html';
+import olsIndex from '../tests/fixtures/legacy-grid/ols.index.html';
+import olsFooter from '../tests/fixtures/legacy-grid/ols.footer.html';
+import olsDesign from '../tests/fixtures/legacy-grid/ols.design.json';
+
+type N = Record;
+const EMPTY = JSON.stringify({ ROOT: { type: { resolvedName: 'Container' }, isCanvas: true, props: { tag: 'div', style: {} }, displayName: 'Container', custom: {}, hidden: false, nodes: [], linkedNodes: {} } });
+const defaults = (type: string): Record => {
+  const c = (componentResolver as any)[type];
+  return c?.craft?.props ? JSON.parse(JSON.stringify(c.craft.props)) : {};
+};
+const node = (type: string, props: Record, extra: N = {}) => ({
+  type: { resolvedName: type }, isCanvas: type === 'Container', props: { ...defaults(type), ...props }, displayName: type,
+  custom: {}, hidden: false, nodes: [], linkedNodes: {}, ...extra,
+});
+const stripHook = (html: string) => html.replace(/ data-wsb-grid(="")?/g, '');
+const doc = (body: string, title: string) => exportComposedPreviewHtml(EMPTY, body, { title, design: olsDesign as any });
+
+/** Every card-grid emitter in its stored (pre-hook) form, Galleries whose gap collides with a card-grid
+ *  signature, and an unwrapped customer grid (legacy HtmlBlock content has no data-wsb-html wrapper). */
+function synthetic(): string {
+  const nodes: N = {};
+  const kids: string[] = [];
+  const add = (id: string, type: string, props: Record = {}) => { nodes[id] = node(type, props, { parent: 'ROOT' }); kids.push(id); };
+  add('features', 'FeaturesGrid');
+  add('t2', 'Testimonials', { columns: 2 });
+  add('t3', 'Testimonials');
+  add('t4', 'Testimonials', { columns: 4 });
+  add('counter', 'NumberCounter', { columns: 4 });
+  add('gal', 'Gallery');
+  add('gal32', 'Gallery', { columns: 3, gap: '32px', lightbox: true });
+  add('gal24', 'Gallery', { columns: 4, gap: '24px' });
+  nodes.ROOT = node('Container', { tag: 'div', style: {} }, { nodes: kids });
+  const body = stripHook(exportBodyHtml(JSON.stringify(nodes)).html);
+  const customer = '
One
Two
Three
'; + return olsHeader + '\n' + body + customer + '\n' + olsFooter; +} + +(window as any).wsbLegacyGridDocs = (): Record => ({ + ols: doc(olsHeader + '\n' + olsIndex + '\n' + olsFooter, 'Home'), + // The same page as a re-save in 2026.11.5 would store it (hook added): phones must look identical. + olsResaved: doc(olsHeader + '\n' + olsIndex.replace('
{ + for (let a = el.parentElement; a && a !== document.body && a !== document.documentElement; a = a.parentElement) { + if (getComputedStyle(a).overflowX !== 'visible' && a.getBoundingClientRect().right <= vw + 0.5) return true; + } + return false; + }; + const label = (el) => `${el.tagName.toLowerCase()} "${(el.textContent || '').trim().slice(0, 24)}"`; + let maxRight = 0; let worst = ''; + const bump = (right, el) => { if (right > maxRight) { maxRight = right; worst = label(el); } }; + for (const el of document.body.querySelectorAll('*')) { + if (skip.has(el.tagName)) continue; + const r = el.getBoundingClientRect(); + if (!(r.width || r.height) || getComputedStyle(el).visibility === 'hidden' || contained(el)) continue; + bump(r.right, el); + } + const tw = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT); + for (let n = tw.nextNode(); n; n = tw.nextNode()) { + const el = n.parentElement; + if (!el || skip.has(el.tagName) || !n.textContent.trim() || contained(n)) continue; + const range = document.createRange(); range.selectNodeContents(n); + for (const r of range.getClientRects()) if (r.width) bump(r.right, el); + } + const grids = [...document.querySelectorAll('div')].filter((d) => getComputedStyle(d).display === 'grid').map((g) => { + const kids = [...g.children].filter((k) => !skip.has(k.tagName) && k.getBoundingClientRect().height > 0); + const firstTop = kids.length ? Math.round(kids[0].getBoundingClientRect().top) : 0; + const kind = g.closest('#customer') ? 'customer' + : g.querySelector(':scope > * > img[style^="width:100%;height:200px;object-fit:cover"]') ? 'gallery' : 'card'; + return { kind, hook: g.hasAttribute('data-wsb-grid'), n: kids.length, perRow: kids.filter((k) => Math.round(k.getBoundingClientRect().top) === firstTop).length }; + }); + return { vw, docSW: document.documentElement.scrollWidth, bodySW: document.body.scrollWidth, maxRight: Math.round(maxRight * 10) / 10, worst, grids }; +} + +function sidewaysScroll() { + const y = window.scrollY; + window.scrollTo(5000, y); + const x = window.scrollX; + window.scrollTo(0, y); + return x; +} + +function boxes() { + return [...document.body.querySelectorAll('*')] + .filter((e) => !['SCRIPT', 'STYLE', 'NOSCRIPT'].includes(e.tagName)) + .map((e) => { const r = e.getBoundingClientRect(); return [e.tagName, ...[r.x, r.y, r.width, r.height].map((v) => Math.round(v * 10) / 10)].join(','); }); +} + +let fail = 0; +const ok = (c, m) => { if (!c) { fail++; console.log(' FAIL', m); } }; +const summary = []; + +async function open(browser, html, w) { + const page = await browser.newPage({ viewport: { width: w, height: 900 } }); + await page.route(/^https?:/, (r) => r.abort()); // deterministic: no fonts/CDN/images racing the layout + await page.setContent(html, { waitUntil: 'load' }); + await page.evaluate(() => document.fonts.ready); + await page.waitForTimeout(50); + return page; +} + +async function run(name, launcher, exe) { + const browser = await launcher.launch({ executablePath: exe || undefined, ...(name === 'chromium' ? { args: ['--no-sandbox'] } : {}) }); + const gen = await browser.newPage(); + await gen.setContent(''); + await gen.addScriptTag({ content: js }); + const docs = await gen.evaluate(() => window.wsbLegacyGridDocs()); + const css = await gen.evaluate(() => window.wsbResponsiveCss); + await gen.close(); + // The legacy rules = everything between the [data-wsb-grid] rule and body{overflow-wrap...} in the head CSS. + const HOOK = '[data-wsb-grid]{grid-template-columns:minmax(0,1fr)!important}'; + const legacy = css.slice(css.indexOf(HOOK) + HOOK.length, css.indexOf('body{overflow-wrap')); + ok(legacy.includes('[style^=') && legacy.includes(':has('), `[${name}] could not isolate the legacy rules from the head CSS: ${legacy}`); + const bodyOf = (h) => h.slice(h.indexOf(' { + ok(v.split(legacy).length === 2, `[${name}] ${k}: the head does not carry the legacy rules exactly once`); + return [k, v.replace(legacy, '')]; + })); + + for (const [docName, html] of Object.entries(docs)) { + for (const w of WIDTHS) { + const tag = `[${name} ${w}] ${docName}`; + const mobile = w <= MOBILE_MAX; + const page = await open(browser, html, w); + const m = await page.evaluate(measure); + const sx = await page.evaluate(sidewaysScroll); + ok(sx === 0, `${tag}: the page scrolls sideways (scrollX=${sx})`); + ok(m.bodySW <= w, `${tag}: body.scrollWidth ${m.bodySW} > ${w}`); + ok(m.maxRight <= w + 0.5, `${tag}: content reaches x=${m.maxRight}: ${m.worst}`); + + const base = await open(browser, without[docName], w); + const mb = await base.evaluate(measure); + const fmt = (g) => g.map((x) => `${x.kind[0]}${x.perRow}/${x.n}`).join(','); + summary.push(`${name.padEnd(8)} ${String(w).padEnd(4)} ${docName.padEnd(10)} bodySW=${m.bodySW} maxRight=${m.maxRight} grids=${fmt(m.grids)} | without rules: bodySW=${mb.bodySW} maxRight=${mb.maxRight}${mb.maxRight > w + 0.5 ? ' (' + mb.worst + ')' : ''} grids=${fmt(mb.grids)}`); + + const cards = m.grids.filter((g) => g.kind === 'card' && g.n > 1); + if (docName !== 'olsResaved') ok(cards.length > 0, `${tag}: no legacy card grid found (fixture broken)`); + if (mobile) { + for (const g of cards) ok(g.perRow === 1, `${tag}: a legacy card grid keeps ${g.perRow} per row on a phone`); + for (const g of m.grids.filter((x) => x.kind === 'gallery')) ok(g.perRow > 1, `${tag}: a Gallery lost its columns (${g.perRow} per row)`); + for (const g of m.grids.filter((x) => x.kind === 'customer')) ok(g.perRow === 3, `${tag}: the customer grid was reflowed (${g.perRow} per row)`); + if (docName !== 'olsResaved') { + const legacyCards = mb.grids.filter((g) => g.kind === 'card' && g.n > 1 && !g.hook); + ok(legacyCards.length > 0 && legacyCards.every((g) => g.perRow > 1), `${tag}: without the legacy rules the card grids were expected to keep their columns (${fmt(legacyCards)})`); + } + } else { + for (const g of cards) ok(g.perRow > 1, `${tag}: a card grid lost its columns at ${w} (${g.perRow} per row)`); + const [b1, b2] = [await page.evaluate(boxes), await base.evaluate(boxes)]; + const diffs = b1.length !== b2.length ? Infinity : b1.filter((b, i) => b !== b2[i]).length; + ok(diffs === 0, `${tag}: ${diffs} element boxes differ with vs without the legacy rules`); + const [s1, s2] = [await page.screenshot({ fullPage: true }), await base.screenshot({ fullPage: true })]; + ok(s1.equals(s2), `${tag}: full-page pixels differ with vs without the legacy rules`); + } + if (mobile && docName === 'ols') { + const resaved = await open(browser, docs.olsResaved, w); + const [s1, s2] = [await page.screenshot({ fullPage: true }), await resaved.screenshot({ fullPage: true })]; + ok(s1.equals(s2), `${tag}: the legacy page differs from its re-saved (hooked) form on a phone`); + await resaved.close(); + } + await base.close(); + await page.close(); + } + } + await browser.close(); +} + +const only = process.env.LEGACY_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); +console.log('\n' + summary.join('\n')); +console.log(fail ? `\n${fail} FAILED` : '\nall passed'); +process.exit(fail ? 1 : 0); diff --git a/craft/src/utils/html-export.head-parity.test.ts b/craft/src/utils/html-export.head-parity.test.ts index 682a40e..fcc4dbc 100644 --- a/craft/src/utils/html-export.head-parity.test.ts +++ b/craft/src/utils/html-export.head-parity.test.ts @@ -14,14 +14,14 @@ 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", - 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', () => { @@ -275,7 +275,7 @@ describe('mobile stacking + navbar wrap head rules', () => { 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('[data-wsb-grid]{grid-template-columns:minmax(0,1fr)!important}[style^=\"max-width:1100px;margin:0 auto;display:grid;grid-template-columns:repeat(\"][style$=\",1fr);gap:32px;text-align:center\"]{grid-template-columns:minmax(0,1fr)!important}[style=\"max-width:1100px;margin:0 auto;display:grid;grid-template-columns:repeat(3,1fr);gap:32px\"]:not(:has(>*>img[style^=\"width:100%;height:200px;object-fit:cover\"])),[style^=\"max-width:1100px;margin:0 auto;display:grid;grid-template-columns:repeat(\"][style$=\",1fr);gap:24px\"]:not(:has(>*>img[style^=\"width:100%;height:200px;object-fit:cover\"])){grid-template-columns:minmax(0,1fr)!important}'); expect(mobile).toContain('body{overflow-wrap:break-word}'); 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). diff --git a/craft/src/utils/html-export.legacy-grid.test.ts b/craft/src/utils/html-export.legacy-grid.test.ts new file mode 100644 index 0000000..538cf90 --- /dev/null +++ b/craft/src/utils/html-export.legacy-grid.test.ts @@ -0,0 +1,113 @@ +/** + * Legacy card grids: pages stored before release 2026.11.5 carry FeaturesGrid / Testimonials / NumberCounter + * markup WITHOUT the data-wsb-grid hook (stored HTML is generated at editor-save time and the backend only wraps + * it in a head at publish). The head's <=767px block matches those stored inline styles byte-for-byte so a + * republish stacks them without the customer re-saving. jsdom has no layout: this pins WHICH elements the rules + * select; scripts/e2e-legacy-grid.mjs measures the layout in Chromium + Firefox. + */ +import { beforeAll, describe, expect, test } from 'vitest'; +import { RESPONSIVE_CSS_MINIFIED, exportToHtml } from './html-export'; +import { FeaturesGrid } from '../components/sections/FeaturesGrid'; +import { Testimonials } from '../components/sections/Testimonials'; +import { NumberCounter } from '../components/sections/NumberCounter'; +import { Gallery } from '../components/sections/Gallery'; + +const toHtml = (c: unknown, props: Record, id = 'n1'): string => (c as any).toHtml(props, '', id).html; + +// The ONLY grid shapes these components ever stored before the hook (craft git history, and every stored page +// under .site-builder/pages on the OLS / TTM sites): no spaces, no trailing semicolon. +const LEGACY = { + features: '
x
', + testimonials: (n: number) => `
x
`, + counter: (n: number) => `
1
`, +}; + +/** Selectors of the rules inside the <=767px block that set grid-template-columns. */ +let gridSelectors: string[] = []; +let outsideMobile: string[] = []; +beforeAll(() => { + const style = document.createElement('style'); + style.textContent = RESPONSIVE_CSS_MINIFIED; + document.head.appendChild(style); + for (const r of Array.from(style.sheet!.cssRules) as any[]) { + const inner = Array.from(r.cssRules || []) as CSSStyleRule[]; + const sel = inner.filter((x) => /grid-template-columns/.test(x.cssText)).map((x) => x.selectorText); + if (r.media?.mediaText.replace(/\s/g, '') === '(max-width:767px)') gridSelectors = sel; + else outsideMobile.push(...sel); + } +}); + +/** Whether any <=767px grid rule selects the first element of `html`. */ +function stacks(html: string): boolean { + const host = document.createElement('div'); + host.innerHTML = html; + document.body.appendChild(host); + const el = host.firstElementChild!; + const hit = gridSelectors.some((s) => el.matches(s)); + host.remove(); + return hit; +} +const stripHook = (html: string) => html.replace(/ data-wsb-grid(="")?/g, ''); + +describe('legacy card grids stack on phones (head <=767px block)', () => { + test('the rules are parsed and live only in the <=767px block', () => { + expect(gridSelectors.length).toBeGreaterThanOrEqual(3); + expect(outsideMobile).toEqual([]); + }); + + test('the pretty (minifyCss:false) head carries the same grid rules', () => { + const html = exportToHtml(JSON.stringify({ ROOT: { type: { resolvedName: 'Container' }, isCanvas: true, props: { tag: 'div', style: {} }, nodes: [], linkedNodes: {} } }), { minifyCss: false }).html; + const doc = new DOMParser().parseFromString(html, 'text/html'); + const style = document.createElement('style'); + style.textContent = doc.querySelector('style')!.textContent; + document.head.appendChild(style); + const sels = (Array.from(style.sheet!.cssRules) as any[]) + .filter((r) => r.media?.mediaText.replace(/\s/g, '') === '(max-width:767px)') + .flatMap((r) => (Array.from(r.cssRules) as CSSStyleRule[]).filter((x) => /grid-template-columns/.test(x.cssText)).map((x) => x.selectorText)); + style.remove(); + const norm = (a: string[]) => a.map((x) => x.replace(/\s*([,>])\s*/g, '$1')); + expect(norm(sels)).toEqual(norm(gridSelectors)); + }); + + test('stored FeaturesGrid / Testimonials / NumberCounter markup without the hook is selected', () => { + expect(stacks(LEGACY.features)).toBe(true); + for (const n of [1, 2, 3, 4, 5, 6]) { + expect(stacks(LEGACY.testimonials(n)), `testimonials ${n}`).toBe(true); + expect(stacks(LEGACY.counter(n)), `counter ${n}`).toBe(true); + } + }); + + test('the legacy shapes are exactly what the current emitters output minus the hook', () => { + const grid = (html: string) => /]*display:grid[^>]*>/.exec(html)![0]; + expect(stripHook(grid(toHtml(FeaturesGrid, {})))).toBe(grid(LEGACY.features)); + for (const n of [2, 3, 4]) { + expect(stacks(stripHook(grid(toHtml(Testimonials, { columns: n }))) + '
')).toBe(true); + expect(stacks(stripHook(grid(toHtml(NumberCounter, { columns: n }))) + '')).toBe(true); + expect(stripHook(grid(toHtml(Testimonials, { columns: n })))).toBe(grid(LEGACY.testimonials(n))); + } + // New saves keep the hook (and so are covered by [data-wsb-grid] as well). + expect(toHtml(FeaturesGrid, {})).toContain('data-wsb-grid'); + }); + + test('Gallery keeps its columns: never selected, even when its gap equals a card-grid signature', () => { + for (const lightbox of [false, true]) { + for (const columns of [2, 3, 4]) { + for (const gap of ['16px', '24px', '32px']) { + const html = toHtml(Gallery, { columns, gap, lightbox }, `g${columns}${gap}`).replace(/^\s*]*>\s*/, ''); + expect(stacks(html), `gallery cols=${columns} gap=${gap} lightbox=${lightbox}`).toBe(false); + } + } + } + }); + + test('customer grids (HtmlBlock / hand-written inline styles) are left alone', () => { + for (const html of [ + '
x
', + '
x
', + '
x
', + '
x
', + '
x
', + '
x
', + ]) expect(stacks(html), html).toBe(false); + }); +}); diff --git a/craft/src/utils/html-export.test.ts b/craft/src/utils/html-export.test.ts index be5c26d..e8bfc6f 100644 --- a/craft/src/utils/html-export.test.ts +++ b/craft/src/utils/html-export.test.ts @@ -576,6 +576,10 @@ 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) => { + // legacy-grid: the stored-markup card-grid rules sit right after [data-wsb-grid]; drop them first. + const legacy = '[style^="max-width:1100px;margin:0 auto;display:grid;grid-template-columns:repeat("][style$=",1fr);gap:32px;text-align:center"]{grid-template-columns:minmax(0,1fr)!important}[style="max-width:1100px;margin:0 auto;display:grid;grid-template-columns:repeat(3,1fr);gap:32px"]:not(:has(>*>img[style^="width:100%;height:200px;object-fit:cover"])),[style^="max-width:1100px;margin:0 auto;display:grid;grid-template-columns:repeat("][style$=",1fr);gap:24px"]:not(:has(>*>img[style^="width:100%;height:200px;object-fit:cover"])){grid-template-columns:minmax(0,1fr)!important}'; + expect(html.split(legacy).length).toBe(2); + html = html.replace(legacy, ''); // 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] pre{max-width:100%;overflow-x:auto}}@media(max-width:991px){nav[class^="nav_"],nav[class^="nav_"] .navbar-links{flex-wrap:wrap}'; diff --git a/craft/src/utils/html-export.ts b/craft/src/utils/html-export.ts index 8900d70..aafd5d9 100644 --- a/craft/src/utils/html-export.ts +++ b/craft/src/utils/html-export.ts @@ -406,6 +406,16 @@ const { mobileMax, tabletMin, tabletMax, desktopMin } = BREAKPOINTS; // `[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. +// Legacy card grids: pages saved before the hook existed are stored WITHOUT data-wsb-grid (stored HTML is +// generated at editor-save time; publish only wraps it in this head), so the next two rules match the exact inline +// styles those three emitters stored (no spaces, no trailing ';', checked against craft git history and stored +// sites) and republishing stacks them with no re-save: NumberCounter (`...;gap:32px;text-align:center`, Gallery's +// gap cannot contain ';'), FeaturesGrid (always repeat(3,1fr);gap:32px) and Testimonials (`...;gap:24px`, any +// column count). Gallery emits the same prefix with a free `gap`, so the last two skip a grid whose items hold +// Gallery's thumbnail (`img[style^="width:100%;height:200px;object-fit:cover"]`): Gallery keeps its columns by +// design. :has() is unforgiving, so those two sit in their own rule: a browser without it (Safari <15.4, Firefox +// <121) drops only that rule and shows them as before. Customer grids (HtmlBlock, hand-written styles) never match +// these whole-attribute signatures and are left alone, like other customer layout (publish-checks warns). // `body{overflow-wrap:break-word}`: an unbreakable word/URL wraps instead of running off a phone screen; text // 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. @@ -430,6 +440,13 @@ const RESPONSIVE_CSS = ` [data-wsb-grid] { grid-template-columns: minmax(0, 1fr) !important; } + [style^="max-width:1100px;margin:0 auto;display:grid;grid-template-columns:repeat("][style$=",1fr);gap:32px;text-align:center"] { + grid-template-columns: minmax(0, 1fr) !important; + } + [style="max-width:1100px;margin:0 auto;display:grid;grid-template-columns:repeat(3,1fr);gap:32px"]:not(:has(>*>img[style^="width:100%;height:200px;object-fit:cover"])), + [style^="max-width:1100px;margin:0 auto;display:grid;grid-template-columns:repeat("][style$=",1fr);gap:24px"]:not(:has(>*>img[style^="width:100%;height:200px;object-fit:cover"])) { + grid-template-columns: minmax(0, 1fr) !important; + } body { overflow-wrap: break-word; } @@ -450,7 +467,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] pre{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}[style^="max-width:1100px;margin:0 auto;display:grid;grid-template-columns:repeat("][style$=",1fr);gap:32px;text-align:center"]{grid-template-columns:minmax(0,1fr)!important}[style="max-width:1100px;margin:0 auto;display:grid;grid-template-columns:repeat(3,1fr);gap:32px"]:not(:has(>*>img[style^="width:100%;height:200px;object-fit:cover"])),[style^="max-width:1100px;margin:0 auto;display:grid;grid-template-columns:repeat("][style$=",1fr);gap:24px"]:not(:has(>*>img[style^="width:100%;height:200px;object-fit:cover"])){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; } } diff --git a/craft/tests/fixtures/legacy-grid/ols.design.json b/craft/tests/fixtures/legacy-grid/ols.design.json new file mode 100644 index 0000000..75ce1a0 --- /dev/null +++ b/craft/tests/fixtures/legacy-grid/ols.design.json @@ -0,0 +1,21 @@ +{ + "accentColor": "#10b981", + "backgroundColor": "#ffffff", + "bodyFont": "Inter, sans-serif", + "borderColor": "#e5e7eb", + "borderRadius": "8px", + "buttonFont": "Inter, sans-serif", + "buttonRadius": "8px", + "errorColor": "#ef4444", + "favicon": "assets/ols-favicon.png", + "headCode": "", + "headingFont": "Inter, sans-serif", + "linkColor": "#3b82f6", + "mutedTextColor": "#6b7280", + "navStyle": "light", + "primaryColor": "#3b82f6", + "secondaryColor": "#8b5cf6", + "successColor": "#10b981", + "textColor": "#1f2937", + "warningColor": "#f59e0b" +} \ No newline at end of file diff --git a/craft/tests/fixtures/legacy-grid/ols.footer.html b/craft/tests/fixtures/legacy-grid/ols.footer.html new file mode 100644 index 0000000..4cbd834 --- /dev/null +++ b/craft/tests/fixtures/legacy-grid/ols.footer.html @@ -0,0 +1 @@ +
© 2026 OLS Digital. All rights reserved. Questions? Email info@olsdigital.com
\ No newline at end of file diff --git a/craft/tests/fixtures/legacy-grid/ols.header.html b/craft/tests/fixtures/legacy-grid/ols.header.html new file mode 100644 index 0000000..29588f9 --- /dev/null +++ b/craft/tests/fixtures/legacy-grid/ols.header.html @@ -0,0 +1,14 @@ + + \ No newline at end of file diff --git a/craft/tests/fixtures/legacy-grid/ols.index.html b/craft/tests/fixtures/legacy-grid/ols.index.html new file mode 100644 index 0000000..d8636a2 --- /dev/null +++ b/craft/tests/fixtures/legacy-grid/ols.index.html @@ -0,0 +1,32 @@ +
+ +
+

OLS Digital

+

The parent company behind a growing family of trusted online brands, including AnHonestHost and StreamerTools.

+ +
+
+
+
+
🏢
+

Multi-Brand Portfolio

+

We own and support a growing collection of independent, high-quality brands.

+
+
+
🌐
+

AnHonestHost

+

Transparent, reliable web hosting built on trust.

+
+
+
🎮
+

StreamerTools

+

Tools and resources built for content creators and streamers.

+
+
+

Building Brands That Matter

OLS Digital is a privately held parent company focused on acquiring and growing digital-first businesses. Each brand under our umbrella operates independently, guided by a shared commitment to honesty, quality, and great customer experiences.

+
+

Want to Learn More?

+

For inquiries about OLS Digital or any of our brands, reach out to our team directly.

+ +
+
\ No newline at end of file