47710908a8
This PR is the result of a critical architectural review of
`twenty-website-new` and the
follow-on cleanup work. It does not touch any other package. Scope is
everything except
the enterprise/billing routes (deferred to a separate PR) and CI wiring
(also deferred).
### Why
The package had grown organically: ad-hoc scroll/motion/halftone code
per section,
WebGL renderers instantiated raw, sections without a shared shape
contract, route-scoped
files leaking across layers, public API routes without rate limiting /
timeouts /
schema validation, unbounded module-level caches in visual code,
drag/resize handlers
re-rendering React 60x/sec, no security headers, and a Lottie
scroll-mapping that would
silently drift if the asset got re-exported. We needed contracts and
primitives in
place before further work (i18n, decomposition of the giant visual
files, MDX migration
of customer/legal pages) is safe to do.
### What changed
**Layering and contracts (now enforced, not just documented).**
- New layering rule: `app → sections → lib → design-system / theme /
icons`.
Cross-section reuse goes through `lib/`. Flipped the design-system
import rule
from warn to error.
- Extracted shared primitives into `lib/`: `scroll`, `motion`,
`halftone`, `customers`,
`partner-application`, `api`, `seo`, `semver`, `community`,
`visual-runtime`.
- Lifted route-scoped data/types out of `app/` into `lib/` +
`sections/`.
- Section shape contract: every section exposes a single compound export
from
`components/index.ts(x)`; non-leaf sections own the outer `<section>`
from
`Root.tsx`; named slots are matched by `displayName` (no
`Children.toArray`
positional indexing). Enforced by `scripts/check-section-shape.mjs`.
- WebGL boundary: `new THREE.WebGLRenderer(...)` is forbidden outside
`src/lib/visual-runtime/`. Everything goes through
`createSiteWebGlRenderer`,
which enforces the site-wide context cap, the
`NEXT_PUBLIC_DISABLE_HEAVY_VISUALS`
kill switch, and GPU/power-preference defaults. Enforced by
`scripts/check-boundaries.mjs` with per-line
`boundary-allow-next-line:<rule-id>`
escape hatches and stale-directive detection.
**Design system grew to cover real cases.**
- Added Modal, Form, and Layout primitives (Stack / Inline / Grid) so
sections stop
reinventing them. Built on `@base-ui/react` for accessibility + focus
management.
**Public API hardening (non-enterprise).**
- New `lib/api/` primitives: `createRateLimiter` (in-memory token
bucket),
`fetchWithTimeout`, `readJsonBody` (Zod-validated). Applied to
newsletter,
community, and partner-application routes. `/api/partner-application`
specifically
got a per-IP rate limit, body cap, and timeout.
**Performance.**
- `DraggableTerminal` and `DraggableAppWindow`: `pointermove` now
mutates `transform`
(via `translate3d`) and `width`/`height` directly on the DOM ref. React
state
commits only on `pointerup`. Eliminates per-frame re-renders during
interaction.
- `createBoundedFailureCache` (FIFO, 256 entries) replaces unbounded
module-level
failure caches in four visual components. Bounds memory growth from bad
asset URLs.
**Lottie frame-map guard.**
- `dotlottie-react`'s `player.totalFrames` returns a raw float (`op -
ip`), not an
integer. The HomeStepper scroll → frame map is keyed to the authored
timeline,
so silent drift would desync every step boundary.
- Reads now `Math.floor(player.totalFrames)` consistently.
- `scripts/check-lottie-frames.mjs` extracts `op - ip` from
`public/lottie/stepper/stepper.lottie` at build time and asserts it
against
`HOME_STEPPER_LOTTIE_EXPECTED_TOTAL_FRAMES`. If anyone re-exports the
Lottie,
the build fails until both that constant and every `STEP_*_END` are
updated together.
**Security headers (`next.config.ts`).**
- HSTS, `X-Content-Type-Options: nosniff`, `Referrer-Policy:
strict-origin-when-cross-origin`,
`Permissions-Policy` (camera/mic/geolocation/payment off),
`X-Frame-Options: DENY`,
`Content-Security-Policy: frame-ancestors 'none'`.
- Full CSP intentionally deferred until we enumerate all third-party
origins
(Cal.com, Stripe, GitHub avatars, twenty-icons.com, etc.).
**Build / config quirks documented in code.**
- `tsconfig.json` is standalone (does NOT extend the monorepo base) —
Next.js +
React Compiler require options that conflict with the base config.
- `sharp` moved from `devDependencies` to `dependencies` so production
image
optimization works.
### What's deliberately NOT in this PR
- **Enterprise / billing routes** — open redirect on
`/api/enterprise/checkout`,
indefinite-bearer JWT, non-idempotent Stripe seat updates, unpinned
Stripe
`apiVersion`, missing webhook reconciliation, inconsistent error
envelope.
Going out as a separate, security-focused PR.
- **CI workflow for `twenty-website-new`** (`lint` / `typecheck` /
`test` / `build`
targets) — separate follow-up PR.
- **i18n via Lingui** — decision made (we need internationalization and
we already
use Lingui in `twenty-front` / `twenty-emails`); 4-phase migration plan
exists
but does not land here.
- **Decomposition of giant visual files** (HomeVisual, ThreeCards
visuals) — blocked
on the i18n landing first; otherwise we'd rebase the world twice.
- **Customer / legal pages → MDX** — same reason.
- **Selective memoization pass** — needs browser profiling, not blind
`useMemo`.
- **Pre-existing lint errors / typecheck noise** (~44 errors, ~41
warnings, plus
generated Next.js types and `@ts-nocheck` files) are unchanged. The
cleanup
did not introduce new ones.
### Test plan
- [ ] `yarn install`
- [ ] `yarn nx run twenty-website-new:dev` — homepage, customers,
partner,
enterprise activate, blog, why-twenty, plans/pricing, legal pages
render.
- [ ] HomeStepper: scroll through, confirm the Lottie animation lines up
with
every step boundary. Console must NOT log a `totalFrames` mismatch.
- [ ] HomeVisual: drag and resize the terminal + app window; verify
smoothness
(no per-frame React re-renders) and that final position/size persists on
release.
- [ ] Public API endpoints: hit `/api/newsletter`, `/api/community`,
`/api/partner-application` with bad payloads → expect 4xx with Zod
errors,
not 500s. Hammer `/api/partner-application` past the per-IP limit → 429.
- [ ] Response headers on any page include HSTS, nosniff, referrer
policy,
permissions policy, X-Frame-Options, and `frame-ancestors 'none'` CSP.
- [ ] `yarn nx run twenty-website-new:lint` — error/warning count must
not exceed
the pre-existing baseline.
- [ ] `yarn nx run twenty-website-new:typecheck` — same baseline rule.
- [ ] `node packages/twenty-website-new/scripts/check-boundaries.mjs` —
passes,
no stale directives.
- [ ] `node packages/twenty-website-new/scripts/check-section-shape.mjs`
— passes.
- [ ] `node packages/twenty-website-new/scripts/check-lottie-frames.mjs`
— passes.
- [ ] `yarn nx run twenty-website-new:build` — green, including the
three checks
above if wired into the build target.
119 lines
3.5 KiB
JavaScript
119 lines
3.5 KiB
JavaScript
#!/usr/bin/env node
|
|
import { execFile } from 'node:child_process';
|
|
import { readFile } from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { promisify } from 'node:util';
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
const ROOT = path.resolve(__dirname, '..');
|
|
|
|
const LOTTIE_PATH = path.join(
|
|
ROOT,
|
|
'public',
|
|
'lottie',
|
|
'stepper',
|
|
'stepper.lottie',
|
|
);
|
|
const ANIMATION_ENTRY = 'animations/main.json';
|
|
const FRAME_MAP_PATH = path.join(
|
|
ROOT,
|
|
'src',
|
|
'sections',
|
|
'HomeStepper',
|
|
'utils',
|
|
'home-stepper-lottie-frame-map.ts',
|
|
);
|
|
|
|
const EXPECTED_CONSTANT_REGEX =
|
|
/export\s+const\s+HOME_STEPPER_LOTTIE_EXPECTED_TOTAL_FRAMES\s*=\s*(\d+)\s*;/;
|
|
|
|
function fail(message) {
|
|
// eslint-disable-next-line no-console
|
|
console.error(`\n check-lottie-frames: ${message}\n`);
|
|
process.exitCode = 1;
|
|
}
|
|
|
|
async function readExpectedTotalFrames() {
|
|
const source = await readFile(FRAME_MAP_PATH, 'utf8');
|
|
const match = source.match(EXPECTED_CONSTANT_REGEX);
|
|
if (match === null) {
|
|
throw new Error(
|
|
`couldn't find HOME_STEPPER_LOTTIE_EXPECTED_TOTAL_FRAMES in ${path.relative(ROOT, FRAME_MAP_PATH)}. ` +
|
|
'Has the constant been renamed? Update both the TS file and the regex in this script.',
|
|
);
|
|
}
|
|
return Number.parseInt(match[1], 10);
|
|
}
|
|
|
|
async function readActualTotalFrames() {
|
|
let stdout;
|
|
try {
|
|
const result = await execFileAsync(
|
|
'unzip',
|
|
['-p', LOTTIE_PATH, ANIMATION_ENTRY],
|
|
{ maxBuffer: 32 * 1024 * 1024, encoding: 'buffer' },
|
|
);
|
|
stdout = result.stdout;
|
|
} catch (error) {
|
|
if (error?.code === 'ENOENT') {
|
|
throw new Error(
|
|
'`unzip` binary not found on PATH. Install it (Debian/Ubuntu: `apt install unzip`, macOS ships with it) — ' +
|
|
'this script needs it to read the Lottie animation JSON.',
|
|
);
|
|
}
|
|
throw new Error(
|
|
`failed to extract ${ANIMATION_ENTRY} from ${path.relative(ROOT, LOTTIE_PATH)}: ${error?.message ?? error}`,
|
|
);
|
|
}
|
|
|
|
let animation;
|
|
try {
|
|
animation = JSON.parse(stdout.toString('utf8'));
|
|
} catch (error) {
|
|
throw new Error(
|
|
`${ANIMATION_ENTRY} inside the .lottie file is not valid JSON: ${error?.message ?? error}`,
|
|
);
|
|
}
|
|
|
|
const ip = animation?.ip;
|
|
const op = animation?.op;
|
|
if (typeof ip !== 'number' || typeof op !== 'number') {
|
|
throw new Error(
|
|
`${ANIMATION_ENTRY} is missing numeric \`ip\` / \`op\` fields ` +
|
|
`(got ip=${JSON.stringify(ip)}, op=${JSON.stringify(op)}). ` +
|
|
'Has the Lottie schema changed?',
|
|
);
|
|
}
|
|
return Math.floor(op - ip);
|
|
}
|
|
|
|
async function main() {
|
|
const [expected, actual] = await Promise.all([
|
|
readExpectedTotalFrames(),
|
|
readActualTotalFrames(),
|
|
]);
|
|
|
|
if (expected !== actual) {
|
|
fail(
|
|
`Lottie totalFrames mismatch — expected ${expected} (per HOME_STEPPER_LOTTIE_EXPECTED_TOTAL_FRAMES), ` +
|
|
`got ${actual} from ${path.relative(ROOT, LOTTIE_PATH)}.\n ` +
|
|
' The home-stepper scroll → frame map is keyed to the authored timeline; ' +
|
|
'either:\n 1. revert the Lottie re-export, or\n 2. update HOME_STEPPER_LOTTIE_EXPECTED_TOTAL_FRAMES ' +
|
|
'and the STEP_*_END anchors in home-stepper-lottie-frame-map.ts together.',
|
|
);
|
|
return;
|
|
}
|
|
|
|
// eslint-disable-next-line no-console
|
|
console.log(
|
|
`check-lottie-frames: OK (stepper.lottie totalFrames = ${actual}).`,
|
|
);
|
|
}
|
|
|
|
main().catch((error) => {
|
|
fail(error?.message ?? String(error));
|
|
});
|