# Landing page i18n — the ID | EN switch at /
The public landing page (web/landing/, served at / by rekan-api.service) is bilingual. It mirrors the control panel's approach exactly — no build step, no framework, plain ES modules and two flat dictionaries — but it keeps its own dictionary files, because the two halves of the product are served from different roots.
# File layout
web/landing/
index.html no prose at all: every visible string is a data-i18n hook
styles.css .seg / .seg--sm — the same segmented-control look as the panel
i18n.js t/tf/getLocale/setLocale/normalizeLocale + storage ('rekan_locale')
lang.js the DOM half: boot resolution, painting, the ID | EN control
locales/id.js Indonesian — the reference locale and the fallback
locales/en.js English — must carry the exact same key set
hero.js the hero canvas (untouched by i18n)
web/src/i18n.js is not imported: / is served from web/landing, /app from web, and /assets maps to web/src, so ../src/i18n.js would resolve to /src/i18n.js, which is not mounted. web/landing/i18n.js is a deliberate copy of the panel's contract with two landing-only additions:
tf(locale, key, vars)— translate for an explicit locale, so the renderer can paint a locale before it becomes the active one;setLocale(next, { notify, store })—store: falseis used for the navigator-derived guess, which must never be persisted as a choice.
# Key convention
Flat, dot-namespaced, <section>.<thing>, one key per line in both files:
| namespace | covers |
|---|---|
meta.* | <title>, meta description, og:title/description/locale |
lang.aria | the switch's aria-label (and each button's title) |
nav.* | header links, the CTAs, and the footer's repeated links |
hero.* | the h1, the lede, the hero pane sample session, the hero stats |
why.* | the "why an Agent Box" cards |
catalog.* | the 12 workload packs (catalog.fn.<slug>, catalog.t.<slug>) |
how.* | the four lifecycle steps and the sample deploy job |
yaml.* | the declarative-configuration section, including the YAML sample |
pricing.* | the three tiers and their CTAs |
faq.* | the five questions and answers |
cta.*, footer.* | the closing band and the footer |
Technical terms are never translated: LXD, Hermes, Agent Box, Telegram, Slack, Lark, READY, CONFIGURING, box.yaml, box.env, rekan-box-apply. The brand is Rekkan.Ai — two k's — in both locales.
# Hooks in index.html
| attribute | applies | for |
|---|---|---|
data-i18n="key" | textContent | plain text |
data-i18n-html="key" | innerHTML | values that legitimately carry <b> / <code> markup |
data-i18n-attr="attr:key[,attr:key]" | one attribute each | content (meta/og), aria-label |
The markup for data-i18n-html values lives inside the dictionary, never in the HTML: .check b { display: block } and <code> styling keep working because the rendered markup is byte-identical to the original.
index.html itself holds no prose. The one exception is deliberate: the crawler-facing <head> values (<title>, meta description, og:*) are written as the English copy, because social scrapers do not run JavaScript. A test pins each of those static values to en['<key>'], so they cannot drift from the dictionary either. The <noscript> fallback carries both languages, one per lang attribute.
# How to add a key
- Add it to
web/landing/locales/id.jsandweb/landing/locales/en.js(the same key, translated — do not leave the English copy identical to the Indonesian one). - Use it in
index.html:data-i18n="mySection.myKey"(or-html/-attr). - Run
cd web && node --test tests/. The tests catch a missing key in either dictionary, an empty value, a raw key leaking into the page, an untranslated (identical) value, and Indonesian prose reappearing inindex.html. - If the string is one of the crawler-facing
<head>values, also update the static attribute to the new English text.
# How the locale is chosen and persists
Boot (lang.js → initLanding):
localStorage['rekan_locale']— the same key the control panel uses;- otherwise
navigator.languageonly when it really is English (en,en-GB,en_US, …) — a guess like this is painted but not stored; - otherwise Indonesian.
Choosing a language with the ID | EN control calls setLocale, which writes localStorage['rekan_locale'], notifies the listener registered by initLanding, and repaints: <html lang>, every hook, <title>, and the switch's aria-pressed / is-active state — in place, with no page reload. The control also supports ← / →, as a segmented control should.
The landing page has no server-side locale store (visitors are anonymous), so unlike the panel there is no /auth/me persistence — the choice is per device. The landing is a client-rendered page: without JavaScript the body is empty and the <noscript> notice is shown.
# Verification (real output, this round)
Branch feat/landing-i18n. Commands and their actual output:
$ cd /home/amd/rekan.ai/web && node --test tests/
ℹ tests 153
ℹ suites 26
ℹ pass 153
ℹ fail 0
(133 tests / 21 suites before this change; web/tests/landing-i18n.test.mjs adds 20, all passing. Dictionaries: 138 keys each, exact parity.)
$ cd /home/amd/rekan.ai/control-plane && .venv/bin/python -m pytest -q
191 passed, 2 warnings in 30.54s
The service reads the landing from disk per request, so the edits went live without any restart (and no rekan* unit was touched):
$ curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8080/ -> 200
$ curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8080/lang.js -> 200
$ curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8080/locales/en.js -> 200
$ grep -c data-i18n /tmp/landing-served.html -> 131
Browser-side observation — a real browser on the served / (its navigator.language is en-US, hence the English boot):
boot: <html lang="en">, title "Rekkan.Ai — an agent coworker for every business function",
h1 "Every business function gets its own coworker.",
#lang-toggle present, id:false | en:true, localStorage['rekan_locale'] = null
click ID: lang=id h1 "Setiap fungsi bisnis dapat rekannya sendiri."
title "Rekkan.Ai — Agent coworker untuk setiap fungsi bisnis"
nav "Kenapa Agent Box / Katalog rekan / Cara kerja / Harga / Tanya jawab"
localStorage['rekan_locale'] = "id"
click EN: lang=en h1 "Every business function gets its own coworker."
title "Rekkan.Ai — an agent coworker for every business function"
aria-pressed id:false en:true, og:locale "en_GB", localStorage = "en"
raw i18n keys visible in the page text: none
reload: lang=en, h1 still English, localStorage = "en" (the choice persists)
Small-screen check (390×844 emulated, the width where .hdr__nav collapses):
getComputedStyle('.hdr__nav').display -> none
#lang-toggle -> display flex, hit-test at its centre returns DIV.seg.seg--sm.lang-toggle
The switch lives in .hdr__act, which does not collapse, so there is no second copy behind a mobile menu to keep in sync.
# Known limits
index.htmlrenders nothing without JavaScript except the<noscript>notice. That is the same contract as the control panel, and the copy it would need is the dictionary's.lang.jsboots from<script type="module">at the end of<body>; on a cold cache a very fast paint can show the empty shell for a frame.- The
<noscript>text is the only prose outside the dictionaries (both languages, one perlangattribute), as documented above.