Skip to content

Landing quality tooling

A developer who changes layout, motion, copy or assets on the landing page and has to prove the change did not break it. Also a reviewer who needs to know what a green npm run audit does and does not guarantee.

  • Six scripts in scripts/ drive a real Chromium (Playwright) against an already running server. The address is SHOTS_BASE, default http://localhost:3000.
  • npm run audit is the only one with hard pass/fail rules for layout and motion. npm run contrast has hard rules for colour pairs. npm run shots fails only on page errors and horizontal overflow.
  • npm run lighthouse and npm run js-weight only print numbers. No script fails on a Lighthouse score, an LCP value or a JavaScript size. Those budgets are conventions.
  • The audit submits one real request through the form. Run it against a server with no LEADS_* and no TELEGRAM_* variables, or a request named “Audit” reaches the real requests group.
  • Every script runs under a watchdog: one minute without progress prints the stalled step and exits with code 1.
  • Output goes to shots/, which is git-ignored.
Command File Server it needs Fails the run when Writes
npm run audit scripts/audit.mjs dev or production any check below fails, or a page throws a JavaScript error shots/reduced-motion-1280.png, a report on stdout
npm run shots scripts/shots.mjs dev or production a page error, horizontal overflow, or the form still showing after a stored “sent” flag shots/*.png, shots/hero/, shots/sheets/
npm run film scripts/scroll-film.mjs dev or production only on a hang or a crash shots/film/
npm run lighthouse scripts/lighthouse.mjs production build only if Lighthouse itself fails or runs past 120 s shots/lighthouse/
npm run js-weight scripts/js-weight.mjs production build only on a hang stdout only
npm run contrast scripts/contrast.mjs none a colour pair is below its minimum in either theme a Markdown table on stdout

Run all of them from the repository root: the audit and the contrast script read source files by relative path.

Input Used by Meaning
SHOTS_BASE audit, shots, film, lighthouse, js-weight Base URL of the running server. Default http://localhost:3000
STEP_TIMEOUT audit, shots, film, js-weight Watchdog limit per step in milliseconds. Default 60000
?step=<name> audit, shots A URL parameter that freezes the hero scene on one step. Nothing on the site links to it
--only= audit, shots Different meaning per script, see below

PowerShell sets an environment variable for one command like this:

Terminal window
$env:SHOTS_BASE = "http://localhost:3000"; npm run audit

useSceneMachine reads ?step= once on load (src/components/hero/scene/useSceneMachine.ts). Accepted values:

Value Frame
retract the opening move: stickers flying back into the phone
start, qr, consent, outfit, photo, delivered, pack, idle that step of the cycle
opening the very first frame: the assembled pack, with the tablet and phone not drawn

The scene stays on that step. The small animations inside the step (the finger tap, the 3-2-1 countdown, the flash) still play once and settle.

The audit makes the checks a screenshot cannot. It opens the page many times at different widths, in different locales, themes and motion preferences, measures the DOM, and collects every violation. At the end it prints each passed group with ✓ and each problem under Problems:. Any problem sets exit code 1.

  • --only=uz or --only=theme — run only the first two groups (languages and themes) and exit. In the code both values behave identically: both groups run, then the script stops.
  • The audit reads uzPublished from src/content/site.ts and expects the running build to match it.
Terminal window
npm run audit
npm run audit -- --only=uz
  • It sends one real request. The form check fills in name Audit, a Telegram handle, a phone number and the comment audit run, waits past the minimum fill time and submits. Against a server with delivery variables set, that request is delivered. Use a local server without LEADS_* and TELEGRAM_*.
  • It takes several minutes. One step alone waits 112 seconds on purpose, to watch the hero scene go to rest after three cycles.
  • Scroll-driven checks can be skipped. If the Chromium build has no animation-timeline support, the report says SKIPPED for that group instead of failing.
Dimension Values
Widths 360, 768, 1280, 1920; 1263 once (1280 minus a Windows scrollbar)
Heights 900 by default; 780 for phone checks; 800, 1000 and 1080 in single checks; 500 for the below-the-fold check
Locales ru, uz, en for structure, header, headline, overflow, mock text size and amber buttons; many motion checks run on /ru only
Themes dark and light for theme, contrast and “devices behind the pack”
Motion no-preference and reduce
Check Rule
/uz reachable always answers 200
noindex on /uz present exactly when uzPublished is false
hreflang="uz" on /ru present exactly when uzPublished is true
/sitemap.xml lists /uz exactly when uzPublished is true
Redirect from / eleven header combinations, including four Uzbek Accept-Language variants that must land on /ru
Cookie NEXT_LOCALE=uz leads to /uz only when published; otherwise the header decides
Language switch shows ru,uz,en when published, ru,en otherwise; on /uz itself it always shows all three
Browser flow an English browser opens /, clicks UZ, reloads /, and must now land on /uz
Check Rule
Default dark background whatever prefers-color-scheme says
Toggle sets data-theme="light", stores ai-sticker-pack.theme, sets aria-pressed="true", survives a reload, toggles back
No flash with all JavaScript bundles blocked, a visitor who stored “light” gets a light first paint (a pixel is sampled from a screenshot)
Contrast all 17 token pairs from scripts/lib/contrast.mjs, read from the live page in both themes and inside a device frame
  • Exactly one <h1>; heading levels never skip.
  • <html lang> equals the locale.
  • No visible <a> or <button> without text, aria-label or aria-labelledby.
  • The hero scene is role="img" with an aria-label of at least 40 characters.
Element 360 768 1280 1920
H1, maximum lines 4 4 3 3
Hero subtitle, maximum lines 4 4 2 2

Both are measured in all three locales. A second pass blocks every .woff2 request and checks that the H1 has the same line count in the fallback font, so the font swap moves nothing.

Also in this group: the kiosk title inside the hero tablet is one line and not clipped at 768, 1280 and 1920 in all locales; mock-up text in the lit device is at least 12 px at 1280 in every step and locale.

At 1280 and 1920:

  • header CTA and hero CTA have identical x, width and height in ru, uz and en;
  • the language switch is identical across published locales;
  • each navigation link’s centre drifts at most 1 px between locales;
  • no CTA or navigation text is clipped.

This is what forces the longest language to define slot widths. See i18n and copy.

  • No pure black (#000, rgb(0,0,0) at full alpha) in any stylesheet rule, shadows excepted.
  • No purple: saturation above 0.35 with hue 255–320.
  • Telegram’s brand blues and any saturated blue (saturation above 0.45, hue 190–255) only in rules whose selector contains .tg.
  • Walking down the page half a screen at a time at 360 and 1280: never more than one amber-filled button or link visible.

No rule with :hover, :active or :focus-within in its selector may set a layout property (width, height, padding, margin, border width, position offsets, font size, gap, flex and grid sizing, and similar). Hover may change colour, shadow, translate, scale and rotate only.

Check Rule
Scene runs on screen at least four different steps seen in nine seconds
Hidden tab the scene step stops changing and no infinite animation is running
Rest after three cycles after 112 s the step is pack and stays pack; only animations whose target class contains spot or cta-breathe may still loop
Below the fold scrolled to the bottom, the scene step stops and no infinite animation runs
Stepper six named buttons; clicking the fourth goes to outfit and sets aria-current="step"; a resting mouse holds the scene; leaving resumes it
Sheen the amber button’s highlight crosses once on hover; never under reduced motion
Locale switch exactly one view transition, which resolves in under 1100 ms (the new page, not the 1.2 s safety timer); scroll position moves by at most 80 px; no view transition under reduced motion
Tilt devices lean at least 1° and at most 6° towards the mouse, return to zero when it leaves, never react to touch
Reduced motion scene on pack, no stk-slap, two hero screenshots three seconds apart are byte-identical, no infinite animation, reading line hidden, no scroll-driven drift, burst not drawn
  • First frame. The server HTML already contains data-scene-step="pack" and data-pack="in". In the browser, 12 stk-slap animations start at load, all 12 stickers are fully opaque within 2.4 s, tablet and phone are at opacity ≤ 0.04 (and scale 0.96 at 768 and wider), and the story has left pack 3.6 s later.
  • Window edge. At 1263, 1280 and 1920, in eight compositions, every visible tablet, phone and sticker is at least 24 px from either window edge.
  • Nothing under the phone. At 360, 768, 1280 and 1920, in six steps: when the phone is in front, it overlaps none of the tablet’s words, the Start button, the QR, or the outfit cards by more than 1 px.
  • One step, one device, one action. At 1280 and 360, per step: the expected focus (tablet, scan or phone), the device in focus at opacity ≥ 0.99, the other at ≤ 0.45, at most one finger, the finger not on the letters of its label, the stepper states, a unique sentence per step, an empty tablet screen behind the phone, no beam or camera frame while scanning.
  • Gaps. At 1280 and 1920 the devices start at most 40 px under the step sentence; the caption sits 4–40 px under the tablet.
  • Behind the pack. On pack and idle, in both themes: tablet and phone at opacity ≤ 0.12 and scale 0.96.
  • Hero size. At 1280×900, 1280×800 and 1920×1080: the hero is at least one screen and at most one screen plus 140 px tall, the scene takes at least 55 % of the content width and does not run off the screen.
  • Phone first screen. At 360×780 the hero CTA’s bottom edge is inside the first screen in all locales.
  • Chosen outfit. The scene’s guest picks the black hoodie from the top row, and the final pack is the owner’s hoodie pack.
  • Telegram mock-ups. At four widths: every bubble, tail and inline key is at least 7.5 px from the screen side, nothing is cut by the chat header or input bar, every message is fully opaque at rest, and the chat column is not masked.

Four widths × three locales × three scene steps: scrollWidth must not exceed clientWidth.

Run once, at 1280, on /ru:

  1. Fields are in the order name, telegram, phone, date, guests, comment, each full width. The phone field is type="tel", inputmode="tel", autocomplete="tel".
  2. An empty submit shows exactly three errors (name, Telegram, phone) and focuses the name field.
  3. A two-letter handle and a phone without + show two errors and focus the Telegram field.
  4. Offline: an alert appears, the form stays, typed text is kept.
  5. A valid submit shows the “sent” screen, stores an ISO timestamp under ai-sticker-pack.request-sent, shows the “new request tomorrow” line, removes the form.
  6. Ten burst stickers are visible shortly after sending and all invisible 1.85 s after it; the burst does not replay on reload.
  7. The stored flag: 23 hours old shows the “sent” screen; 25 hours old, a value without a date, or a future date shows the form and removes the flag.

Separately, cta lands on the form: clicking the hero CTA at 1280 and 360 leaves the top of #request between −4 and 140 px from the top of the window.

  • Setup timeline. The line’s progress is below 0.2 before the section and above 0.95 after it; stages light in order; under reduced motion the line is whole and all stages lit.
  • “Why” chat. Two messages before it is on screen, plays to six, holds while off screen, stops all animations when finished, keeps the last message whole above the input bar, respects a manual scroll up; hearts toggle by click and by Enter; the link scrolls to the form; whole under reduced motion.
  • Showcase. Six people; keyboard and mouse switching; the pack box height changes by at most 1 px during the turn; kiosk rows are men:3,women:3 with no outfit twice and no card in the tab order.
  • Sticker ribbon. Still off screen; two running rows at 1280 and one at 360; paused under the mouse and in a hidden tab; no animation under reduced motion.
  • Scroll-driven. The reading line goes from scale 0 to 1; at least three mock-ups ride a view timeline and stand in place, opaque, at mid-screen; no mock-up below the fold is half-faded.

At 360 and 1280 the page is loaded and scrolled top to bottom in 400 px steps while a layout-shift observer sums the entries. The run fails above 0.001.

Screenshots, so layout is reviewed by looking at it.

  1. Delete its own previous output: shots/hero/, shots/sheets/, and the page and section files of the locales and widths about to be retaken.
  2. Load one throwaway page to warm the browser (a cold headless browser holds CSS animations still on its first page).
  3. For each theme, locale and width: open the page with reduced motion off, wait 1.2 s, shoot the first screen.
  4. Dark theme only: for each scene step, open ?step=<name>, wait 3.4 s, shoot. Then compose all those frames into one captioned contact sheet.
  5. Record horizontal overflow as a failure.
  6. Scroll the whole page down and back so every reveal has fired, then shoot the full page and each main > section separately.
  7. Click submit on the empty form and shoot the three errors.
  8. Open a second page with the “sent” flag pre-set in localStorage and shoot the “sent” screen.
Dimension Values
Locales ru, uz, en
Widths 360, 768, 1280, 1920
Viewport height 780 below 700 px width, otherwise 900; device scale factor 1
Themes dark and light
Scene steps at 360 and 1280 all nine: retract, start, qr, consent, outfit, photo, delivered, pack, idle
Scene steps at 768 and 1920 four: start, outfit, delivered, pack
Flag Example Effect
--only= --only=1280 or --only=360,1280 widths to shoot
--locale= --locale=uz locales to shoot
--theme= --theme=light themes to shoot
--full=false skip full-page, section, error and “sent” shots; hero only
Terminal window
npm run shots -- --only=1280 --locale=uz --theme=light
Path Content
shots/hero/<locale>-<width>-first.png first screen 1.2 s after load
shots/hero/<locale>-<width>-<nn>-<step>.png first screen frozen on a step
shots/sheets/hero-<locale>-<width>.png all step frames on one sheet
shots/<locale>-<width>-page.png whole page
shots/<locale>-<width>-sNN.png one section
shots/<locale>-<width>-errors.png form after an empty submit
shots/<locale>-<width>-sent.png “sent” screen

Light-theme files carry -light after the width, for example ru-1280-light-s03.png.

Records a slow scroll, so motion is watched instead of imagined.

  1. Delete and recreate shots/film/.
  2. For each of four combinations — ru 1280, uz 1280, uz 360, en 1280 — open the page with video recording on.
  3. Wait 32 s on the hero: the opening and one whole scene cycle.
  4. Scroll down by 0.6 of the viewport at a time with smooth scrolling. At each stop take three screenshots, after 120, 260 and 700 ms.
  5. Close the context, rename the video.

Output: shots/film/<locale>-<width>.webm and numbered frames shots/film/<locale>-<width>-NNN.png. The script takes no flags.

  1. Needs a production server: npm run build, then npm run start. Build with NEXT_PUBLIC_SITE_URL equal to the address you serve from.
  2. Runs npx -y lighthouse@12 with Playwright’s Chromium in headless mode, once per run configuration.
  3. Default runs: ru mobile, uz mobile, en mobile, ru desktop. Narrow it with --locale=<code> and --form=mobile|desktop.
  4. Each run is limited to 120 s by execFileSync’s own timeout, because the call is synchronous and the watchdog cannot interrupt it.
  5. Prints the four category scores, then FCP, LCP, TBT, CLS and Speed Index, then every failing weighted audit outside the Performance category.

Output: shots/lighthouse/<locale>-<form>.report.html and .report.json.

Terminal window
npm run lighthouse -- --locale=uz --form=mobile

The script contains no thresholds. A low score does not fail it.

  1. Needs a production server.
  2. For ru, uz and en, opens the page at 390×844 and records every response of type script whose URL starts with SHOTS_BASE.
  3. Waits for network idle and sums the bodies, re-compressed with gzip level 9.
  4. Scrolls to the bottom in 500 px steps (mock-ups below the first screen mount on approach and pull their chunks), waits for network idle, sums again.
  5. Prints one line per locale: first-load size, number of files, size after a full scroll.

No threshold, no output file.

  1. Reads src/app/globals.css: dark tokens from the @theme block, light overrides from html[data-theme="light"].
  2. Computes the WCAG 2.x contrast ratio for 17 foreground/background pairs listed in scripts/lib/contrast.mjs.
  3. Prints a Markdown table (headers are in Russian) and exits with code 1 if any pair is below its minimum in either theme.
Minimum Applies to
4.5 ink and muted on bg, surface, raised; on-primary on primary, accent, success; accent-ink and danger on bg, surface
3 line-strong and focus on bg, surface (field borders, outline buttons, focus rings)

The decorative line token is deliberately not in the list. The audit checks the same pairs on the live page.

scripts/lib/watchdog.mjs exists because a check that loops forever once held a run for hours.

const { step, alive, done } = watchdog("audit");
step("palette"); // new step, 60 s on the clock
step("three cycles", 150_000); // a step that is long on purpose says so
alive(); // progress inside a step: the clock restarts
done(); // at the end
Behaviour Detail
Default limit 60 000 ms without a step() or alive() call
On expiry prints <script>: step "<name>" made no progress for <n> s — stopped. to stderr and exits with code 1
Override for a slow machine STEP_TIMEOUT=<ms> changes the default limit. Do not remove the limit
Custom limits in the repository audit, “the scene rests after three cycles”: 150 s. Film: 90 s per film
What counts as progress audit: every finding, every pass and every opened page. Shots: every navigation, element shot and scroll step. Film: every frame
Not covered lighthouse.mjs (uses a 120 s process timeout instead) and contrast.mjs (no browser)

The unit tests have the same idea: npm test passes --test-timeout=60000.

Rule for a new check script: start it with its own watchdog(...), call step() per section, and give any intentionally long step an explicit limit.

Budget Value Enforced by Source of the number
Cumulative layout shift over load and a full scroll ≤ 0.001 npm run audit scripts/audit.mjs
H1 line count identical with and without web fonts equal npm run audit scripts/audit.mjs
H1 lines ≤ 4 at 360 and 768, ≤ 3 at 1280 and 1920 npm run audit scripts/audit.mjs
Mock-up text size at 1280 ≥ 12 px npm run audit scripts/audit.mjs
Colour contrast 4.5 for text, 3 for UI edges npm run contrast, npm run audit scripts/lib/contrast.mjs
Hero CTA lands on the form −4 to 140 px npm run audit scripts/audit.mjs
Lighthouse mobile Performance target ≥ 90 nobody — convention docs/TZ.md §10
Lighthouse Accessibility target ≥ 95 nobody — convention docs/TZ.md §10
Lighthouse CLS target < 0.05 nobody — convention (the audit’s own CLS rule is stricter) docs/TZ.md §10
First-screen JavaScript no limit set nobody — measured only CLAUDE.md
Mobile LCP goal ≤ 2.5 s, not reached nobody — measured only CLAUDE.md

These are numbers written down in the landing repository. They were not re-measured for this page.

Measurement Value Where it is recorded
JavaScript downloaded ≈ 172 KB gzip CLAUDE.md, amendment 8 and “Идеи на будущее”
Mobile LCP in Lighthouse’s simulation typically 3.5–3.6 s, with FCP 1.1 s same
Accepted Lighthouse level mobile Performance “90+” is accepted as is CLAUDE.md, “Идеи на будущее”
Lighthouse at step 4, local mobile 87 / 100 / 100 / 100, desktop 100 / 100 / 100 / 100 docs/TZ.md §10
HTML document of /ru 295 KB before, 180 KB after trimming srcset and lazy-mounting the ribbon CLAUDE.md, amendment 8
HTML with experimental.inlineCss (rejected) 31 KB gzip without, 71 KB gzip with CLAUDE.md, amendment 8
Full zod build as a first-screen chunk (removed) 106 KB gzip CLAUDE.md amendment 8, src/lib/requestSchema.ts

The two Lighthouse rows disagree (87 versus “90+”) because they were written at different times. Take a fresh baseline with npm run js-weight and npm run lighthouse before any performance work.

The current limit on LCP is JavaScript, not fonts or HTML. In Lighthouse’s fast trace the scripts finish executing before the first frame, and the simulation counts them as blocking LCP. The next step towards 2.5 s is less client JavaScript on the first screen (the hero scene and the header). It is listed as a future idea, not as work in progress.

All of these are conventions from CLAUDE.md, amendment 8. Where a script backs one up, the table says so.

Rule How it is implemented Backed by a script?
The first screen does not wait for JavaScript The H1 rises as one block with a CSS animation at first paint; subtitle and CTA follow with CSS delays. SplitLines is not used for the H1 (it is still used for section headings). Waiting for hydration cost about 6 s of mobile LCP Partly: the audit checks the first frame is the pack in server HTML
Client components get only their slice of the dictionary Props are typed Pick<Dictionary, …>, for example Pick<Dictionary, "form"> for the form. Everything passed to a client component is serialised into the HTML once per component No
Mock-ups below the first screen mount on approach LazyMount renders an empty box of the same size until the element is within 800 px of the viewport The CLS check catches a placeholder of the wrong size
The tablet draws only the current screen One kiosk screen in the DOM, not all five No
zod/mini, loaded on submit requestSchema.ts imports zod/mini; the form does await import of the schema only when a request is submitted No
No animation library All motion is CSS, the Web Animations API and View Transitions. If motion is ever needed, import it narrowly and re-run Lighthouse No
Mock-up text ≥ 12 px at any width max(12px, …cqw) in CSS; smaller text fails Lighthouse “Legible font sizes” on a phone Audit, at 1280 only
The first frame does not wait for a font See “Font strategy” below Audit: H1 line count with fonts blocked
Small images only See “Image sizes” below No

Defined in the locale layout (src/app/[locale]/layout.tsx) and src/app/globals.css.

Family Weights Use display Preloaded
Unbounded 700 headings swap yes
Onest 400, 600 body text swap yes
IBM Plex Mono 400 mock-ups only swap no
  • Subsets are latin and cyrillic. latin-ext is not loaded: the Uzbek letters ʻ (U+02BB) and ʼ (U+02BC) are in latin.
  • Unbounded’s fallback is a hand-written @font-face named Unbounded Fallback: local Arial Bold with size-adjust: 128.5%. CLAUDE.md records the working window as 126–131 %.
  • Onest uses adjustFontFallback from next/font.
  • font-display: optional was tried for Unbounded and rejected: no steady Lighthouse gain, and on a slow network the headline would stay in the fallback for the whole visit.

From next.config.ts:

Setting Value
images.formats AVIF first, then WebP
images.imageSizes 48, 96, 128, 192, 256, 384
images.deviceSizes 640, 828
images.minimumCacheTTL one year

The default width list goes up to 3840 px and wrote sixteen widths into every srcset; half of the HTML was srcset. Other image rules:

  • the hero pack images use loading="eager" with fetchPriority="low", otherwise React emits twelve <link rel="preload"> ahead of the H1;
  • ribbon stickers mount on approach and are 96 px;
  • the input placeholder in Telegram mock-ups comes from data-placeholder through ::before, because Lighthouse caught the real placeholder mid-fade.
Do not Why Caught by
Turn on experimental.inlineCss The CSS lands in the HTML twice (in <style> and in the RSC payload) nothing — convention
Put content-visibility: auto on sections The hero CTA and the chat link miss the form, and the setup line stops following the scroll audit: cta lands on the form, setup timeline
Leave a below-the-fold mock-up half-transparent Lighthouse reads text at 0.35 opacity as failed contrast; it cost Accessibility 97 once. Off-screen mock-ups are at opacity 0 audit: scroll-driven group
Import the full zod 106 KB gzip on the first screen nothing — convention
Add an animation library, a UI kit, GSAP, three.js or Lottie Forbidden by the repository rules nothing — convention
  1. npm run dev in one terminal, with no delivery variables set.
  2. npm run shots -- --only=360,1280 and look at the files.
  3. npm run audit.
  4. npm run contrast if you touched a colour token.
  5. npx tsc --noEmit, npm run lint, npm test, npm run build.
  6. If you touched fonts, images, client components or dependencies: npm run start, then npm run js-weight and npm run lighthouse, and compare with your baseline.

Last verified: 2026-11-13 against ai-sticker-pack@b715418 and ai-stickers-landing@e350ff5.