snapshot of https://github.com/twentyhq/twenty.git (main, 2026-09-17; blobs > 131072B omitted)
Some checks failed
App Prod-Parity E2E Dispatch / dispatch (push) Failing after 10s
CD Storybook UI / build (push) Failing after 3m25s
CD Storybook UI / dispatch (push) Has been skipped
CD deploy main / website-changed-files (push) Successful in 18m8s
CI Agent Skills / changed-files-check (push) Successful in 18m9s
CI Create App E2E minimal / changed-files-check (push) Successful in 24s
CI Create App / changed-files-check (push) Successful in 26s
CI Docs / changed-files-check (push) Successful in 26s
CI Emails / changed-files-check (push) Successful in 28s
CI Example App Hello World / changed-files-check (push) Successful in 23s
CI Front / changed-files-check (push) Has been skipped
CI Example App Postcard / changed-files-check (push) Successful in 27s
CI UI / changed-files-check (push) Has been skipped
CI Utils Test / changed-files-check (push) Successful in 45s
CI Twenty Apps / discover (push) Successful in 1m3s
CD deploy main / deploy-main (push) Failing after 3s
CI Agent Skills / agent-skills-validate (push) Has been skipped
CI Create App / create-app-test (lint) (push) Has been skipped
CI Emails / emails-test (push) Has been skipped
CI Front / front-task (lingui:compile) (push) Has been skipped
CI Front / front-task (lint) (push) Has been skipped
CI Example App Postcard / example-app-postcard (push) Has been skipped
CI UI / ui-task (lint) (push) Has been skipped
CI UI / ui-task (pack-size) (push) Has been skipped
CI UI / ui-task (size) (push) Has been skipped
CI UI / ui-task (test) (push) Has been skipped
CI UI / ui-task (typecheck) (push) Has been skipped
CI Utils Test / utils-test (push) Has been skipped
CI Twenty Apps / ${{ matrix.app.name }} (push) Has been skipped
CI Emails / ci-emails-status-check (push) Successful in 2s
CI Example App Hello World / ci-example-app-hello-world-status-check (push) Successful in 2s
CI Example App Postcard / ci-example-app-postcard-status-check (push) Successful in 3s
CI UI / ui-sb-test (push) Has been skipped
CI Utils Test / ci-utils-test-status-check (push) Successful in 2s
CI Twenty Apps / ci-twenty-apps-status-check (push) Successful in 2s
CI UI / ci-ui-status-check (push) Successful in 2s
CI Desktop / desktop (push) Failing after 1m8s
Push docs to Crowdin / Push documentation to Crowdin (push) Failing after 55s
Push translations to Crowdin / Extract and upload translations (push) Failing after 5s
Push website translations to Crowdin / Extract and upload website translations (push) Failing after 5s
CI Create App / create-app-test (test) (push) Has been skipped
CI Create App / create-app-test (typecheck) (push) Has been skipped
CI Docs / docs-lint (push) Has been skipped
CI Example App Hello World / example-app-hello-world (push) Has been skipped
CI Front / front-task (lingui:extract) (push) Has been skipped
CI Front / front-task (test) (push) Has been skipped
CI Front / front-task (typecheck) (push) Has been skipped
CI UI / ui-sb-build (push) Failing after 1m32s
CI Twenty Apps / ${{ matrix.app.name }} (${{ matrix.server-source }}) (push) Has been skipped
CI Agent Skills / agent-skills-publish (push) Has been skipped
CI Create App / ci-create-app-status-check (push) Successful in 2s
CI Agent Skills / agent-skills-remote-source (push) Has been skipped
CI Create App E2E minimal / create-app-e2e-minimal (push) Has been cancelled
CI E2E Main / e2e-test (push) Has been cancelled
CI Front / front-sb-build (push) Has been cancelled
CI Front / front-build (push) Has been cancelled
CI Create App E2E minimal / ci-create-app-e2e-minimal-status-check (push) Has been cancelled
CI E2E Main / ci-e2e-main-status-check (push) Has been cancelled
CI E2E Main / notify-main-ci-failure (push) Has been cancelled
CI E2E Main / qa-scout-comment (push) Has been cancelled
CI Front / front-sb-test (push) Has been cancelled
CI Front / front-sb-screenshots (push) Has been cancelled
CI Front / ci-front-status-check (push) Has been cancelled
DPA Sub-Processor Sync / sync-subprocessors (push) Failing after 3s
AI Catalog Sync / sync-catalog (push) Failing after 2s

This commit is contained in:
upstream-mirror
2026-09-17 23:28:13 +05:00
commit e7da97194a
32688 changed files with 4208681 additions and 0 deletions

View File

@@ -0,0 +1,104 @@
import { readFileSync } from 'node:fs';
import {
createBattery,
launchBrowser,
NEW_BASE,
openPage,
} from './battery-kit.mjs';
// Decorative section backgrounds (gradients, halftones, notched cards,
// crosshairs) must never bleed past the content column — only the section's
// solid colour is full-bleed. The cap is read from the token itself, so this
// guard can never drift from it. Measured well past the cap so the centring is
// exercised: every [data-background-layer] must land <= the cap and centred.
const tokenSource = readFileSync(
new URL('../src/tokens/max-content-width.ts', import.meta.url),
'utf8',
);
const MAX_CONTENT_WIDTH_PX = Number(
/MAX_CONTENT_WIDTH_PX\s*=\s*(\d+)/.exec(tokenSource)?.[1],
);
const WIDE_VIEWPORT = { height: 1200, width: 2400 };
const PAGES = [
'/',
'/product',
'/pricing',
'/customers',
'/partners',
'/partners/become',
'/why-twenty',
'/releases',
];
const battery = createBattery('background-cap');
const browser = await launchBrowser();
for (const pagePath of PAGES) {
// eslint-disable-next-line no-await-in-loop
const page = await openPage(browser, `${NEW_BASE}${pagePath}`, {
settleMs: 1000,
viewport: WIDE_VIEWPORT,
});
// eslint-disable-next-line no-await-in-loop
const pageHeight = await page.evaluate(
() => document.documentElement.scrollHeight,
);
for (let y = 0; y <= pageHeight; y += 800) {
// eslint-disable-next-line no-await-in-loop
await page.evaluate((scrollY) => window.scrollTo(0, scrollY), y);
// eslint-disable-next-line no-await-in-loop
await page.waitForTimeout(150);
}
// eslint-disable-next-line no-await-in-loop
const layers = await page.evaluate(() =>
[...document.querySelectorAll('[data-background-layer]')]
.map((element) => element.getBoundingClientRect())
.filter((rect) => rect.width > 0)
.map((rect) => ({
leftMargin: Math.round(rect.left),
rightMargin: Math.round(window.innerWidth - rect.right),
width: Math.round(rect.width),
})),
);
// eslint-disable-next-line no-await-in-loop
await page.close();
if (layers.length === 0) {
battery.ok(`${pagePath} — no capped background layers rendered`, 'none');
continue;
}
const overflowing = layers.filter(
(layer) => layer.width > MAX_CONTENT_WIDTH_PX + 1,
);
const offCentre = layers.filter(
(layer) => Math.abs(layer.leftMargin - layer.rightMargin) > 2,
);
if (overflowing.length > 0) {
battery.fail(
`${pagePath} background layers exceed ${MAX_CONTENT_WIDTH_PX}px`,
JSON.stringify(overflowing),
);
}
if (offCentre.length > 0) {
battery.fail(
`${pagePath} background layers not centred`,
JSON.stringify(offCentre),
);
}
if (overflowing.length === 0 && offCentre.length === 0) {
const widths = [...new Set(layers.map((layer) => layer.width))].toSorted(
(first, second) => second - first,
);
battery.ok(
`${pagePath} — ${layers.length} background layers capped + centred`,
`widths ${widths.join(', ')}px <= ${MAX_CONTENT_WIDTH_PX}`,
);
}
}
await battery.finish(browser);

View File

@@ -0,0 +1,63 @@
import { chromium } from 'playwright';
// The shared chassis every parity battery runs on: one place for the
// pass/fail ledger, the A/B compare funnel and page setup. New batteries
// MUST build on this — and when the old site retires, compare() is the
// single seam where frozen fixtures replace live :3002 values.
export const OLD_BASE = process.env.MOCKUP_OLD_URL ?? 'http://localhost:3002';
export const NEW_BASE =
process.env.VISUAL_BATTERY_URL ?? 'http://localhost:3004';
export const VIEWPORT = { width: 1440, height: 900 };
const ok = (label, detail) =>
console.log(` ✓ ${label}${detail ? ` (${detail})` : ''}`);
export function createBattery(name) {
const failures = [];
const fail = (label, detail) => {
failures.push(`${label}: ${detail}`);
console.log(` ✗ ${label}: ${detail}`);
};
const compare = (label, oldValue, newValue) => {
const oldText = JSON.stringify(oldValue);
const newText = JSON.stringify(newValue);
if (oldText === newText) {
ok(label, oldText.length > 90 ? undefined : oldText);
} else {
fail(label, `old ${oldText} vs new ${newText}`);
}
};
const finish = async (browser) => {
await browser?.close();
if (failures.length > 0) {
console.error(`${name}: FAILED (${failures.length})`);
process.exitCode = 1;
} else {
console.log(`${name}: OK`);
}
};
return { compare, fail, finish, ok };
}
export function launchBrowser() {
return chromium.launch({ channel: 'chrome', headless: true });
}
export async function openPage(
browser,
url,
{ reducedMotion = false, settleMs = 800, viewport = VIEWPORT } = {},
) {
const page = await browser.newPage({
viewport: { width: viewport.width, height: viewport.height },
reducedMotion: reducedMotion ? 'reduce' : 'no-preference',
});
await page.goto(url, { waitUntil: 'load', timeout: 240000 });
await page.waitForTimeout(settleMs);
return page;
}

View File

