Landing quality tooling
Who is this for
Section titled “Who is this for”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 isSHOTS_BASE, defaulthttp://localhost:3000. npm run auditis the only one with hard pass/fail rules for layout and motion.npm run contrasthas hard rules for colour pairs.npm run shotsfails only on page errors and horizontal overflow.npm run lighthouseandnpm run js-weightonly 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 noTELEGRAM_*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.
The scripts at a glance
Section titled “The scripts at a glance”| 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.
Common inputs
Section titled “Common inputs”| 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:
$env:SHOTS_BASE = "http://localhost:3000"; npm run auditThe ?step= hook
Section titled “The ?step= hook”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.
npm run audit
Section titled “npm run audit”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.
Inputs
Section titled “Inputs”--only=uzor--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
uzPublishedfromsrc/content/site.tsand expects the running build to match it.
npm run auditnpm run audit -- --only=uzSide effects you must know about
Section titled “Side effects you must know about”- It sends one real request. The form check fills in name
Audit, a Telegram handle, a phone number and the commentaudit run, waits past the minimum fill time and submits. Against a server with delivery variables set, that request is delivered. Use a local server withoutLEADS_*andTELEGRAM_*. - 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-timelinesupport, the report saysSKIPPEDfor that group instead of failing.
Viewports and locales
Section titled “Viewports and locales”| 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 |
What it checks, by theme
Section titled “What it checks, by theme”Languages and the uzPublished flag
Section titled “Languages and the uzPublished flag”| 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 |
Themes
Section titled “Themes”| 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 |
Structure and accessibility
Section titled “Structure and accessibility”- Exactly one
<h1>; heading levels never skip. <html lang>equals the locale.- No visible
<a>or<button>without text,aria-labeloraria-labelledby. - The hero scene is
role="img"with anaria-labelof at least 40 characters.
Typography: headline and subtitle lines
Section titled “Typography: headline and subtitle lines”| 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.
Header geometry
Section titled “Header geometry”At 1280 and 1920:
- header CTA and hero CTA have identical
x,widthandheightinru,uzanden; - 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.
Palette
Section titled “Palette”- 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.
Motion gating
Section titled “Motion gating”| 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 |
Hero scene geometry
Section titled “Hero scene geometry”- First frame. The server HTML already contains
data-scene-step="pack"anddata-pack="in". In the browser, 12stk-slapanimations 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 leftpack3.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,scanorphone), 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
packandidle, 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.
No horizontal overflow
Section titled “No horizontal overflow”Four widths × three locales × three scene steps: scrollWidth must not exceed clientWidth.
Form behaviour
Section titled “Form behaviour”Run once, at 1280, on /ru:
- Fields are in the order
name, telegram, phone, date, guests, comment, each full width. The phone field istype="tel",inputmode="tel",autocomplete="tel". - An empty submit shows exactly three errors (name, Telegram, phone) and focuses the name field.
- A two-letter handle and a phone without
+show two errors and focus the Telegram field. - Offline: an alert appears, the form stays, typed text is kept.
- 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. - Ten burst stickers are visible shortly after sending and all invisible 1.85 s after it; the burst does not replay on reload.
- 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.
Sections below the hero
Section titled “Sections below the hero”- 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:3with 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.
Cumulative layout shift
Section titled “Cumulative layout shift”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.
npm run shots
Section titled “npm run shots”Screenshots, so layout is reviewed by looking at it.
- Delete its own previous output:
shots/hero/,shots/sheets/, and the page and section files of the locales and widths about to be retaken. - Load one throwaway page to warm the browser (a cold headless browser holds CSS animations still on its first page).
- For each theme, locale and width: open the page with reduced motion off, wait 1.2 s, shoot the first screen.
- 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. - Record horizontal overflow as a failure.
- Scroll the whole page down and back so every reveal has fired, then shoot the full page and each
main > sectionseparately. - Click submit on the empty form and shoot the three errors.
- Open a second page with the “sent” flag pre-set in
localStorageand shoot the “sent” screen.
Coverage
Section titled “Coverage”| 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 |
npm run shots -- --only=1280 --locale=uz --theme=lightOutput
Section titled “Output”| 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.
npm run film
Section titled “npm run film”Records a slow scroll, so motion is watched instead of imagined.
- Delete and recreate
shots/film/. - For each of four combinations —
ru1280,uz1280,uz360,en1280 — open the page with video recording on. - Wait 32 s on the hero: the opening and one whole scene cycle.
- 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.
- 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.
npm run lighthouse
Section titled “npm run lighthouse”- Needs a production server:
npm run build, thennpm run start. Build withNEXT_PUBLIC_SITE_URLequal to the address you serve from. - Runs
npx -y lighthouse@12with Playwright’s Chromium in headless mode, once per run configuration. - Default runs:
rumobile,uzmobile,enmobile,rudesktop. Narrow it with--locale=<code>and--form=mobile|desktop. - Each run is limited to 120 s by
execFileSync’s own timeout, because the call is synchronous and the watchdog cannot interrupt it. - 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.
npm run lighthouse -- --locale=uz --form=mobileThe script contains no thresholds. A low score does not fail it.
npm run js-weight
Section titled “npm run js-weight”- Needs a production server.
- For
ru,uzanden, opens the page at 390×844 and records every response of typescriptwhose URL starts withSHOTS_BASE. - Waits for network idle and sums the bodies, re-compressed with gzip level 9.
- 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.
- Prints one line per locale: first-load size, number of files, size after a full scroll.
No threshold, no output file.
npm run contrast
Section titled “npm run contrast”- Reads
src/app/globals.css: dark tokens from the@themeblock, light overrides fromhtml[data-theme="light"]. - Computes the WCAG 2.x contrast ratio for 17 foreground/background pairs listed in
scripts/lib/contrast.mjs. - 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.
The watchdog
Section titled “The watchdog”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 clockstep("three cycles", 150_000); // a step that is long on purpose says soalive(); // progress inside a step: the clock restartsdone(); // 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.
Performance budgets and rules
Section titled “Performance budgets and rules”What is enforced and what is not
Section titled “What is enforced and what is not”| 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 |
Recorded measurements
Section titled “Recorded measurements”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.
The rules that keep the numbers
Section titled “The rules that keep the numbers”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 |
Font strategy
Section titled “Font strategy”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
latinandcyrillic.latin-extis not loaded: the Uzbek lettersʻ(U+02BB) andʼ(U+02BC) are inlatin. - Unbounded’s fallback is a hand-written
@font-facenamedUnbounded Fallback: local Arial Bold withsize-adjust: 128.5%.CLAUDE.mdrecords the working window as 126–131 %. - Onest uses
adjustFontFallbackfromnext/font. font-display: optionalwas 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.
Image sizes
Section titled “Image sizes”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"withfetchPriority="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-placeholderthrough::before, because Lighthouse caught the real placeholder mid-fade.
What not to do
Section titled “What not to do”| 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 |
Recommended order for a change
Section titled “Recommended order for a change”npm run devin one terminal, with no delivery variables set.npm run shots -- --only=360,1280and look at the files.npm run audit.npm run contrastif you touched a colour token.npx tsc --noEmit,npm run lint,npm test,npm run build.- If you touched fonts, images, client components or dependencies:
npm run start, thennpm run js-weightandnpm run lighthouse, and compare with your baseline.
Where to go next
Section titled “Where to go next”- Animation rules — the motion rules that the audit’s motion group enforces.
- i18n and copy — why the header and H1 checks constrain every text change.
- Showcase packs — the asset scripts
demo-packs,sticker-gapandassets. - Local setup — landing page — getting the server these scripts need.
- Performance — performance across the whole system.
Last verified: 2026-11-13 against ai-sticker-pack@b715418 and ai-stickers-landing@e350ff5.