# 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: false is 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:

namespacecovers
meta.*<title>, meta description, og:title/description/locale
lang.ariathe 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

attributeappliesfor
data-i18n="key"textContentplain text
data-i18n-html="key"innerHTMLvalues that legitimately carry <b> / <code> markup
data-i18n-attr="attr:key[,attr:key]"one attribute eachcontent (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

  1. Add it to web/landing/locales/id.js and web/landing/locales/en.js (the same key, translated — do not leave the English copy identical to the Indonesian one).
  2. Use it in index.html: data-i18n="mySection.myKey" (or -html / -attr).
  3. 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 in index.html.
  4. 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):

  1. localStorage['rekan_locale'] — the same key the control panel uses;
  2. otherwise navigator.language only when it really is English (en, en-GB, en_US, …) — a guess like this is painted but not stored;
  3. 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.html renders 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.js boots 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 per lang attribute), as documented above.