i18n and copy
Who is this for
Section titled “Who is this for”A developer or copy editor who needs to change a text on the landing page, publish or hide the Uzbek version, or add a language. It also explains to a translator why a “simple” wording change can fail the build.
- Every visible string lives in
src/i18n/dictionaries/{en,ru,uz}.ts.en.tsdefines theDictionarytype;ruanduzmust match it key for key. No hard-coded strings in components. npm testrejects forbidden words, any digits other than12,72and3D, wrong apostrophes in Uzbek, a breakable space before a dash, and the wrong form of address.- Each language is written as an original, not a translation. Russian addresses the organiser as «вы» on the page and the guest as «ты» inside product mock-ups; Uzbek is «siz» everywhere.
- Uzbek is published (
uzPublished: true) but never the default: no browser language leads to/uz. - A longer text can fail
npm run audit: the H1 may take at most 3 lines at 1280 and 1920 and 4 lines at 360 and 768, and header elements must have identical geometry in all three languages. - The Uzbek dictionary has not been proofread by a native speaker yet. See Known gaps.
Where texts live
Section titled “Where texts live”| File | Role |
|---|---|
src/i18n/dictionaries/en.ts |
English. Its shape is the Dictionary type |
src/i18n/dictionaries/ru.ts |
Russian, typed Dictionary |
src/i18n/dictionaries/uz.ts |
Uzbek in Latin script, typed Dictionary |
src/i18n/index.ts |
export type Dictionary = typeof en and getDictionary(locale) |
src/i18n/dictionaries.test.ts |
The rules below |
src/content/site.ts |
Things that are not words and read the same in every language: studio name and link, the partner agency credit and its on/off switch, the year, the uzPublished flag |
Top-level keys
Section titled “Top-level keys”| Key | Covers |
|---|---|
meta |
page title and description (also used for Open Graph) |
nav, header, localeSwitch, footer |
chrome |
hero |
H1, subtitle, CTA, scene caption, stepper labels, one sentence per scene step |
scene |
product mock-ups: kiosk screens, bot messages, the group chat in “Why” |
showcase, stickers |
showcase texts, people descriptions, the 12 sticker labels |
how, why, organizer, data, setup, faq |
page sections |
form |
labels, placeholders, error messages, consent line, “sent” screen |
scene is the only key with a different voice. Everything outside it talks to the event organiser. scene is the product talking to a guest.
How to change a text
Section titled “How to change a text”- Find the key in
en.ts. Change the string in all three files. Each language is an original: rewrite it so it reads naturally, do not translate word for word. - If you add a key, add it to
en.tsfirst. TypeScript then reports the missing key inru.tsanduz.ts. - If you add an array item, add it to all three. The type does not check array lengths; the test does.
- Put a no-break space (written
in the source) before every em dash. See the dash rule below. - Pass the string to components through props. A client component receives only its slice:
Pick<Dictionary, "form">, not the whole dictionary. - Run the checks:
npx tsc --noEmitnpm testnpm run shots -- --only=360,1280npm run auditshots and audit need a running server. See Local setup — landing page.
Never write a string literal in a component. The only texts drawn by code are things that are not copy: the clock time inside Telegram mock-ups and the 3-2-1 countdown on the kiosk.
Tone and form of address
Section titled “Tone and form of address”| Area | Russian | Uzbek | English |
|---|---|---|---|
| The whole page: hero, sections, FAQ, form, “sent” screen | «вы», to the organiser; the guest is spoken of in the third person | «siz» | direct; “you” is the organiser |
Product mock-ups (every key under scene): the bot and kiosk talking to a guest |
«ты», as the real product does | «siz» | — |
Further tone rules, from the repository’s CLAUDE.md:
- Short sentences, active voice, concrete statements.
- The page says “we”. It never names the company. The partner agency appears only in the footer credit, which comes from
SITE.partnerand is switched off withpartner.enabled. - The reader is the organiser who gets a turnkey service. Do not write that they “run” or “manage” the station.
- Mock-ups are in the page’s language, including the pack title.
- The sticker style is described in words (“chunky cartoon”, «объёмные мультяшные», «hajmli multfilm»). Its internal name never appears.
What may and may not be promised
Section titled “What may and may not be promised”| May be said | Must not be said or named |
|---|---|
| QR without an app, entry through Telegram | prices |
| guest consent before the photo | any figure except “12 stickers”, “a couple of minutes” and “72 hours” |
| choosing an outfit on the tablet | reviews and client logos |
| 12 chunky cartoon stickers with the guest’s face | other messengers |
| the pack in Telegram in a couple of minutes | printing stickers |
| the station is free immediately, it does not wait for generation | “works without internet” |
| one pack per Telegram account | “100 % likeness” |
| photos deleted automatically within 72 hours | names of AI models and providers |
| monitoring and technical support during the event | a backup or fallback AI provider |
| the station in the colours and with the logos of the event | branded outfits or props |
| setup for the event with a rehearsal | brands of past events |
What the dictionary test enforces
Section titled “What the dictionary test enforces”npm test runs these against all three dictionaries.
Same shape, nothing empty
Section titled “Same shape, nothing empty”ruanduzhave exactly the same key paths and array lengths asen.- No string is empty or whitespace only.
Forbidden words
Section titled “Forbidden words”The whole dictionary is serialised to JSON, lower-cased and searched for substrings. Two consequences:
- Key names count too, not only values.
- Substrings match. A harmless word that contains a forbidden one fails. For example, any English word containing
printormagic, or any word containing the lettersgptin sequence.
| Category | Blocked substrings |
|---|---|
| Another messenger | one messenger brand name |
| Printing stickers | печать стикер, печатаем, print, chop etish |
| Working without internet | без интернета, offline, internetsiz |
| Guaranteed likeness | 100%, 100 % |
| AI models and providers | three provider and model names |
| Brands of past events | two brand names |
| A backup provider | резервн, запасн, fallback, backup, zaxira |
| Hype | революц, revolution, inqilob, новый уровень, next level, all-in-one, магия, magic, sehr |
| The style’s internal name | stylized |
| The partner agency | its short name |
The brand names are deliberately not repeated here. The exact list is the forbidden array in the test file.
Allowed digits
Section titled “Allowed digits”Every run of digits, optionally followed by one letter, is checked. Only three tokens pass:
| Token | Meaning |
|---|---|
12 |
stickers in a pack |
72 |
hours until photos are deleted |
3D |
allowed as a token |
One exception: in the keys form.phone.placeholder and form.errors.phoneInvalid a sample phone number starting with +998 is removed before the check, because it shows the format to type and promises nothing.
So “24/7”, “5 minutes”, “2026”, “1st” and “No. 1” all fail. Write numbers as words if you must mention them, and check first whether you may promise them at all.
Uzbek apostrophes
Section titled “Uzbek apostrophes”| Letter | Required code point | Rejected |
|---|---|---|
oʻ, gʻ (also capital) |
U+02BB ʻ (modifier letter turned comma) |
U+02BC after o or g |
tutuq belgisi, as in maʼlumot |
U+02BC ʼ (modifier letter apostrophe) |
U+02BB after any letter other than o or g |
| — | — | ASCII ', typographic ‘ and ’, backtick, anywhere in uz |
Most keyboards and translation tools produce the rejected characters. Paste the correct ones from an existing string. The fonts load only the latin and cyrillic subsets, and both code points are in latin; this is also why the monospace font is IBM Plex Mono, which has ʻ.
The dash rule
Section titled “The dash rule”A dash must never start a line. The test rejects a normal space followed by an em dash ( —) in any string. Write a no-break space (U+00A0) before the dash. The dictionaries use the escape so the character is visible in review:
title: "AI Sticker Pack — personal stickers for every guest at your event",Form of address
Section titled “Form of address”The test each part keeps one form of address checks whole words, case-insensitively:
| Dictionary part | Must not contain |
|---|---|
ru, every key not under scene |
ты, тебя, тебе, тобой, твой and its forms |
ru, every key under scene |
вы, вас, вам, вами, ваш and its forms |
uz, every key |
sen, seni, senga, sening, sendan, senda |
English has no such check.
Routing rules in the same file
Section titled “Routing rules in the same file”The test file also pins the routing functions:
Accept-Languageresolution for eleven sample headers;- six Uzbek headers that must never resolve to
uz; publishedLocalesandlanguageAlternatesfor both values of the flag;- that the site’s own setting is currently
true.
Locale routing
Section titled “Locale routing”Defined in src/proxy.ts and src/i18n/config.ts. In Next.js 16 the file is called proxy.ts; it plays the role earlier versions called middleware.
flowchart TD
A["Request path"] --> B{"Starts with /ru, /uz or /en?"}
B -- yes --> C["Serve the page"]
B -- no --> D{"NEXT_LOCALE cookie is a published locale?"}
D -- yes --> E["Redirect to the cookie's locale"]
D -- no --> F{"Accept-Language, by quality"}
F -- "first match is a CIS language" --> G["Redirect to /ru"]
F -- "first match is English" --> H["Redirect to /en"]
F -- "only other languages" --> H
F -- "missing, empty or a wildcard" --> G
| Rule | Detail |
|---|---|
| Locales | ru, uz, en, in the order the switch shows them |
| Default | ru, used when the browser says nothing useful |
CIS languages mapped to /ru |
ru, uz, uk, be, kk, ky, tg, tk, az, hy — by primary subtag, so uz-Latn-UZ and kk resolve alike |
| Quality values | honoured: en;q=0.3, kk;q=0.9 gives /ru |
| Uzbek browser | /ru. The Uzbek page is chosen by the visitor, never detected |
| Cookie | NEXT_LOCALE, written by the language switch, one year, SameSite=Lax. It records only the chosen language |
| Paths left alone | anything under /_next, /api, and any path containing a dot (static files) |
| Other unprefixed paths | redirected to /<locale><path> |
hreflang |
one entry per published locale, plus x-default pointing at /en |
| Language switch | swaps the first path segment, keeps the hash and the scroll position, cross-fades with a view transition |
The uzPublished flag
Section titled “The uzPublished flag”One boolean in src/content/site.ts:
locales: { uzPublished: true,},| Effect on | true (current) |
false |
|---|---|---|
/uz by direct link |
opens | opens |
Search indexing of /uz |
indexable | robots: noindex, nofollow in the page metadata |
Language switch on /ru and /en |
RU / UZ / EN | RU / EN |
Language switch on /uz itself |
RU / UZ / EN | RU / UZ / EN — the draft still shows itself lit, for the proofreader |
hreflang alternates |
ru, uz, en, x-default |
ru, en, x-default |
sitemap.xml |
lists /uz |
omits /uz |
Cookie NEXT_LOCALE=uz on / |
redirects to /uz |
ignored; Accept-Language decides |
Browser language Uzbek on / |
/ru |
/ru |
How the checks follow the flag:
npm run auditreads the flag fromsite.tsand expects the running build to behave accordingly.npm run audit -- --only=uzruns just the language and theme groups, for trying the other value on a second build.npm testchecks the routing functions for both values. It also asserts that the committed value istrue(testthe site's own setting is what the defaults follow — and it is published). If you set the flag tofalse, update that assertion in the same commit, ornpm testfails.- The flag is read at build time. Changing it needs a new build and deploy.
Layout constraints from the longest language
Section titled “Layout constraints from the longest language”Text length is a layout input here. Two different languages are “the longest”, depending on the element.
| Element | Longest language | Constraint | Enforced by |
|---|---|---|---|
| Overall copy | Uzbek | it defines the hero text column (44 %), the header slots and the CTA width | convention |
| H1 | Russian | the top of the H1 size clamp, 2.35rem, was chosen for the Russian headline |
audit |
| H1 line count | all three | ≤ 4 lines at 360 and 768; ≤ 3 lines at 1280 and 1920 | audit |
| H1 line count through the font swap | all three | identical in the fallback font and in Unbounded | audit |
| Hero subtitle | all three | ≤ 4 lines at 360 and 768; ≤ 2 lines at 1280 and 1920 | audit |
| Header CTA and hero CTA | all three | identical x, width and height in every language at 1280 and 1920; text not clipped |
audit |
| Navigation links | published locales | centre drifts at most 1 px between languages | audit |
| Kiosk title in the hero tablet | all three | one line at 768, 1280 and 1920 | audit |
| Hero CTA on a phone | all three | visible without scrolling at 360×780 | audit |
| Sentence under the stepper | per locale | every step has a distinct sentence | audit (on /ru) |
The H1 font size is clamp(1.55rem, 1rem + 2vw, 2.35rem). If a new headline does not fit, shorten the headline. Do not raise a line limit to make a text pass.
Adding a new locale
Section titled “Adding a new locale”- Config. In
src/i18n/config.ts: add the code tolocales; extend the hard-coded comparison inisLocale; add an entry toOG_LOCALE; decide howAccept-Languageshould treat the language (today the function returns onlyruoren) and whether the new locale needs its own published flag likeuzPublished. - Dictionary. Create
src/i18n/dictionaries/<code>.tstyped asDictionaryand register it insrc/i18n/index.ts. - Switch label. Add
localeSwitch.<code>to every dictionary; the switch rendersdict.localeSwitch[code]. - Switch component. In
src/components/site/LocaleSwitch.tsxextend theORDERarray and the path regular expression/^\/(ru|uz|en)/. - Fonts. Check that Unbounded, Onest and IBM Plex Mono cover the script with the loaded subsets (
latin,cyrillic). Adding a subset changes font weight on the first screen; re-measure. - Tests. In
dictionaries.test.tsadd the dictionary toALL, add the language’s forbidden words and its form-of-address rule, and update the expected arrays in thepublishedLocalesandlanguageAlternatestests. - Scripts. Update the hard-coded locale lists:
LOCALESinscripts/audit.mjsandscripts/shots.mjs(and the file-name pattern(ru|uz|en)inshots.mjs), the loop inscripts/js-weight.mjs, the default runs inscripts/lighthouse.mjs, the list inscripts/scroll-film.mjs. The audit’s expected switch contents (ru,uz,en) change too. - Layout. Run
npm run shotsandnpm run audit. A new language may be the new longest one; the header geometry and H1 limits apply to it from the first run. - Leads server. The server accepts
localevaluesru,uz,enand stores anything else asen(LOCALESin the product repository’sbot/services/leads.py). Add the code there, or requests from the new page are recorded as English. - Things that follow automatically because they iterate
localesorpublishedLocales(): the redirect inproxy.ts,generateStaticParams, the sitemap, the hreflang alternates, the Open Graph image route. - Product language. The real bot and kiosk speak Russian only. A landing page in a new language shows mock-ups in that language; the product has to follow before the first event in it.
Proofreading status of Uzbek
Section titled “Proofreading status of Uzbek”- The Uzbek dictionary was written as a draft. Its header comment and one comment in the
formsection (К вычитке, “to be proofread”) say a native speaker’s review is pending. - The page is nevertheless published and indexable (
uzPublished: true). - Treat every Uzbek string as unreviewed. When the review happens, re-check the three typographic rules above, because reviewers’ tools tend to replace
ʻandʼwith ordinary apostrophes.
The gap is tracked on Known gaps.
Where to go next
Section titled “Where to go next”- Landing quality tooling — the audit checks that constrain text length.
- i18n across the system — languages in the bot, the kiosk and the landing page together.
- Showcase packs — outfit names, people descriptions and sticker labels are dictionary keys too.
- Known gaps — the pending Uzbek proofread and other open items.
- Client FAQ — what sales may promise, in line with the “may be said” list.
Last verified: 2026-11-13 against ai-sticker-pack@b715418 and ai-stickers-landing@e350ff5.