@@ -0,0 +1,426 @@
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const sourceRoot = path.join(
path.dirname(fileURLToPath(import.meta.url)),
'..',
'src',
);
// Next.js route files are framework contracts: they require default exports
// and may export route config alongside (metadata, generateStaticParams...).
const NEXT_CONTRACT_FILES = new Set([
'default.tsx',
'error.tsx',
'forbidden.tsx',
'global-error.tsx',
'layout.tsx',
'loading.tsx',
'manifest.ts',
'not-found.tsx',
'opengraph-image.tsx',
'page.tsx',
'robots.ts',
'route.ts',
'sitemap.ts',
'template.tsx',
'unauthorized.tsx',
]);
const VALUE_EXPORT_PATTERN =
/^export (?:const|let|function|async function|class) /gm;
const DEFAULT_EXPORT_PATTERN = /^export default /m;
const REEXPORT_STATEMENT_PATTERN =
/export (?:type )?\{[\s\S]*?\} from '[^']+';|export \* from '[^']+';/g;
const failures = [];
// Locale rewrites run BEFORE the filesystem: every top-level public/ dir
// must be a reserved prefix or its assets 404 under /fr/* style rewrites.
// This bug class shipped three times (models, halftone, lottie) before
// this check existed.
{
const patternsSource = fs.readFileSync(
'src/platform/routing/locale-rewrite-patterns.ts',
'utf8',
);
const publicDirectories = fs
.readdirSync('public', { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name);
for (const directory of publicDirectories) {
if (!new RegExp(`'${directory}'`).test(patternsSource)) {
failures.push(
`public/${directory}/ is not in RESERVED_PREFIXES (locale-rewrite-patterns.ts) — its assets 404 under locale rewrites.`,
);
}
}
}
// Color and easing literals live only in src/tokens (comments stripped
// before matching). Authored one-offs are allowlisted with their reason.
const LITERAL_ALLOWLIST = new Set([]);
// Files allowed to set the new-tab security attributes themselves.
const EXTERNAL_LINK_OWNERS = new Set([
'src/ui/ExternalLink.tsx',
'src/ui/Button.tsx',
]);
// Owned vector glyphs are React components in src/icons — never .svg
// files in public/. Files here are third-party brand assets the site can
// only serve by URL (plus twenty.svg, the data layer's static export of
// src/icons/twenty-logo.tsx for the mockup's brand-image-by-URL path).
const PUBLIC_SVG_BRAND_FILES = new Set([
'public/images/logo-bar/otiima.svg',
'public/images/logo-bar/civicactions.svg',
'public/images/logo-bar/fora.svg',
'public/images/logo-bar/wazoku.svg',
'public/images/shared/companies/logos/linear.svg',
'public/images/shared/companies/logos/twenty.svg',
// The halftone studio's default image input: fetched at runtime as an
// <img> and fed through the halftone shader, not rendered as an icon glyph.
'public/images/shared/halftone/twenty-logo.svg',
]);
// Root README documentation assets: the repo's top-level README.md embeds
// these SVGs by URL. They are not site UI (so not src/icons components) nor
// third-party brand marks — they live here only so the README's relative
// paths keep resolving once this package takes over the twenty-website
// public/ path. A trailing slash matches a whole directory.
const PUBLIC_SVG_README_DOC_PATHS = [
'public/images/core/logo.svg',
'public/images/readme/',
];
// Vertical rhythm rides margins ('& > * + *'), not row-gap: gap breaks
// silently when a wrapper changes the child list. row-gap is allowed only
// where layout is genuinely multi-axis (wrapping rows, multi-column
// tracks) — listed here explicitly.
const ROW_GAP_MULTI_AXIS_FILES = new Set([
'sections/case-study-detail/CaseStudyHero.tsx',
'sections/faq/Faq.tsx',
'sections/faq/FaqItems.tsx',
'sections/pricing-plans/PricingBoard.tsx',
'sections/problem/Problem.tsx',
'sections/releases-feed/ReleasesFeed.tsx',
'sections/product-stepper/ProductStepper.tsx',
'sections/home-stepper/HomeStepper.tsx',
'sections/partner-lead/PartnerBecomeStrip.tsx',
'sections/partner-lead/PartnerServicesExplainer.tsx',
'sections/testimonials/PartnerTestimonialsCarousel.tsx',
'sections/testimonials/TestimonialsCarousel.tsx',
'sections/trusted-by/TrustedBy.tsx',
'sections/why-twenty-editorial/Editorial.tsx',
]);
const LITERAL_PATTERNS = [
[/#[0-9a-fA-F]{3,8}\b/, 'hex color literal'],
[/rgba?\(/, 'rgb/rgba literal'],
[/cubic-bezier\(/, 'cubic-bezier literal'],
];
function walk(directory) {
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
const fullPath = path.join(directory, entry.name);
if (entry.isDirectory()) {
// oxfmt silently ignores directories named "lib" (build-output
// convention), so a lib/ directory would dodge formatting forever.
// src/locales/generated is the one sanctioned exception: it is the
// lingui compile output the shared CI regenerates, and oxfmt ignoring
// machine-generated catalogs is exactly what we want there.
const isLocalesGenerated =
entry.name === 'generated' && path.basename(directory) === 'locales';
if (
entry.name === 'lib' ||
(entry.name === 'generated' && !isLocalesGenerated)
) {
failures.push(
`${fullPath}: directories named "lib" or "generated" are forbidden (oxfmt ignores them).`,
);
}
walk(fullPath);
continue;
}
if (!/\.(ts|tsx)$/.test(entry.name)) continue;
const content = fs.readFileSync(fullPath, 'utf8');
const relativePath = path.relative(sourceRoot, fullPath);
const posixPath = relativePath.split(path.sep).join('/');
if (
(posixPath.startsWith('ui/') || posixPath.startsWith('sections/')) &&
/row-gap:/.test(content) &&
!ROW_GAP_MULTI_AXIS_FILES.has(posixPath)
) {
failures.push(
`src/${relativePath}: row-gap in a flow stack — use '& > * + * { margin-top: … }' (mind: unlike gap, the margin shifts absolutely-positioned non-first children; allowlist the file if the layout is genuinely multi-axis).`,
);
}
// The global * reset (layout.tsx) already zeroes every element's margin,
// so a component's own 'margin: 0' is redundant — and it ties with the
// owl rhythm ('& > * + * { margin-top }', equal specificity), silently
// collapsing the gap by source order (this broke a heading once). Cancel
// an owl gap deliberately with the specific 'margin-top: 0' instead.
if (
posixPath !== 'app/[locale]/layout.tsx' &&
// The /halftone generator bakes standalone HTML whose own '* { margin: 0 }'
// reset is required — the downloaded file has no global reset to inherit.
!posixPath.startsWith('platform/visuals/halftone-studio/') &&
!relativePath.includes('.test.') &&
/^[ \t]*margin:[ \t]*0;[ \t]*$/m.test(content)
) {
failures.push(
`src/${relativePath}: redundant 'margin: 0' — the global * reset zeroes margins and it ties with the owl rhythm; remove it (use 'margin-top: 0' to deliberately cancel an owl gap).`,
);
}
if (
!relativePath.startsWith('tokens' + path.sep) &&
// The /halftone generator is a standalone color/shader tool: hex + rgba
// colors and cubic-bezier eases are its domain values (and what it
// exports), not design-system tokens.
!posixPath.startsWith('platform/visuals/halftone-studio/') &&
!relativePath.includes('.test.') &&
!LITERAL_ALLOWLIST.has(`src/${relativePath}`)
) {
const withoutComments = content
.split('\n')
.map((line) => line.replace(/\/\/.*$/, ''))
.join('\n')
.replace(/\/\*[\s\S]*?\*\//g, '');
for (const [pattern, label] of LITERAL_PATTERNS) {
if (pattern.test(withoutComments)) {
failures.push(
`src/${relativePath}: ${label} outside src/tokens — use a token.`,
);
}
}
}
// Breakpoints exist only through mediaUp(); a raw width query bypasses
// the breakpoint tokens (reduced-motion and print queries are fine).
if (
!relativePath.startsWith('tokens' + path.sep) &&
/@media \((?:min|max)-width/.test(content)
) {
failures.push(
`src/${relativePath}: raw width @media query — use mediaUp().`,
);
}
if (
!EXTERNAL_LINK_OWNERS.has(`src/${relativePath}`) &&
/target="_blank"|noopener/.test(content)
) {
failures.push(
`src/${relativePath}: new-tab attributes belong to ui/ExternalLink — compose it.`,
);
}
// Screen-reader strings are user-facing: a11y attributes must be
// localized, never string literals.
if (
(relativePath.startsWith('sections' + path.sep) ||
relativePath.startsWith('case-studies' + path.sep) ||
relativePath.startsWith('app-preview' + path.sep) ||
relativePath.startsWith('client-brief' + path.sep) ||
relativePath.startsWith('contact-cal' + path.sep) ||
relativePath.startsWith('partner-application' + path.sep) ||
relativePath.startsWith('partners-marketplace' + path.sep) ||
relativePath.startsWith('pricing-state' + path.sep)) &&
/(?:aria-label|ariaLabel|aria-roledescription|placeholder|alt)="[A-Za-z]/.test(
content,
)
) {
failures.push(
`src/${relativePath}: untranslated a11y string literal — wrap in i18n._(msg\`...\`).`,
);
}
// Sections are islands: importing another section couples compositions
// that must evolve independently. Shared shapes live in ui/icons/platform.
if (relativePath.startsWith('sections' + path.sep)) {
const ownSection = relativePath.split(path.sep)[1];
const crossImport = [...content.matchAll(/from '@\/sections\/([a-z-]+)/g)]
.map((m) => m[1])
.find((section) => section !== ownSection);
if (crossImport) {
failures.push(
`src/${relativePath}: imports from sections/${crossImport} — sections may not import each other.`,
);
}
}
// Shared composite layers (the product mockup, the contact modal) sit
// between sections and primitives: multiple sections consume them, so
// they may reach only the pure and platform layers, never sections.
const sharedLayer = [
'app-preview',
'case-studies',
'contact-cal',
'partner-application',
'partners-marketplace',
'pricing-state',
].find((layer) => relativePath.startsWith(layer + path.sep));
if (sharedLayer) {
const allowedLayers = new Set([
'tokens',
'icons',
'ui',
'platform',
sharedLayer,
]);
const forbiddenLayer = [...content.matchAll(/from '@\/([a-z-]+)/g)]
.map((m) => m[1])
.find((layer) => !allowedLayers.has(layer));
if (forbiddenLayer) {
failures.push(
`src/${relativePath}: ${sharedLayer} may import only tokens/icons/ui/platform, found @/${forbiddenLayer}.`,
);
}
}
// twenty-ui's theme is pure data, baked by Linaria at build time — consume
// it directly so the mockups can't drift from the product. Its components
// are React runtime (+ react-tooltip): importing them would weigh down the
// marketing bundle, so the mockups stay on lean primitives built against
// the theme.
const badTwentyUiSubpath = [
...content.matchAll(/from 'twenty-ui(\/[a-z-]+)?'/g),
]
.map((match) => match[1] ?? '')
.find(
(subpath) => subpath !== '/theme' && subpath !== '/theme-constants',
);
if (badTwentyUiSubpath !== undefined) {
failures.push(
`src/${relativePath}: only twenty-ui/theme is importable (pure data, baked at build); twenty-ui${badTwentyUiSubpath} pulls React runtime into the bundle — build a lean primitive instead.`,
);
}
// three is heavy (~150KB gz): only the visuals heavy zones may value-
// import it, reached exclusively via the rigs' dynamic imports — the
// bundle boundary as a build invariant. (halftone-studio is the standalone
// /halftone generator tool, dynamic-imported on its own code-split route.)
if (
!/^platform\/visuals\/(three-runtime|halftone|halftone-studio)\//.test(
relativePath.split(path.sep).join('/'),
) &&
/^import (?!type )[^;]*from 'three/m.test(content)
) {
failures.push(
`src/${relativePath}: value-imports three outside platform/visuals heavy zones (use "import type" for types).`,
);
}
// tokens and icons are pure: no client runtime.
if (
(relativePath.startsWith('tokens' + path.sep) ||
relativePath.startsWith('icons' + path.sep)) &&
content.includes("'use client'")
) {
failures.push(`src/${relativePath}: 'use client' in a pure layer.`);
}
// .tsx files are PascalCase (named after their React component); .ts
// files are kebab-case. Test files mirror their subject's name, so the
// .test infix is stripped before the casing rule (TagInput.test.tsx,
// partner-fields.test.ts) — matching the .test-only exemptions elsewhere.
// Next.js route files (page/layout/...) and the compiled locale catalogs
// are exempt.
if (
!NEXT_CONTRACT_FILES.has(entry.name) &&
!relativePath.startsWith('locales' + path.sep)
) {
const nameForCasing = entry.name.replace(/\.test(?=\.[tj]sx?$)/, '');
if (nameForCasing.endsWith('.tsx')) {
if (!/^[A-Z][A-Za-z0-9]*\.tsx$/.test(nameForCasing)) {
failures.push(`src/${relativePath}: .tsx filenames are PascalCase.`);
}
} else if (/[A-Z]/.test(nameForCasing)) {
failures.push(`src/${relativePath}: .ts filenames are kebab-case.`);
}
}
// SectionShell is the only owner of <section>: it is where vertical
// rhythm and surface schemes live, so no other file may create one.
if (
relativePath !== path.join('ui', 'SectionShell.tsx') &&
/<section[\s>]|styled\.section/.test(content)
) {
failures.push(
`src/${relativePath}: <section> may only be rendered by ui/SectionShell.tsx.`,
);
}
const isNextContractFile =
relativePath.startsWith('app' + path.sep) &&
NEXT_CONTRACT_FILES.has(entry.name);
if (isNextContractFile) continue;
// The module-shape rules are line-anchored and must not read inside
// template literals (mock source-code fiction contains export lines).
const withoutTemplateLiterals = content.replace(/`[\s\S]*?`/g, '``');
if (DEFAULT_EXPORT_PATTERN.test(withoutTemplateLiterals)) {
failures.push(
`src/${relativePath}: default export outside a Next.js route file (use named exports).`,
);
}
if (entry.name === 'index.ts') {
const withoutReexports = content
.replace(REEXPORT_STATEMENT_PATTERN, '')
.replace(/\/\/[^\n]*/g, '');
if (/\S/.test(withoutReexports)) {
failures.push(
`src/${relativePath}: barrels may only re-export (found: ${withoutReexports.trim().split('\n')[0]}).`,
);
}
continue;
}
const valueExportCount = (
withoutTemplateLiterals.match(VALUE_EXPORT_PATTERN) ?? []
).length;
if (valueExportCount > 1) {
failures.push(
`src/${relativePath}: ${valueExportCount} value exports (limit is one per file).`,
);
}
}
}
walk(sourceRoot);
// Public SVG audit: any .svg outside the brand-file allowlist means an
// owned glyph leaked out of src/icons.
const publicSvgs = [];
const walkPublic = (dir) => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) walkPublic(fullPath);
else if (entry.name.endsWith('.svg')) publicSvgs.push(fullPath);
}
};
walkPublic('public');
for (const svgPath of publicSvgs) {
const isReadmeDocAsset = PUBLIC_SVG_README_DOC_PATHS.some((allowed) =>
allowed.endsWith('/') ? svgPath.startsWith(allowed) : svgPath === allowed,
);
if (!PUBLIC_SVG_BRAND_FILES.has(svgPath) && !isReadmeDocAsset) {
failures.push(
`${svgPath}: owned vector glyphs are components in src/icons — public/ svg files are third-party brand assets only (or add to PUBLIC_SVG_BRAND_FILES with a reason).`,
);
}
}
if (failures.length > 0) {
console.error('check-conventions: FAILED');
for (const failure of failures) console.error(` ${failure}`);
process.exit(1);
}
console.log('check-conventions: OK');

View File

@@ -0,0 +1,48 @@
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
// The three chunk must never enter the initial bundle: rigs reach the heavy
// zones only through next/dynamic. This reads the production build manifest
// and fails if any initial chunk contains the three marker.
const packageRoot = path.join(
path.dirname(fileURLToPath(import.meta.url)),
'..',
);
const manifestPath = path.join(packageRoot, '.next', 'build-manifest.json');
if (!fs.existsSync(manifestPath)) {
console.error(
'check-visual-bundle: run `next build` first (.next/build-manifest.json missing).',
);
process.exit(1);
}
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
const initialChunks = new Set([
...(manifest.rootMainFiles ?? []),
...Object.values(manifest.pages ?? {}).flat(),
]);
const offending = [];
for (const chunk of initialChunks) {
if (!chunk.endsWith('.js')) continue;
const chunkPath = path.join(packageRoot, '.next', chunk);
if (!fs.existsSync(chunkPath)) continue;
const content = fs.readFileSync(chunkPath, 'utf8');
if (content.includes('WebGLRenderer') || content.includes('three.module')) {
offending.push(chunk);
}
}
if (offending.length > 0) {
console.error(
'check-visual-bundle: FAILED — three reached the initial bundle:',
);
for (const chunk of offending) console.error(` ${chunk}`);
process.exit(1);
}
console.log(
`check-visual-bundle: OK (${initialChunks.size} initial chunks, three absent)`,
);

View File

@@ -0,0 +1,71 @@
import { chromium } from 'playwright';
// Same-run LCP comparison: both sites measured back to back under the
// same throttle (Fast-3G-ish network, 4x CPU), 3 cold loads each, median
// reported. A report, not a gate — run at wave close.
const OLD_URL = process.env.MOCKUP_OLD_URL ?? 'http://localhost:3002/';
const NEW_URL = process.env.VISUAL_BATTERY_URL ?? 'http://localhost:3004/';
const RUNS = 3;
const browser = await chromium.launch({ channel: 'chrome', headless: true });
async function measureLcp(url) {
const context = await browser.newContext({
viewport: { width: 1440, height: 900 },
deviceScaleFactor: 1,
});
const page = await context.newPage();
const cdp = await context.newCDPSession(page);
await cdp.send('Network.emulateNetworkConditions', {
offline: false,
latency: 150,
downloadThroughput: (1.6 * 1024 * 1024) / 8,
uploadThroughput: (750 * 1024) / 8,
});
await cdp.send('Emulation.setCPUThrottlingRate', { rate: 4 });
await page.addInitScript(() => {
window.__lcp = 0;
new PerformanceObserver((entryList) => {
for (const entry of entryList.getEntries()) {
window.__lcp = entry.startTime;
}
}).observe({ type: 'largest-contentful-paint', buffered: true });
});
await page.goto(url, { waitUntil: 'load', timeout: 240000 });
await page.waitForTimeout(3000);
const lcp = await page.evaluate(() => window.__lcp);
await context.close();
return lcp;
}
const median = (values) =>
values.toSorted((a, b) => a - b)[Math.floor(values.length / 2)];
const report = {};
for (const [label, url] of [
['old (:3002)', OLD_URL],
['new (:3004)', NEW_URL],
]) {
const samples = [];
for (let run = 0; run < RUNS; run += 1) {
// eslint-disable-next-line no-await-in-loop
samples.push(await measureLcp(url));
}
report[label] = { samples, median: median(samples) };
console.log(
` ${label}: median ${Math.round(median(samples))}ms (${samples
.map((sample) => Math.round(sample))
.join(', ')})`,
);
}
await browser.close();
const oldMedian = report['old (:3002)'].median;
const newMedian = report['new (:3004)'].median;
const delta = ((newMedian - oldMedian) / oldMedian) * 100;
console.log(
`lcp-report: new is ${delta <= 0 ? '' : '+'}${delta.toFixed(1)}% vs old`,
);

View File

@@ -0,0 +1,882 @@
{
"base-390": {
"sections": [
{
"scheme": "muted",
"rhythm": "hero",
"paddingTop": "30px",
"paddingBottom": "30px",
"background": "rgb(244, 244, 244)",
"followsSameScheme": false
},
{
"scheme": "light",
"rhythm": "section",
"paddingTop": "48px",
"paddingBottom": "48px",
"background": "rgb(255, 255, 255)",
"followsSameScheme": false
},
{
"scheme": "light",
"rhythm": "section",
"paddingTop": "6px",
"paddingBottom": "48px",
"background": "rgb(255, 255, 255)",
"followsSameScheme": true
},
{
"scheme": "light",
"rhythm": "section",
"paddingTop": "6px",
"paddingBottom": "48px",
"background": "rgb(255, 255, 255)",
"followsSameScheme": true
},
{
"scheme": "muted",
"rhythm": "section",
"paddingTop": "48px",
"paddingBottom": "48px",
"background": "rgb(244, 244, 244)",
"followsSameScheme": false
},
{
"scheme": "light",
"rhythm": "section",
"paddingTop": "48px",
"paddingBottom": "48px",
"background": "rgb(255, 255, 255)",
"followsSameScheme": false
},
{
"scheme": "muted",
"rhythm": "flush",
"paddingTop": "0px",
"paddingBottom": "0px",
"background": "rgb(244, 244, 244)",
"followsSameScheme": false
},
{
"scheme": "muted",
"rhythm": "spacious",
"paddingTop": "120px",
"paddingBottom": "120px",
"background": "rgb(244, 244, 244)",
"followsSameScheme": true
},
{
"scheme": "dark",
"rhythm": "spacious",
"paddingTop": "120px",
"paddingBottom": "120px",
"background": "rgb(28, 28, 28)",
"followsSameScheme": false
}
],
"eyebrows": [
{
"label": "The Problem.",
"gapToHeading": 24,
"centered": true
},
{
"label": "Stop settling for trade-offs.",
"gapToHeading": 24,
"centered": true
},
{
"label": "In production.",
"gapToHeading": 24,
"centered": true
},
{
"label": "They are the real sales",
"gapToHeading": 24,
"centered": true
},
{
"label": "Any Questions?",
"gapToHeading": 24,
"centered": true
}
],
"headings": [
{
"tag": "H1",
"text": "Build your Enterprise CRM at AI Speed",
"fontSize": "40px",
"fontWeight": "300",
"textAlign": "center"
},
{
"tag": "H2",
"text": "A custom CRM gives your org an edge, but building one comes ",
"fontSize": "40.0005px",
"fontWeight": "300",
"textAlign": "start"
},
{
"tag": "H2",
"text": "Assemble, iterate and adapt a robust CRM, that's quick to fl",
"fontSize": "40px",
"fontWeight": "300",
"textAlign": "start"
},
{
"tag": "H2",
"text": "Make your GTM team happy with a CRM they'll love",
"fontSize": "40px",
"fontWeight": "300",
"textAlign": "start"
},
{
"tag": "H2",
"text": "Dev teams power company-wide change with Twenty",
"fontSize": "37.05px",
"fontWeight": "300",
"textAlign": "center"
},
{
"tag": "H2",
"text": "The flexibility is really what made the difference. Our need",
"fontSize": "40.0005px",
"fontWeight": "300",
"textAlign": "start"
},
{
"tag": "H2",
"text": "We didn't want to patch over the problem. We wanted to build",
"fontSize": "40.0005px",
"fontWeight": "300",
"textAlign": "start"
},
{
"tag": "H2",
"text": "It is just such a nicer experience than dealing with a Sales",
"fontSize": "40.0005px",
"fontWeight": "300",
"textAlign": "start"
},
{
"tag": "H2",
"text": "Stop fighting custom. Start building, with Twenty",
"fontSize": "40px",
"fontWeight": "300",
"textAlign": "start"
}
],
"visualSlots": [
"diamond",
"familiar-interface",
"faq",
"fast-path",
"flash",
"footer-backdrop",
"hero-bridge",
"hourglass",
"live-data",
"lock",
"money",
"monolith",
"spaceship",
"stepper-backdrop",
"target"
],
"ctas": [
{
"label": "Get started",
"href": "https://app.twenty.com/welcome"
},
{
"label": "",
"href": "/customers/w3villa"
},
{
"label": "",
"href": "/customers/alternative-partners"
},
{
"label": "",
"href": "/customers/act-education"
},
{
"label": "Read the case",
"href": "/customers/w3villa"
},
{
"label": "Read the case",
"href": "/customers/act-education"
},
{
"label": "Read the case",
"href": "/customers/netzero"
},
{
"label": "Get started",
"href": "https://app.twenty.com/welcome"
}
],
"sectionCount": 9
},
"sm-820": {
"sections": [
{
"scheme": "muted",
"rhythm": "hero",
"paddingTop": "30px",
"paddingBottom": "30px",
"background": "rgb(244, 244, 244)",
"followsSameScheme": false
},
{
"scheme": "light",
"rhythm": "section",
"paddingTop": "48px",
"paddingBottom": "48px",
"background": "rgb(255, 255, 255)",
"followsSameScheme": false
},
{
"scheme": "light",
"rhythm": "section",
"paddingTop": "6px",
"paddingBottom": "48px",
"background": "rgb(255, 255, 255)",
"followsSameScheme": true
},
{
"scheme": "light",
"rhythm": "section",
"paddingTop": "6px",
"paddingBottom": "48px",
"background": "rgb(255, 255, 255)",
"followsSameScheme": true
},
{
"scheme": "muted",
"rhythm": "section",
"paddingTop": "48px",
"paddingBottom": "48px",
"background": "rgb(244, 244, 244)",
"followsSameScheme": false
},
{
"scheme": "light",
"rhythm": "section",
"paddingTop": "48px",
"paddingBottom": "48px",
"background": "rgb(255, 255, 255)",
"followsSameScheme": false
},
{
"scheme": "muted",
"rhythm": "flush",
"paddingTop": "0px",
"paddingBottom": "0px",
"background": "rgb(244, 244, 244)",
"followsSameScheme": false
},
{
"scheme": "muted",
"rhythm": "spacious",
"paddingTop": "120px",
"paddingBottom": "120px",
"background": "rgb(244, 244, 244)",
"followsSameScheme": true
},
{
"scheme": "dark",
"rhythm": "spacious",
"paddingTop": "120px",
"paddingBottom": "120px",
"background": "rgb(28, 28, 28)",
"followsSameScheme": false
}
],
"eyebrows": [
{
"label": "The Problem.",
"gapToHeading": 24,
"centered": true
},
{
"label": "Stop settling for trade-offs.",
"gapToHeading": 24,
"centered": true
},
{
"label": "In production.",
"gapToHeading": 24,
"centered": true
},
{
"label": "They are the real sales",
"gapToHeading": 24,
"centered": true
},
{
"label": "Any Questions?",
"gapToHeading": 24,
"centered": true
}
],
"headings": [
{
"tag": "H1",
"text": "Build your Enterprise CRM at AI Speed",
"fontSize": "56.1957px",
"fontWeight": "300",
"textAlign": "center"
},
{
"tag": "H2",
"text": "A custom CRM gives your org an edge, but building one comes ",
"fontSize": "46.4789px",
"fontWeight": "300",
"textAlign": "start"
},
{
"tag": "H2",
"text": "Assemble, iterate and adapt a robust CRM, that's quick to fl",
"fontSize": "56.1957px",
"fontWeight": "300",
"textAlign": "start"
},
{
"tag": "H2",
"text": "Make your GTM team happy with a CRM they'll love",
"fontSize": "56.1957px",
"fontWeight": "300",
"textAlign": "start"
},
{
"tag": "H2",
"text": "Dev teams power company-wide change with Twenty",
"fontSize": "60px",
"fontWeight": "300",
"textAlign": "center"
},
{
"tag": "H2",
"text": "The flexibility is really what made the difference. Our need",
"fontSize": "46.4789px",
"fontWeight": "300",
"textAlign": "start"
},
{
"tag": "H2",
"text": "We didn't want to patch over the problem. We wanted to build",
"fontSize": "46.4789px",
"fontWeight": "300",
"textAlign": "start"
},
{
"tag": "H2",
"text": "It is just such a nicer experience than dealing with a Sales",
"fontSize": "46.4789px",
"fontWeight": "300",
"textAlign": "start"
},
{
"tag": "H2",
"text": "Stop fighting custom. Start building, with Twenty",
"fontSize": "56.1957px",
"fontWeight": "300",
"textAlign": "start"
}
],
"visualSlots": [
"diamond",
"familiar-interface",
"faq",
"fast-path",
"flash",
"footer-backdrop",
"hero-bridge",
"hourglass",
"live-data",
"lock",
"money",
"monolith",
"spaceship",
"stepper-backdrop",
"target"
],
"ctas": [
{
"label": "Get started",
"href": "https://app.twenty.com/welcome"
},
{
"label": "",
"href": "/customers/w3villa"
},
{
"label": "",
"href": "/customers/alternative-partners"
},
{
"label": "",
"href": "/customers/act-education"
},
{
"label": "Read the case",
"href": "/customers/w3villa"
},
{
"label": "Read the case",
"href": "/customers/act-education"
},
{
"label": "Read the case",
"href": "/customers/netzero"
},
{
"label": "Get started",
"href": "https://app.twenty.com/welcome"
}
],
"sectionCount": 9
},
"md-1100": {
"sections": [
{
"scheme": "muted",
"rhythm": "hero",
"paddingTop": "48px",
"paddingBottom": "48px",
"background": "rgb(244, 244, 244)",
"followsSameScheme": false
},
{
"scheme": "light",
"rhythm": "section",
"paddingTop": "64px",
"paddingBottom": "64px",
"background": "rgb(255, 255, 255)",
"followsSameScheme": false
},
{
"scheme": "light",
"rhythm": "section",
"paddingTop": "6px",
"paddingBottom": "64px",
"background": "rgb(255, 255, 255)",
"followsSameScheme": true
},
{
"scheme": "light",
"rhythm": "section",
"paddingTop": "6px",
"paddingBottom": "64px",
"background": "rgb(255, 255, 255)",
"followsSameScheme": true
},
{
"scheme": "muted",
"rhythm": "section",
"paddingTop": "64px",
"paddingBottom": "64px",
"background": "rgb(244, 244, 244)",
"followsSameScheme": false
},
{
"scheme": "light",
"rhythm": "section",
"paddingTop": "64px",
"paddingBottom": "64px",
"background": "rgb(255, 255, 255)",
"followsSameScheme": false
},
{
"scheme": "muted",
"rhythm": "flush",
"paddingTop": "0px",
"paddingBottom": "0px",
"background": "rgb(244, 244, 244)",
"followsSameScheme": false
},
{
"scheme": "muted",
"rhythm": "spacious",
"paddingTop": "120px",
"paddingBottom": "120px",
"background": "rgb(244, 244, 244)",
"followsSameScheme": true
},
{
"scheme": "dark",
"rhythm": "spacious",
"paddingTop": "120px",
"paddingBottom": "120px",
"background": "rgb(28, 28, 28)",
"followsSameScheme": false
}
],
"eyebrows": [
{
"label": "The Problem.",
"gapToHeading": 24,
"centered": true
},
{
"label": "Stop settling for trade-offs.",
"gapToHeading": 24,
"centered": true
},
{
"label": "In production.",
"gapToHeading": 24,
"centered": true
},
{
"label": "They are the real sales",
"gapToHeading": 56,
"centered": true
},
{
"label": "Any Questions?",
"gapToHeading": 24,
"centered": true
}
],
"headings": [
{
"tag": "H1",
"text": "Build your Enterprise CRM at AI Speed",
"fontSize": "60px",
"fontWeight": "300",
"textAlign": "center"
},
{
"tag": "H2",
"text": "A custom CRM gives your org an edge, but building one comes ",
"fontSize": "48px",
"fontWeight": "300",
"textAlign": "start"
},
{
"tag": "H2",
"text": "Assemble, iterate and adapt a robust CRM, that's quick to fl",
"fontSize": "60px",
"fontWeight": "300",
"textAlign": "start"
},
{
"tag": "H2",
"text": "Make your GTM team happy with a CRM they'll love",
"fontSize": "60px",
"fontWeight": "300",
"textAlign": "center"
},
{
"tag": "H2",
"text": "Dev teams power company-wide change with Twenty",
"fontSize": "80px",
"fontWeight": "300",
"textAlign": "center"
},
{
"tag": "H2",
"text": "The flexibility is really what made the difference. Our need",
"fontSize": "48px",
"fontWeight": "300",
"textAlign": "start"
},
{
"tag": "H2",
"text": "We didn't want to patch over the problem. We wanted to build",
"fontSize": "48px",
"fontWeight": "300",
"textAlign": "start"
},
{
"tag": "H2",
"text": "It is just such a nicer experience than dealing with a Sales",
"fontSize": "48px",
"fontWeight": "300",
"textAlign": "start"
},
{
"tag": "H2",
"text": "Stop fighting custom. Start building, with Twenty",
"fontSize": "60px",
"fontWeight": "300",
"textAlign": "start"
}
],
"visualSlots": [
"diamond",
"familiar-interface",
"faq",
"fast-path",
"flash",
"footer-backdrop",
"hero-bridge",
"hourglass",
"live-data",
"lock",
"money",
"monolith",
"spaceship",
"stepper-backdrop",
"target"
],
"ctas": [
{
"label": "Get started",
"href": "https://app.twenty.com/welcome"
},
{
"label": "",
"href": "/customers/w3villa"
},
{
"label": "",
"href": "/customers/alternative-partners"
},
{
"label": "",
"href": "/customers/act-education"
},
{
"label": "Read the case",
"href": "/customers/w3villa"
},
{
"label": "Read the case",
"href": "/customers/act-education"
},
{
"label": "Read the case",
"href": "/customers/netzero"
},
{
"label": "Get started",
"href": "https://app.twenty.com/welcome"
}
],
"sectionCount": 9
},
"lg-1440": {
"sections": [
{
"scheme": "muted",
"rhythm": "hero",
"paddingTop": "48px",
"paddingBottom": "48px",
"background": "rgb(244, 244, 244)",
"followsSameScheme": false
},
{
"scheme": "light",
"rhythm": "section",
"paddingTop": "64px",
"paddingBottom": "64px",
"background": "rgb(255, 255, 255)",
"followsSameScheme": false
},
{
"scheme": "light",
"rhythm": "section",
"paddingTop": "6px",
"paddingBottom": "64px",
"background": "rgb(255, 255, 255)",
"followsSameScheme": true
},
{
"scheme": "light",
"rhythm": "section",
"paddingTop": "6px",
"paddingBottom": "64px",
"background": "rgb(255, 255, 255)",
"followsSameScheme": true
},
{
"scheme": "muted",
"rhythm": "section",
"paddingTop": "64px",
"paddingBottom": "64px",
"background": "rgb(244, 244, 244)",
"followsSameScheme": false
},
{
"scheme": "light",
"rhythm": "section",
"paddingTop": "64px",
"paddingBottom": "64px",
"background": "rgb(255, 255, 255)",
"followsSameScheme": false
},
{
"scheme": "muted",
"rhythm": "flush",
"paddingTop": "0px",
"paddingBottom": "0px",
"background": "rgb(244, 244, 244)",
"followsSameScheme": false
},
{
"scheme": "muted",
"rhythm": "spacious",
"paddingTop": "120px",
"paddingBottom": "120px",
"background": "rgb(244, 244, 244)",
"followsSameScheme": true
},
{
"scheme": "dark",
"rhythm": "spacious",
"paddingTop": "120px",
"paddingBottom": "120px",
"background": "rgb(28, 28, 28)",
"followsSameScheme": false
}
],
"eyebrows": [
{
"label": "The Problem.",
"gapToHeading": 24,
"centered": true
},
{
"label": "Stop settling for trade-offs.",
"gapToHeading": 24,
"centered": true
},
{
"label": "In production.",
"gapToHeading": 24,
"centered": true
},
{
"label": "They are the real sales",
"gapToHeading": 56,
"centered": true
},
{
"label": "Any Questions?",
"gapToHeading": 24,
"centered": true
}
],
"headings": [
{
"tag": "H1",
"text": "Build your Enterprise CRM at AI Speed",
"fontSize": "60px",
"fontWeight": "300",
"textAlign": "center"
},
{
"tag": "H2",
"text": "A custom CRM gives your org an edge, but building one comes ",
"fontSize": "48px",
"fontWeight": "300",
"textAlign": "start"
},
{
"tag": "H2",
"text": "Assemble, iterate and adapt a robust CRM, that's quick to fl",
"fontSize": "60px",
"fontWeight": "300",
"textAlign": "start"
},
{
"tag": "H2",
"text": "Make your GTM team happy with a CRM they'll love",
"fontSize": "60px",
"fontWeight": "300",
"textAlign": "center"
},
{
"tag": "H2",
"text": "Dev teams power company-wide change with Twenty",
"fontSize": "80px",
"fontWeight": "300",
"textAlign": "center"
},
{
"tag": "H2",
"text": "The flexibility is really what made the difference. Our need",
"fontSize": "48px",
"fontWeight": "300",
"textAlign": "start"
},
{
"tag": "H2",
"text": "We didn't want to patch over the problem. We wanted to build",
"fontSize": "48px",
"fontWeight": "300",
"textAlign": "start"
},
{
"tag": "H2",
"text": "It is just such a nicer experience than dealing with a Sales",
"fontSize": "48px",
"fontWeight": "300",
"textAlign": "start"
},
{
"tag": "H2",
"text": "Stop fighting custom. Start building, with Twenty",
"fontSize": "60px",
"fontWeight": "300",
"textAlign": "start"
}
],
"visualSlots": [
"diamond",
"familiar-interface",
"faq",
"fast-path",
"flash",
"footer-backdrop",
"hero-bridge",
"hourglass",
"live-data",
"lock",
"money",
"monolith",
"spaceship",
"stepper-backdrop",
"target"
],
"ctas": [
{
"label": "Get started",
"href": "https://app.twenty.com/welcome"
},
{
"label": "",
"href": "/customers/w3villa"
},
{
"label": "",
"href": "/customers/alternative-partners"
},
{
"label": "",
"href": "/customers/act-education"
},
{
"label": "Read the case",
"href": "/customers/w3villa"
},
{
"label": "Read the case",
"href": "/customers/act-education"
},
{
"label": "Read the case",
"href": "/customers/netzero"
},
{
"label": "Get started",
"href": "https://app.twenty.com/welcome"
}
],
"sectionCount": 9
}
}

View File

@@ -0,0 +1,730 @@
{
"base-390": {
"sections": [
{
"scheme": "light",
"rhythm": "section",
"paddingTop": "48px",
"paddingBottom": "48px",
"background": "rgb(255, 255, 255)",
"followsSameScheme": false
},
{
"scheme": "light",
"rhythm": "section",
"paddingTop": "6px",
"paddingBottom": "48px",
"background": "rgb(255, 255, 255)",
"followsSameScheme": true
},
{
"scheme": "light",
"rhythm": "section",
"paddingTop": "6px",
"paddingBottom": "48px",
"background": "rgb(255, 255, 255)",
"followsSameScheme": true
},
{
"scheme": "muted",
"rhythm": "section",
"paddingTop": "48px",
"paddingBottom": "48px",
"background": "rgb(244, 244, 244)",
"followsSameScheme": false
},
{
"scheme": "light",
"rhythm": "section",
"paddingTop": "48px",
"paddingBottom": "48px",
"background": "rgb(255, 255, 255)",
"followsSameScheme": false
},
{
"scheme": "dark",
"rhythm": "spacious",
"paddingTop": "120px",
"paddingBottom": "120px",
"background": "rgb(28, 28, 28)",
"followsSameScheme": false
}
],
"eyebrows": [
{
"label": "Core Features",
"gapToHeading": 24,
"centered": true
},
{
"label": "Stop settling for trade-offs.",
"gapToHeading": 24,
"centered": true
},
{
"label": "Customization",
"gapToHeading": 24,
"centered": true
},
{
"label": "Try it live",
"gapToHeading": 24,
"centered": true
},
{
"label": "Any Questions?",
"gapToHeading": 24,
"centered": true
}
],
"headings": [
{
"tag": "H1",
"text": "A CRM for teams that move fast",
"fontSize": "40px",
"fontWeight": "300",
"textAlign": "center"
},
{
"tag": "H2",
"text": "...with AI that actually helps you work faster",
"fontSize": "40px",
"fontWeight": "300",
"textAlign": "center"
},
{
"tag": "H1",
"text": "A CRM for teams that move fast",
"fontSize": "40px",
"fontWeight": "300",
"textAlign": "center"
},
{
"tag": "H2",
"text": "...with AI that actually helps you work faster",
"fontSize": "40px",
"fontWeight": "300",
"textAlign": "center"
},
{
"tag": "H2",
"text": "A CRM for teams that move fast",
"fontSize": "40px",
"fontWeight": "300",
"textAlign": "center"
},
{
"tag": "H2",
"text": "...with AI that actually helps you work faster",
"fontSize": "40px",
"fontWeight": "300",
"textAlign": "center"
},
{
"tag": "H2",
"text": "Everything you need, out of the box",
"fontSize": "40px",
"fontWeight": "300",
"textAlign": "center"
},
{
"tag": "H2",
"text": "A modern CRM with an intuitive interface",
"fontSize": "40px",
"fontWeight": "300",
"textAlign": "start"
},
{
"tag": "H2",
"text": "Go the extra mile with no-code",
"fontSize": "40px",
"fontWeight": "300",
"textAlign": "start"
},
{
"tag": "H2",
"text": "A demo worth a thousand words",
"fontSize": "40px",
"fontWeight": "300",
"textAlign": "center"
},
{
"tag": "H2",
"text": "Stop fighting custom. Start building, with Twenty",
"fontSize": "40px",
"fontWeight": "300",
"textAlign": "start"
}
],
"visualSlots": ["eye", "faq", "footer-backdrop", "singleScreen", "speed"],
"ctas": [
{
"label": "Get started",
"href": "https://app.twenty.com/welcome"
},
{
"label": "Get started",
"href": "https://app.twenty.com/welcome"
},
{
"label": "Get started",
"href": "https://app.twenty.com/welcome"
},
{
"label": "Try Twenty Cloud",
"href": "https://app.twenty.com/welcome"
},
{
"label": "Get started",
"href": "https://app.twenty.com/welcome"
}
],
"sectionCount": 6
},
"sm-820": {
"sections": [
{
"scheme": "light",
"rhythm": "section",
"paddingTop": "48px",
"paddingBottom": "48px",
"background": "rgb(255, 255, 255)",
"followsSameScheme": false
},
{
"scheme": "light",
"rhythm": "section",
"paddingTop": "6px",
"paddingBottom": "48px",
"background": "rgb(255, 255, 255)",
"followsSameScheme": true
},
{
"scheme": "light",
"rhythm": "section",
"paddingTop": "6px",
"paddingBottom": "48px",
"background": "rgb(255, 255, 255)",
"followsSameScheme": true
},
{
"scheme": "muted",
"rhythm": "section",
"paddingTop": "48px",
"paddingBottom": "48px",
"background": "rgb(244, 244, 244)",
"followsSameScheme": false
},
{
"scheme": "light",
"rhythm": "section",
"paddingTop": "48px",
"paddingBottom": "48px",
"background": "rgb(255, 255, 255)",
"followsSameScheme": false
},
{
"scheme": "dark",
"rhythm": "spacious",
"paddingTop": "120px",
"paddingBottom": "120px",
"background": "rgb(28, 28, 28)",
"followsSameScheme": false
}
],
"eyebrows": [
{
"label": "Core Features",
"gapToHeading": 24,
"centered": true
},
{
"label": "Stop settling for trade-offs.",
"gapToHeading": 24,
"centered": true
},
{
"label": "Customization",
"gapToHeading": 24,
"centered": true
},
{
"label": "Try it live",
"gapToHeading": 24,
"centered": true
},
{
"label": "Any Questions?",
"gapToHeading": 24,
"centered": true
}
],
"headings": [
{
"tag": "H1",
"text": "A CRM for teams that move fast",
"fontSize": "56.1957px",
"fontWeight": "300",
"textAlign": "center"
},
{
"tag": "H2",
"text": "...with AI that actually helps you work faster",
"fontSize": "56.1957px",
"fontWeight": "300",
"textAlign": "center"
},
{
"tag": "H1",
"text": "A CRM for teams that move fast",
"fontSize": "56.1957px",
"fontWeight": "300",
"textAlign": "center"
},
{
"tag": "H2",
"text": "...with AI that actually helps you work faster",
"fontSize": "56.1957px",
"fontWeight": "300",
"textAlign": "center"
},
{
"tag": "H2",
"text": "A CRM for teams that move fast",
"fontSize": "56.1957px",
"fontWeight": "300",
"textAlign": "center"
},
{
"tag": "H2",
"text": "...with AI that actually helps you work faster",
"fontSize": "56.1957px",
"fontWeight": "300",
"textAlign": "center"
},
{
"tag": "H2",
"text": "Everything you need, out of the box",
"fontSize": "56.1957px",
"fontWeight": "300",
"textAlign": "center"
},
{
"tag": "H2",
"text": "A modern CRM with an intuitive interface",
"fontSize": "56.1957px",
"fontWeight": "300",
"textAlign": "start"
},
{
"tag": "H2",
"text": "Go the extra mile with no-code",
"fontSize": "56.1957px",
"fontWeight": "300",
"textAlign": "start"
},
{
"tag": "H2",
"text": "A demo worth a thousand words",
"fontSize": "56.1957px",
"fontWeight": "300",
"textAlign": "center"
},
{
"tag": "H2",
"text": "Stop fighting custom. Start building, with Twenty",
"fontSize": "56.1957px",
"fontWeight": "300",
"textAlign": "start"
}
],
"visualSlots": ["eye", "faq", "footer-backdrop", "singleScreen", "speed"],
"ctas": [
{
"label": "Get started",
"href": "https://app.twenty.com/welcome"
},
{
"label": "Get started",
"href": "https://app.twenty.com/welcome"
},
{
"label": "Get started",
"href": "https://app.twenty.com/welcome"
},
{
"label": "Try Twenty Cloud",
"href": "https://app.twenty.com/welcome"
},
{
"label": "Get started",
"href": "https://app.twenty.com/welcome"
}
],
"sectionCount": 6
},
"md-1100": {
"sections": [
{
"scheme": "light",
"rhythm": "section",
"paddingTop": "64px",
"paddingBottom": "64px",
"background": "rgb(255, 255, 255)",
"followsSameScheme": false
},
{
"scheme": "light",
"rhythm": "section",
"paddingTop": "6px",
"paddingBottom": "64px",
"background": "rgb(255, 255, 255)",
"followsSameScheme": true
},
{
"scheme": "light",
"rhythm": "section",
"paddingTop": "6px",
"paddingBottom": "64px",
"background": "rgb(255, 255, 255)",
"followsSameScheme": true
},
{
"scheme": "muted",
"rhythm": "section",
"paddingTop": "64px",
"paddingBottom": "64px",
"background": "rgb(244, 244, 244)",
"followsSameScheme": false
},
{
"scheme": "light",
"rhythm": "section",
"paddingTop": "64px",
"paddingBottom": "64px",
"background": "rgb(255, 255, 255)",
"followsSameScheme": false
},
{
"scheme": "dark",
"rhythm": "spacious",
"paddingTop": "120px",
"paddingBottom": "120px",
"background": "rgb(28, 28, 28)",
"followsSameScheme": false
}
],
"eyebrows": [
{
"label": "Core Features",
"gapToHeading": 24,
"centered": true
},
{
"label": "Stop settling for trade-offs.",
"gapToHeading": 24,
"centered": true
},
{
"label": "Customization",
"gapToHeading": 24,
"centered": true
},
{
"label": "Try it live",
"gapToHeading": 24,
"centered": true
},
{
"label": "Any Questions?",
"gapToHeading": 24,
"centered": true
}
],
"headings": [
{
"tag": "H1",
"text": "A CRM for teams that move fast",
"fontSize": "60px",
"fontWeight": "300",
"textAlign": "center"
},
{
"tag": "H2",
"text": "...with AI that actually helps you work faster",
"fontSize": "60px",
"fontWeight": "300",
"textAlign": "center"
},
{
"tag": "H1",
"text": "A CRM for teams that move fast",
"fontSize": "60px",
"fontWeight": "300",
"textAlign": "center"
},
{
"tag": "H2",
"text": "...with AI that actually helps you work faster",
"fontSize": "60px",
"fontWeight": "300",
"textAlign": "center"
},
{
"tag": "H2",
"text": "A CRM for teams that move fast",
"fontSize": "60px",
"fontWeight": "300",
"textAlign": "center"
},
{
"tag": "H2",
"text": "...with AI that actually helps you work faster",
"fontSize": "60px",
"fontWeight": "300",
"textAlign": "center"
},
{
"tag": "H2",
"text": "Everything you need, out of the box",
"fontSize": "60px",
"fontWeight": "300",
"textAlign": "center"
},
{
"tag": "H2",
"text": "A modern CRM with an intuitive interface",
"fontSize": "60px",
"fontWeight": "300",
"textAlign": "start"
},
{
"tag": "H2",
"text": "Go the extra mile with no-code",
"fontSize": "60px",
"fontWeight": "300",
"textAlign": "start"
},
{
"tag": "H2",
"text": "A demo worth a thousand words",
"fontSize": "60px",
"fontWeight": "300",
"textAlign": "center"
},
{
"tag": "H2",
"text": "Stop fighting custom. Start building, with Twenty",
"fontSize": "60px",
"fontWeight": "300",
"textAlign": "start"
}
],
"visualSlots": ["eye", "faq", "footer-backdrop", "singleScreen", "speed"],
"ctas": [
{
"label": "Get started",
"href": "https://app.twenty.com/welcome"
},
{
"label": "Get started",
"href": "https://app.twenty.com/welcome"
},
{
"label": "Get started",
"href": "https://app.twenty.com/welcome"
},
{
"label": "Try Twenty Cloud",
"href": "https://app.twenty.com/welcome"
},
{
"label": "Get started",
"href": "https://app.twenty.com/welcome"
}
],
"sectionCount": 6
},
"lg-1440": {
"sections": [
{
"scheme": "light",
"rhythm": "section",
"paddingTop": "64px",
"paddingBottom": "64px",
"background": "rgb(255, 255, 255)",
"followsSameScheme": false
},
{
"scheme": "light",
"rhythm": "section",
"paddingTop": "6px",
"paddingBottom": "64px",
"background": "rgb(255, 255, 255)",
"followsSameScheme": true
},
{
"scheme": "light",
"rhythm": "section",
"paddingTop": "6px",
"paddingBottom": "64px",
"background": "rgb(255, 255, 255)",
"followsSameScheme": true
},
{
"scheme": "muted",
"rhythm": "section",
"paddingTop": "64px",
"paddingBottom": "64px",
"background": "rgb(244, 244, 244)",
"followsSameScheme": false
},
{
"scheme": "light",
"rhythm": "section",
"paddingTop": "64px",
"paddingBottom": "64px",
"background": "rgb(255, 255, 255)",
"followsSameScheme": false
},
{
"scheme": "dark",
"rhythm": "spacious",
"paddingTop": "120px",
"paddingBottom": "120px",
"background": "rgb(28, 28, 28)",
"followsSameScheme": false
}
],
"eyebrows": [
{
"label": "Core Features",
"gapToHeading": 24,
"centered": true
},
{
"label": "Stop settling for trade-offs.",
"gapToHeading": 24,
"centered": true
},
{
"label": "Customization",
"gapToHeading": 24,
"centered": true
},
{
"label": "Try it live",
"gapToHeading": 24,
"centered": true
},
{
"label": "Any Questions?",
"gapToHeading": 24,
"centered": true
}
],
"headings": [
{
"tag": "H1",
"text": "A CRM for teams that move fast",
"fontSize": "60px",
"fontWeight": "300",
"textAlign": "center"
},
{
"tag": "H2",
"text": "...with AI that actually helps you work faster",
"fontSize": "60px",
"fontWeight": "300",
"textAlign": "center"
},
{
"tag": "H1",
"text": "A CRM for teams that move fast",
"fontSize": "60px",
"fontWeight": "300",
"textAlign": "center"
},
{
"tag": "H2",
"text": "...with AI that actually helps you work faster",
"fontSize": "60px",
"fontWeight": "300",
"textAlign": "center"
},
{
"tag": "H2",
"text": "A CRM for teams that move fast",
"fontSize": "60px",
"fontWeight": "300",
"textAlign": "center"
},
{
"tag": "H2",
"text": "...with AI that actually helps you work faster",
"fontSize": "60px",
"fontWeight": "300",
"textAlign": "center"
},
{
"tag": "H2",
"text": "Everything you need, out of the box",
"fontSize": "60px",
"fontWeight": "300",
"textAlign": "center"
},
{
"tag": "H2",
"text": "A modern CRM with an intuitive interface",
"fontSize": "60px",
"fontWeight": "300",
"textAlign": "start"
},
{
"tag": "H2",
"text": "Go the extra mile with no-code",
"fontSize": "60px",
"fontWeight": "300",
"textAlign": "start"
},
{
"tag": "H2",
"text": "A demo worth a thousand words",
"fontSize": "60px",
"fontWeight": "300",
"textAlign": "center"
},
{
"tag": "H2",
"text": "Stop fighting custom. Start building, with Twenty",
"fontSize": "60px",
"fontWeight": "300",
"textAlign": "start"
}
],
"visualSlots": ["eye", "faq", "footer-backdrop", "singleScreen", "speed"],
"ctas": [
{
"label": "Get started",
"href": "https://app.twenty.com/welcome"
},
{
"label": "Get started",
"href": "https://app.twenty.com/welcome"
},
{
"label": "Get started",
"href": "https://app.twenty.com/welcome"
},
{
"label": "Try Twenty Cloud",
"href": "https://app.twenty.com/welcome"
},
{
"label": "Get started",
"href": "https://app.twenty.com/welcome"
}
],
"sectionCount": 6
}
}

View File

@@ -0,0 +1,193 @@
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');

View File

@@ -0,0 +1,212 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
createBattery,
launchBrowser,
NEW_BASE,
openPage,
} from './battery-kit.mjs';
// Locks approved pages against THEMSELVES: a structured snapshot of the
// page's composition (sections, rhythm, intros, headings, CTAs, visual
// slots) committed as fixtures. Porting further pages must not move any
// of it; a deliberate change is re-recorded and reviewed as a fixture
// diff in the commit.
//
// node scripts/page-lock.mjs verify against fixtures
// node scripts/page-lock.mjs --record re-record fixtures
const LOCKED_PAGES = ['/', '/product'];
// One viewport inside each breakpoint tier (sm 768 / md 921 / lg 1281),
// so every responsive variant of the approved pages is under lock —
// phone stacks and swipe decks, the sm band, the md band where the
// hero window crops, and the full desktop layout.
const LOCKED_VIEWPORTS = [
{ label: 'base-390', width: 390, height: 844 },
{ label: 'sm-820', width: 820, height: 900 },
{ label: 'md-1100', width: 1100, height: 900 },
{ label: 'lg-1440', width: 1440, height: 900 },
];
const FIXTURES_DIR = path.join(
path.dirname(fileURLToPath(import.meta.url)),
'locks',
);
const isRecording = process.argv.includes('--record');
function fixturePath(pagePath) {
const name = pagePath === '/' ? 'home' : pagePath.replaceAll('/', '');
return path.join(FIXTURES_DIR, `${name}.json`);
}
// The page's structural fingerprint: everything the house rules govern,
// read with stable selectors (no text anchors that break on copy edits
// — except headings, which ARE content under lock).
function readPageSnapshot(page) {
return page.evaluate(() => {
const sections = [...document.querySelectorAll('section')].map((el) => {
const previous = el.previousElementSibling;
return {
scheme: el.getAttribute('data-scheme'),
rhythm: el.getAttribute('data-rhythm'),
paddingTop: getComputedStyle(el).paddingTop,
paddingBottom: getComputedStyle(el).paddingBottom,
background: getComputedStyle(el).backgroundColor,
followsSameScheme:
previous?.tagName === 'SECTION' &&
previous.getAttribute('data-scheme') ===
el.getAttribute('data-scheme'),
};
});
const eyebrows = [...document.querySelectorAll('p')]
.filter(
(el) =>
el.querySelector('span[aria-hidden]') &&
el.textContent.trim().length < 40,
)
.map((row) => {
const block = row.parentElement;
const heading =
block.querySelector('h1, h2, h3') ??
block.parentElement.querySelector('h1, h2, h3');
const rect = row.getBoundingClientRect();
const parentRect = row.parentElement.getBoundingClientRect();
return {
label: row.textContent.trim(),
gapToHeading: heading
? Math.round(heading.getBoundingClientRect().top - rect.bottom)
: null,
centered:
Math.abs(
rect.left - parentRect.left - (parentRect.right - rect.right),
) <= 2,
};
});
const headings = [...document.querySelectorAll('h1, h2')].map((el) => {
const headingStyle = getComputedStyle(el);
return {
tag: el.tagName,
text: el.textContent.trim().slice(0, 60),
fontSize: headingStyle.fontSize,
fontWeight: headingStyle.fontWeight,
textAlign: headingStyle.textAlign,
};
});
const visualSlots = [...document.querySelectorAll('[data-illustration]')]
.map((el) => el.getAttribute('data-illustration'))
.toSorted();
const ctas = [...document.querySelectorAll('main a')]
.filter((el) => el.querySelector('svg, span[aria-hidden]'))
.slice(0, 12)
.map((el) => ({
label: el.textContent.trim().slice(0, 30),
href: el.getAttribute('href'),
}));
return {
sections,
eyebrows,
headings,
visualSlots,
ctas,
sectionCount: sections.length,
};
});
}
function diffSnapshots(battery, pagePath, expected, actual) {
const walk = (keyPath, expectedValue, actualValue) => {
const expectedText = JSON.stringify(expectedValue);
const actualText = JSON.stringify(actualValue);
if (expectedText === actualText) return true;
if (
Array.isArray(expectedValue) &&
Array.isArray(actualValue) &&
expectedValue.length === actualValue.length
) {
let allEqual = true;
expectedValue.forEach((item, index) => {
if (!walk(`${keyPath}[${index}]`, item, actualValue[index])) {
allEqual = false;
}
});
return allEqual;
}
battery.fail(
`${pagePath} ${keyPath}`,
`locked ${expectedText} vs live ${actualText}`,
);
return false;
};
let clean = true;
for (const key of Object.keys(expected)) {
if (!walk(key, expected[key], actual[key])) clean = false;
}
if (clean) {
battery.ok(
`${pagePath} matches its lock`,
`${expected.sectionCount} sections, ${expected.eyebrows.length} eyebrows, ${expected.headings.length} headings`,
);
}
}
const battery = createBattery('page-lock');
const browser = await launchBrowser();
for (const pagePath of LOCKED_PAGES) {
const snapshots = {};
for (const viewport of LOCKED_VIEWPORTS) {
// eslint-disable-next-line no-await-in-loop
const page = await openPage(browser, `${NEW_BASE}${pagePath}`, {
settleMs: 1200,
viewport,
});
// eslint-disable-next-line no-await-in-loop
snapshots[viewport.label] = await readPageSnapshot(page);
// eslint-disable-next-line no-await-in-loop
await page.close();
}
if (isRecording) {
fs.mkdirSync(FIXTURES_DIR, { recursive: true });
fs.writeFileSync(
fixturePath(pagePath),
`${JSON.stringify(snapshots, null, 2)}\n`,
);
battery.ok(`${pagePath} recorded`, fixturePath(pagePath));
continue;
}
if (!fs.existsSync(fixturePath(pagePath))) {
battery.fail(`${pagePath} lock exists`, 'run with --record first');
continue;
}
const expected = JSON.parse(fs.readFileSync(fixturePath(pagePath), 'utf8'));
for (const viewport of LOCKED_VIEWPORTS) {
if (!expected[viewport.label]) {
battery.fail(
`${pagePath} ${viewport.label} lock exists`,
're-record (new viewport tier)',
);
continue;
}
diffSnapshots(
battery,
`${pagePath} @${viewport.label}`,
expected[viewport.label],
snapshots[viewport.label],
);
}
}
await battery.finish(browser);

View File

@@ -0,0 +1,297 @@
import {
createBattery,
launchBrowser,
NEW_BASE,
openPage,
} from './battery-kit.mjs';
// Invariant battery for the partners landing. The hero/promo WebGL visuals are
// reserved frames that differ from :3002 by design, so this pins the redone's
// OWN load-bearing mechanisms rather than an A/B: the connectsUp frame seam
// (the audit flagged it as net-new behaviour on a shared, locked primitive with
// no test), the localized case-study count, and every hero's h1.
const { fail, finish, ok } = createBattery('partners-parity');
const assert = (label, condition, detail) =>
condition ? ok(label, detail) : fail(label, detail);
const browser = await launchBrowser();
// connectsUp: the promo makes the TrustedBy band above it yield its bottom
// rhythm and show overflow, so the two read as one continuous frame across the
// seam. If a future refactor of the `:has(+ [data-connect-up])` rule breaks
// this, the corner markers re-clip and the frame splits.
const partners = await openPage(browser, `${NEW_BASE}/partners/become`, {
viewport: { width: 1440, height: 1400 },
settleMs: 500,
});
const seam = await partners.evaluate(() => {
const band = [...document.querySelectorAll('section')].find((s) =>
/trusted by/i.test(s.textContent || ''),
);
const style = getComputedStyle(band);
return {
bottom: style.paddingBottom,
overflow: style.overflowY,
hasCount: /\d+ Case Studies/.test(document.body.textContent || ''),
};
});
assert(
'connectsUp drops the band bottom rhythm',
seam.bottom === '0px',
`padding-bottom ${seam.bottom}`,
);
assert(
'connectsUp shows the band overflow',
seam.overflow === 'visible',
`overflow-y ${seam.overflow}`,
);
assert('case-study count renders', seam.hasCount, 'no "N Case Studies"');
// Partner testimonials: a dark notched panel on a light section. The notch
// reveals the section's WHITE surface (the gray-notch defect we fixed), the
// panel fill is the dark scheme surface, and the carousel adopts the dark
// scheme so its content ink resolves to light — the half-applied scheme that
// left the name/quote/counter invisible (vars set, `color` not) is what this
// guards. The counter is centred and the quote runs sans, both matching the
// home testimonials (the quote shares the counter's sans family).
const panel = await partners.evaluate(() => {
const carousel = document.querySelector(
'[aria-label="Partner testimonials"]',
);
const section = carousel?.closest('section');
const shape = section?.querySelector('[data-card-scheme]');
const bodyFill = shape?.lastElementChild;
const counter = [...(section?.querySelectorAll('p') ?? [])].find((node) =>
/^\d+\/\d+$/.test((node.textContent ?? '').trim()),
);
const quote = section?.querySelector('h2');
return {
surface: section ? getComputedStyle(section).backgroundColor : '',
cardScheme: shape?.getAttribute('data-card-scheme') ?? '',
fill: bodyFill ? getComputedStyle(bodyFill).backgroundColor : '',
ink: carousel ? getComputedStyle(carousel).color : '',
counterAlign: counter ? getComputedStyle(counter).textAlign : '',
counterFont: counter ? getComputedStyle(counter).fontFamily : '',
quoteFont: quote ? getComputedStyle(quote).fontFamily : '',
};
});
assert(
'testimonials notch reveals a white surface',
panel.surface === 'rgb(255, 255, 255)',
panel.surface,
);
assert(
'testimonials panel fills with the dark surface',
panel.cardScheme === 'dark' && panel.fill === 'rgb(28, 28, 28)',
`${panel.cardScheme} / ${panel.fill}`,
);
assert(
'testimonials content adopts the light ink',
panel.ink === 'rgb(255, 255, 255)',
panel.ink,
);
assert(
'testimonials counter is centred',
panel.counterAlign === 'center',
panel.counterAlign,
);
assert(
'testimonials quote shares the counter sans family',
panel.quoteFont !== '' && panel.quoteFont === panel.counterFont,
`quote ${panel.quoteFont} / counter ${panel.counterFont}`,
);
// The closing sign-off: a tall centred panel whose heading carries both faces
// (serif + sans accent). The page recruits partners only, so the panel carries
// exactly ONE cta — "Become a partner", the application-modal trigger (a
// button, no href). A second, buyer-facing cta here is the regression this
// guards. The body is measure-constrained so it breaks across lines rather
// than running the panel width.
const signoff = await partners.evaluate(() => {
const heading = [...document.querySelectorAll('h2')].find((node) =>
/Ready to grow/.test(node.textContent ?? ''),
);
const section = heading?.closest('section');
const ctas = [...(section?.querySelectorAll('a, button') ?? [])];
const become = ctas.find((node) =>
/Become a partner/i.test(node.textContent ?? ''),
);
const body = [...(section?.querySelectorAll('p') ?? [])].find((node) =>
/certify real builders/.test(node.textContent ?? ''),
);
return {
headingText: (heading?.textContent ?? '').replace(/\s+/g, ' ').trim(),
becomeTag: become?.tagName.toLowerCase() ?? '',
becomeHref: become?.getAttribute('href') ?? null,
ctaCount: ctas.length,
bodyWidth: body ? Math.round(body.getBoundingClientRect().width) : 0,
sectionHeight: section
? Math.round(section.getBoundingClientRect().height)
: 0,
hasCrosshair: Boolean(section?.querySelector('[data-slot="plus"]')),
};
});
assert(
'signoff heading carries both faces',
signoff.headingText.includes('Ready to grow') &&
signoff.headingText.includes('with Twenty?'),
signoff.headingText,
);
assert(
'signoff become-a-partner is a modal trigger, not a link',
signoff.becomeTag === 'button' && signoff.becomeHref === null,
`${signoff.becomeTag} href=${signoff.becomeHref}`,
);
assert(
'signoff carries exactly one cta',
signoff.ctaCount === 1,
`${signoff.ctaCount} ctas`,
);
assert(
'signoff body is measure-constrained',
signoff.bodyWidth > 0 && signoff.bodyWidth <= 410,
`${signoff.bodyWidth}px`,
);
assert(
'signoff is a tall centred panel',
signoff.sectionHeight >= 759 && signoff.hasCrosshair,
`${signoff.sectionHeight}px / crosshair ${signoff.hasCrosshair}`,
);
// The application modal: clicking "Become a partner" opens a dark dialog that
// hosts the deferred wizard. The dialog carries the wizard's "Apply to build"
// title on the near-black panel, runs on the dark scheme, and its width is set
// up to ease between the form (<=720) and booking widths rather than snapping.
await partners.evaluate(() => {
const become = [...document.querySelectorAll('button')].find((node) =>
/Become a partner/i.test(node.textContent ?? ''),
);
become?.click();
});
const modal = await partners
.waitForFunction(
() =>
/Apply to build/.test(
document.querySelector('[role="dialog"]')?.textContent ?? '',
),
{ timeout: 5000 },
)
.then(() =>
partners.evaluate(() => {
const dialog = document.querySelector('[role="dialog"]');
const style = dialog ? getComputedStyle(dialog) : null;
return {
opened: Boolean(dialog),
darkScope: Boolean(dialog?.querySelector('[data-scheme="dark"]')),
panel: style?.backgroundColor ?? '',
transition: style?.transitionProperty ?? '',
width: dialog ? Math.round(dialog.getBoundingClientRect().width) : 0,
};
}),
)
.catch(() => ({
opened: false,
darkScope: false,
panel: '',
transition: '',
width: 0,
}));
assert(
'become-a-partner opens the dark application modal',
modal.opened && modal.darkScope,
`opened=${modal.opened} darkScope=${modal.darkScope}`,
);
assert(
'application modal sits on the near-black panel',
modal.panel === 'rgb(12, 12, 12)',
modal.panel,
);
assert(
'application modal eases between form and booking widths',
/width/.test(modal.transition) && modal.width <= 720,
`transition="${modal.transition}" width=${modal.width}`,
);
await partners.close();
// Every hero renders its h1 (the shared hero composition holds across schemes).
async function heroH1(path) {
const page = await openPage(browser, `${NEW_BASE}${path}`, {
viewport: { width: 1440, height: 1000 },
settleMs: 300,
});
const text = await page.evaluate(
() => document.querySelector('main h1')?.textContent || '',
);
await page.close();
return text;
}
assert(
'partners/become hero h1',
(await heroH1('/partners/become')).includes('our partner'),
'missing',
);
assert(
'partners hero h1',
(await heroH1('/partners')).includes('48 hours'),
'missing',
);
assert(
'why-twenty hero h1',
(await heroH1('/why-twenty')).includes('not bought'),
'missing',
);
assert(
'releases hero h1',
(await heroH1('/releases')).includes('Releases'),
'missing',
);
assert(
'customers hero h1',
(await heroH1('/customers')).includes('on Twenty'),
'missing',
);
// The lead page's directory zone is an anchor target two files depend on: the
// hero CTA jumps to it and a partner profile's back link returns to it.
const leadPage = await openPage(browser, `${NEW_BASE}/partners`, {
viewport: { width: 1440, height: 1000 },
settleMs: 500,
});
const directoryAnchor = await leadPage.evaluate(() => {
const zone = document.getElementById('partner-directory');
const cta = document.querySelector('main a[href="#partner-directory"]');
return { hasCta: cta !== null, hasZone: zone !== null };
});
await leadPage.close();
assert(
'partners directory anchor target',
directoryAnchor.hasZone,
`#partner-directory present=${directoryAnchor.hasZone}`,
);
assert(
'partners hero links to the directory anchor',
directoryAnchor.hasCta,
`anchor cta present=${directoryAnchor.hasCta}`,
);
// /partners/list folded into the lead page; both the bare and the
// locale-prefixed URL were in the sitemap, so both must keep their 308.
const legacyListPaths = ['/partners/list', '/fr/partners/list'];
const legacyListResponses = await Promise.all(
legacyListPaths.map((path) =>
fetch(`${NEW_BASE}${path}`, { redirect: 'manual' }),
),
);
legacyListResponses.forEach((response, index) => {
const location = response.headers.get('location') ?? '';
assert(
`${legacyListPaths[index]} redirects to the lead page`,
response.status === 308 && location.endsWith('/partners'),
`${response.status} -> ${location}`,
);
});
await finish(browser);

View File

@@ -0,0 +1,5 @@
// Slots staged for the AppPreview wave — mounted as reserved boxes, no
// visual yet, BY PLAN. The single declaration both batteries consume:
// the sweep tolerates them empty (and fails once they go live), the
// battery's spec-completeness check exempts them. Burn down per commit.
export const PENDING_VISUAL_SLOTS = new Set([]);

View File

@@ -0,0 +1,130 @@
import {
createBattery,
launchBrowser,
NEW_BASE,
OLD_BASE,
openPage,
} from './battery-kit.mjs';
// A/B battery for the pricing plans: both cards' static anatomy, and the
// two toggles that drive the shared hosting state and local billing
// state through the price counter and the feature-list transition.
const { compare, fail, finish, ok } = createBattery('pricing-plans-parity');
const browser = await launchBrowser();
// Reads the two plan cards: heading, price value/suffix, CTA, bullets.
function readPlanCards(page) {
return page.evaluate(() => {
const headings = [...document.querySelectorAll('h3')].filter((el) =>
['Pro', 'Organization'].includes(el.textContent.trim()),
);
return headings.map((heading) => {
const card = heading.closest('div[class]')?.parentElement?.parentElement;
const scope = card ?? heading.closest('section');
const priceHeading = scope?.querySelector('h4');
const suffix = priceHeading?.nextElementSibling;
const cta = scope?.querySelector('a');
const bullets = [...scope.querySelectorAll('li')]
.filter((li) => li.offsetParent !== null)
.map((li) => li.textContent.trim());
return {
heading: heading.textContent.trim(),
price: priceHeading?.textContent.trim() ?? null,
suffix: suffix?.textContent.trim() ?? null,
ctaLabel: cta?.textContent.trim() ?? null,
bullets,
};
});
});
}
async function scrollToPlans(page) {
await page.evaluate(() => {
const toggle = [...document.querySelectorAll('button')].find((button) =>
button.textContent.includes('Monthly'),
);
toggle?.scrollIntoView({ block: 'center' });
});
await page.waitForTimeout(700);
}
async function clickToggle(page, label) {
await page.evaluate((needle) => {
const target = [...document.querySelectorAll('button, label')].find((el) =>
el.textContent.includes(needle),
);
if (target instanceof HTMLElement) target.click();
}, label);
// Past the feature transition (110ms + stagger) and the price tween (500ms).
await page.waitForTimeout(900);
}
const oldPage = await openPage(browser, `${OLD_BASE}/pricing`, {
settleMs: 1200,
});
const newPage = await openPage(browser, `${NEW_BASE}/pricing`, {
settleMs: 1200,
});
await scrollToPlans(oldPage);
await scrollToPlans(newPage);
// --- Default state (cloud + yearly) ------------------------------------
const oldDefault = await readPlanCards(oldPage);
const newDefault = await readPlanCards(newPage);
if (oldDefault.length !== 2 || newDefault.length !== 2) {
fail(
'two plan cards on both sites',
`old ${oldDefault.length} new ${newDefault.length}`,
);
} else {
compare('default cards (cloud + yearly)', oldDefault, newDefault);
// CTA variants: Pro outlined, Organization filled (our button system
// expresses fill via a shape layer, so the variant lives on the data
// attribute rather than the link's background — the old site's
// contained/outlined maps onto it).
const ctaVariants = await newPage.evaluate(() =>
[...document.querySelectorAll('a')]
.filter((a) => a.textContent.includes('Start for free'))
.map((a) => a.getAttribute('data-variant')),
);
compare('Pro CTA outlined, Organization CTA filled', ctaVariants, [
'outlined',
'filled',
]);
}
// --- Billing toggle: yearly -> monthly raises Pro to $12 ----------------
await clickToggle(oldPage, 'Monthly');
await clickToggle(newPage, 'Monthly');
const oldMonthly = await readPlanCards(oldPage);
const newMonthly = await readPlanCards(newPage);
compare('monthly billing cards', oldMonthly, newMonthly);
if (newMonthly[0].price.includes('12')) {
ok('Pro rises to $12 on monthly billing');
} else {
fail('Pro rises to $12 on monthly billing', newMonthly[0].price);
}
// --- Self-host toggle: Pro drops to $0, bullets swap to self-host -------
await clickToggle(oldPage, 'Selfhosting');
await clickToggle(newPage, 'Selfhosting');
const oldSelfHost = await readPlanCards(oldPage);
const newSelfHost = await readPlanCards(newPage);
compare('self-host cards', oldSelfHost, newSelfHost);
if (
newSelfHost[0].price.includes('0') &&
newSelfHost[1].bullets.some((bullet) => bullet.includes('Custom AI models'))
) {
ok('self-host swaps Pro to $0 and Organization bullets to the self-host set');
} else {
fail(
'self-host swaps Pro to $0 and Organization bullets to the self-host set',
JSON.stringify(newSelfHost.map((card) => card.price)),
);
}
await finish(browser);

View File

@@ -0,0 +1,168 @@
import {
createBattery,
launchBrowser,
NEW_BASE,
OLD_BASE,
VIEWPORT,
} from './battery-kit.mjs';
// A/B battery for the product demo closer: centered intro, the emergent
// two-line heading break, CTA chrome, the pattern backdrop, and the
// static (terminal-less) mockup.
const { compare, fail, finish, ok } = createBattery('product-demo-parity');
const browser = await launchBrowser();
async function readDemo(base) {
const page = await browser.newPage({ viewport: VIEWPORT });
await page.goto(`${base}/product`, { waitUntil: 'load', timeout: 240000 });
await page.waitForTimeout(1200);
// Park on the section first so the lazy pattern image loads.
await page.evaluate(() => {
const heading = [...document.querySelectorAll('h2')].find((el) =>
el.textContent.includes('thousand words'),
);
heading?.scrollIntoView({ block: 'start' });
});
await page.waitForTimeout(1200);
const state = await page.evaluate(() => {
const heading = [...document.querySelectorAll('h2')].find((el) =>
el.textContent.includes('thousand words'),
);
if (!heading) return null;
heading.scrollIntoView({ block: 'center' });
const headingStyle = getComputedStyle(heading);
// First-line text: walk characters until the line top jumps.
const range = document.createRange();
const walker = document.createTreeWalker(heading, NodeFilter.SHOW_TEXT);
let firstLineTop = null;
let firstLine = '';
let node;
outer: while ((node = walker.nextNode())) {
for (let index = 0; index < node.textContent.length; index += 1) {
range.setStart(node, index);
range.setEnd(node, index + 1);
const rect = range.getBoundingClientRect();
if (rect.width === 0) continue;
if (firstLineTop === null) firstLineTop = rect.top;
if (Math.abs(rect.top - firstLineTop) > 5) break outer;
firstLine += node.textContent[index];
}
}
const root = heading.closest('section') ?? heading.closest('div');
const cta = [...root.querySelectorAll('a')].find((el) =>
el.textContent.includes('Try Twenty Cloud'),
);
const ctaStyle = cta ? getComputedStyle(cta) : null;
// The pattern layer is a sibling of the section on the old site;
// search from the shared wrapper.
const wrapper = root.parentElement ?? root;
const pattern = [...wrapper.querySelectorAll('img')].find((img) =>
decodeURIComponent(img.currentSrc).includes('product/demo/background'),
);
const patternLayer = pattern?.closest('div[aria-hidden]');
const patternStyle = patternLayer ? getComputedStyle(patternLayer) : null;
const ctaToMockupGap = (() => {
const ctaRect = cta?.getBoundingClientRect();
if (!ctaRect) return null;
const frameBelow = [...document.querySelectorAll('main div')].find(
(el) => {
const rect = el.getBoundingClientRect();
return (
rect.width >= 1000 &&
rect.top > ctaRect.bottom &&
getComputedStyle(el).borderRadius === '20px'
);
},
);
return frameBelow
? Math.round(frameBelow.getBoundingClientRect().top - ctaRect.bottom)
: null;
})();
// The static mockup window: the 20px-radius frame at the scene's
// 1040x676 (old aspect 1280/832 reduces to the same 20:13).
const frame = [...document.querySelectorAll('div')].find((el) => {
const rect = el.getBoundingClientRect();
return (
rect.width >= 900 &&
rect.top > heading.getBoundingClientRect().top &&
getComputedStyle(el).borderRadius === '20px' &&
Math.abs(rect.width / rect.height - 20 / 13) < 0.01 &&
el.textContent.includes('Companies')
);
});
return {
ctaToMockupGap,
firstLine: firstLine.trim(),
headingStyle: `${headingStyle.fontSize} ${headingStyle.fontWeight} ${headingStyle.textAlign}`,
ctaHref: cta?.getAttribute('href') ?? null,
ctaInk: ctaStyle ? `${ctaStyle.backgroundColor} ${ctaStyle.color}` : null,
patternLoaded: Boolean(pattern && pattern.naturalWidth > 0),
patternOpacity: patternStyle?.opacity ?? null,
mockupPresent: Boolean(frame),
mockupRows: frame
? frame.textContent.includes('Anthropic') &&
frame.textContent.includes('Stripe')
: false,
};
});
await page.close();
return state;
}
const oldDemo = await readDemo(OLD_BASE);
const newDemo = await readDemo(NEW_BASE);
if (!oldDemo || !newDemo) {
fail(
'demo section present on both sites',
`old=${Boolean(oldDemo)} new=${Boolean(newDemo)}`,
);
} else {
compare(
'heading first line breaks after "a"',
oldDemo.firstLine,
newDemo.firstLine,
);
compare('heading style', oldDemo.headingStyle, newDemo.headingStyle);
compare('CTA href', oldDemo.ctaHref, newDemo.ctaHref);
compare('CTA chrome', oldDemo.ctaInk, newDemo.ctaInk);
compare(
'pattern backdrop at 0.6 over the lower stage',
`${oldDemo.patternLoaded} ${oldDemo.patternOpacity}`,
`${newDemo.patternLoaded} ${newDemo.patternOpacity}`,
);
compare(
'static mockup with the companies fiction',
`${oldDemo.mockupPresent} ${oldDemo.mockupRows}`,
`${newDemo.mockupPresent} ${newDemo.mockupRows}`,
);
// RATIFIED DIVERGENCE (user, 2026-06-13): the demo hangs the mockup at
// the hero's CTA-to-window measure (68px token) instead of the old
// demo's looser stack spacing — the two CTA-over-mockup moments read
// the same. Asserting the difference keeps it deliberate.
if (
newDemo.ctaToMockupGap === 68 &&
oldDemo.ctaToMockupGap !== newDemo.ctaToMockupGap
) {
ok(
'demo mockup hangs at the hero CTA gap (ledgered divergence)',
`old ${oldDemo.ctaToMockupGap}px vs new ${newDemo.ctaToMockupGap}px`,
);
} else {
fail(
'demo mockup hangs at the hero CTA gap (ledgered divergence)',
`old ${oldDemo.ctaToMockupGap}px vs new ${newDemo.ctaToMockupGap}px`,
);
}
}
await finish(browser);

View File

@@ -0,0 +1,454 @@
import {
createBattery,
launchBrowser,
NEW_BASE,
OLD_BASE,
openPage,
} from './battery-kit.mjs';
// A/B battery for the ProductFeature section: both sites are driven down
// the tile grid and compared on lattice styles, tile content styles, the
// counter format, the entrance fade, and the interactive visuals
// (donut sweep, contacts checkbox + drag-scroll, kanban drag-drop-reorder
// with FLIP).
const OLD_URL = `${OLD_BASE}/product`;
const NEW_URL = `${NEW_BASE}/product`;
const { compare, fail, finish, ok } = createBattery('product-feature-parity');
const browser = await launchBrowser();
async function scrollToText(page, text, settleMs) {
const found = await page.evaluate((needle) => {
// The eyebrow is a span on the old site and a p (house Eyebrow) here.
const target = [...document.querySelectorAll('span, p, h2, h3')].find(
(el) => el.textContent.trim() === needle,
);
if (!target) return false;
target.scrollIntoView({ block: 'center' });
return true;
}, text);
await page.waitForTimeout(settleMs);
return found;
}
// The grid + tile anatomy, read from the counters' shared lattice.
function readSectionAnatomy(page) {
return page.evaluate(() => {
const counters = [...document.querySelectorAll('span')].filter((el) =>
/^\d{2} \/ \d{2}$/.test(el.textContent.trim()),
);
if (counters.length === 0) return null;
let grid = counters[0].parentElement;
while (grid) {
const style = getComputedStyle(grid);
const holdsAll = counters.every((counter) => grid.contains(counter));
if (holdsAll && style.borderTopWidth === '1px') break;
grid = grid.parentElement;
}
const gridStyle = grid ? getComputedStyle(grid) : null;
const cellEdges = grid
? [...grid.children].map((cell) => {
const style = getComputedStyle(cell);
return `${style.borderRightWidth} ${style.borderBottomWidth}`;
})
: [];
let entrance = counters[0].parentElement;
while (entrance) {
const style = getComputedStyle(entrance);
if (
style.transitionProperty.includes('opacity') &&
style.transitionDuration.includes('0.6s')
)
break;
entrance = entrance.parentElement;
}
const entranceStyle = entrance ? getComputedStyle(entrance) : null;
const headerRow = counters[0].parentElement;
const category = headerRow?.querySelector('span');
const categoryStyle = category ? getComputedStyle(category) : null;
const counterStyle = getComputedStyle(counters[0]);
const heading = [...document.querySelectorAll('h3')].find((el) =>
grid?.contains(el),
);
const headingStyle = heading ? getComputedStyle(heading) : null;
return {
counters: counters.map((el) => el.textContent.trim()),
gridBorder: gridStyle
? `${gridStyle.borderTopWidth} ${gridStyle.borderTopColor} r${gridStyle.borderRadius}`
: null,
cellEdges,
entranceTransition: entranceStyle
? `${entranceStyle.transitionProperty} ${entranceStyle.transitionDuration}`
: null,
entranceOpacity: entranceStyle?.opacity ?? null,
categoryStyle: categoryStyle
? `${categoryStyle.fontFamily.slice(0, 24)} ${categoryStyle.fontSize} ${categoryStyle.letterSpacing} ${categoryStyle.textTransform} ${categoryStyle.color}`
: null,
counterStyle: `${counterStyle.fontSize} ${counterStyle.color}`,
headingStyle: headingStyle
? `${headingStyle.fontSize} ${headingStyle.fontWeight} ${headingStyle.color}`
: null,
};
});
}
// The donut's value arc: the dasharrayed circle with the 1.2s sweep.
function readDonutState(page) {
return page.evaluate(() => {
const circle = [...document.querySelectorAll('circle')].find((el) => {
const style = getComputedStyle(el);
return (
style.strokeDasharray !== 'none' &&
style.transitionDuration.includes('1.2s')
);
});
if (!circle) return null;
const style = getComputedStyle(circle);
return {
transition: `${style.transitionProperty} ${style.transitionDuration}`,
dashoffset: Number.parseFloat(style.strokeDashoffset),
};
});
}
// The contacts mini-table root, found from its view header. The hero's
// mockup also shows an "All Companies" view, so the match is scoped to
// the dark feature-card surface (#1d1d25).
const CONTACTS_ROOT = `
const root = [...document.querySelectorAll('span')]
.filter((el) => el.textContent.trim() === 'All Companies')
.map((el) => el.parentElement?.parentElement)
.find(
(candidate) =>
candidate &&
getComputedStyle(candidate).backgroundColor === 'rgb(29, 29, 37)',
);
`;
// "All Companies" first matches the hero viewbar, so scrolling goes
// through the scoped root rather than scrollToText.
async function scrollToContacts(page) {
await page.evaluate(`(() => {
${CONTACTS_ROOT}
root?.scrollIntoView({ block: 'center' });
})()`);
await page.waitForTimeout(1400);
}
function readContactsCheckbox(page) {
return page.evaluate(`(() => {
${CONTACTS_ROOT}
if (!root) return null;
const boxes = [...root.querySelectorAll('div')].filter((el) => {
const style = getComputedStyle(el);
return style.width === '14px' && style.borderRadius === '3px';
});
const rowBox = boxes[1];
if (!rowBox) return null;
const rect = rowBox.getBoundingClientRect();
const style = getComputedStyle(rowBox);
return {
x: rect.x + rect.width / 2,
y: rect.y + rect.height / 2,
background: style.backgroundColor,
borderColor: style.borderTopColor,
};
})()`);
}
function readContactsViewport(page) {
return page.evaluate(`(() => {
${CONTACTS_ROOT}
if (!root) return null;
const viewport = [...root.querySelectorAll('div')].find(
(el) => getComputedStyle(el).overflowX === 'auto',
);
if (!viewport) return null;
const rect = viewport.getBoundingClientRect();
return {
x: rect.x + rect.width / 2,
y: rect.y + rect.height / 2,
scrollLeft: viewport.scrollLeft,
};
})()`);
}
// The pipeline board, found from its header; cards carry OPP- record ids.
function readPipelineState(page) {
return page.evaluate(() => {
const title = [...document.querySelectorAll('span')].find(
(el) => el.textContent.trim() === 'All opportunities',
);
const root = title?.parentElement?.parentElement;
if (!root) return null;
const counts = ['Identified', 'Qualified'].map((label) => {
const pill = [...root.querySelectorAll('span')].find(
(el) => el.textContent.trim() === label,
);
return pill?.nextElementSibling?.textContent.trim() ?? null;
});
const cardRect = (recordId) => {
const cards = [...root.querySelectorAll('div')].filter(
(el) =>
getComputedStyle(el).cursor === 'grab' &&
el.textContent.includes(recordId),
);
const card = cards.at(-1);
if (!card) return null;
const rect = card.getBoundingClientRect();
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
};
return {
counts,
github: cardRect('OPP-1'),
airbnb: cardRect('OPP-8'),
};
});
}
function countFlipAnimations(page) {
return page.evaluate(
() =>
document
.getAnimations()
.filter(
(animation) =>
animation.constructor.name === 'Animation' &&
animation.playState === 'running',
).length,
);
}
const oldPage = await openPage(browser, OLD_URL);
const newPage = await openPage(browser, NEW_URL);
const pages = [
['old', oldPage],
['new', newPage],
];
// --- Section anatomy + lattice + entrance + tile styles ---------------
const anatomies = {};
for (const [name, page] of pages) {
// eslint-disable-next-line no-await-in-loop
const found = await scrollToText(page, 'Core Features', 1100);
if (!found) fail(`feature section present on ${name} site`, 'no eyebrow');
// eslint-disable-next-line no-await-in-loop
anatomies[name] = await readSectionAnatomy(page);
}
if (!anatomies.old || !anatomies.new) {
fail(
'feature tiles readable on both sites',
`old=${Boolean(anatomies.old)} new=${Boolean(anatomies.new)}`,
);
} else {
compare(
'tile counters match',
anatomies.old.counters,
anatomies.new.counters,
);
compare(
'grid lattice border',
anatomies.old.gridBorder,
anatomies.new.gridBorder,
);
compare(
'cell edge lattice',
anatomies.old.cellEdges,
anatomies.new.cellEdges,
);
compare(
'entrance fade transition',
anatomies.old.entranceTransition,
anatomies.new.entranceTransition,
);
compare(
'entrance settled opacity',
anatomies.old.entranceOpacity,
anatomies.new.entranceOpacity,
);
compare(
'category label style',
anatomies.old.categoryStyle,
anatomies.new.categoryStyle,
);
compare(
'counter style',
anatomies.old.counterStyle,
anatomies.new.counterStyle,
);
compare(
'tile heading style',
anatomies.old.headingStyle,
anatomies.new.headingStyle,
);
}
// --- Donut sweep (spotlight active state) ------------------------------
const donuts = {};
for (const [name, page] of pages) {
// eslint-disable-next-line no-await-in-loop
await scrollToText(page, 'Sales performances', 1700);
// eslint-disable-next-line no-await-in-loop
donuts[name] = await readDonutState(page);
}
if (!donuts.old || !donuts.new) {
fail(
'donut arc present on both sites',
`old=${Boolean(donuts.old)} new=${Boolean(donuts.new)}`,
);
} else {
compare(
'donut sweep transition',
donuts.old.transition,
donuts.new.transition,
);
if (Math.abs(donuts.old.dashoffset - donuts.new.dashoffset) <= 1) {
ok(
'donut sweep settles at the same arc',
`old ${donuts.old.dashoffset.toFixed(1)} vs new ${donuts.new.dashoffset.toFixed(1)}`,
);
} else {
fail(
'donut sweep settles at the same arc',
`old ${donuts.old.dashoffset} vs new ${donuts.new.dashoffset}`,
);
}
}
// --- Contacts: checkbox + drag-scroll ----------------------------------
const checkboxes = {};
for (const [name, page] of pages) {
// eslint-disable-next-line no-await-in-loop
await scrollToContacts(page);
// eslint-disable-next-line no-await-in-loop
const before = await readContactsCheckbox(page);
if (!before) {
fail(`contacts checkbox found on ${name} site`, 'no 14px box');
continue;
}
// eslint-disable-next-line no-await-in-loop
await page.mouse.click(before.x, before.y);
// eslint-disable-next-line no-await-in-loop
await page.waitForTimeout(250);
// eslint-disable-next-line no-await-in-loop
checkboxes[name] = { before, after: await readContactsCheckbox(page) };
}
if (checkboxes.old && checkboxes.new) {
compare(
'checkbox unchecked chrome',
`${checkboxes.old.before.background} ${checkboxes.old.before.borderColor}`,
`${checkboxes.new.before.background} ${checkboxes.new.before.borderColor}`,
);
compare(
'checkbox checked chrome',
`${checkboxes.old.after.background} ${checkboxes.old.after.borderColor}`,
`${checkboxes.new.after.background} ${checkboxes.new.after.borderColor}`,
);
if (checkboxes.new.before.background !== checkboxes.new.after.background) {
ok('checkbox toggles on click');
} else {
fail('checkbox toggles on click', 'background unchanged');
}
}
const scrolls = {};
for (const [name, page] of pages) {
// eslint-disable-next-line no-await-in-loop
const viewport = await readContactsViewport(page);
if (!viewport) {
fail(`contacts viewport found on ${name} site`, 'no overflow-x element');
continue;
}
// eslint-disable-next-line no-await-in-loop
await page.mouse.move(viewport.x, viewport.y);
// eslint-disable-next-line no-await-in-loop
await page.mouse.down();
for (const step of [40, 80, 120, 160]) {
// eslint-disable-next-line no-await-in-loop
await page.mouse.move(viewport.x - step, viewport.y);
}
// eslint-disable-next-line no-await-in-loop
await page.mouse.up();
// eslint-disable-next-line no-await-in-loop
const after = await readContactsViewport(page);
scrolls[name] = { from: viewport.scrollLeft, to: after?.scrollLeft ?? null };
}
if (scrolls.old && scrolls.new) {
if (scrolls.old.to > scrolls.old.from && scrolls.new.to > scrolls.new.from) {
ok(
'contacts table drag-scrolls on both sites',
`old +${scrolls.old.to - scrolls.old.from}px, new +${scrolls.new.to - scrolls.new.from}px`,
);
} else {
fail(
'contacts table drag-scrolls on both sites',
`old ${JSON.stringify(scrolls.old)} vs new ${JSON.stringify(scrolls.new)}`,
);
}
}
// --- Pipeline: drag-drop-reorder + FLIP --------------------------------
for (const [name, page] of pages) {
// eslint-disable-next-line no-await-in-loop
await scrollToText(page, 'All opportunities', 1400);
// eslint-disable-next-line no-await-in-loop
const before = await readPipelineState(page);
if (!before?.github || !before.airbnb) {
fail(`pipeline cards found on ${name} site`, JSON.stringify(before));
continue;
}
compare(`pipeline lane counts at rest (${name})`, before.counts, ['2', '2']);
const grabX = before.github.x + before.github.width / 2;
const grabY = before.github.y + 12;
// Drop above Airbnb's midpoint: lane 2, index 0 — both resident cards
// get displaced, so the FLIP animations must fire.
const dropX = before.airbnb.x + before.airbnb.width / 2;
const dropY = before.airbnb.y + 5;
// eslint-disable-next-line no-await-in-loop
await page.mouse.move(grabX, grabY);
// eslint-disable-next-line no-await-in-loop
await page.mouse.down();
for (const step of [0.25, 0.5, 0.75, 1]) {
// eslint-disable-next-line no-await-in-loop
await page.mouse.move(
grabX + (dropX - grabX) * step,
grabY + (dropY - grabY) * step,
);
}
// eslint-disable-next-line no-await-in-loop
await page.mouse.up();
// eslint-disable-next-line no-await-in-loop
const flipCount = await countFlipAnimations(page);
// eslint-disable-next-line no-await-in-loop
await page.waitForTimeout(450);
// eslint-disable-next-line no-await-in-loop
const after = await readPipelineState(page);
if (after && after.counts[0] === '1' && after.counts[1] === '3') {
ok(`pipeline drop reorders lanes on ${name} site`, '2/2 → 1/3');
} else {
fail(
`pipeline drop reorders lanes on ${name} site`,
`counts ${JSON.stringify(after?.counts)}`,
);
}
if (flipCount >= 1) {
ok(`displaced cards FLIP on ${name} site`, `${flipCount} running`);
} else {
fail(`displaced cards FLIP on ${name} site`, 'no WAAPI animation running');
}
}
await finish(browser);

View File

@@ -0,0 +1,289 @@
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');
}

View File

@@ -0,0 +1,322 @@
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);

View File

@@ -0,0 +1,261 @@
import {
createBattery,
launchBrowser,
NEW_BASE,
OLD_BASE,
openPage,
} from './battery-kit.mjs';
// A/B battery for the three-cards family: the home section's restored
// scroll choreography (old == new at pinned scroll positions) and the
// product variant's intro, section rhythm, and footerless cards.
const HOME_HEADING = 'Assemble, iterate and adapt a robust CRM,';
const PRODUCT_HEADING = 'A modern CRM with';
const { compare, fail, finish, ok } = createBattery('three-cards-parity');
const browser = await launchBrowser();
const openSitePage = (base, path, reducedMotion = false) =>
openPage(browser, `${base}${path}`, { reducedMotion, settleMs: 900 });
// The grid + its will-change slots, found from the section's heading.
function readSection(page, headingNeedle) {
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 slots = [...section.querySelectorAll('div')].filter((el) => {
const style = getComputedStyle(el);
return style.willChange.includes('transform') && el.children.length === 1;
});
const grid = slots[0]?.parentElement;
const gridStyle = grid ? getComputedStyle(grid) : null;
const sectionStyle = getComputedStyle(section);
const containerStyle = grid?.closest('section > *')
? getComputedStyle(section.children[0])
: null;
const headingStyle = getComputedStyle(heading);
const rect = grid?.getBoundingClientRect();
return {
slotPoses: slots.map((el) => `${el.style.opacity}|${el.style.transform}`),
slotCount: slots.length,
gridTop: rect ? rect.top + window.scrollY : null,
gridColumns: gridStyle
? `${gridStyle.gridAutoFlow} ${gridStyle.gap}`
: null,
sectionPadding: `${sectionStyle.paddingTop} ${sectionStyle.paddingBottom}`,
containerPadding: containerStyle
? `${containerStyle.paddingLeft} ${containerStyle.rowGap}`
: null,
headingStyle: `${headingStyle.fontSize} ${headingStyle.fontWeight} ${headingStyle.maxWidth}`,
// Per-card anatomy: an h3, a body, and whether a footer rendered.
cards: slots.map((slot) => ({
hasHeading: Boolean(slot.querySelector('h3')),
hasFooter: Boolean(slot.querySelector('footer')),
})),
};
}, headingNeedle);
}
// Sub-pixel scroll residuals differ between the sites' page heights, so
// mid-travel poses compare numerically, not byte-wise.
const parsePose = (pose) => {
const [opacity, transform] = pose.split('|');
const translate = /translateY\(([-\d.]+)px\)/.exec(transform);
const scale = /scale\(([-\d.]+)\)/.exec(transform);
return {
opacity: Number(opacity),
translateY: translate ? Number(translate[1]) : null,
scale: scale ? Number(scale[1]) : null,
};
};
async function scrollGridTo(page, gridTop, viewportFraction) {
await page.evaluate(
({ top, fraction }) =>
window.scrollTo(0, Math.max(0, top - window.innerHeight * fraction)),
{ top: gridTop, fraction: viewportFraction },
);
await page.waitForTimeout(350);
}
// --- HOME: restored scroll choreography --------------------------------
{
const oldPage = await openSitePage(OLD_BASE, '/');
const newPage = await openSitePage(NEW_BASE, '/');
const oldRest = await readSection(oldPage, HOME_HEADING);
const newRest = await readSection(newPage, HOME_HEADING);
if (!oldRest || !newRest) {
fail(
'home three-cards present on both sites',
`old=${Boolean(oldRest)} new=${Boolean(newRest)}`,
);
} else {
compare('home slot count', oldRest.slotCount, newRest.slotCount);
compare(
'home initial card poses (grid off-screen)',
oldRest.slotPoses,
newRest.slotPoses,
);
// Mid-travel: grid top at 60% of the viewport → progress 0.5.
await scrollGridTo(oldPage, oldRest.gridTop, 0.6);
await scrollGridTo(newPage, newRest.gridTop, 0.6);
const oldMid = await readSection(oldPage, HOME_HEADING);
const newMid = await readSection(newPage, HOME_HEADING);
const midMatches = oldMid.slotPoses.every((oldPose, poseNumber) => {
const a = parsePose(oldPose);
const b = parsePose(newMid.slotPoses[poseNumber] ?? '');
return (
Math.abs(a.opacity - b.opacity) <= 0.02 &&
a.translateY !== null &&
b.translateY !== null &&
Math.abs(a.translateY - b.translateY) <= 2 &&
Math.abs(a.scale - b.scale) <= 0.002
);
});
if (midMatches) {
ok('home mid-scroll card poses match within tolerance');
} else {
fail(
'home mid-scroll card poses match within tolerance',
`old ${JSON.stringify(oldMid.slotPoses)} vs new ${JSON.stringify(newMid.slotPoses)}`,
);
}
const midMoving = newMid.slotPoses.some(
(pose) => !pose.startsWith('1|') || pose.includes('translateY'),
);
if (midMoving) {
ok('home cards are mid-travel at the checkpoint');
} else {
fail(
'home cards are mid-travel at the checkpoint',
JSON.stringify(newMid.slotPoses),
);
}
// Settled: grid top at 10% of the viewport → full progress.
await scrollGridTo(oldPage, oldRest.gridTop, 0.1);
await scrollGridTo(newPage, newRest.gridTop, 0.1);
const oldDone = await readSection(oldPage, HOME_HEADING);
const newDone = await readSection(newPage, HOME_HEADING);
compare(
'home settled card poses byte-equal',
oldDone.slotPoses,
newDone.slotPoses,
);
compare('home settled pose value', newDone.slotPoses, [
'1|translateY(0px) scale(1)',
'1|translateY(0px) scale(1)',
'1|translateY(0px) scale(1)',
]);
compare(
'home cards keep attribution footers',
newDone.cards.map((card) => card.hasFooter),
[true, true, true],
);
}
await oldPage.close();
await newPage.close();
// RATIFIED DIVERGENCE: reduced motion settles the cards; the old site
// animates regardless. Asserting the difference keeps it deliberate.
const oldReduced = await openSitePage(OLD_BASE, '/', true);
const newReduced = await openSitePage(NEW_BASE, '/', true);
const oldReducedRest = await readSection(oldReduced, HOME_HEADING);
const newReducedRest = await readSection(newReduced, HOME_HEADING);
await scrollGridTo(oldReduced, oldReducedRest.gridTop, 0.6);
await scrollGridTo(newReduced, newReducedRest.gridTop, 0.6);
const oldReducedMid = await readSection(oldReduced, HOME_HEADING);
const newReducedMid = await readSection(newReduced, HOME_HEADING);
const newSettled = newReducedMid.slotPoses.every((pose) => pose === '1|none');
const divergesFromOld =
JSON.stringify(oldReducedMid.slotPoses) !==
JSON.stringify(newReducedMid.slotPoses);
if (newSettled && divergesFromOld) {
ok('reduced motion settles cards (ledgered divergence from old)');
} else {
fail(
'reduced motion settles cards (ledgered divergence from old)',
`new ${JSON.stringify(newReducedMid.slotPoses)} old ${JSON.stringify(oldReducedMid.slotPoses)}`,
);
}
await oldReduced.close();
await newReduced.close();
}
// --- PRODUCT: intro, rhythm, footerless cards ---------------------------
{
const oldPage = await openSitePage(OLD_BASE, '/product');
const newPage = await openSitePage(NEW_BASE, '/product');
const oldSection = await readSection(oldPage, PRODUCT_HEADING);
const newSection = await readSection(newPage, PRODUCT_HEADING);
// The rhythm system owns section padding universally (ratified standard:
// the old site padded each section's inner container ad hoc) — the
// product section must carry exactly the home section's rhythm.
const newHomePage = await openSitePage(NEW_BASE, '/');
const newHomeSection = await readSection(newHomePage, HOME_HEADING);
compare(
'product section rhythm equals home section rhythm (universal padding)',
newHomeSection?.sectionPadding,
newSection?.sectionPadding,
);
await newHomePage.close();
if (!oldSection || !newSection) {
fail(
'product three-cards present on both sites',
`old=${Boolean(oldSection)} new=${Boolean(newSection)}`,
);
} else {
compare('product slot count', oldSection.slotCount, newSection.slotCount);
compare(
'product grid flow and gap',
oldSection.gridColumns,
newSection.gridColumns,
);
compare(
'product heading style + measure',
oldSection.headingStyle,
newSection.headingStyle,
);
compare(
'product cards carry no footer',
newSection.cards.map((card) => card.hasFooter),
[false, false, false],
);
compare(
'product card anatomy matches old',
oldSection.cards,
newSection.cards,
);
// Settled choreography on product too (same shared machinery).
await scrollGridTo(oldPage, oldSection.gridTop, 0.1);
await scrollGridTo(newPage, newSection.gridTop, 0.1);
const oldDone = await readSection(oldPage, PRODUCT_HEADING);
const newDone = await readSection(newPage, PRODUCT_HEADING);
compare(
'product settled card poses byte-equal',
oldDone.slotPoses,
newDone.slotPoses,
);
}
await oldPage.close();
await newPage.close();
}
await finish(browser);

