465eb05aaf
twenty-website had accumulated structural problems that were cheaper to fix by rebuilding than to refactor in place: - Drift had no guardrails. Values were matched at call sites instead of single-sourced, so things silently diverged — e.g. the radius token base was wrong for days (every radius() consumer rendered double) because nothing measured it against the old site's CSS variables. - A whole tree escaped quality checks. src/lib/ (~9.8k lines) was never format-checked, because oxfmt silently ignores directories named lib/. - Inconsistent rhythm. Hero spacing varied 24–88px between pages (CEO-flagged), because section spacing wasn't a token. - Over-extraction. -config.ts sprawl pulled single-component configuration out into the wrong place. The goal: a ground-up rebuild where drift is structurally impossible, held to a Linear / Railway / Notion / Attio quality bar. The old site is treated as source of intent only — nothing is blindly ported; every piece is re-decided and A/B-verified. **Rebuild** A full rebuild on Next 16 + Turbopack + Linaria (zero-runtime CSS), ~1,100 files. Marketing pages (home, product, pricing, partners + marketplace, customers/case-studies, why-twenty, releases, legal), the interactive AppPreview product mockup, the platform/visuals WebGL system (engine + rigs, three code-split off every initial chunk), and the standalone /halftone studio (the dev tool that generates the site's halftone art — engine, exporters, and full UI ported as an isolated island). **Architecture & guarantees** - Parity by construction. src/tokens/definitions.ts is the only file with raw values; the :root CSS-variable block is generated from it at build time and accessors derive var names through the same helpers — derived alpha tokens appear in served CSS without ever being hand-written. - Mobile-first by API shape. mediaUp() is the only media helper (no max-width helper exists, on purpose). - Section rhythm is a token (RHYTHM.section) — the hero-spacing inconsistency class is fixed by construction. - Fluid type ramps interpolate font-size and line-height between designed endpoints [390px → md]; TYPE_SCALE is the single source. - three.js never enters an initial chunk — confined to platform/visuals heavy zones, reached only via dynamic(ssr:false), enforced by check-visual-bundle.
213 lines
6.5 KiB
JavaScript
213 lines
6.5 KiB
JavaScript
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);
|