Files
twenty/packages/twenty-website/scripts/product-hero-parity.mjs
T
Abdullah. 569d887d1e [Website] Cut over to the rebuilt site (#21825)
Renaming the package so any further PRs directed to the website are
targeted to the reworked code instead of diverging. Once merged, I will
start preparing this for deployment to dev to test before releasing to
prod. Any improvements will also be applied to this package.

I avoided making significant changes to API routes so nothing breaks,
but will test it thoroughly today to confirm. That said, everything is
ported - double checked.

Big diff PR, impossible to review, but last one! No more rebuilds.
2026-06-19 10:22:46 +02:00

290 lines
9.8 KiB
JavaScript

import { chromium } from 'playwright';
// A/B battery for the product hero's scroll choreography: both sites are
// driven to the same morph positions (derived from each site's own track
// geometry) and compared on the values the old site defines as law.
const OLD_URL = process.env.MOCKUP_OLD_URL ?? 'http://localhost:3002/product';
const NEW_URL =
process.env.VISUAL_BATTERY_URL ?? 'http://localhost:3004/product';
const VIEWPORT = { width: 1440, height: 900 };
const failures = [];
const ok = (label, detail) =>
console.log(`${label}${detail ? ` (${detail})` : ''}`);
const fail = (label, detail) => {
failures.push(`${label}: ${detail}`);
console.log(`${label}: ${detail}`);
};
const browser = await chromium.launch({ channel: 'chrome', headless: true });
async function openHero(url) {
const page = await browser.newPage({ viewport: VIEWPORT });
await page.goto(url, { waitUntil: 'load', timeout: 240000 });
await page.waitForTimeout(1200);
return page;
}
// The 200vh scroll track, found by its geometry on both sites.
async function trackGeometry(page) {
return page.evaluate(() => {
const candidates = [...document.querySelectorAll('section, div')];
const track = candidates.find(
(el) =>
Math.abs(el.offsetHeight - window.innerHeight * 2) < 8 &&
el.querySelector('h1, h2'),
);
if (!track) return null;
const rect = track.getBoundingClientRect();
return {
top: rect.top + window.scrollY,
height: track.offsetHeight,
};
});
}
async function scrollToProgress(page, geometry, progress) {
const scrollable = geometry.height - VIEWPORT.height;
await page.evaluate(({ top }) => window.scrollTo(0, top), {
top: geometry.top + progress * scrollable,
});
await page.waitForTimeout(450);
}
// The dark AI layer: the element carrying a clip-path inset.
async function readHeroState(page) {
return page.evaluate(() => {
const layers = [...document.querySelectorAll('div')];
const dark = layers.find((el) => {
const style = getComputedStyle(el);
return (
style.clipPath !== 'none' &&
style.clipPath.includes('inset') &&
style.position === 'absolute'
);
});
// The sticky menu bar: 64px tall, pinned to the top on both sites.
const header = [...document.querySelectorAll('header, section')].find(
(el) => {
const style = getComputedStyle(el);
return (
(style.position === 'fixed' || style.position === 'sticky') &&
el.getBoundingClientRect().top <= 1 &&
el.offsetHeight >= 40 &&
el.offsetHeight <= 120
);
},
);
const headerStyle = header ? getComputedStyle(header) : null;
const cursorNames = ['Alice', 'Ben', 'Cara'].filter((name) =>
[...document.querySelectorAll('span')].some(
(el) =>
el.textContent === name.toUpperCase() || el.textContent === name,
),
);
const darkStyle = dark ? getComputedStyle(dark) : null;
return {
clipPath: darkStyle?.clipPath ?? null,
darkBackground: darkStyle?.backgroundColor ?? null,
darkPointerEvents: darkStyle?.pointerEvents ?? null,
menuBackground: headerStyle?.backgroundColor ?? null,
cursorNames,
hasStackCards: [...document.querySelectorAll('[role="tab"]')].length >= 4,
};
});
}
function readDeckButtons(page) {
return page.evaluate(() =>
[...document.querySelectorAll('[role="tab"]')]
.filter((el) => el.id.includes('stack'))
.slice(0, 2)
.map((button) => {
const style = getComputedStyle(button);
return [
style.backgroundColor,
style.backgroundImage,
style.borderColor,
style.maxWidth,
style.color,
].join(' | ');
}),
);
}
function parseInsetTop(clipPath) {
const match = /inset\(([\d.]+)%/.exec(clipPath ?? '');
return match ? Number(match[1]) : null;
}
const oldPage = await openHero(OLD_URL);
const newPage = await openHero(NEW_URL);
const oldGeometry = await trackGeometry(oldPage);
const newGeometry = await trackGeometry(newPage);
if (!oldGeometry || !newGeometry) {
fail(
'scroll track present on both sites',
`old=${Boolean(oldGeometry)} new=${Boolean(newGeometry)}`,
);
} else {
ok('scroll track present on both sites');
// scrollProgress -> expected morph via the shared smoothstep law.
const CHECKPOINTS = [0, 0.1375, 0.275, 0.41, 0.6];
// Checkpoints are inherently sequential: scroll, settle, read.
for (const progress of CHECKPOINTS) {
// eslint-disable-next-line no-await-in-loop
await scrollToProgress(oldPage, oldGeometry, progress);
// eslint-disable-next-line no-await-in-loop
await scrollToProgress(newPage, newGeometry, progress);
// eslint-disable-next-line no-await-in-loop
const oldState = await readHeroState(oldPage);
// eslint-disable-next-line no-await-in-loop
const newState = await readHeroState(newPage);
const oldInset = parseInsetTop(oldState.clipPath);
const newInset = parseInsetTop(newState.clipPath);
if (
oldInset !== null &&
newInset !== null &&
Math.abs(oldInset - newInset) <= 1.5
) {
ok(
`wipe position matches at progress ${progress}`,
`old ${oldInset?.toFixed(1)}% vs new ${newInset?.toFixed(1)}%`,
);
} else {
fail(
`wipe position matches at progress ${progress}`,
`old ${oldState.clipPath} vs new ${newState.clipPath}`,
);
}
if (oldState.menuBackground === newState.menuBackground) {
ok(
`menu background matches at progress ${progress}`,
newState.menuBackground,
);
} else {
fail(
`menu background matches at progress ${progress}`,
`old ${oldState.menuBackground} vs new ${newState.menuBackground}`,
);
}
if (oldState.darkPointerEvents === newState.darkPointerEvents) {
ok(
`AI layer interactivity matches at progress ${progress}`,
newState.darkPointerEvents,
);
} else {
fail(
`AI layer interactivity matches at progress ${progress}`,
`old ${oldState.darkPointerEvents} vs new ${newState.darkPointerEvents}`,
);
}
}
// At rest: the collaborative cursors tour the intro on both sites.
await scrollToProgress(oldPage, oldGeometry, 0);
await scrollToProgress(newPage, newGeometry, 0);
const oldRest = await readHeroState(oldPage);
const newRest = await readHeroState(newPage);
if (oldRest.cursorNames.length === 3 && newRest.cursorNames.length === 3) {
ok('three collaborator cursors at rest on both sites');
} else {
fail(
'three collaborator cursors at rest on both sites',
`old ${oldRest.cursorNames.join(',')} vs new ${newRest.cursorNames.join(',')}`,
);
}
if (oldRest.darkBackground === newRest.darkBackground) {
ok('dark layer surface matches', newRest.darkBackground);
} else {
fail(
'dark layer surface matches',
`old ${oldRest.darkBackground} vs new ${newRest.darkBackground}`,
);
}
// Fully morphed: playback starts (agent steps / streamed copy appear).
await scrollToProgress(newPage, newGeometry, 0.6);
await newPage.waitForTimeout(4500);
// By 4.5s the steps have collapsed behind the summary and the answer
// streams: either trace proves playback ran.
const playbackStarted = await newPage.evaluate(
() =>
document.body.textContent.includes('Organized your open deals') ||
document.body.textContent.includes('3 steps') ||
document.body.textContent.includes('Read 24 deals'),
);
if (playbackStarted) {
ok('AI playback runs once fully morphed (first scene steps complete)');
} else {
fail('AI playback runs once fully morphed', 'no completed step copy found');
}
if (newRest.hasStackCards) {
ok('stacked tab deck renders four tabs');
} else {
fail('stacked tab deck renders four tabs', 'fewer than 4 role=tab');
}
// Deck button chrome (the card override the user caught regressing):
// compared at full morph for the active and first inactive tab.
await scrollToProgress(oldPage, oldGeometry, 0.7);
await scrollToProgress(newPage, newGeometry, 0.7);
const oldDeck = await readDeckButtons(oldPage);
const newDeck = await readDeckButtons(newPage);
if (oldDeck.length === 2 && oldDeck.join('\n') === newDeck.join('\n')) {
ok('deck tab button chrome byte-equal (active + inactive)');
} else {
fail(
'deck tab button chrome byte-equal (active + inactive)',
`old ${JSON.stringify(oldDeck)} vs new ${JSON.stringify(newDeck)}`,
);
}
// RATIFIED DIVERGENCE (user, 2026-06-12): the exit crossing hands off
// transparently like the entry; the old site faded through greys that
// matched neither surface. Asserting the difference keeps it deliberate.
const exitProgress =
(oldGeometry.height - VIEWPORT.height + (VIEWPORT.height - 32)) /
(oldGeometry.height - VIEWPORT.height);
await scrollToProgress(oldPage, oldGeometry, exitProgress);
await scrollToProgress(newPage, newGeometry, exitProgress);
const oldExit = await readHeroState(oldPage);
const newExit = await readHeroState(newPage);
const newExitTransparent =
newExit.menuBackground === 'rgba(0, 0, 0, 0)' ||
newExit.menuBackground === 'transparent';
if (newExitTransparent && newExit.menuBackground !== oldExit.menuBackground) {
ok(
'exit crossing hands off transparently (ledgered divergence)',
`old ${oldExit.menuBackground} vs new ${newExit.menuBackground}`,
);
} else {
fail(
'exit crossing hands off transparently (ledgered divergence)',
`old ${oldExit.menuBackground} vs new ${newExit.menuBackground}`,
);
}
}
await browser.close();
if (failures.length > 0) {
console.error(`product-hero-parity: FAILED (${failures.length})`);
process.exitCode = 1;
} else {
console.log('product-hero-parity: OK');
}