View File

@@ -0,0 +1,629 @@
import { chromium } from 'playwright';
import { PENDING_VISUAL_SLOTS } from './pending-visual-slots.mjs';
import sharp from 'sharp';
// The WebGL verification battery. Dot patterns are not pixel-comparable
// across runs (old-vs-old fails pixel identity), so the grammar is: box,
// coverage, dominant-hue class, liveliness, lifecycle — with thresholds
// calibrated against noise, never assumed.
//
// Usage: node scripts/visual-battery.mjs [visualKey ...] (default: all)
const BASE_URL = process.env.VISUAL_BATTERY_URL ?? 'http://localhost:3004/';
const VIEWPORT = { width: 1440, height: 900 };
const VISUALS = {
hourglass: {
slotSelector: '[data-illustration="hourglass"]',
// blue #4a38f5
hueRangeDegrees: [200, 260],
minCoverage: 0.02,
animated: true,
interactive: false,
},
'hero-bridge': {
slotSelector: '[data-illustration="hero-bridge"]',
// blue dashes from the bridge's dark areas; hover light is the
// interactivity; priority mount at the very top of the page
hueRangeDegrees: [200, 260],
minCoverage: 0.01,
animated: false,
interactive: true,
// the hero center is the copy block, which opts out of hover via
// [data-halftone-exclude] — probe the artwork's left field instead
interactionPoint: [0.06, 0.72],
// the hover light brightens only the dashes inside its 158px circle:
// ~0.3% of canvas pixels at full effect (reduced baseline is 0.00%)
interactionFloor: 0.002,
},
'footer-backdrop': {
slotSelector: '[data-illustration="footer-backdrop"]',
// charcoal on black: hueless and subtle — breathe is the liveliness
hueRangeDegrees: null,
minCoverage: 0.005,
animated: true,
// charcoal-on-black breathe is subtle; its reduced-motion baseline
// measures 0.00%, so 0.1% is unambiguous motion.
motionFloor: 0.001,
interactive: false,
},
'stepper-backdrop': {
slotSelector: '[data-illustration="stepper-backdrop"]',
// fog gray: hueless; hover (pointer move) is the interactivity proof
hueRangeDegrees: null,
minCoverage: 0.02,
animated: false,
interactive: true,
},
faq: {
slotSelector: '[data-illustration="faq"]',
// blue rows, rotate-only, no pointer
hueRangeDegrees: [200, 260],
minCoverage: 0.005,
animated: true,
interactive: false,
},
monolith: {
slotSelector: '[data-illustration="monolith"]',
// ash gray: hueless — coverage + hover-light interactivity only
hueRangeDegrees: null,
minCoverage: 0.02,
animated: false,
interactive: true,
},
diamond: {
slotSelector: '[data-illustration="diamond"]',
// blue on the white stage
hueRangeDegrees: [200, 260],
minCoverage: 0.01,
animated: true,
interactive: true,
settle: { cardReveal: true },
},
flash: {
slotSelector: '[data-illustration="flash"]',
hueRangeDegrees: [200, 260],
minCoverage: 0.01,
animated: true,
// The thin bolt rotates near edge-on at the sampling phase (the card
// reveal shifted mount timing); ~0.25% diff vs a 0.00% reduced
// baseline is unambiguous motion.
motionFloor: 0.002,
interactive: true,
settle: { cardReveal: true },
},
lock: {
slotSelector: '[data-illustration="lock"]',
hueRangeDegrees: [200, 260],
minCoverage: 0.01,
animated: true,
interactive: true,
settle: { cardReveal: true },
},
target: {
slotSelector: '[data-illustration="target"]',
// pink #ed87fc
hueRangeDegrees: [270, 330],
minCoverage: 0.01,
// breathe-only: passive motion is sub-pixel; drag is the liveliness proof
animated: false,
interactive: true,
settle: { stageProgress: 0.3 },
},
spaceship: {
slotSelector: '[data-illustration="spaceship"]',
// green #89fc9a
hueRangeDegrees: [95, 160],
minCoverage: 0.01,
animated: false,
interactive: true,
settle: { stageProgress: 0.6 },
},
money: {
slotSelector: '[data-illustration="money"]',
// yellow #feffb7 (near-white: low saturation tolerated)
hueRangeDegrees: [40, 80],
minCoverage: 0.005,
minSaturation: 0.12,
animated: false,
interactive: true,
settle: { stageProgress: 0.9 },
},
'fast-path': {
slotSelector: '[data-illustration="fast-path"]',
// the white command palette over the dark dash backdrop; mixed
// palette so hue is skipped. Idle is static; the drag probe's click
// on a command fires the confetti burst plus the hover lift.
hueRangeDegrees: null,
minCoverage: 0.02,
animated: false,
interactive: true,
},
'live-data': {
slotSelector: '[data-illustration="live-data"]',
// the white companies panel over the dark dash backdrop with the
// collaborator markers; mixed palette so hue is skipped. Idle is
// static; HOVERING runs the whole two-actor demo (tag rename, filter
// pop-away, rows arriving), so the probe's pointer presence is the
// liveliness.
hueRangeDegrees: null,
minCoverage: 0.02,
animated: false,
interactive: true,
},
'familiar-interface': {
slotSelector: '[data-illustration="familiar-interface"]',
// the white opportunity board over the dark dash backdrop; the
// palette is mixed (pink/purple pills, blue active card) so hue is
// skipped. Idle is static; the drag probe grabs a card (and retires
// the hand-cursor affordance), so interaction is the liveliness.
hueRangeDegrees: null,
minCoverage: 0.02,
animated: false,
interactive: true,
},
// The product page's three-cards models — same grammar as the home
// trio (blue band halftone on the white stage, auto-rotate + drag).
speed: {
slotSelector: '[data-illustration="speed"]',
path: '/product',
hueRangeDegrees: [200, 260],
minCoverage: 0.01,
animated: true,
interactive: true,
settle: { cardReveal: true },
},
eye: {
slotSelector: '[data-illustration="eye"]',
path: '/product',
hueRangeDegrees: [200, 260],
minCoverage: 0.01,
animated: true,
interactive: true,
settle: { cardReveal: true },
},
singleScreen: {
slotSelector: '[data-illustration="singleScreen"]',
path: '/product',
hueRangeDegrees: [200, 260],
minCoverage: 0.01,
animated: true,
interactive: true,
settle: { cardReveal: true },
},
// The partner hero halftone: stone dashes (#959595) at rest are hueless;
// the cursor turns nearby dashes blue and shifts the band (interactive).
'partner-hero': {
slotSelector: '[data-illustration="partner-hero"]',
path: '/partners/become',
hueRangeDegrees: null,
minCoverage: 0.02,
animated: false,
interactive: true,
},
// The promo mic: iron dashes (#777) at rest are hueless on the light panel;
// the hover light brightens the dashes near the cursor (interactive).
'promo-mic': {
slotSelector: '[data-illustration="promo-mic"]',
path: '/partners/become',
hueRangeDegrees: null,
minCoverage: 0.02,
animated: false,
interactive: true,
},
// The testimonial author portrait: white dashes (hueless) on the dark panel;
// the cursor shifts the band and adds a light (interactive).
'partner-portrait': {
slotSelector: '[data-illustration="partner-portrait"]',
path: '/partners/become',
hueRangeDegrees: null,
minCoverage: 0.02,
animated: false,
interactive: true,
},
// The decorative quote-mark GLB behind the text: blue (#4a38f5) band
// halftone with a slow breathe; desktop-only + aria-hidden.
'partner-quote': {
slotSelector: '[data-illustration="partner-quote"]',
path: '/partners/become',
hueRangeDegrees: [200, 260],
minCoverage: 0.01,
animated: false,
interactive: false,
},
};
const MOTION_DIFF_FLOOR = 0.005;
const LOAD_TIMEOUT_MS = 30000;
const failures = [];
// Every data-illustration slot on the page must carry a battery spec —
// a new visual without coverage is a failure, not a silent gap.
async function assertSpecCompleteness(page, specNames) {
const mounted = await page.evaluate(() =>
[...document.querySelectorAll('[data-illustration]')].map((el) =>
el.getAttribute('data-illustration'),
),
);
const uncovered = mounted.filter(
(name) => !specNames.has(name) && !PENDING_VISUAL_SLOTS.has(name),
);
const pendingOnPage = mounted.filter((name) =>
PENDING_VISUAL_SLOTS.has(name),
);
if (pendingOnPage.length > 0) {
console.log(
` - pending (declared, no spec yet): ${pendingOnPage.join(', ')}`,
);
}
if (uncovered.length > 0) {
console.error(` ✗ slots without battery specs: ${uncovered.join(', ')}`);
failures.push(`uncovered slots: ${uncovered.join(', ')}`);
} else {
console.log(` ✓ all ${mounted.length} mounted slots carry specs`);
}
}
const assert = (condition, message) => {
if (condition) {
console.log(` ✓ ${message}`);
} else {
failures.push(message);
console.error(` ✗ ${message}`);
}
};
const readClip = async (page, box) => {
const buffer = await page.screenshot({
clip: {
x: Math.max(0, box.x),
y: Math.max(0, box.y),
width: Math.max(1, box.width),
height: Math.max(1, box.height),
},
});
return sharp(buffer).raw().toBuffer({ resolveWithObject: true });
};
const rgbToHueSaturation = (r, g, b) => {
const max = Math.max(r, g, b) / 255;
const min = Math.min(r, g, b) / 255;
const delta = max - min;
let hue = 0;
if (delta > 0) {
if (max === r / 255) hue = (((g - b) / 255 / delta) % 6) * 60;
else if (max === g / 255) hue = ((b - r) / 255 / delta + 2) * 60;
else hue = ((r - g) / 255 / delta + 4) * 60;
}
if (hue < 0) hue += 360;
const saturation = max === 0 ? 0 : delta / max;
return { hue, saturation };
};
const analyzeClip = (
{ data, info },
backgroundSample,
minSaturation = 0.25,
) => {
let foreground = 0;
let hueWeightedSum = 0;
let hueSamples = 0;
const total = info.width * info.height;
for (let i = 0; i < total; i += 1) {
const offset = i * info.channels;
const r = data[offset];
const g = data[offset + 1];
const b = data[offset + 2];
const isBackground =
Math.abs(r - backgroundSample[0]) <= 12 &&
Math.abs(g - backgroundSample[1]) <= 12 &&
Math.abs(b - backgroundSample[2]) <= 12;
if (isBackground) continue;
foreground += 1;
const { hue, saturation } = rgbToHueSaturation(r, g, b);
if (saturation > minSaturation) {
hueWeightedSum += hue;
hueSamples += 1;
}
}
return {
coverage: foreground / total,
dominantHue: hueSamples > 0 ? hueWeightedSum / hueSamples : null,
};
};
const diffClips = (a, b) => {
const total = Math.min(a.data.length, b.data.length);
let changed = 0;
let compared = 0;
for (let offset = 0; offset < total; offset += a.info.channels) {
compared += 1;
if (
Math.abs(a.data[offset] - b.data[offset]) > 8 ||
Math.abs(a.data[offset + 1] - b.data[offset + 1]) > 8 ||
Math.abs(a.data[offset + 2] - b.data[offset + 2]) > 8
) {
changed += 1;
}
}
return changed / compared;
};
const cornerSample = ({ data }) => [data[0], data[1], data[2]];
const waitForLoadedCanvas = async (page, slotSelector) => {
const deadline = Date.now() + LOAD_TIMEOUT_MS;
// Polling: sequential await is the semantics, not an oversight.
while (Date.now() < deadline) {
// eslint-disable-next-line no-await-in-loop
const box = await page
.locator(`${slotSelector} canvas`)
.first()
.boundingBox()
.catch(() => null);
if (box) {
// eslint-disable-next-line no-await-in-loop
const clip = await readClip(page, box);
const sample = cornerSample(clip);
const { coverage } = analyzeClip(clip, sample);
if (coverage > 0.001) {
return box;
}
}
// eslint-disable-next-line no-await-in-loop
await new Promise((resolve) => setTimeout(resolve, 250));
}
return null;
};
const settleForSpec = async (page, spec) => {
if (spec.settle?.stageProgress !== undefined) {
// The helped cards travel a 280vh fan; each card is readable only in
// its own hold window of the stage progress.
await page.evaluate((progress) => {
const stage = document.querySelector('#homepage-cases');
if (stage) {
const rect = stage.getBoundingClientRect();
const top = rect.top + window.scrollY;
const scrollable = rect.height - window.innerHeight;
window.scrollTo(0, top + progress * scrollable);
}
}, spec.settle.stageProgress);
await page.waitForTimeout(800);
return;
}
if (spec.settle?.cardReveal) {
// The three-cards grid rides the scroll-driven reveal; park the slot
// high enough that the choreography has fully settled before probing.
await page.evaluate((selector) => {
const slot = document.querySelector(selector);
if (slot) {
const rect = slot.getBoundingClientRect();
window.scrollTo(
0,
rect.top + window.scrollY - window.innerHeight * 0.15,
);
}
}, spec.slotSelector);
await page.waitForTimeout(900);
return;
}
await page.locator(spec.slotSelector).first().scrollIntoViewIfNeeded();
};
const runVisual = async (browser, key, spec) => {
console.log(`── ${key}`);
const page = await browser.newPage({
viewport: VIEWPORT,
deviceScaleFactor: 1,
});
await page.goto(new URL(spec.path ?? '', BASE_URL).href, {
waitUntil: 'networkidle',
timeout: 180000,
});
const slot = page.locator(spec.slotSelector).first();
await settleForSpec(page, spec);
const slotBox = await slot.boundingBox();
assert(slotBox !== null, 'slot exists');
const canvasBox = await waitForLoadedCanvas(page, spec.slotSelector);
assert(
canvasBox !== null,
'canvas renders non-uniform pixels before timeout',
);
if (canvasBox === null) {
await page.close();
return;
}
const sizeMatches =
Math.abs(canvasBox.width - slotBox.width) <= 2 &&
canvasBox.height <= slotBox.height + 2;
assert(
sizeMatches,
`canvas box ≈ slot box (${Math.round(canvasBox.width)}x${Math.round(canvasBox.height)} in ${Math.round(slotBox.width)}x${Math.round(slotBox.height)})`,
);
const clipA = await readClip(page, canvasBox);
const background = cornerSample(clipA);
const { coverage, dominantHue } = analyzeClip(
clipA,
background,
spec.minSaturation,
);
assert(
coverage >= spec.minCoverage,
`coverage ${(coverage * 100).toFixed(1)}% ≥ ${spec.minCoverage * 100}%`,
);
if (spec.hueRangeDegrees === null) {
console.log(' - hue skipped (hueless artwork)');
} else if (dominantHue !== null) {
assert(
dominantHue >= spec.hueRangeDegrees[0] &&
dominantHue <= spec.hueRangeDegrees[1],
`dominant hue ${Math.round(dominantHue)}° within [${spec.hueRangeDegrees}]`,
);
} else {
assert(false, 'saturated pixels present for hue measurement');
}
if (spec.animated) {
await page.waitForTimeout(400);
const clipB = await readClip(page, canvasBox);
const motionDiff = diffClips(clipA, clipB);
assert(
motionDiff > (spec.motionFloor ?? MOTION_DIFF_FLOOR),
`motion alive (diff ${(motionDiff * 100).toFixed(2)}%)`,
);
}
if (spec.interactive) {
// Bleed canvases extend past the viewport: interaction fractions
// resolve against the VISIBLE region or the pointer hits nothing.
const visibleLeft = Math.max(canvasBox.x, 0);
const visibleTop = Math.max(canvasBox.y, 0);
const visibleWidth =
Math.min(canvasBox.x + canvasBox.width, VIEWPORT.width) - visibleLeft;
const visibleHeight =
Math.min(canvasBox.y + canvasBox.height, VIEWPORT.height) - visibleTop;
const [fractionX, fractionY] = spec.interactionPoint ?? [0.5, 0.5];
const centerX = visibleLeft + visibleWidth * fractionX;
const centerY = visibleTop + visibleHeight * fractionY;
const before = await readClip(page, canvasBox);
await page.mouse.move(centerX, centerY);
await page.mouse.down();
for (let step = 1; step <= 5; step += 1) {
// eslint-disable-next-line no-await-in-loop
await page.mouse.move(centerX + step * 14, centerY + step * 6);
}
await page.mouse.up();
await page.waitForTimeout(250);
const after = await readClip(page, canvasBox);
const dragDiff = diffClips(before, after);
assert(
dragDiff > (spec.interactionFloor ?? MOTION_DIFF_FLOOR),
`drag changes pixels (diff ${(dragDiff * 100).toFixed(2)}%)`,
);
}
// Lifecycle: scroll far away, wait past the dispose grace, expect release.
const counts = () =>
page.evaluate(
() => window.__visualRuntimeTest?.getActiveContextCount() ?? -1,
);
const activeWhileVisible = await counts();
assert(
activeWhileVisible >= 1,
`context held while visible (count ${activeWhileVisible})`,
);
// Leave toward whichever page end is farther, so slots near the top
// actually exit the generous mount margins.
await page.evaluate((selector) => {
const slotElement = document.querySelector(selector);
const slotCenter =
slotElement.getBoundingClientRect().top +
window.scrollY +
slotElement.getBoundingClientRect().height / 2;
const pageHeight = document.documentElement.scrollHeight;
window.scrollTo(0, slotCenter < pageHeight / 2 ? pageHeight : 0);
}, spec.slotSelector);
await page.waitForTimeout(5500);
const canvasAfterLeave = await page
.locator(`${spec.slotSelector} canvas`)
.count();
assert(
canvasAfterLeave === 0,
`scene unmounted after leaving (canvases ${canvasAfterLeave}, global count ${await counts()})`,
);
await settleForSpec(page, spec);
const reacquired = await waitForLoadedCanvas(page, spec.slotSelector);
assert(reacquired !== null, 'scene re-acquires on return');
await page.close();
// Reduced motion: designed scenes render one frozen frame.
const reducedPage = await browser.newPage({
viewport: VIEWPORT,
deviceScaleFactor: 1,
reducedMotion: 'reduce',
});
await reducedPage.goto(new URL(spec.path ?? '', BASE_URL).href, {
waitUntil: 'networkidle',
timeout: 180000,
});
await settleForSpec(reducedPage, spec);
const reducedCanvas = await waitForLoadedCanvas(
reducedPage,
spec.slotSelector,
);
if (reducedCanvas !== null) {
const frozenA = await readClip(reducedPage, reducedCanvas);
await reducedPage.waitForTimeout(400);
const frozenB = await readClip(reducedPage, reducedCanvas);
const frozenDiff = diffClips(frozenA, frozenB);
assert(
frozenDiff <= MOTION_DIFF_FLOOR,
`reduced motion frozen (diff ${(frozenDiff * 100).toFixed(2)}%)`,
);
} else {
// poster mode: no canvas at all is also a pass
const canvasCount = await reducedPage
.locator(`${spec.slotSelector} canvas`)
.count();
assert(canvasCount === 0, 'reduced motion shows poster (no canvas)');
}
await reducedPage.close();
};
const keys =
process.argv.slice(2).length > 0
? process.argv.slice(2)
: Object.keys(VISUALS);
const browser = await chromium.launch({ channel: 'chrome', headless: true });
// Completeness runs only on full sweeps (a single-visual run is a dev loop).
if (process.argv.slice(2).length === 0) {
const specPaths = [
...new Set(Object.values(VISUALS).map((spec) => spec.path ?? '')),
];
// Pages are checked one at a time so failures stay attributable.
for (const specPath of specPaths) {
// eslint-disable-next-line no-await-in-loop
const completenessPage = await browser.newPage({ viewport: VIEWPORT });
// eslint-disable-next-line no-await-in-loop
await completenessPage.goto(new URL(specPath, BASE_URL).href, {
waitUntil: 'networkidle',
timeout: 240000,
});
console.log(`── spec completeness (${specPath || '/'})`);
// eslint-disable-next-line no-await-in-loop
await assertSpecCompleteness(
completenessPage,
new Set(Object.keys(VISUALS)),
);
// eslint-disable-next-line no-await-in-loop
await completenessPage.close();
}
}
for (const key of keys) {
const spec = VISUALS[key];
if (!spec) {
failures.push(`unknown visual: ${key}`);
continue;
}
// Visuals run one at a time so context counts stay interpretable.
// eslint-disable-next-line no-await-in-loop
await runVisual(browser, key, spec);
}
await browser.close();
if (failures.length > 0) {
console.error(`\nvisual-battery: FAILED (${failures.length})`);
process.exit(1);
}
console.log('\nvisual-battery: OK');

