Files
twenty/packages/twenty-website-redone/scripts/product-stepper-parity.mjs
T
Abdullah. 465eb05aaf Reworked website. (#21763)
twenty-website had accumulated structural problems that were cheaper to
fix by rebuilding than to refactor in place:
  
- Drift had no guardrails. Values were matched at call sites instead of
single-sourced, so things silently diverged — e.g. the radius token base
was wrong for days (every radius() consumer rendered double) because
nothing measured it against the old site's CSS variables.
- A whole tree escaped quality checks. src/lib/ (~9.8k lines) was never
format-checked, because oxfmt silently ignores directories named lib/.
- Inconsistent rhythm. Hero spacing varied 24–88px between pages
(CEO-flagged), because section spacing wasn't a token.
- Over-extraction. -config.ts sprawl pulled single-component
configuration out into the wrong place.
  
The goal: a ground-up rebuild where drift is structurally impossible,
held to a Linear / Railway / Notion / Attio quality bar. The old site is
treated as source of intent only — nothing is blindly ported; every
piece is re-decided and A/B-verified.

**Rebuild**
  
A full rebuild on Next 16 + Turbopack + Linaria (zero-runtime CSS),
~1,100 files. Marketing pages (home, product, pricing, partners +
marketplace, customers/case-studies, why-twenty, releases, legal), the
interactive AppPreview product mockup, the platform/visuals WebGL system
(engine + rigs, three code-split off every initial chunk), and the
standalone /halftone studio (the dev tool that generates the site's
halftone art — engine, exporters, and full UI ported as an isolated
island).
  
**Architecture & guarantees**
  
- Parity by construction. src/tokens/definitions.ts is the only file
with raw values; the :root CSS-variable block is generated from it at
build time and accessors derive var names through the same helpers —
derived alpha tokens appear in served CSS without ever being
hand-written.
- Mobile-first by API shape. mediaUp() is the only media helper (no
max-width helper exists, on purpose).
- Section rhythm is a token (RHYTHM.section) — the hero-spacing
inconsistency class is fixed by construction.
- Fluid type ramps interpolate font-size and line-height between
designed endpoints [390px → md]; TYPE_SCALE is the single source.
- three.js never enters an initial chunk — confined to platform/visuals
heavy zones, reached only via dynamic(ssr:false), enforced by
check-visual-bundle.
2026-06-18 11:55:24 +02:00

323 lines
11 KiB
JavaScript

import {
createBattery,
launchBrowser,
NEW_BASE,
OLD_BASE,
openPage,
} from './battery-kit.mjs';
// A/B battery for the product stepper: section chrome, the sticky scroll
// choreography at pinned positions, the visual crossfade, and the three
// interactive editors (entity drag, workflow beat, layout toggle+reorder).
const HEADING = 'Go the extra mile';
const { compare, fail, finish, ok } = createBattery('product-stepper-parity');
// The old site tints the stepper section with 5% black over white; ours
// bakes the same color as a solid token. Composite before comparing.
const compositeOverWhite = (cssColor) => {
const match = /rgba?\(([\d.]+), ([\d.]+), ([\d.]+)(?:, ([\d.]+))?\)/.exec(
cssColor ?? '',
);
if (!match) return cssColor;
const alpha = match[4] === undefined ? 1 : Number(match[4]);
const channel = (value) =>
Math.round(Number(value) * alpha + 255 * (1 - alpha));
return `rgb(${channel(match[1])}, ${channel(match[2])}, ${channel(match[3])})`;
};
const browser = await launchBrowser();
// Scrolls the stepper section to a fraction of its own scrollable track.
async function scrollSectionTo(page, trackFraction) {
await page.evaluate(
({ needle, fraction }) => {
const heading = [...document.querySelectorAll('h2')].find((el) =>
el.textContent.includes(needle),
);
const section = heading?.closest('section');
if (!section) return;
const rect = section.getBoundingClientRect();
const top = rect.top + window.scrollY;
const scrollable = section.offsetHeight - window.innerHeight;
window.scrollTo(0, top + fraction * Math.max(scrollable, 0));
},
{ needle: HEADING, fraction: trackFraction },
);
await page.waitForTimeout(700);
}
function readStepperState(page) {
return page.evaluate((needle) => {
const heading = [...document.querySelectorAll('h2')].find((el) =>
el.textContent.includes(needle),
);
const section = heading?.closest('section');
if (!section) return null;
const sectionStyle = getComputedStyle(section);
const headingStyle = getComputedStyle(heading);
// Step rows: each holds a 22px icon box; read its block's inline
// opacity/transform vars or styles.
const stepLabels = ['Data model', 'Automation', 'Layout'];
const steps = stepLabels.map((label) => {
const labelNode = [...section.querySelectorAll('span, h3, div')].find(
(el) => el.childElementCount === 0 && el.textContent.trim() === label,
);
let block = labelNode;
while (block && block.parentElement !== null) {
const style = getComputedStyle(block);
if (style.transitionProperty.includes('opacity')) break;
block = block.parentElement;
}
const blockStyle = block ? getComputedStyle(block) : null;
const iconBox = labelNode?.parentElement?.querySelector('div');
return {
opacity: blockStyle ? Number(blockStyle.opacity).toFixed(2) : null,
moving: blockStyle ? blockStyle.transform !== 'none' : null,
iconBoxBackground: iconBox
? getComputedStyle(iconBox).backgroundColor
: null,
};
});
// The visual slides: absolute crossfading layers inside the frame.
const slides = [...section.querySelectorAll('div')].filter((el) => {
const style = getComputedStyle(el);
return (
style.position === 'absolute' &&
style.transitionProperty.includes('opacity') &&
style.transitionDuration.includes('0.4s') &&
el.querySelector('svg')
);
});
const frame = [...section.querySelectorAll('div')].find((el) => {
const ratio = el.offsetWidth / el.offsetHeight;
return el.querySelector('img') && Math.abs(ratio - 672 / 705) < 0.01;
});
const frameImages = frame
? [...frame.querySelectorAll('img')].map((img) => img.naturalWidth > 0)
: [];
return {
sectionBackground: sectionStyle.backgroundColor,
headingStyle: `${headingStyle.fontSize} ${headingStyle.fontWeight}`,
steps,
slideOpacities: slides.map((el) => Number(getComputedStyle(el).opacity)),
frameAspectOk: Boolean(frame),
frameImages,
};
}, HEADING);
}
// Finds a draggable card by its label inside the active slide and returns
// its center plus the connector layer's path data for follow checks.
function readDraggable(page, labelText) {
return page.evaluate((label) => {
const node = [...document.querySelectorAll('span, div')].find(
(el) => el.childElementCount === 0 && el.textContent.trim() === label,
);
let card = node;
while (card) {
const style = getComputedStyle(card);
if (style.position === 'absolute' && style.cursor === 'grab') break;
card = card.parentElement;
}
if (!card) return null;
const rect = card.getBoundingClientRect();
const svg = card.parentElement?.querySelector('svg');
return {
x: rect.x + rect.width / 2,
y: rect.y + 10,
left: rect.x,
paths: svg
? [...svg.querySelectorAll('path')].map((p) => p.getAttribute('d'))
: [],
};
}, labelText);
}
const oldPage = await openPage(browser, `${OLD_BASE}/product`);
const newPage = await openPage(browser, `${NEW_BASE}/product`);
const pages = [
['old', oldPage],
['new', newPage],
];
// --- Step 1: section chrome + initial choreography ---------------------
await scrollSectionTo(oldPage, 0.05);
await scrollSectionTo(newPage, 0.05);
const oldStart = await readStepperState(oldPage);
const newStart = await readStepperState(newPage);
if (!oldStart || !newStart) {
fail(
'stepper present on both sites',
`old=${Boolean(oldStart)} new=${Boolean(newStart)}`,
);
} else {
compare(
'section background (composited)',
compositeOverWhite(oldStart.sectionBackground),
compositeOverWhite(newStart.sectionBackground),
);
compare('heading style', oldStart.headingStyle, newStart.headingStyle);
compare(
'step choreography at start',
oldStart.steps.map((step) => `${step.opacity}|${step.moving}`),
newStart.steps.map((step) => `${step.opacity}|${step.moving}`),
);
compare(
'active step icon box ink',
oldStart.steps[0].iconBoxBackground,
newStart.steps[0].iconBoxBackground,
);
compare(
'first slide active, rest hidden',
oldStart.slideOpacities,
newStart.slideOpacities,
);
if (newStart.frameAspectOk && newStart.frameImages.every(Boolean)) {
ok('frame holds 672/705 with both artwork images loaded');
} else {
fail(
'frame holds 672/705 with both artwork images loaded',
JSON.stringify(newStart),
);
}
}
// --- Mid-track: second step takes over ----------------------------------
await scrollSectionTo(oldPage, 0.5);
await scrollSectionTo(newPage, 0.5);
const oldMid = await readStepperState(oldPage);
const newMid = await readStepperState(newPage);
compare(
'step choreography mid-track',
oldMid.steps.map((step) => `${step.opacity}|${step.moving}`),
newMid.steps.map((step) => `${step.opacity}|${step.moving}`),
);
compare(
'second slide active mid-track',
oldMid.slideOpacities,
newMid.slideOpacities,
);
// Workflow beat: while the second slide is active, check badges tick in.
for (const [name, page] of pages) {
// eslint-disable-next-line no-await-in-loop
await page.waitForTimeout(2600);
// eslint-disable-next-line no-await-in-loop
const visibleChecks = await page.evaluate(() => {
return [...document.querySelectorAll('span')].filter((el) => {
const style = getComputedStyle(el);
return (
style.width === '12px' &&
style.height === '12px' &&
Number(style.opacity) === 1 &&
el.querySelector('svg')
);
}).length;
});
if (visibleChecks >= 1) {
ok(`workflow beat ticks check badges on ${name} site`, `${visibleChecks}`);
} else {
fail(`workflow beat ticks check badges on ${name} site`, '0 visible');
}
}
// --- Back to step 1: entity drag + edges follow -------------------------
for (const [name, page] of pages) {
// eslint-disable-next-line no-await-in-loop
await scrollSectionTo(page, 0.05);
// eslint-disable-next-line no-await-in-loop
await page.waitForTimeout(600);
// eslint-disable-next-line no-await-in-loop
const before = await readDraggable(page, 'Workspaces');
if (!before) {
fail(`entity card found on ${name} site`, 'no Workspaces card');
continue;
}
// eslint-disable-next-line no-await-in-loop
await page.mouse.move(before.x, before.y);
// eslint-disable-next-line no-await-in-loop
await page.mouse.down();
for (const step of [10, 25, 40]) {
// eslint-disable-next-line no-await-in-loop
await page.mouse.move(before.x + step, before.y + step);
}
// eslint-disable-next-line no-await-in-loop
await page.mouse.up();
// eslint-disable-next-line no-await-in-loop
const after = await readDraggable(page, 'Workspaces');
const moved = after && Math.abs(after.left - before.left) >= 30;
const edgesFollowed =
after && JSON.stringify(after.paths) !== JSON.stringify(before.paths);
if (moved && edgesFollowed) {
ok(`entity drag moves card and edges follow on ${name} site`);
} else {
fail(
`entity drag moves card and edges follow on ${name} site`,
`moved=${moved} edges=${edgesFollowed}`,
);
}
}
// --- Step 3: layout visibility toggle ------------------------------------
for (const [name, page] of pages) {
// eslint-disable-next-line no-await-in-loop
await scrollSectionTo(page, 0.85);
// eslint-disable-next-line no-await-in-loop
await page.waitForTimeout(700);
// eslint-disable-next-line no-await-in-loop
const eye = await page.evaluate(() => {
// The hidden ICP field renders the slashed eye (a 3-13 diagonal line).
const slashed = [...document.querySelectorAll('svg')].find((svg) =>
[...svg.querySelectorAll('path')].some(
(path) => path.getAttribute('d') === 'M3 3l10 10',
),
);
if (!slashed) return null;
const rect = slashed.getBoundingClientRect();
return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };
});
if (!eye) {
fail(`hidden-field eye found on ${name} site`, 'no slashed eye glyph');
continue;
}
// The field row captures the pointer on press (drag affordance), which
// retargets synthesized clicks on both sites — drive the button itself.
// eslint-disable-next-line no-await-in-loop
await page.evaluate(() => {
const slashed = [...document.querySelectorAll('svg')].find((svg) =>
[...svg.querySelectorAll('path')].some(
(path) => path.getAttribute('d') === 'M3 3l10 10',
),
);
slashed?.closest('span, div')?.click();
});
// eslint-disable-next-line no-await-in-loop
await page.waitForTimeout(250);
// eslint-disable-next-line no-await-in-loop
const slashedCount = await page.evaluate(
() =>
[...document.querySelectorAll('svg')].filter((svg) =>
[...svg.querySelectorAll('path')].some(
(path) => path.getAttribute('d') === 'M3 3l10 10',
),
).length,
);
if (slashedCount === 0) {
ok(`layout eye toggle reveals the hidden field on ${name} site`);
} else {
fail(
`layout eye toggle reveals the hidden field on ${name} site`,
`${slashedCount} slashed eyes remain`,
);
}
}
await finish(browser);