Files
twenty/packages/twenty-website-redone/scripts/mockup-old-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

194 lines
7.9 KiB
JavaScript

import { chromium } from 'playwright';
// OLD-parity battery: computed values measured identically on :3002 and
// :3004 with selectors that resolve on BOTH DOMs. Exact equality except
// the executable ledger below.
const OLD_URL = process.env.MOCKUP_OLD_URL ?? 'http://localhost:3002/';
const NEW_URL = process.env.VISUAL_BATTERY_URL ?? 'http://localhost:3004/';
// Every deliberate divergence is declared here; an unledgered diff fails,
// a ledgered-but-absent diff also fails.
// (Empty today: the one candidate — product transparent.medium p3 0.078
// vs old rgba 0.08 — sits below colorimetric resolution and passes as
// equal.) Shape: [{ key, reason }].
const LEDGER = [];
const failures = [];
const assert = (condition, message) => {
console.log(` ${condition ? '✓' : '✗'} ${message}`);
if (!condition) {
failures.push(message);
}
};
async function measure(browser, url) {
const page = await browser.newPage({
viewport: { width: 1440, height: 950 },
deviceScaleFactor: 1,
});
await page.goto(url, { waitUntil: 'networkidle', timeout: 240000 });
await page.waitForTimeout(4000);
const values = await page.evaluate(() => {
// page.evaluate serializes this callback: helpers must live inside it.
// eslint-disable-next-line unicorn/consistent-function-scoping
const styleOf = (el) => (el ? getComputedStyle(el) : null);
// page.evaluate serializes this callback: helpers must live inside it.
// eslint-disable-next-line unicorn/consistent-function-scoping
const round = (n) => Math.round(n * 10) / 10;
// The framed window: both sites render a 20px-radius white shell.
const frame = [...document.querySelectorAll('div')].find((el) => {
const cs = getComputedStyle(el);
const rect = el.getBoundingClientRect();
return cs.borderRadius === '20px' && rect.width > 900;
});
const sidebar = frame?.querySelector('aside');
const companiesLabel = [...(sidebar?.querySelectorAll('span') ?? [])].find(
(el) => el.textContent === 'Companies',
);
const anthropicRow = frame?.querySelector('[data-row-id="anthropic"]');
const firstCell = anthropicRow?.firstElementChild;
const chip = [...(anthropicRow?.querySelectorAll('div') ?? [])].find(
(el) => el.textContent?.trim() === 'Anthropic',
);
const workspaceName = [...(sidebar?.querySelectorAll('span') ?? [])].find(
(el) => el.textContent === 'Apple',
);
const newButton = [...(frame?.querySelectorAll('div, span') ?? [])].find(
(el) =>
el.textContent?.trim() === 'New' &&
styleOf(el.parentElement)?.borderRadius === '4px',
);
const terminal = document.querySelector('[data-terminal-shell]');
const terminalRect = terminal?.getBoundingClientRect();
const terminalStyle = styleOf(terminal);
const sendButton = terminal?.querySelector(
'button[aria-label="Send message"]',
);
const workspaceChip = [
...(terminal?.querySelectorAll('button') ?? []),
].find((el) => el.textContent?.includes('my-twenty-app'));
const promptText = [...(terminal?.querySelectorAll('p') ?? [])].find((el) =>
el.textContent?.startsWith('Scaffold a launch-ops CRM'),
);
const toggle = terminal?.querySelector('[role="tablist"]');
const activeSegment = toggle?.querySelector('[aria-selected="true"]');
const frameStyle = styleOf(frame);
const frameRect = frame?.getBoundingClientRect();
return {
frameWidth: frameRect ? round(frameRect.width) : null,
frameRadius: frameStyle?.borderRadius ?? null,
frameBorder: frameStyle?.borderTopWidth ?? null,
sidebarWidth: sidebar
? round(sidebar.getBoundingClientRect().width)
: null,
navLabelFont: styleOf(companiesLabel)?.fontSize ?? null,
navLabelWeight: styleOf(companiesLabel)?.fontWeight ?? null,
navLabelColor: styleOf(companiesLabel)?.color ?? null,
rowHeight: firstCell
? round(firstCell.getBoundingClientRect().height)
: null,
cellBorderBottom: styleOf(firstCell)?.borderBottomColor ?? null,
chipHeight: chip ? round(chip.getBoundingClientRect().height) : null,
chipRadius: styleOf(chip)?.borderRadius ?? null,
chipBackground: styleOf(chip)?.backgroundColor ?? null,
workspaceNameFont: styleOf(workspaceName)?.fontSize ?? null,
workspaceNameColor: styleOf(workspaceName)?.color ?? null,
navActionBorderColor: newButton
? styleOf(newButton.parentElement)?.borderTopColor
: null,
terminalWidth: terminalRect ? round(terminalRect.width) : null,
terminalHeight: terminalRect ? round(terminalRect.height) : null,
terminalRadius: terminalStyle?.borderRadius ?? null,
terminalBackground: terminalStyle?.backgroundColor ?? null,
terminalBorderColor: terminalStyle?.borderTopColor ?? null,
sendBackground: styleOf(sendButton)?.backgroundColor ?? null,
sendRadius: styleOf(sendButton)?.borderRadius ?? null,
workspaceChipBackground: styleOf(workspaceChip)?.backgroundColor ?? null,
workspaceChipColor: styleOf(workspaceChip)?.color ?? null,
workspaceChipHeight: workspaceChip
? round(workspaceChip.getBoundingClientRect().height)
: null,
promptFont: styleOf(promptText)?.fontSize ?? null,
promptColor: styleOf(promptText)?.color ?? null,
promptLineHeight: styleOf(promptText)?.lineHeight ?? null,
toggleBackground: styleOf(toggle)?.backgroundColor ?? null,
toggleRadius: styleOf(toggle)?.borderRadius ?? null,
activeSegmentBackground: styleOf(activeSegment)?.backgroundColor ?? null,
activeSegmentShadow: styleOf(activeSegment)?.boxShadow ?? null,
activeSegmentFont: styleOf(activeSegment)?.fontSize ?? null,
};
});
await page.close();
return values;
}
const browser = await chromium.launch({ channel: 'chrome', headless: true });
const oldValues = await measure(browser, OLD_URL);
const newValues = await measure(browser, NEW_URL);
await browser.close();
// Colors compare colorimetrically: the old site serializes sRGB, ours
// display-p3 — identical colors, different encodings.
function normalizeColor(value) {
if (typeof value !== 'string') {
return value;
}
const p3 = value.match(
/color\(display-p3 ([\d.]+) ([\d.]+) ([\d.]+)(?: \/ ([\d.]+))?\)/,
);
if (p3) {
const [, r, g, b, a] = p3;
return [r, g, b]
.map((channel) => Math.round(Number(channel) * 255))
.concat([a === undefined ? 1 : Math.round(Number(a) * 1000) / 1000])
.join(',');
}
const rgb = value.match(
/rgba?\(([\d.]+), ([\d.]+), ([\d.]+)(?:, ([\d.]+))?\)/,
);
if (rgb) {
const [, r, g, b, a] = rgb;
return [r, g, b]
.map((channel) => Math.round(Number(channel)))
.concat([a === undefined ? 1 : Math.round(Number(a) * 1000) / 1000])
.join(',');
}
return value;
}
function colorsEqual(a, b) {
const left = normalizeColor(a).split(',').map(Number);
const right = normalizeColor(b).split(',').map(Number);
if (left.length !== right.length || left.some(Number.isNaN)) {
return normalizeColor(a) === normalizeColor(b);
}
return left.every(
(channel, index) =>
Math.abs(channel - right[index]) <= (index === 3 ? 0.005 : 1),
);
}
const ledgeredKeys = new Set(LEDGER.map((entry) => entry.key));
for (const key of Object.keys(oldValues)) {
const oldValue = oldValues[key];
const newValue = newValues[key];
const isColorKey = /color|background|border(?!.*Width)/i.test(key);
const equal = isColorKey
? colorsEqual(String(oldValue), String(newValue))
: String(oldValue) === String(newValue);
if (ledgeredKeys.has(key)) {
assert(
!equal,
`${key}: ledgered divergence present (old ${oldValue} vs new ${newValue})`,
);
continue;
}
assert(equal, `${key}: ${oldValue} == ${newValue}`);
}
if (failures.length > 0) {
console.error(`mockup-old-parity: FAILED (${failures.length})`);
process.exit(1);
}
console.log('mockup-old-parity: OK');