PawMap / static /i18n.js
Sarolanda's picture
chore: point to new Space BetweenClicks/PawMap + bucket storage
b324a66
Raw
History Blame Contribute Delete
25.1 kB
/* ─────────────────────────────────────────────────────────────────────────────
* i18n.js — PawMap lightweight runtime localizer (EN ⇄ PT-BR)
*
* How it works
* - English is the canonical language. A few strings are hardcoded in
* Portuguese in the source; the dictionary stores every pair as
* [English, Portuguese] so BOTH directions work:
* · PT mode: an English node → its Portuguese; a node already in
* Portuguese isn't an English key, so it's left untouched (stays PT).
* · EN mode: a Portuguese node → its English (via the reverse map);
* English nodes are native and left alone.
* - A set of regex PATTERNS (PT mode only) handles interpolated strings
* like "5 sightings" or "3d ago".
* - A MutationObserver re-applies translation to anything the app renders at
* runtime, so JS-generated text is covered.
* - Switching language persists the choice and reloads (the app re-fetches
* its data anyway), giving a clean re-render.
* - The AI free-text sentence is NOT machine-translated; in PT mode app.js
* rebuilds a description from the structured AI fields using the maps
* exposed here on window.PAWMAP_I18N. The AI prompt itself stays English.
* ──────────────────────────────────────────────────────────────────────────── */
(function () {
'use strict';
var LANG_KEY = 'pawmap_lang';
function detectLang() {
var saved = localStorage.getItem(LANG_KEY);
if (saved === 'pt' || saved === 'en') return saved;
return (navigator.language || '').toLowerCase().indexOf('pt') === 0 ? 'pt' : 'en';
}
var LANG = detectLang();
window.PAWMAP_LANG = LANG;
document.documentElement.lang = LANG === 'pt' ? 'pt-BR' : 'en';
/* ── Word maps (reused in dictionary + exposed for AI-description rebuild) ── */
var SPECIES = { dog: 'Cachorro', cat: 'Gato' };
var COLORS = {
black: 'Preto', white: 'Branco', gray: 'Cinza', grey: 'Cinza', brown: 'Marrom',
chocolate: 'Chocolate', caramel: 'Caramelo', tan: 'Bege', golden: 'Dourado',
cream: 'Creme', fawn: 'Fulvo', orange: 'Laranja', ginger: 'Ruivo', red: 'Vermelho',
sable: 'Zibelina', cinnamon: 'Canela', blue: 'Azul', lilac: 'Lilás', tabby: 'Tigrado',
tuxedo: 'Smoking', calico: 'Calico', tortoiseshell: 'Escaminha', tricolor: 'Tricolor',
bicolor: 'Bicolor', brindle: 'Rajado', merle: 'Merle', spotted: 'Malhado', mixed: 'Misto'
};
var SIZES = {
tiny: 'Mini', small: 'Pequeno', medium: 'Médio', large: 'Grande',
'extra large': 'Extra Grande', giant: 'Gigante'
};
var CONDITIONS = {
healthy: 'saudável', thin: 'magro', injured: 'ferido',
'looks healthy': 'parece saudável', 'has collar': 'tem coleira'
};
var MARKS = {
collar: 'coleira', scar: 'cicatriz', patch: 'mancha', 'missing ear': 'orelha faltando',
'floppy ears': 'orelhas caídas', wound: 'ferimento', spot: 'pinta', limp: 'mancando'
};
function ptColor(c) {
if (!c) return '';
var k = String(c).toLowerCase();
return COLORS[k] || (c.charAt(0).toUpperCase() + c.slice(1));
}
function ptSize(s) {
if (!s) return '';
return SIZES[String(s).toLowerCase()] || s;
}
function ptSpecies(sp) { return SPECIES[String(sp).toLowerCase()] || sp; }
function ptCondition(c) {
if (!c) return '';
var k = String(c).toLowerCase();
return CONDITIONS[k] || c;
}
function ptMark(m) {
if (!m) return '';
return MARKS[String(m).toLowerCase()] || m;
}
window.PAWMAP_I18N = {
get lang() { return window.PAWMAP_LANG; },
color: ptColor, size: ptSize, species: ptSpecies, condition: ptCondition, mark: ptMark
};
/* ── Dictionary: [English, Portuguese] (always this order) ── */
var PAIRS = [
// Onboarding / desktop sidebars
['Collaborative stray animal mapping, powered by AI.', 'Mapeamento colaborativo de animais de rua, com IA.'],
['The problem', 'O problema'],
['Independent rescuers in Brasília, DF keep track of entire stray animal colonies in their heads — which ones were neutered, which one got hurt last winter, which one hasn\'t shown up this week. That knowledge lives in WhatsApp groups and fades when people move on.',
'Protetores independentes em Brasília, DF guardam de cabeça colônias inteiras de animais de rua — quais foram castrados, qual se machucou no último inverno, qual não apareceu esta semana. Esse conhecimento vive em grupos de WhatsApp e se perde quando as pessoas seguem em frente.'],
['PawMap turns those scattered sightings into a shared, persistent map — one photo at a time.',
'O PawMap transforma esses avistamentos dispersos em um mapa compartilhado e permanente — uma foto de cada vez.'],
['How it works', 'Como funciona'],
['Snap a photo', 'Tire uma foto'],
['Point your camera at any stray dog or cat.', 'Aponte a câmera para qualquer cão ou gato de rua.'],
['AI identifies', 'A IA identifica'],
['Species, breed, color and condition — automatically.', 'Espécie, raça, cor e estado — automaticamente.'],
['Smart matching', 'Correspondência inteligente'],
['Checks if this animal has been seen before.', 'Verifica se este animal já foi visto antes.'],
['Map updates', 'O mapa atualiza'],
['Sighting linked to the animal\'s profile — or a new one is created.', 'Avistamento ligado ao perfil do animal — ou um novo é criado.'],
['Better on mobile', 'Melhor no celular'],
['Skip', 'Pular'],
['Step 1 of 4', 'Passo 1 de 4'],
['Step 2 of 4', 'Passo 2 de 4'],
['Step 3 of 4', 'Passo 3 de 4'],
['Step 4 of 4', 'Passo 4 de 4'],
['Help a stray find its way', 'Ajude um animal de rua a encontrar seu caminho'],
['Every street dog and cat deserves to be seen. PawMap lets your community map them together — one photo at a time.',
'Todo cão e gato de rua merece ser visto. O PawMap deixa sua comunidade mapeá-los juntos — uma foto de cada vez.'],
['Snap a photo — AI does the rest', 'Tire uma foto — a IA faz o resto'],
['Point your camera and tap. Our AI instantly identifies species, breed, color, and condition. No forms to fill.',
'Aponte a câmera e toque. Nossa IA identifica na hora espécie, raça, cor e estado. Sem formulários para preencher.'],
['AI matches the same animal', 'A IA reconhece o mesmo animal'],
['Seen this dog before? The AI compares your photo with previous sightings and links them automatically — building each animal\'s story.',
'Já viu este cão antes? A IA compara sua foto com avistamentos anteriores e os conecta automaticamente — construindo a história de cada animal.'],
['Every map pin is a chance', 'Cada ponto no mapa é uma chance'],
['Mapping a dog brings more chances to find a forever family — or get help from the community when it\'s needed most.',
'Mapear um cão traz mais chances de encontrar uma família para sempre — ou de receber ajuda da comunidade na hora mais necessária.'],
['Next →', 'Próximo →'],
['Get Started', 'Começar'],
// Filter row
['All', 'Todos'],
['Dogs', 'Cachorros'],
['Cats', 'Gatos'],
['Today', 'Hoje'],
['This Week', 'Esta semana'],
// Map / card
['No sightings yet', 'Nenhum avistamento ainda'],
['Dog', 'Cachorro'],
['Cat', 'Gato'],
['Dog spotted', 'Cachorro avistado'],
['Cat spotted', 'Gato avistado'],
['Yesterday', 'Ontem'],
['View profile', 'Ver perfil'],
// Register
['Add a photo to detect location', 'Adicione uma foto para detectar a localização'],
// reset() in app.js emits this exact PT sentence; pair it (PT→EN) so EN mode restores English.
// The duplicate English key is harmless: enToPt keeps the first, ptToEn gets this PT key.
['Add a photo to detect location', 'Adicione uma foto para definir a localização'],
['Tap the button to take a photo', 'Toque no botão para tirar uma foto'],
['Add a note (optional)', 'Adicione uma nota (opcional)'],
['Use current location', 'Usar localização atual'],
['Select on map', 'Selecionar no mapa'],
['Report Sighting', 'Registrar avistamento'],
['Reading photo location…', 'Lendo a localização da foto…'],
['No location in the photo — set it below', 'Sem localização na foto — defina abaixo'],
['GPS not available', 'GPS indisponível'],
['Detecting...', 'Detectando...'],
['Permission denied', 'Permissão negada'],
['Location unavailable', 'Localização indisponível'],
['Timed out', 'Tempo esgotado'],
['GPS error', 'Erro de GPS'],
// Analysis
['Analyzing Sighting', 'Analisando avistamento'],
['AI Analyzing...', 'IA analisando...'],
['AI Done', 'IA concluída'],
['Analysis error', 'Erro na análise'],
['No animal found', 'Nenhum animal encontrado'],
['Identified Details', 'Detalhes identificados'],
['AI filled in these details. Correct if needed.', 'A IA preencheu estes detalhes. Corrija se precisar.'],
['Species', 'Espécie'],
['Breed (Estimated)', 'Raça (estimada)'],
['Primary Color', 'Cor principal'],
['Size', 'Porte'],
['Condition (Optional)', 'Estado (opcional)'],
['Injured', 'Ferido'],
['Looks Healthy', 'Parece saudável'],
['Has Collar', 'Tem coleira'],
['Similar animals nearby', 'Animais parecidos por perto'],
['Found by AI for context — recognition is automatic.', 'Encontrados pela IA para contexto — o reconhecimento é automático.'],
['Name', 'Nome'],
['optional', 'opcional'],
['Registered', 'Registrado'],
['Confirm & Report →', 'Confirmar e registrar →'],
['Confirm without AI →', 'Confirmar sem IA →'],
['Discard Photo', 'Descartar foto'],
['← Take another photo', '← Tirar outra foto'],
['Analyzing...', 'Analisando...'],
['Saving...', 'Salvando...'],
['Saving…', 'Salvando…'],
['Error saving. Please try again.', 'Erro ao salvar. Tente novamente.'],
// help-proof.js emits this exact PT sentence; pair it (PT→EN) for EN mode.
['Error saving. Please try again.', 'Erro ao registrar. Tente novamente.'],
// Color options
['Black', 'Preto'], ['White', 'Branco'], ['Gray', 'Cinza'], ['Brown', 'Marrom'],
['Chocolate', 'Chocolate'], ['Caramel', 'Caramelo'], ['Tan', 'Bege'], ['Golden', 'Dourado'],
['Cream', 'Creme'], ['Fawn', 'Fulvo'], ['Orange', 'Laranja'], ['Ginger', 'Ruivo'],
['Red', 'Vermelho'], ['Sable', 'Zibelina'], ['Cinnamon', 'Canela'], ['Blue (gray)', 'Azul (cinza)'],
['Lilac', 'Lilás'], ['Tabby', 'Tigrado'], ['Tuxedo', 'Smoking'], ['Calico', 'Calico'],
['Tortoiseshell', 'Escaminha'], ['Tricolor', 'Tricolor'], ['Bicolor', 'Bicolor'],
['Brindle', 'Rajado'], ['Merle', 'Merle'], ['Spotted', 'Malhado'], ['Mixed', 'Misto'],
// Size options + labels
['Tiny', 'Mini'], ['Small', 'Pequeno'], ['Medium', 'Médio'], ['Large', 'Grande'],
['Extra Large', 'Extra Grande'], ['Giant', 'Gigante'],
['↕ Tiny', '↕ Mini'], ['↕ Small', '↕ Pequeno'], ['↕ Medium', '↕ Médio'],
['↕ Large', '↕ Grande'], ['↕ Extra Large', '↕ Extra Grande'], ['↕ Giant', '↕ Gigante'],
// Confirm screen
['Sighting', 'Avistamento'],
['Reported!', 'registrado!'],
['Animal:', 'Animal:'],
['Location:', 'Local:'],
['Time:', 'Hora:'],
['AI Identification', 'Identificação da IA'],
['Breed', 'Raça'],
['Color', 'Cor'],
['Condition', 'Estado'],
['Report Another', 'Registrar outro'],
['View on Map', 'Ver no mapa'],
// Sightings list
['Loading...', 'Carregando...'],
['Failed to load. Try again.', 'Falha ao carregar. Tente novamente.'],
['No animals on record yet.', 'Nenhum animal registrado ainda.'],
['Go to the Report tab to get started!', 'Vá para a aba Registrar para começar!'],
['Unknown breed', 'Raça desconhecida'],
['Unknown', 'Desconhecida'],
['today', 'hoje'],
['yesterday', 'ontem'],
// Profile
['This animal has been helped by the community', 'Este animal foi ajudado pela comunidade'],
['Identification', 'Identificação'],
['Sightings Gallery', 'Galeria de avistamentos'],
['See all', 'Ver todos'],
['Community Help', 'Ajuda da comunidade'],
['Recent Path', 'Trajeto recente'],
['I Want to Adopt / Help', 'Quero adotar / ajudar'],
['Seen today', 'Visto hoje'],
['Seen yesterday', 'Visto ontem'],
['No photos recorded.', 'Nenhuma foto registrada.'],
['Location not recorded', 'Local não registrado'],
['No locations recorded', 'Nenhum local registrado'],
['Failed to load', 'Falha ao carregar'],
// Help types
['Fed', 'Alimentou'],
['Took to vet', 'Levou ao veterinário'],
['Adopted', 'Adotou'],
['Rescued', 'Resgatou'],
['Helped', 'Ajudou'],
['Other', 'Outro'],
// Help sheet
['Share this animal', 'Compartilhar este animal'],
['Send via WhatsApp or copy link', 'Enviar pelo WhatsApp ou copiar link'],
['Get directions', 'Ver rota'],
['Navigate to last known location', 'Navegar até o último local conhecido'],
['I helped this animal', 'Eu ajudei este animal'],
['Log that you fed, treated, or fostered them', 'Registre que você alimentou, tratou ou acolheu'],
['Cancel', 'Cancelar'],
['No location recorded for this animal yet.', 'Nenhum local registrado para este animal ainda.'],
// Helped confirm
['Thank you', 'Obrigada'],
['Your help has been recorded.', 'Sua ajuda foi registrada.'],
['Every act of care makes a difference.', 'Cada gesto de cuidado faz diferença.'],
['Done', 'Concluído'],
// Source chooser (hardcoded PT in source)
['Take photo', 'Tirar foto'],
['Open the camera', 'Abrir a câmera'],
['Choose from gallery', 'Escolher da galeria'],
['Select an existing photo', 'Selecionar uma foto existente'],
// Map picker (hardcoded PT in source)
['Where did you see the animal?', 'Onde você viu o animal?'],
['Search address or neighborhood…', 'Buscar endereço ou bairro…'],
['Use my location', 'Usar minha localização'],
['Drag the map to position the pin', 'Arraste o mapa para posicionar o pin'],
['Confirm location', 'Confirmar localização'],
// Help proof
['I Helped This Animal', 'Eu ajudei este animal'],
['What did you do?', 'O que você fez?'],
['Add a photo', 'Adicione uma foto'],
['Tap to add photo proof', 'Toque para adicionar uma foto de prova'],
['Verifying with AI…', 'Verificando com IA…'],
['Same animal confirmed', 'Mesmo animal confirmado'],
['Likely the same animal', 'Provavelmente o mesmo animal'],
['Photo added — animal not matched', 'Foto adicionada — animal não correspondido'],
['No animal detected in photo', 'Nenhum animal detectado na foto'],
['Tell the story', 'Conte a história'],
['What happened? Where did you take them?', 'O que aconteceu? Para onde você o levou?'],
['Register Help', 'Registrar ajuda'],
// Photo menu
['Download photo', 'Baixar foto'],
// Bottom nav
['Map', 'Mapa'],
['Report', 'Registrar'],
['Sightings', 'Avistamentos'],
// Right sidebar
['Built for', 'Feito para'],
['Backyard AI track · Hugging Face', 'Trilha Backyard AI · Hugging Face'],
['Tech stack', 'Tecnologias'],
['Vision AI', 'IA de visão'],
['Reasoning brain', 'Cérebro de raciocínio'],
['Matching', 'Correspondência'],
['Frontend', 'Frontend'],
['Storage', 'Armazenamento'],
['Help an animal today', 'Ajude um animal hoje'],
['Live Space ↗', 'Space ao vivo ↗'],
['Dataset ↗', 'Dataset ↗'],
// aria / titles
['Menu', 'Menu'],
['Options', 'Opções'],
['Close', 'Fechar'],
['Refresh', 'Atualizar']
];
/* ── Build lookup maps ── */
var enToPt = Object.create(null);
var ptToEn = Object.create(null);
PAIRS.forEach(function (p) {
var en = p[0], pt = p[1];
if (!(en in enToPt)) enToPt[en] = pt;
if (!(pt in ptToEn)) ptToEn[pt] = en;
});
/* ── Helpers for interpolated strings (EN → PT, PT mode only) ── */
function plural(n, sing, plur) { return n === 1 ? sing : plur; }
function dayPhrasePt(s) {
if (s === 'today') return 'hoje';
if (s === 'yesterday') return 'ontem';
var m = s.match(/^(\d+) days ago$/);
if (m) return 'há ' + m[1] + ' dias';
return s;
}
var PATTERNS = [
[/^(\d+)d ago$/, function (m) { return 'há ' + m[1] + 'd'; }],
[/^(\d+) days ago$/, function (m) { return 'há ' + m[1] + ' dias'; }],
[/^Seen (\d+) days ago$/, function (m) { return 'Visto há ' + m[1] + ' dias'; }],
[/^Not seen for (\d+)d$/, function (m) { return 'Sem ser visto há ' + m[1] + 'd'; }],
[/^(\d+) animals? on record$/, function (m) {
var n = +m[1]; return n + ' ' + plural(n, 'animal registrado', 'animais registrados');
}],
[/^(\d+) sightings?$/, function (m) {
var n = +m[1]; return n + ' ' + plural(n, 'avistamento', 'avistamentos');
}],
[/^(\d+) sightings? · last: (.+)$/, function (m) {
var n = +m[1]; return n + ' ' + plural(n, 'avistamento', 'avistamentos') + ' · último: ' + m[2];
}],
[/^Seen (.+) · (\d+)x spotted$/, function (m) {
return 'Visto ' + dayPhrasePt(m[1]) + ' · ' + m[2] + 'x avistado';
}],
[/^Animal #(\d+)$/, function (m) { return 'Animal nº' + m[1]; }],
[/^Dog #(\d+)$/, function (m) { return 'Cachorro nº' + m[1]; }],
[/^Cat #(\d+)$/, function (m) { return 'Gato nº' + m[1]; }],
// "Dog Caramel" / "Cat White" — species + known color
[/^(Dog|Cat) (.+)$/, function (m) {
var col = m[2].toLowerCase();
if (!COLORS[col]) return null;
return ptSpecies(m[1].toLowerCase()) + ' ' + COLORS[col];
}],
// GPS coords with an English source label appended
[/^(.+) · from photo$/, function (m) { return m[1] + ' · da foto'; }],
// share text
[/^Help this stray animal find a home! Track their location on PawMap: (.+)$/, function (m) {
return 'Ajude este animal de rua a encontrar um lar! Acompanhe a localização no PawMap: ' + m[1];
}]
];
/* ── Core translation for a string ── */
function translateString(text) {
var key = text.trim();
if (!key) return null;
if (LANG === 'pt') {
if (enToPt[key] !== undefined && enToPt[key] !== key) return rewrap(text, key, enToPt[key]);
for (var i = 0; i < PATTERNS.length; i++) {
var m = key.match(PATTERNS[i][0]);
if (m) {
var out = PATTERNS[i][1](m);
if (out != null && out !== key) return rewrap(text, key, out);
}
}
return null;
} else { // EN: only fix hardcoded-PT source strings
if (ptToEn[key] !== undefined && ptToEn[key] !== key) return rewrap(text, key, ptToEn[key]);
return null;
}
}
// preserve any leading/trailing whitespace of the original node text
function rewrap(orig, trimmed, replacement) {
var idx = orig.indexOf(trimmed);
if (idx < 0) return replacement;
return orig.slice(0, idx) + replacement + orig.slice(idx + trimmed.length);
}
/* ── DOM walking ── */
var SKIP_TAGS = { SCRIPT: 1, STYLE: 1, NOSCRIPT: 1, TEXTAREA: 1 };
function shouldSkip(node) {
var p = node.parentNode;
while (p && p.nodeType === 1) {
if (SKIP_TAGS[p.tagName]) return true;
if (p.hasAttribute && p.hasAttribute('data-noi18n')) return true;
if (p.classList && p.classList.contains('leaflet-container')) return true;
p = p.parentNode;
}
return false;
}
var applying = false;
function localizeTextNode(node) {
if (!node.nodeValue) return;
if (shouldSkip(node)) return;
var out = translateString(node.nodeValue);
if (out != null && out !== node.nodeValue) node.nodeValue = out;
}
var ATTRS = ['placeholder', 'title', 'aria-label'];
function localizeAttrs(el) {
if (!el || !el.getAttribute) return;
if (el.hasAttribute('data-noi18n')) return;
for (var i = 0; i < ATTRS.length; i++) {
var v = el.getAttribute(ATTRS[i]);
if (!v) continue;
var out = translateString(v);
if (out != null && out !== v) el.setAttribute(ATTRS[i], out);
}
}
function walk(root) {
if (root.nodeType === 3) { localizeTextNode(root); return; }
if (root.nodeType !== 1) return;
if (SKIP_TAGS[root.tagName]) return;
if (root.classList && root.classList.contains('leaflet-container')) return;
if (root.hasAttribute && root.hasAttribute('data-noi18n')) return;
localizeAttrs(root);
var tw = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null, false);
var batch = [];
var n;
while ((n = tw.nextNode())) batch.push(n);
batch.forEach(localizeTextNode);
var els = root.querySelectorAll('[placeholder],[title],[aria-label]');
for (var i = 0; i < els.length; i++) localizeAttrs(els[i]);
}
function run(fn) {
if (applying) return fn();
applying = true;
try { fn(); } finally { applying = false; }
}
/* ── Initial sweep + observer ── */
function initialSweep() {
run(function () { walk(document.body); });
}
function startObserver() {
var appEl = document.getElementById('app');
if (!appEl) return;
var obs = new MutationObserver(function (mutations) {
if (applying) return;
run(function () {
for (var i = 0; i < mutations.length; i++) {
var mu = mutations[i];
if (mu.type === 'characterData') {
localizeTextNode(mu.target);
} else if (mu.type === 'attributes') {
localizeAttrs(mu.target);
} else {
for (var j = 0; j < mu.addedNodes.length; j++) walk(mu.addedNodes[j]);
}
}
});
});
obs.observe(appEl, {
childList: true, subtree: true, characterData: true,
attributes: true, attributeFilter: ATTRS
});
}
/* ── Language toggle UI (the top-right three-dots button) ── */
function buildToggle() {
var header = document.getElementById('header');
if (!header) return;
var btns = header.querySelectorAll('.icon-btn');
var optBtn = btns.length ? btns[btns.length - 1] : null; // "Options" (three-dots) button
if (!optBtn) return;
var menu = document.createElement('div');
menu.id = 'lang-menu';
menu.setAttribute('data-noi18n', '');
menu.style.cssText =
'position:absolute;top:52px;right:10px;background:#fff;border-radius:12px;' +
'box-shadow:0 8px 28px rgba(0,0,0,.18);overflow:hidden;z-index:1200;display:none;' +
'min-width:150px;font-family:inherit;border:1px solid rgba(0,0,0,.06);';
function row(code, label) {
var active = LANG === code;
return '<button data-lang="' + code + '" style="display:flex;align-items:center;gap:8px;width:100%;' +
'border:none;background:' + (active ? '#F1F8F3' : '#fff') + ';padding:11px 14px;font-size:14px;' +
'color:#222;cursor:pointer;text-align:left;font-weight:' + (active ? '600' : '400') + ';">' +
'<span style="width:16px;display:inline-block;color:#388C59;">' + (active ? '✓' : '') + '</span>' +
label + '</button>';
}
menu.innerHTML = row('pt', 'Português') + row('en', 'English');
var app = document.getElementById('app');
(app || document.body).appendChild(menu);
function close() { menu.style.display = 'none'; }
optBtn.addEventListener('click', function (e) {
e.stopPropagation();
menu.style.display = menu.style.display === 'block' ? 'none' : 'block';
});
document.addEventListener('click', function (e) {
if (e.target.closest && e.target.closest('#lang-menu')) return;
close();
});
menu.addEventListener('click', function (e) {
var b = e.target.closest('[data-lang]');
if (!b) return;
var code = b.getAttribute('data-lang');
if (code === LANG) { close(); return; }
localStorage.setItem(LANG_KEY, code);
location.reload();
});
}
/* ── Boot ── */
function boot() {
buildToggle();
initialSweep();
startObserver();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot);
} else {
boot();
}
})();