View File

@@ -0,0 +1,300 @@
import { chromium } from 'playwright';
// Whole-page closing batteries: the context cap holds while every slot
// gets evidenced during a full scroll sweep, and the frame loops go idle
// when everything is offscreen.
const BASE_URL = process.env.VISUAL_BATTERY_URL ?? 'http://localhost:3004/';
// The cap is read LIVE from the budget via the test instrumentation —
// never duplicated here (a raised budget must fail loudly, not silently).
import { PENDING_VISUAL_SLOTS as PENDING_SLOTS } from './pending-visual-slots.mjs';
const failures = [];
const assert = (condition, message) => {
console.log(` ${condition ? '✓' : '✗'} ${message}`);
if (!condition) {
failures.push(message);
}
};
const browser = await chromium.launch({ channel: 'chrome', headless: true });
const page = await browser.newPage({
viewport: { width: 1280, height: 2400 },
deviceScaleFactor: 1,
});
// Wave-close gates: every same-origin request resolves, and the page's
// total image weight stays inside the budget. External fallbacks (e.g.
// twenty-icons.com favicons) are by-design misses and out of scope.
const sameOriginNotFound = [];
let totalImageBytes = 0;
page.on('response', (response) => {
const url = response.url();
const isSameOrigin = url.startsWith(BASE_URL.replace(/\/$/, ''));
if (isSameOrigin && response.status() === 404) {
sameOriginNotFound.push(new URL(url).pathname);
}
if (isSameOrigin && /\.(webp|png|jpe?g|svg|gif|avif)(\?|$)/.test(url)) {
void response
.body()
.then((body) => {
totalImageBytes += body.length;
})
.catch(() => {});
}
});
await page.goto(BASE_URL, { waitUntil: 'networkidle', timeout: 240000 });
const slots = await page.evaluate(() =>
[...document.querySelectorAll('[data-illustration]')].map((el) =>
el.getAttribute('data-illustration'),
),
);
console.log(`── sweep over ${slots.length} slots: ${slots.join(', ')}`);
const evidenced = new Set();
let maxCount = 0;
const pageHeight = await page.evaluate(
() => document.documentElement.scrollHeight,
);
for (let y = 0; y <= pageHeight; y += 600) {
// eslint-disable-next-line no-await-in-loop
await page.evaluate((scrollY) => window.scrollTo(0, scrollY), y);
// eslint-disable-next-line no-await-in-loop
await page.waitForTimeout(450);
// eslint-disable-next-line no-await-in-loop
const state = await page.evaluate(() => ({
count: window.__visualRuntimeTest?.getActiveContextCount() ?? -1,
withCanvas: [...document.querySelectorAll('[data-illustration]')]
.filter((el) => el.querySelector('canvas'))
.map((el) => el.getAttribute('data-illustration')),
}));
maxCount = Math.max(maxCount, state.count);
state.withCanvas.forEach((name) => evidenced.add(name));
}
const liveCap = await page.evaluate(
() => window.__visualRuntimeTest?.getContextCap() ?? -1,
);
assert(liveCap > 0, `context cap readable from instrumentation (${liveCap})`);
assert(
maxCount <= liveCap,
`context count never exceeds cap (max ${maxCount} ≤ ${liveCap})`,
);
const missing = slots.filter(
(name) => !evidenced.has(name) && !PENDING_SLOTS.has(name),
);
const pending = slots.filter((name) => PENDING_SLOTS.has(name));
if (pending.length > 0) {
console.log(
` - pending (AppPreview wave, expected empty): ${pending.join(', ')}`,
);
}
const pendingButLive = pending.filter((name) => evidenced.has(name));
assert(
pendingButLive.length === 0,
`PENDING_SLOTS stays honest${pendingButLive.length ? ` (now live, remove: ${pendingButLive.join(', ')})` : ''}`,
);
assert(
missing.length === 0,
`every slot evidenced during sweep${missing.length ? ` (missing: ${missing.join(', ')})` : ''}`,
);
// Idle: park between sections (helped stage top is canvas-free), wait out
// the dispose grace, then expect the rAF loops quiet.
await page.evaluate(() => window.scrollTo(0, 0));
await page.waitForTimeout(6000);
const ticksBefore = await page.evaluate(
() => window.__visualRuntimeTest?.getRafTicks() ?? -1,
);
await page.waitForTimeout(2000);
const ticksAfter = await page.evaluate(
() => window.__visualRuntimeTest?.getRafTicks() ?? -1,
);
const heroAreaCanvases = await page.evaluate(
() => document.querySelectorAll('canvas').length,
);
// The top of the page has live visuals only if slots sit in range; allow
// their loops, but if no canvases are mounted the loops must be silent.
if (heroAreaCanvases === 0) {
assert(
ticksAfter - ticksBefore <= 2,
`rAF idle with nothing mounted (${ticksAfter - ticksBefore} ticks/2s)`,
);
} else {
console.log(
` - idle check skipped: ${heroAreaCanvases} canvases legitimately in range at page top`,
);
}
// Composition checks — both encode bug classes the user caught by eye:
// a 40px off-center hero body (box left-aligned inside a centered grid)
// and muted ink silently bound to black-70 instead of the old site's 60.
await page.evaluate(() => window.scrollTo(0, 0));
await page.waitForTimeout(600);
const composition = await page.evaluate(() => {
const center = window.innerWidth / 2;
const measure = (element) => {
const rect = element.getBoundingClientRect();
return Math.round(rect.left + rect.width / 2 - center);
};
const h1 = document.querySelector('h1');
const heroBody = h1?.closest('div')?.parentElement?.querySelector('p');
const stage = document.querySelector('[data-mockup-stage]');
const styles = getComputedStyle(document.body);
return {
h1Offset: h1 ? measure(h1) : null,
bodyOffset: heroBody ? measure(heroBody) : null,
stageOffset: stage ? measure(stage) : null,
inkMuted: styles.getPropertyValue('--ink-muted').trim(),
black60: styles.getPropertyValue('--color-black-60').trim(),
};
});
for (const [label, offset] of [
['h1', composition.h1Offset],
['hero body', composition.bodyOffset],
['mockup stage', composition.stageOffset],
]) {
assert(
offset !== null && Math.abs(offset) <= 1,
`${label} centered (offset ${offset}px)`,
);
}
assert(
composition.inkMuted !== '' && composition.inkMuted === composition.black60,
`muted ink binds to black-60 (got "${composition.inkMuted}" vs "${composition.black60}")`,
);
// Universal eyebrow-to-heading measure: 24px everywhere the intro
// grammar runs (testimonials' 56px carousel header is the authored
// old-parity exception, asserted so drift is still caught).
const readEyebrowGaps = (target) =>
target.evaluate(() =>
[...document.querySelectorAll('p')]
.filter(
(el) =>
el.querySelector('span[aria-hidden]') &&
el.textContent.trim().length < 40,
)
.map((row) => {
const block = row.parentElement;
const heading =
block.querySelector('h1, h2, h3') ??
block.parentElement.querySelector('h1, h2, h3');
const rect = row.getBoundingClientRect();
const parentRect = row.parentElement.getBoundingClientRect();
const centered =
getComputedStyle(row.parentElement).textAlign === 'center';
return {
label: row.textContent.trim(),
gap: heading
? Math.round(heading.getBoundingClientRect().top - rect.bottom)
: null,
// Centered intros must center the row as one unit (a grid
// ancestor blockifying the inline-flex eyebrow broke this once).
misaligned:
centered &&
Math.abs(
rect.left - parentRect.left - (parentRect.right - rect.right),
) > 2,
};
}),
);
const EYEBROW_GAP_PX = 24;
const TESTIMONIALS_EYEBROW = 'They are the real sales';
const TESTIMONIALS_GAP_PX = 56;
const readSectionRhythms = (target) =>
target.evaluate(() =>
[...document.querySelectorAll('section')].map((el) => {
const previous = el.previousElementSibling;
return {
rhythm: el.getAttribute('data-rhythm'),
paddingTop: getComputedStyle(el).paddingTop,
paddingBottom: getComputedStyle(el).paddingBottom,
// Same-scheme neighbors share one surface: the second section
// drops its top padding (flush neighbors contribute no spacing,
// so they never trigger the collapse).
collapsesTop:
previous?.tagName === 'SECTION' &&
previous.getAttribute('data-scheme') ===
el.getAttribute('data-scheme') &&
previous.getAttribute('data-rhythm') !== 'flush',
};
}),
);
const homeSections = await readSectionRhythms(page);
const homeEyebrows = await readEyebrowGaps(page);
// The product page joins the 404 net (its slots/composition have their
// own battery; the sweep guards asset integrity site-wide).
await page.goto(`${BASE_URL.replace(/\/$/, '')}/product`, {
waitUntil: 'networkidle',
timeout: 240000,
});
const productPageHeight = await page.evaluate(
() => document.documentElement.scrollHeight,
);
for (let y = 0; y <= productPageHeight; y += 600) {
// eslint-disable-next-line no-await-in-loop
await page.evaluate((scrollY) => window.scrollTo(0, scrollY), y);
// eslint-disable-next-line no-await-in-loop
await page.waitForTimeout(350);
}
// Universal section rhythm: every <section> is a SectionShell with a
// rhythm class, and its block padding is exactly the token value
// (md tier at this viewport — src/tokens/rhythm.ts, spacing unit 4px).
const RHYTHM_MD_PADDING = {
hero: '48px',
section: '64px',
flush: '0px',
spacious: '120px',
};
const offRhythmSections = [
...homeSections,
...(await readSectionRhythms(page)),
];
const allEyebrows = [...homeEyebrows, ...(await readEyebrowGaps(page))];
const eyebrowViolations = allEyebrows.filter(
(eyebrow) =>
eyebrow.misaligned ||
(eyebrow.label.startsWith(TESTIMONIALS_EYEBROW)
? eyebrow.gap !== TESTIMONIALS_GAP_PX
: eyebrow.gap !== EYEBROW_GAP_PX),
);
assert(
allEyebrows.length > 0 && eyebrowViolations.length === 0,
`every eyebrow sits ${EYEBROW_GAP_PX}px above its heading, centered intros centered (${allEyebrows.length} eyebrows${eyebrowViolations.length > 0 ? `; violations: ${JSON.stringify(eyebrowViolations)}` : ''})`,
);
const rhythmViolations = offRhythmSections.filter(
(section) =>
!(section.rhythm in RHYTHM_MD_PADDING) ||
section.paddingTop !==
(section.collapsesTop ? '0px' : RHYTHM_MD_PADDING[section.rhythm]) ||
section.paddingBottom !== RHYTHM_MD_PADDING[section.rhythm],
);
assert(
offRhythmSections.length > 0 && rhythmViolations.length === 0,
`every section carries token rhythm padding (same-scheme seams collapsed) (${offRhythmSections.length} sections${rhythmViolations.length > 0 ? `; violations: ${JSON.stringify(rhythmViolations)}` : ''})`,
);
assert(
sameOriginNotFound.length === 0,
`no same-origin 404s during the sweep${sameOriginNotFound.length > 0 ? ` (${sameOriginNotFound.join(', ')})` : ''}`,
);
// Budget recalibrated at the product wave close: both pages fully
// landed measure ~1.79MB worst-case (lazy-load timing varies runs by
// ~350KB); headroom covers that variance, not new art.
const IMAGE_BYTES_BUDGET = 2_100_000;
assert(
totalImageBytes > 0 && totalImageBytes <= IMAGE_BYTES_BUDGET,
`total same-origin image bytes within budget (${Math.round(totalImageBytes / 1024)}KB ≤ ${Math.round(IMAGE_BYTES_BUDGET / 1024)}KB)`,
);
await browser.close();
if (failures.length > 0) {
console.error(`visual-sweep: FAILED (${failures.length})`);
process.exit(1);
}
console.log('visual-sweep: OK');