From 4c42cd3b2e0e8891a69bdc5001be7a86c3562425 Mon Sep 17 00:00:00 2001 From: Jibles Date: Thu, 19 Feb 2026 07:12:19 +0700 Subject: [PATCH 01/10] feat: implement translations skill --- .claude/skills/benchmark-translate/SKILL.md | 140 +++++ .../scripts/compile-report.js | 495 ++++++++++++++++++ .../benchmark-translate/scripts/restore.js | 27 + .../scripts/select-keys.js | 251 +++++++++ .../benchmark-translate/scripts/setup.js | 119 +++++ .claude/skills/translate/SKILL.md | 380 ++++++++++++++ .claude/skills/translate/locales/de.md | 12 + .claude/skills/translate/locales/es.md | 7 + .claude/skills/translate/locales/fr.md | 11 + .claude/skills/translate/locales/ja.md | 14 + .claude/skills/translate/locales/pt.md | 9 + .claude/skills/translate/locales/ru.md | 10 + .claude/skills/translate/locales/tr.md | 7 + .claude/skills/translate/locales/uk.md | 10 + .claude/skills/translate/locales/zh.md | 11 + .claude/skills/translate/scripts/diff.js | 25 + .claude/skills/translate/scripts/merge.js | 52 ++ .../skills/translate/scripts/missing-keys.js | 26 + .../translate/scripts/prepare-locale.js | 91 ++++ .../skills/translate/scripts/term-context.js | 127 +++++ .claude/skills/translate/scripts/validate.js | 185 +++++++ .gitignore | 3 + src/assets/translations/glossary.json | 304 +++++++++++ 23 files changed, 2316 insertions(+) create mode 100644 .claude/skills/benchmark-translate/SKILL.md create mode 100644 .claude/skills/benchmark-translate/scripts/compile-report.js create mode 100644 .claude/skills/benchmark-translate/scripts/restore.js create mode 100644 .claude/skills/benchmark-translate/scripts/select-keys.js create mode 100644 .claude/skills/benchmark-translate/scripts/setup.js create mode 100644 .claude/skills/translate/SKILL.md create mode 100644 .claude/skills/translate/locales/de.md create mode 100644 .claude/skills/translate/locales/es.md create mode 100644 .claude/skills/translate/locales/fr.md create mode 100644 .claude/skills/translate/locales/ja.md create mode 100644 .claude/skills/translate/locales/pt.md create mode 100644 .claude/skills/translate/locales/ru.md create mode 100644 .claude/skills/translate/locales/tr.md create mode 100644 .claude/skills/translate/locales/uk.md create mode 100644 .claude/skills/translate/locales/zh.md create mode 100644 .claude/skills/translate/scripts/diff.js create mode 100644 .claude/skills/translate/scripts/merge.js create mode 100644 .claude/skills/translate/scripts/missing-keys.js create mode 100644 .claude/skills/translate/scripts/prepare-locale.js create mode 100644 .claude/skills/translate/scripts/term-context.js create mode 100644 .claude/skills/translate/scripts/validate.js create mode 100644 src/assets/translations/glossary.json diff --git a/.claude/skills/benchmark-translate/SKILL.md b/.claude/skills/benchmark-translate/SKILL.md new file mode 100644 index 00000000000..d196fea43c7 --- /dev/null +++ b/.claude/skills/benchmark-translate/SKILL.md @@ -0,0 +1,140 @@ +--- +name: benchmark-translate +description: Run a quality benchmark of the /translate skill by selecting stratified test keys, capturing ground truth, translating, judging with sub-agents, and compiling a regression report. Invoke with /benchmark-translate. +allowed-tools: Read, Write, Edit, Grep, Glob, Bash(node *), Bash(git checkout*), Bash(git diff*), Bash(git status*), Bash(git rev-parse*), Task, Skill, AskUserQuestion +--- + +# Translation Quality Benchmark + +Measures the quality of the `/translate` skill by comparing its output against existing human translations. Uses stratified key selection with a fixed/rotating split, LLM judges, and programmatic validation to produce a comprehensive quality report with regression tracking across all 9 supported locales. + +## Data Artifacts + +All benchmark data lives in `scripts/translations/benchmark/` (gitignored): + +| File | Purpose | +|------|---------| +| `testKeys.json` | Selected test keys with categories and `fixed` flag | +| `coreKeys.json` | Persistent core key set (stable across runs) | +| `ground-truth.json` | Captured human translations before removal | +| `report.json` | Latest benchmark report (becomes baseline on next run) | +| `baseline.json` | Previous report (auto-copied by setup.js) | + +## Pipeline (7 Steps) + +### Step 1: Select Keys + +```bash +node .claude/skills/benchmark-translate/scripts/select-keys.js [--count N] [--core N] +``` + +Selects N keys (default 150) stratified across 6 categories: glossary-term, financial-error, single-word, interpolation, defi-jargon, general. Validates all selected keys exist in en + all 9 locales. + +**Fixed/rotating split:** +- `--core N` (default 100): Number of fixed core keys for stable regression tracking +- `--count N` (default 150): Total keys (core + rotating) +- If `coreKeys.json` exists: loads it, validates keys still exist in all locales, tops up if needed +- If `coreKeys.json` doesn't exist: selects core keys via stratified sampling and saves them +- Remaining keys (default 50) are randomly selected as rotating keys from the non-core pool +- Each entry in `testKeys.json` has `"fixed": true` (core) or `"fixed": false` (rotating) + +Outputs `scripts/translations/benchmark/testKeys.json`. + +### Step 2: Setup + +```bash +node .claude/skills/benchmark-translate/scripts/setup.js +``` + +- If `report.json` exists from a previous run, copies it to `baseline.json` +- Reads `testKeys.json`, captures ground truth translations for all 9 locales +- Writes `ground-truth.json` +- Removes test keys from locale files so `/translate` can regenerate them + +### Step 3: Translate + +Invoke the `/translate` skill using the Skill tool. This regenerates the removed keys through the full translate-review-refine pipeline. + +### Step 4: Judge (Sub-Agents) + +Launch **9 sub-agents in 3 waves of 3** (matching `/translate`'s wave structure) using the Task tool. Each sub-agent receives the locale info, all key triplets, and glossary terms. + +**Wave 1:** de, es, fr +**Wave 2:** pt, ru, tr +**Wave 3:** ja, uk, zh + +**For each locale, use this prompt:** + +``` +You are an expert multilingual localization quality assessor for a cryptocurrency/DeFi application. +Rate translations from English into {LANGUAGE_NAME} on a 1-5 scale. + +1 = Wrong/misleading meaning +2 = Significant issues (wrong register, missing nuance) +3 = Acceptable but could be more natural +4 = Good, natural, accurate +5 = Excellent, indistinguishable from professional native translation + +Check: meaning preservation, naturalness, register ({REGISTER}), UI conciseness, +glossary compliance (these stay English: {NEVER_TRANSLATE_TERMS}), +placeholder integrity (%{...} preserved), DeFi terminology conventions. + +Rate each translation INDEPENDENTLY. Community translations can contain errors. + +Input: JSON array of {key, english, human, skill} +{ITEMS_JSON} + +Output: Return ONLY a JSON array of objects with these exact fields: +{key, humanScore, skillScore, humanJustification, skillJustification, preferenceNote} + +Scores must be integers 1-5. Justifications should be 1-2 sentences. preferenceNote should say which is better and why, or "tie" if equal. +``` + +**Locale info for prompt substitution:** + +| Locale | Language | Register | +|--------|----------|----------| +| `de` | German | Formal (Sie) | +| `es` | Spanish | Informal (tú) | +| `fr` | French | Formal (vous) | +| `ja` | Japanese | Polite (です/ます) | +| `pt` | Portuguese | Informal (você) | +| `ru` | Russian | Formal (вы) | +| `tr` | Turkish | Formal (siz) | +| `uk` | Ukrainian | Formal (ви) | +| `zh` | Chinese (Simplified) | Neutral/formal | + +**Building the items array for each locale:** + +1. Read `scripts/translations/benchmark/ground-truth.json` +2. Read the current (post-translate) `src/assets/translations/{locale}/main.json` +3. For each test key, build: `{ key: dottedPath, english: groundTruth.english[key], human: groundTruth.groundTruth[locale][key], skill: getValueFromLocaleFile(key) }` + +**Getting never-translate terms:** Read `src/assets/translations/glossary.json`, collect all keys where value is `null` (excluding `_meta`). + +**Each sub-agent must write its output** to `/tmp/{locale}-judge-scores.json`. Parse the JSON array from the sub-agent's response and write it to that path. + +### Step 5: Compile Report + +```bash +node .claude/skills/benchmark-translate/scripts/compile-report.js +``` + +Loads judge scores from `/tmp/{locale}-judge-scores.json`, runs programmatic validation (including Cyrillic script check for ru/uk), computes summary stats, and writes `scripts/translations/benchmark/report.json`. If `baseline.json` exists, includes regression deltas. Report includes `coreSummary` and `rotatingSummary` alongside the overall `summary`. + +### Step 6: Restore + +```bash +node .claude/skills/benchmark-translate/scripts/restore.js +``` + +Restores locale files via `git checkout --`, verifies no diff remains. + +### Step 7: Present Results + +Read the compile output (printed to stdout) and present to the user: +- Overall score summary with baseline regression (if available) +- Core vs rotating stats (divergence suggests overfitting) +- Notable improvements/regressions +- Per-locale and per-category highlights +- Any items needing attention (low scores, validation failures, glossary issues) diff --git a/.claude/skills/benchmark-translate/scripts/compile-report.js b/.claude/skills/benchmark-translate/scripts/compile-report.js new file mode 100644 index 00000000000..ca65ec28aa1 --- /dev/null +++ b/.claude/skills/benchmark-translate/scripts/compile-report.js @@ -0,0 +1,495 @@ +const fs = require('fs') +const path = require('path') + +const TRANSLATIONS_DIR = path.resolve(__dirname, '../../../../src/assets/translations') +const BENCHMARK_DIR = path.resolve(__dirname, '../../../../scripts/translations/benchmark') +const GT_PATH = path.join(BENCHMARK_DIR, 'ground-truth.json') +const REPORT_PATH = path.join(BENCHMARK_DIR, 'report.json') +const BASELINE_PATH = path.join(BENCHMARK_DIR, 'baseline.json') +const GLOSSARY_PATH = path.join(TRANSLATIONS_DIR, 'glossary.json') + +const TEST_LOCALES = ['de', 'es', 'fr', 'ja', 'pt', 'ru', 'tr', 'uk', 'zh'] +const CJK_LOCALES = ['ja', 'zh'] +const CYRILLIC_LOCALES = ['ru', 'uk'] + +const LOCALE_INFO = { + de: { name: 'German', register: 'Formal (Sie)' }, + es: { name: 'Spanish', register: 'Informal (tú)' }, + fr: { name: 'French', register: 'Formal (vous)' }, + ja: { name: 'Japanese', register: 'Polite (です/ます)' }, + pt: { name: 'Portuguese', register: 'Informal (você)' }, + ru: { name: 'Russian', register: 'Formal (вы)' }, + tr: { name: 'Turkish', register: 'Formal (siz)' }, + uk: { name: 'Ukrainian', register: 'Formal (ви)' }, + zh: { name: 'Chinese (Simplified)', register: 'Neutral/formal' }, +} + +const GLOSSARY_TARGET_TERMS = [ + { englishPattern: 'dust', glossaryKey: 'dust (crypto)', isNeverTranslate: true }, + { englishPattern: 'claim', glossaryKey: 'claim (DeFi rewards)', isNeverTranslate: false }, + { englishPattern: 'trade', glossaryKey: 'trade (action)', isNeverTranslate: false }, + { englishPattern: 'impermanent loss', glossaryKey: 'impermanent loss', isNeverTranslate: false }, + { englishPattern: 'approve', glossaryKey: 'approve (on-chain)', isNeverTranslate: false }, + { englishPattern: 'seed phrase', glossaryKey: 'seed phrase', isNeverTranslate: false }, + { englishPattern: 'deposit', glossaryKey: 'deposit (funds)', isNeverTranslate: false }, + { englishPattern: 'staking', glossaryKey: 'staking', isNeverTranslate: false }, + { englishPattern: 'swap', glossaryKey: 'swap', isNeverTranslate: false }, + { englishPattern: 'wallet', glossaryKey: 'wallet', isNeverTranslate: false }, + { englishPattern: 'insufficient funds', glossaryKey: 'insufficient funds', isNeverTranslate: false }, +] + +function getVal(obj, p) { + return p.split('.').reduce((o, k) => o && o[k], obj) +} + +function extractPh(s) { + return [...s.matchAll(/%\{(\w+)\}/g)].map(m => m[1]).sort() +} + +function escRe(s) { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +if (!fs.existsSync(GT_PATH)) { + console.error('Error: ground-truth.json not found. Run setup.js first.') + process.exit(1) +} + +const gt = JSON.parse(fs.readFileSync(GT_PATH, 'utf8')) +const glossary = JSON.parse(fs.readFileSync(GLOSSARY_PATH, 'utf8')) + +const neverTranslateTerms = Object.entries(glossary) + .filter(([k, v]) => k !== '_meta' && v === null) + .map(([k]) => k) + +const approvedByLocale = {} +for (const [key, value] of Object.entries(glossary)) { + if (key === '_meta' || value === null || typeof value !== 'object') continue + for (const locale of TEST_LOCALES) { + if (!approvedByLocale[locale]) approvedByLocale[locale] = [] + if (value[locale]) approvedByLocale[locale].push({ term: key, approved: value[locale] }) + } +} + +const judgeScores = {} +let hasJudgeScores = true +for (const locale of TEST_LOCALES) { + const scorePath = `/tmp/${locale}-judge-scores.json` + if (!fs.existsSync(scorePath)) { + console.error(`Warning: ${scorePath} not found — skipping judge scores for ${locale}`) + hasJudgeScores = false + judgeScores[locale] = new Map() + continue + } + const scores = JSON.parse(fs.readFileSync(scorePath, 'utf8')) + judgeScores[locale] = new Map() + for (const s of scores) { + judgeScores[locale].set(s.key, s) + } +} + +function checkGlossaryCorrectness(english, skill, locale) { + const englishLower = english.toLowerCase() + const matched = [] + for (const term of GLOSSARY_TARGET_TERMS) { + if (!englishLower.includes(term.englishPattern)) continue + if (term.isNeverTranslate) { + const has = skill.toLowerCase().includes(term.englishPattern) + matched.push({ passed: has, reason: has ? `"${term.englishPattern}" kept` : `"${term.englishPattern}" missing` }) + } else { + const entry = glossary[term.glossaryKey] + if (entry && typeof entry === 'object' && entry[locale]) { + const has = skill.toLowerCase().includes(entry[locale].toLowerCase()) + matched.push({ passed: has, reason: has ? `"${term.englishPattern}" → "${entry[locale]}"` : `"${term.englishPattern}" should be "${entry[locale]}"` }) + } + } + } + if (matched.length === 0) return { correct: null, detail: null } + return { correct: matched.every(m => m.passed), detail: matched.map(m => m.reason).join('; ') } +} + +function validate(english, skill, locale) { + const isCjk = CJK_LOCALES.includes(locale) + const isCyrillic = CYRILLIC_LOCALES.includes(locale) + const isEmpty = skill.trim().length === 0 + const words = english.trim().split(/\s+/).length + const isUntranslated = skill === english && words > 3 + const srcPh = extractPh(english) + const tgtPh = extractPh(skill) + const placeholderIntegrity = srcPh.length === tgtPh.length && srcPh.every((p, i) => p === tgtPh[i]) + const ratio = english.length > 0 ? skill.length / english.length : 1 + const lengthRatioOk = ratio <= (isCjk ? 4.0 : 3.0) && ratio >= (isCjk ? 0.15 : 0.25) + + const glossaryViolations = [] + for (const term of neverTranslateTerms) { + if (english.toLowerCase().includes(term.toLowerCase()) && !skill.toLowerCase().includes(term.toLowerCase())) { + glossaryViolations.push(`"${term}" should stay in English`) + } + } + for (const { term, approved } of (approvedByLocale[locale] || [])) { + const tl = term.toLowerCase() + if (english.toLowerCase().includes(tl) && !skill.toLowerCase().includes(approved.toLowerCase())) { + glossaryViolations.push(`"${term}" should be "${approved}"`) + } + } + + const { correct: glossaryCorrectness, detail: glossaryCorrectnessDetail } = checkGlossaryCorrectness(english, skill, locale) + + let scriptCorrect = true + if ((isCjk || isCyrillic) && !isEmpty && !isUntranslated) { + let stripped = skill.replace(/%\{\w+\}/g, '') + stripped = neverTranslateTerms.reduce((s, t) => s.replace(new RegExp(escRe(t), 'g'), ''), stripped) + const nw = stripped.replace(/\s/g, '') + if (nw.length > 0) { + const latin = nw.replace(/[^a-zA-Z]/g, '').length + scriptCorrect = latin / nw.length <= 0.7 + } + } + + return { + placeholderIntegrity, lengthRatioOk, lengthRatio: Math.round(ratio * 100) / 100, + glossaryCompliance: glossaryViolations.length === 0, glossaryViolations, + glossaryCorrectness, glossaryCorrectnessDetail, + scriptCorrect, isEmpty, isUntranslated, + } +} + +const results = [] +for (const locale of TEST_LOCALES) { + const localeData = JSON.parse(fs.readFileSync(path.join(TRANSLATIONS_DIR, `${locale}/main.json`), 'utf8')) + for (const tk of gt.testKeys) { + const english = gt.english[tk.dottedPath] + const human = gt.groundTruth[locale][tk.dottedPath] + const skill = getVal(localeData, tk.dottedPath) || '' + const exactMatch = skill === human + const validation = validate(english, skill, locale) + const js = judgeScores[locale].get(tk.dottedPath) + const judgeScore = js ? { + humanScore: js.humanScore, skillScore: js.skillScore, + humanJustification: js.humanJustification, skillJustification: js.skillJustification, + preferenceNote: js.preferenceNote, + } : null + results.push({ dottedPath: tk.dottedPath, category: tk.category, fixed: tk.fixed, locale, english, humanTranslation: human, skillTranslation: skill, exactMatch, validation, judgeScore }) + } +} + +function computeStats(subset) { + const total = subset.length + const exactMatchCount = subset.filter(r => r.exactMatch).length + const validPass = subset.filter(r => + r.validation.placeholderIntegrity && r.validation.lengthRatioOk && + r.validation.glossaryCompliance && r.validation.scriptCorrect && + !r.validation.isEmpty && !r.validation.isUntranslated + ).length + const judged = subset.filter(r => r.judgeScore) + const avgHuman = judged.length > 0 ? judged.reduce((s, r) => s + r.judgeScore.humanScore, 0) / judged.length : null + const avgSkill = judged.length > 0 ? judged.reduce((s, r) => s + r.judgeScore.skillScore, 0) / judged.length : null + const humanWins = judged.filter(r => r.judgeScore.humanScore > r.judgeScore.skillScore).length + const skillWins = judged.filter(r => r.judgeScore.skillScore > r.judgeScore.humanScore).length + const ties = judged.filter(r => r.judgeScore.humanScore === r.judgeScore.skillScore).length + const lowScores = judged.filter(r => r.judgeScore.skillScore <= 3).length + return { total, exactMatchCount, validPass, judged, avgHuman, avgSkill, humanWins, skillWins, ties, lowScores } +} + +const overall = computeStats(results) +const coreResults = results.filter(r => r.fixed === true) +const rotatingResults = results.filter(r => r.fixed === false) +const coreStats = computeStats(coreResults) +const rotatingStats = computeStats(rotatingResults) + +const gcResults = results.filter(r => r.validation.glossaryCorrectness !== null) +const gcPass = gcResults.filter(r => r.validation.glossaryCorrectness === true).length + +const baseline = fs.existsSync(BASELINE_PATH) + ? JSON.parse(fs.readFileSync(BASELINE_PATH, 'utf8')) + : null + +console.log('\n=== Translation Benchmark Report ===\n') +console.log(`Ground truth SHA: ${gt.gitSha}`) +console.log(`Total comparisons: ${overall.total} (${gt.testKeys.length} keys × ${TEST_LOCALES.length} locales)\n`) + +console.log('--- Overall Summary ---') +console.log(`Exact match rate: ${overall.exactMatchCount}/${overall.total} (${((overall.exactMatchCount / overall.total) * 100).toFixed(1)}%)`) +console.log(`Validation pass rate: ${overall.validPass}/${overall.total} (${((overall.validPass / overall.total) * 100).toFixed(1)}%)`) +if (overall.avgHuman !== null && overall.avgSkill !== null) { + console.log(`Avg human score: ${overall.avgHuman.toFixed(2)}/5`) + console.log(`Avg skill score: ${overall.avgSkill.toFixed(2)}/5`) + console.log(`Human wins: ${overall.humanWins} | Skill wins: ${overall.skillWins} | Ties: ${overall.ties}`) + console.log(`Low skill scores (≤3): ${overall.lowScores}`) +} + +const coreKeyCount = gt.testKeys.filter(k => k.fixed === true).length +const rotatingKeyCount = gt.testKeys.filter(k => k.fixed === false).length +console.log('\n--- Core vs Rotating ---') +const coreLine = coreStats.avgSkill !== null + ? `Core (${coreKeyCount} fixed keys): skill ${coreStats.avgSkill.toFixed(2)} valid ${((coreStats.validPass / coreStats.total) * 100).toFixed(1)}%` + : `Core (${coreKeyCount} fixed keys): valid ${((coreStats.validPass / coreStats.total) * 100).toFixed(1)}%` +const rotatingLine = rotatingStats.avgSkill !== null + ? `Rotating (${rotatingKeyCount} keys): skill ${rotatingStats.avgSkill.toFixed(2)} valid ${((rotatingStats.validPass / rotatingStats.total) * 100).toFixed(1)}%` + : `Rotating (${rotatingKeyCount} keys): valid ${((rotatingStats.validPass / rotatingStats.total) * 100).toFixed(1)}%` +console.log(` ${coreLine}`) +console.log(` ${rotatingLine}`) + +console.log('\n--- By Category ---') +const cats = ['glossary-term', 'financial-error', 'single-word', 'interpolation', 'defi-jargon', 'general'] +for (const cat of cats) { + const cr = results.filter(r => r.category === cat) + if (cr.length === 0) continue + const cj = cr.filter(r => r.judgeScore) + const ch = cj.length > 0 ? cj.reduce((s, r) => s + r.judgeScore.humanScore, 0) / cj.length : null + const cs = cj.length > 0 ? cj.reduce((s, r) => s + r.judgeScore.skillScore, 0) / cj.length : null + const cv = cr.filter(r => r.validation.placeholderIntegrity && r.validation.lengthRatioOk && !r.validation.isEmpty && !r.validation.isUntranslated).length + const scoreStr = ch !== null && cs !== null ? ` | human: ${ch.toFixed(2)} skill: ${cs.toFixed(2)}` : '' + console.log(` ${cat.padEnd(20)} n=${cr.length.toString().padEnd(3)} valid: ${cv}/${cr.length}${scoreStr}`) +} + +console.log('\n--- By Locale ---') +for (const locale of TEST_LOCALES) { + const lr = results.filter(r => r.locale === locale) + const lj = lr.filter(r => r.judgeScore) + const lh = lj.length > 0 ? lj.reduce((s, r) => s + r.judgeScore.humanScore, 0) / lj.length : null + const ls = lj.length > 0 ? lj.reduce((s, r) => s + r.judgeScore.skillScore, 0) / lj.length : null + const lv = lr.filter(r => r.validation.placeholderIntegrity && r.validation.lengthRatioOk && !r.validation.isEmpty && !r.validation.isUntranslated).length + const sw = lj.filter(r => r.judgeScore.skillScore > r.judgeScore.humanScore).length + const hw = lj.filter(r => r.judgeScore.humanScore > r.judgeScore.skillScore).length + const ti = lj.filter(r => r.judgeScore.humanScore === r.judgeScore.skillScore).length + const scoreStr = lh !== null && ls !== null ? ` | human: ${lh.toFixed(2)} skill: ${ls.toFixed(2)} | wins: skill ${sw} human ${hw} tie ${ti}` : '' + console.log(` ${locale} valid: ${lv}/${lr.length}${scoreStr}`) +} + +if (overall.judged.length > 0) { + console.log('\n--- Score Difference Distribution (skill - human) ---') + const diffs = overall.judged.map(r => r.judgeScore.skillScore - r.judgeScore.humanScore) + const mean = diffs.reduce((s, d) => s + d, 0) / diffs.length + const variance = diffs.reduce((s, d) => s + (d - mean) ** 2, 0) / diffs.length + const stddev = Math.sqrt(variance) + const buckets = { '≤-3': 0, '-2': 0, '-1': 0, '0': 0, '+1': 0, '+2': 0, '≥+3': 0 } + for (const d of diffs) { + if (d <= -3) buckets['≤-3']++ + else if (d === -2) buckets['-2']++ + else if (d === -1) buckets['-1']++ + else if (d === 0) buckets['0']++ + else if (d === 1) buckets['+1']++ + else if (d === 2) buckets['+2']++ + else buckets['≥+3']++ + } + const maxBucket = Math.max(...Object.values(buckets)) + const barScale = maxBucket > 0 ? 40 / maxBucket : 1 + for (const [label, count] of Object.entries(buckets)) { + const bar = '█'.repeat(Math.round(count * barScale)) + console.log(` ${label.padStart(3)} ${bar} ${count}`) + } + console.log(` Mean: ${mean >= 0 ? '+' : ''}${mean.toFixed(2)} Stddev: ${stddev.toFixed(2)}`) +} + +console.log('\n--- UI Length Expansion Risk (>150% of English) ---') +const expansionThreshold = 1.5 +const expanded = results.filter(r => { + if (r.validation.isEmpty || r.validation.isUntranslated) return false + return r.english.length > 0 && r.skillTranslation.length / r.english.length > expansionThreshold +}) +const expansionByLocale = {} +for (const locale of TEST_LOCALES) { + const localeExpanded = expanded.filter(r => r.locale === locale) + expansionByLocale[locale] = localeExpanded + if (localeExpanded.length > 0) { + console.log(` ${locale}: ${localeExpanded.length} strings over 150%`) + } +} +if (expanded.length > 0) { + const sorted = expanded.toSorted((a, b) => (b.skillTranslation.length / b.english.length) - (a.skillTranslation.length / a.english.length)) + console.log(` Total: ${expanded.length} strings`) + console.log(` Worst 10:`) + for (const r of sorted.slice(0, 10)) { + const ratio = Math.round((r.skillTranslation.length / r.english.length) * 100) + console.log(` ${r.locale}:${r.dottedPath} (${ratio}%) EN[${r.english.length}ch] → ${r.locale.toUpperCase()}[${r.skillTranslation.length}ch]`) + } +} else { + console.log(' None found.') +} + +console.log('\n--- Glossary Term Correctness ---') +for (const tt of GLOSSARY_TARGET_TERMS) { + const tr = results.filter(r => r.english.toLowerCase().includes(tt.englishPattern)) + const checked = tr.filter(r => r.validation.glossaryCorrectness !== null) + const correct = checked.filter(r => r.validation.glossaryCorrectness === true).length + console.log(` "${tt.englishPattern}": ${correct}/${checked.length} correct (${checked.length > 0 ? ((correct / checked.length) * 100).toFixed(0) : 'N/A'}%)`) + for (const locale of TEST_LOCALES) { + const lc = checked.filter(r => r.locale === locale) + const lcOk = lc.filter(r => r.validation.glossaryCorrectness === true).length + if (lc.length > 0) console.log(` ${locale}: ${lcOk}/${lc.length}`) + } +} + +console.log('\n--- Validation Details ---') +const phFail = results.filter(r => !r.validation.placeholderIntegrity) +const lenFail = results.filter(r => !r.validation.lengthRatioOk) +const glosFail = results.filter(r => !r.validation.glossaryCompliance) +const scrFail = results.filter(r => !r.validation.scriptCorrect) +const empFail = results.filter(r => r.validation.isEmpty) +const untFail = results.filter(r => r.validation.isUntranslated) +console.log(` Placeholder failures: ${phFail.length}`) +console.log(` Length ratio OOB: ${lenFail.length}`) +console.log(` Glossary violations: ${glosFail.length}`) +console.log(` Wrong script: ${scrFail.length}`) +console.log(` Empty: ${empFail.length}`) +console.log(` Untranslated: ${untFail.length}`) + +if (phFail.length > 0) { + console.log('\n Placeholder failures:') + for (const r of phFail) { + console.log(` ${r.locale}:${r.dottedPath}`) + console.log(` EN: ${r.english}`) + console.log(` Skill: ${r.skillTranslation}`) + } +} + +if (glosFail.length > 0) { + console.log('\n Glossary violations:') + for (const r of glosFail) { + console.log(` ${r.locale}:${r.dottedPath} — ${r.validation.glossaryViolations.join('; ')}`) + } +} + +if (overall.judged.length > 0) { + console.log('\n--- Notable Results ---') + const skillBigWins = overall.judged.filter(r => r.judgeScore.skillScore - r.judgeScore.humanScore >= 2) + const humanBigWins = overall.judged.filter(r => r.judgeScore.humanScore - r.judgeScore.skillScore >= 2) + + if (skillBigWins.length > 0) { + console.log(`\n Skill significantly better (≥2 pts, ${skillBigWins.length} cases):`) + for (const r of skillBigWins) { + console.log(` ${r.locale}:${r.dottedPath} (human: ${r.judgeScore.humanScore} skill: ${r.judgeScore.skillScore})`) + console.log(` ${r.judgeScore.preferenceNote}`) + } + } + + if (humanBigWins.length > 0) { + console.log(`\n Human significantly better (≥2 pts, ${humanBigWins.length} cases):`) + for (const r of humanBigWins) { + console.log(` ${r.locale}:${r.dottedPath} (human: ${r.judgeScore.humanScore} skill: ${r.judgeScore.skillScore})`) + console.log(` ${r.judgeScore.preferenceNote}`) + } + } +} + +if (baseline && baseline.summary) { + const bs = baseline.summary + console.log('\n--- Regression vs Baseline ---') + console.log(` Comparisons: ${bs.totalComparisons} → ${overall.total}`) + + const r3vpr = Math.round((overall.validPass / overall.total) * 1000) / 10 + const r3as = overall.avgSkill !== null ? Math.round(overall.avgSkill * 100) / 100 : null + const r3ah = overall.avgHuman !== null ? Math.round(overall.avgHuman * 100) / 100 : null + + if (r3as !== null && bs.avgSkillScore !== null) { + console.log(` Avg skill score: ${bs.avgSkillScore} → ${r3as} (${r3as >= bs.avgSkillScore ? '+' : ''}${(r3as - bs.avgSkillScore).toFixed(2)})`) + } + if (r3ah !== null && bs.avgHumanScore !== null) { + console.log(` Avg human score: ${bs.avgHumanScore} → ${r3ah} (${r3ah >= bs.avgHumanScore ? '+' : ''}${(r3ah - bs.avgHumanScore).toFixed(2)})`) + } + if (bs.skillWins !== null && bs.totalComparisons > 0) { + const bsSwr = Math.round((bs.skillWins / bs.totalComparisons) * 1000) / 10 + const r3swr = overall.total > 0 ? Math.round((overall.skillWins / overall.total) * 1000) / 10 : null + if (r3swr !== null) { + console.log(` Skill win rate: ${bsSwr}% → ${r3swr}% (${r3swr >= bsSwr ? '+' : ''}${(r3swr - bsSwr).toFixed(1)}pp)`) + } + } + if (bs.lowScoreCount !== null) { + console.log(` Low scores (≤3): ${bs.lowScoreCount} → ${overall.lowScores}`) + } + if (bs.validationPassRate !== null) { + console.log(` Validation pass rate: ${bs.validationPassRate}% → ${r3vpr}%`) + } + const bsGlosViolations = baseline.results + ? baseline.results.filter(r => !r.validation.glossaryCompliance).length + : null + if (bsGlosViolations !== null) { + console.log(` Glossary violations: ${bsGlosViolations} → ${glosFail.length}`) + } + + if (baseline.coreSummary && coreStats.avgSkill !== null) { + const bcs = baseline.coreSummary + console.log('\n Core regression:') + if (bcs.avgSkillScore !== null) { + const coreSkill = Math.round(coreStats.avgSkill * 100) / 100 + console.log(` Avg skill score: ${bcs.avgSkillScore} → ${coreSkill} (${coreSkill >= bcs.avgSkillScore ? '+' : ''}${(coreSkill - bcs.avgSkillScore).toFixed(2)})`) + } + if (bcs.validationPassRate !== null) { + const coreVpr = Math.round((coreStats.validPass / coreStats.total) * 1000) / 10 + console.log(` Validation pass rate: ${bcs.validationPassRate}% → ${coreVpr}%`) + } + } + + if (baseline.results && baseline.results.length > 0) { + const baselineLocales = new Set(baseline.results.map(r => r.locale)) + const currentLocales = new Set(results.map(r => r.locale)) + const overlapping = TEST_LOCALES.filter(l => baselineLocales.has(l) && currentLocales.has(l)) + + if (overlapping.length < TEST_LOCALES.length || overlapping.length < baselineLocales.size) { + console.log(`\n Apples-to-apples (${overlapping.length} overlapping locales: ${overlapping.join(', ')}):`) + const bCoreKeys = new Set(baseline.results.filter(r => r.fixed === true).map(r => r.dottedPath)) + const cCoreKeys = new Set(coreResults.map(r => r.dottedPath)) + const sharedCoreKeys = [...bCoreKeys].filter(k => cCoreKeys.has(k)) + if (sharedCoreKeys.length > 0) { + const bShared = baseline.results.filter(r => overlapping.includes(r.locale) && r.fixed === true && sharedCoreKeys.includes(r.dottedPath) && r.judgeScore) + const cShared = results.filter(r => overlapping.includes(r.locale) && r.fixed === true && sharedCoreKeys.includes(r.dottedPath) && r.judgeScore) + const bAvg = bShared.length > 0 ? bShared.reduce((s, r) => s + r.judgeScore.skillScore, 0) / bShared.length : null + const cAvg = cShared.length > 0 ? cShared.reduce((s, r) => s + r.judgeScore.skillScore, 0) / cShared.length : null + if (bAvg !== null && cAvg !== null) { + const bAvgR = Math.round(bAvg * 100) / 100 + const cAvgR = Math.round(cAvg * 100) / 100 + console.log(` Shared core keys (${sharedCoreKeys.length} keys × ${overlapping.length} locales): skill ${bAvgR} → ${cAvgR} (${cAvgR >= bAvgR ? '+' : ''}${(cAvgR - bAvgR).toFixed(2)})`) + } + } + } + + console.log('\n Per-locale regression deltas:') + for (const locale of TEST_LOCALES) { + const bLocale = baseline.results.filter(r => r.locale === locale && r.judgeScore) + const cLocale = results.filter(r => r.locale === locale && r.judgeScore) + if (bLocale.length === 0 || cLocale.length === 0) continue + const bSkill = bLocale.reduce((s, r) => s + r.judgeScore.skillScore, 0) / bLocale.length + const cSkill = cLocale.reduce((s, r) => s + r.judgeScore.skillScore, 0) / cLocale.length + const delta = cSkill - bSkill + const bSkillR = Math.round(bSkill * 100) / 100 + const cSkillR = Math.round(cSkill * 100) / 100 + const arrow = delta > 0.05 ? '↑' : delta < -0.05 ? '↓' : '→' + console.log(` ${locale}: ${bSkillR} → ${cSkillR} (${delta >= 0 ? '+' : ''}${delta.toFixed(2)}) ${arrow}`) + } + } +} else { + console.log('\n--- No baseline found (first run) ---') +} + +function buildSummaryObj(stats) { + return { + totalComparisons: stats.total, + exactMatchCount: stats.exactMatchCount, + exactMatchRate: Math.round((stats.exactMatchCount / stats.total) * 1000) / 10, + validationPassCount: stats.validPass, + validationPassRate: Math.round((stats.validPass / stats.total) * 1000) / 10, + avgHumanScore: stats.avgHuman !== null ? Math.round(stats.avgHuman * 100) / 100 : null, + avgSkillScore: stats.avgSkill !== null ? Math.round(stats.avgSkill * 100) / 100 : null, + humanWins: stats.judged.length > 0 ? stats.humanWins : null, + skillWins: stats.judged.length > 0 ? stats.skillWins : null, + ties: stats.judged.length > 0 ? stats.ties : null, + lowScoreCount: stats.judged.length > 0 ? stats.lowScores : null, + } +} + +const report = { + generatedAt: new Date().toISOString(), + groundTruthSha: gt.gitSha, + summary: { + ...buildSummaryObj(overall), + glossaryCorrectnessChecked: gcResults.length, + glossaryCorrectnessPass: gcPass, + glossaryCorrectnessRate: gcResults.length > 0 ? Math.round((gcPass / gcResults.length) * 1000) / 10 : null, + }, + coreSummary: buildSummaryObj(coreStats), + rotatingSummary: buildSummaryObj(rotatingStats), + results, +} +fs.writeFileSync(REPORT_PATH, JSON.stringify(report, null, 2) + '\n') +console.log(`\nFull report written to ${REPORT_PATH}`) diff --git a/.claude/skills/benchmark-translate/scripts/restore.js b/.claude/skills/benchmark-translate/scripts/restore.js new file mode 100644 index 00000000000..17220235aeb --- /dev/null +++ b/.claude/skills/benchmark-translate/scripts/restore.js @@ -0,0 +1,27 @@ +const { execSync } = require('child_process') + +const TEST_LOCALES = ['de', 'es', 'fr', 'ja', 'pt', 'ru', 'tr', 'uk', 'zh'] + +function main() { + for (const locale of TEST_LOCALES) { + const filePath = `src/assets/translations/${locale}/main.json` + execSync(`git checkout -- ${filePath}`, { encoding: 'utf8' }) + console.log(`Restored ${filePath}`) + } + + console.log('\nAll locale files restored to their original state.') + + const diff = execSync( + `git diff --stat -- ${TEST_LOCALES.map(l => `src/assets/translations/${l}/main.json`).join(' ')}`, + { encoding: 'utf8' }, + ).trim() + + if (diff.length === 0) { + console.log('Verified: no diff from HEAD.') + } else { + console.error('Warning: unexpected diff remains:') + console.error(diff) + } +} + +main() diff --git a/.claude/skills/benchmark-translate/scripts/select-keys.js b/.claude/skills/benchmark-translate/scripts/select-keys.js new file mode 100644 index 00000000000..8fa3a18d356 --- /dev/null +++ b/.claude/skills/benchmark-translate/scripts/select-keys.js @@ -0,0 +1,251 @@ +const fs = require('fs') +const path = require('path') + +const TRANSLATIONS_DIR = path.resolve(__dirname, '../../../../src/assets/translations') +const BENCHMARK_DIR = path.resolve(__dirname, '../../../../scripts/translations/benchmark') +const OUTPUT_PATH = path.join(BENCHMARK_DIR, 'testKeys.json') +const CORE_KEYS_PATH = path.join(BENCHMARK_DIR, 'coreKeys.json') + +const TEST_LOCALES = ['de', 'es', 'fr', 'ja', 'pt', 'ru', 'tr', 'uk', 'zh'] + +function flatten(obj, prefix) { + prefix = prefix || '' + const entries = [] + for (const [key, value] of Object.entries(obj)) { + const fullPath = prefix ? `${prefix}.${key}` : key + if (typeof value === 'string') { + entries.push({ dottedPath: fullPath, value }) + } else if (typeof value === 'object' && value !== null) { + entries.push(...flatten(value, fullPath)) + } + } + return entries +} + +function getValueByPath(obj, dottedPath) { + const parts = dottedPath.split('.') + let current = obj + for (const part of parts) { + if (current === null || current === undefined || typeof current !== 'object') return undefined + current = current[part] + } + return typeof current === 'string' ? current : undefined +} + +function categorize(dottedPath, value) { + const lower = value.toLowerCase() + const pathLower = dottedPath.toLowerCase() + + if ( + lower.includes('dust') || + lower.includes('impermanent loss') || + lower.includes('claim') || + lower.includes('trade') + ) { + return 'glossary-term' + } + + if ( + pathLower.includes('.errors.') || + pathLower.includes('.error.') || + lower.includes('fee') || + lower.includes('collateral') || + lower.includes('deposit') || + lower.includes('withdraw') || + lower.includes('insufficient') + ) { + return 'financial-error' + } + + const wordCount = value.trim().split(/\s+/).length + if (wordCount <= 3 && !value.includes('%{')) { + return 'single-word' + } + + if (value.includes('%{')) { + return 'interpolation' + } + + const defiPrefixes = ['defi.', 'trade.', 'lending.', 'earn.', 'pools.'] + if (defiPrefixes.some(p => pathLower.startsWith(p))) { + return 'defi-jargon' + } + + return 'general' +} + +function shuffleArray(arr) { + const shuffled = [...arr] + for (let i = shuffled.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)) + ;[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]] + } + return shuffled +} + +function stratifiedSelect(pool, candidates, count, selectedPaths) { + const pools = { + 'glossary-term': [], + 'financial-error': [], + 'single-word': [], + 'interpolation': [], + 'defi-jargon': [], + 'general': [], + } + + for (const entry of candidates) { + if (selectedPaths.has(entry.dottedPath)) continue + if (!pool.has(entry.dottedPath)) continue + const category = categorize(entry.dottedPath, entry.value) + if (pools[category]) pools[category].push(entry) + } + + const quotas = { + 'glossary-term': Math.round(18 * count / 75), + 'financial-error': Math.round(12 * count / 75), + 'single-word': Math.round(12 * count / 75), + 'interpolation': Math.round(12 * count / 75), + 'defi-jargon': Math.round(8 * count / 75), + } + + const selected = [] + const usedPaths = new Set() + + function selectFrom(entries, category, target) { + const shuffled = shuffleArray(entries) + for (const entry of shuffled) { + if (usedPaths.has(entry.dottedPath)) continue + selected.push({ dottedPath: entry.dottedPath, category }) + usedPaths.add(entry.dottedPath) + if (selected.filter(s => s.category === category).length >= target) break + } + } + + selectFrom(pools['glossary-term'], 'glossary-term', quotas['glossary-term']) + selectFrom(pools['financial-error'], 'financial-error', quotas['financial-error']) + selectFrom(pools['single-word'], 'single-word', quotas['single-word']) + selectFrom(pools['interpolation'], 'interpolation', quotas['interpolation']) + selectFrom(pools['defi-jargon'], 'defi-jargon', quotas['defi-jargon']) + + const remaining = count - selected.length + selectFrom(pools['general'], 'general', remaining) + + if (selected.length < count) { + const allRemaining = candidates.filter( + e => pool.has(e.dottedPath) && !usedPaths.has(e.dottedPath) && !selectedPaths.has(e.dottedPath), + ) + const shuffled = shuffleArray(allRemaining) + for (const entry of shuffled) { + if (selected.length >= count) break + selected.push({ dottedPath: entry.dottedPath, category: 'general' }) + usedPaths.add(entry.dottedPath) + } + } + + return selected +} + +function main() { + const countArg = process.argv.indexOf('--count') + const totalTarget = countArg !== -1 ? parseInt(process.argv[countArg + 1], 10) : 150 + + const coreArg = process.argv.indexOf('--core') + const coreTarget = coreArg !== -1 ? parseInt(process.argv[coreArg + 1], 10) : 100 + + if (isNaN(totalTarget) || totalTarget < 1) { + console.error('Error: --count must be a positive integer') + process.exit(1) + } + if (isNaN(coreTarget) || coreTarget < 1) { + console.error('Error: --core must be a positive integer') + process.exit(1) + } + if (coreTarget >= totalTarget) { + console.error(`Error: --core (${coreTarget}) must be less than --count (${totalTarget})`) + process.exit(1) + } + + const enData = JSON.parse(fs.readFileSync(path.join(TRANSLATIONS_DIR, 'en/main.json'), 'utf8')) + const allFlat = flatten(enData) + console.log(`Total English strings: ${allFlat.length}`) + + const localeData = {} + for (const locale of TEST_LOCALES) { + localeData[locale] = JSON.parse(fs.readFileSync(path.join(TRANSLATIONS_DIR, `${locale}/main.json`), 'utf8')) + } + + const candidates = allFlat.filter(entry => { + return TEST_LOCALES.every(locale => { + const val = getValueByPath(localeData[locale], entry.dottedPath) + return val !== undefined && val.trim().length > 0 + }) + }) + console.log(`Candidates (exist in all ${TEST_LOCALES.length} locales): ${candidates.length}`) + const candidatePathSet = new Set(candidates.map(e => e.dottedPath)) + + let coreKeys = [] + let coreCreated = false + + if (fs.existsSync(CORE_KEYS_PATH)) { + const loaded = JSON.parse(fs.readFileSync(CORE_KEYS_PATH, 'utf8')) + const validated = loaded.filter(entry => candidatePathSet.has(entry.dottedPath)) + const staleCount = loaded.length - validated.length + if (staleCount > 0) { + console.log(`Core keys: removed ${staleCount} stale keys (no longer in all locales)`) + } + coreKeys = validated + console.log(`Core keys loaded: ${coreKeys.length}/${loaded.length} valid`) + } + + const corePathSet = new Set(coreKeys.map(e => e.dottedPath)) + + if (coreKeys.length < coreTarget) { + const needed = coreTarget - coreKeys.length + console.log(`Core keys: selecting ${needed} additional keys to reach target of ${coreTarget}`) + const topUp = stratifiedSelect( + new Set(candidates.filter(e => !corePathSet.has(e.dottedPath)).map(e => e.dottedPath)), + candidates, + needed, + corePathSet, + ) + coreKeys = [...coreKeys, ...topUp] + coreCreated = true + } + + if (!fs.existsSync(CORE_KEYS_PATH) || coreCreated) { + fs.mkdirSync(path.dirname(CORE_KEYS_PATH), { recursive: true }) + fs.writeFileSync(CORE_KEYS_PATH, JSON.stringify(coreKeys, null, 2) + '\n') + console.log(`Core keys saved to ${CORE_KEYS_PATH} (${coreKeys.length} keys)`) + } + + const allCorePathSet = new Set(coreKeys.map(e => e.dottedPath)) + const rotatingCount = totalTarget - coreKeys.length + console.log(`\nSelecting ${rotatingCount} rotating keys...`) + + const rotatingPool = new Set( + candidates.filter(e => !allCorePathSet.has(e.dottedPath)).map(e => e.dottedPath), + ) + const rotatingKeys = stratifiedSelect(rotatingPool, candidates, rotatingCount, allCorePathSet) + + const selected = [ + ...coreKeys.map(e => ({ ...e, fixed: true })), + ...rotatingKeys.map(e => ({ ...e, fixed: false })), + ] + + console.log(`\nSelected ${selected.length} keys (${coreKeys.length} fixed + ${rotatingKeys.length} rotating)`) + + const categoryCounts = {} + for (const entry of selected) { + categoryCounts[entry.category] = (categoryCounts[entry.category] || 0) + 1 + } + console.log('\nCategory distribution:') + for (const [cat, count] of Object.entries(categoryCounts).sort((a, b) => b[1] - a[1])) { + console.log(` ${cat}: ${count}`) + } + + fs.mkdirSync(path.dirname(OUTPUT_PATH), { recursive: true }) + fs.writeFileSync(OUTPUT_PATH, JSON.stringify(selected, null, 2) + '\n') + console.log(`\nWrote ${OUTPUT_PATH}`) +} + +main() diff --git a/.claude/skills/benchmark-translate/scripts/setup.js b/.claude/skills/benchmark-translate/scripts/setup.js new file mode 100644 index 00000000000..c745600b293 --- /dev/null +++ b/.claude/skills/benchmark-translate/scripts/setup.js @@ -0,0 +1,119 @@ +const fs = require('fs') +const path = require('path') +const { execSync } = require('child_process') + +const TRANSLATIONS_DIR = path.resolve(__dirname, '../../../../src/assets/translations') +const BENCHMARK_DIR = path.resolve(__dirname, '../../../../scripts/translations/benchmark') +const TEST_KEYS_PATH = path.join(BENCHMARK_DIR, 'testKeys.json') +const GROUND_TRUTH_PATH = path.join(BENCHMARK_DIR, 'ground-truth.json') +const REPORT_PATH = path.join(BENCHMARK_DIR, 'report.json') +const BASELINE_PATH = path.join(BENCHMARK_DIR, 'baseline.json') + +const TEST_LOCALES = ['de', 'es', 'fr', 'ja', 'pt', 'ru', 'tr', 'uk', 'zh'] + +function getValueByPath(obj, dottedPath) { + const parts = dottedPath.split('.') + let current = obj + for (const part of parts) { + if (current === null || current === undefined || typeof current !== 'object') return undefined + current = current[part] + } + return typeof current === 'string' ? current : undefined +} + +function deletePropertyByPath(obj, dottedPath) { + const parts = dottedPath.split('.') + let current = obj + for (let i = 0; i < parts.length - 1; i++) { + if (!current.hasOwnProperty(parts[i])) return false + current = current[parts[i]] + } + delete current[parts[parts.length - 1]] + return true +} + +function main() { + if (!fs.existsSync(TEST_KEYS_PATH)) { + console.error('Error: testKeys.json not found. Run select-keys.js first.') + process.exit(1) + } + + const testKeys = JSON.parse(fs.readFileSync(TEST_KEYS_PATH, 'utf8')) + + const localeFiles = TEST_LOCALES.map(locale => `src/assets/translations/${locale}/main.json`) + const gitStatus = execSync(`git status --porcelain -- ${localeFiles.join(' ')}`, { + encoding: 'utf8', + }).trim() + + if (gitStatus.length > 0) { + console.error('Error: Git working tree is not clean for locale files:') + console.error(gitStatus) + console.error('Please commit or stash changes before running the benchmark setup.') + process.exit(1) + } + + if (fs.existsSync(REPORT_PATH)) { + fs.copyFileSync(REPORT_PATH, BASELINE_PATH) + console.log('Copied report.json → baseline.json (previous run becomes baseline)') + } + + const gitSha = execSync('git rev-parse HEAD', { encoding: 'utf8' }).trim() + const enData = JSON.parse(fs.readFileSync(path.join(TRANSLATIONS_DIR, 'en/main.json'), 'utf8')) + + const groundTruth = {} + for (const locale of TEST_LOCALES) groundTruth[locale] = {} + const english = {} + + for (const { dottedPath } of testKeys) { + const enValue = getValueByPath(enData, dottedPath) + if (!enValue) { + console.error(`Error: English key not found: ${dottedPath}`) + process.exit(1) + } + english[dottedPath] = enValue + } + + for (const locale of TEST_LOCALES) { + const localeData = JSON.parse(fs.readFileSync(path.join(TRANSLATIONS_DIR, `${locale}/main.json`), 'utf8')) + + for (const { dottedPath } of testKeys) { + const value = getValueByPath(localeData, dottedPath) + if (!value) { + console.error(`Error: Key not found in ${locale}: ${dottedPath}`) + process.exit(1) + } + groundTruth[locale][dottedPath] = value + } + } + + const groundTruthData = { + generatedAt: new Date().toISOString(), + gitSha, + testKeys, + groundTruth, + english, + } + fs.mkdirSync(BENCHMARK_DIR, { recursive: true }) + fs.writeFileSync(GROUND_TRUTH_PATH, JSON.stringify(groundTruthData, null, 2) + '\n') + console.log(`Ground truth saved to ${GROUND_TRUTH_PATH}`) + console.log(`Git SHA: ${gitSha}`) + console.log(`Test keys: ${testKeys.length}`) + + for (const locale of TEST_LOCALES) { + const localePath = path.join(TRANSLATIONS_DIR, `${locale}/main.json`) + const localeData = JSON.parse(fs.readFileSync(localePath, 'utf8')) + let removedCount = 0 + + for (const { dottedPath } of testKeys) { + const deleted = deletePropertyByPath(localeData, dottedPath) + if (deleted) removedCount++ + } + + fs.writeFileSync(localePath, JSON.stringify(localeData, null, 2) + '\n') + console.log(`${locale}: removed ${removedCount}/${testKeys.length} keys`) + } + + console.log('\nSetup complete. Run /translate to regenerate the removed keys, then run compile-report.js.') +} + +main() diff --git a/.claude/skills/translate/SKILL.md b/.claude/skills/translate/SKILL.md new file mode 100644 index 00000000000..6877957273a --- /dev/null +++ b/.claude/skills/translate/SKILL.md @@ -0,0 +1,380 @@ +--- +name: translate +description: Translate new/changed English UI strings into all supported languages using a translate-review-refine pipeline. Invoke with /translate to detect untranslated strings and produce high-quality translations for de, es, fr, ja, pt, ru, tr, uk, zh. +allowed-tools: Read, Write, Edit, Grep, Glob, Bash(node *), Bash(git diff*), Bash(git log*), Bash(git rev-parse*), Bash(wc *), Bash(mkdir *), Task, AskUserQuestion +--- + +# Automated i18n Translation Skill + +Translates new or changed English UI strings into all 9 supported non-English languages using a translate-review-refine pipeline. Detects what changed, translates in batches with terminology glossary enforcement, validates output programmatically, and merges results into the existing translation files. + +## Supported Languages + +| Locale | Language | Register | +|--------|----------|----------| +| `de` | German | Formal (Sie) | +| `es` | Spanish | Informal (tú) | +| `fr` | French | Formal (vous) | +| `ja` | Japanese | Polite (です/ます) | +| `pt` | Portuguese (BR) | Informal (você) | +| `ru` | Russian | Formal (вы) | +| `tr` | Turkish | Formal (siz) | +| `uk` | Ukrainian | Formal (ви) | +| `zh` | Chinese (Simplified) | Neutral/formal | + +See `.claude/skills/translate/locales/{locale}.md` for detailed locale-specific rules. + +## Precautions + +- **Do not translate `en`** — English is the source language. +- **Do not touch `id` or `ko`** — these locales exist on disk but are not imported in `index.ts` or declared in `constants.ts`. +- **Preserve existing translations** — only add/update keys, never delete existing translated keys. +- **Interpolation is sacred** — `%{variableName}` placeholders must survive translation intact, including exact variable names. +- **JSON validity** — all output files must be valid JSON. Validate before writing. +- **File format** — 2-space indent, trailing newline, UTF-8 encoding (matches `saveJSONFile` in `scripts/translations/utils.ts`). +- **The glossary is a living document** — it grows over time as new terms are identified. Check it at the start of every run. + +## Step 1: Compute Translation Diff + +Determine which English strings need translation. + +### 1a. Check for marker file + +``` +Read src/assets/translations/.last-translation-sha +``` + +### 1b. If marker file exists — diff-based detection + +The marker contains a git SHA from the last translation run. Use it to find new/modified English strings since then. + +```bash +node .claude/skills/translate/scripts/diff.js +``` + +This follows the same recursive comparison pattern as `scripts/translations/utils.ts` `findStringsToTranslate()`. + +### 1c. If no marker file — missing-key detection + +Compare each non-English language against English to find missing keys: + +```bash +node .claude/skills/translate/scripts/missing-keys.js +``` + +### 1d. Early exit + +If no changes or missing keys are found, print: + +> Translations are up to date. No new or modified English strings detected. + +and stop. + +### 1e. For modified keys (English value changed) + +Mark these as needing re-translation in ALL languages, not just those missing the key. + +## Step 2: Batching Strategy + +Group the strings to translate by their **top-level JSON namespace** (the first segment of the dotted path, e.g., `agenticChat`, `common`, `trade`). + +- Target **20-50 strings per batch** +- If a namespace has more than 50 strings, split into sub-batches of **30-35 strings, preserving original key order** +- If a namespace has fewer than 5 strings, combine with other small namespaces into a single batch +- Each batch item includes: `{ dottedPath, englishValue }` +- Keep batch contents together because namespace context improves translation consistency + +## Step 3: Load Glossary + +Read `src/assets/translations/glossary.json`. This file contains: +- Terms with value `null` = **never translate**, keep in English (e.g., "Bitcoin", "DeFi", "MetaMask") +- Terms with locale-keyed objects = **use the approved translation** for that language (e.g., "staking" → "ステーキング" in Japanese) + +The glossary is passed to every translator and reviewer sub-agent. If the glossary file is missing or contains invalid JSON, log an error and exit immediately with a clear message. + +## Step 3b: Cross-Reference Existing Translations for Term Consistency + +Before translation begins, extract significant terms from the new English strings and search **all namespaces** in the target locale for existing translations containing those terms. This ensures that domain terms like "pool", "vault", "bridge", "fee", etc. are translated consistently with how they've already been translated elsewhere in the app — even across different namespaces. + +Run the term-context script **once per locale at pipeline start**: + +```bash +node .claude/skills/translate/scripts/term-context.js LOCALE NEW_STRINGS_JSON_OR_FILE +``` + +- `LOCALE` — the target locale code (e.g., `fr`) +- `NEW_STRINGS_JSON_OR_FILE` — path to a temp file containing the new strings as `[{ path, value }]` array or `{ "dotted.path": "english value" }` object + +The script: +1. Extracts significant words and 2-word phrases from the new English strings (filtering stop words and glossary terms which are already handled) +2. Searches the full `{locale}/main.json` for existing translated strings whose English source contains those terms +3. Returns up to 3 matches per term, prioritizing multi-word phrases, capped at 30 terms total + +Output format: +```json +{ + "liquidity pool": [ + { "key": "defi.liquidityPools.title", "en": "Liquidity Pool", "fr": "Pool de liquidité" } + ], + "fee": [ + { "key": "common.gasFee", "en": "Gas Fee", "fr": "Frais de gas" }, + { "key": "trade.networkFee", "en": "Network Fee", "fr": "Frais de réseau" } + ] +} +``` + +Pass this as `{TERM_CONTEXT}` in the translator prompt (see Step 5a). If the script returns an empty object (no matching terms found in existing translations), omit the `{TERM_CONTEXT}` section from the prompt. + +**Caching**: Run term-context.js and load few-shot context once per locale at pipeline start. Reuse this context for all batches of that locale — do not re-run scripts per batch. + +## Step 4: Load Few-Shot Context from Existing Translations + +Before translating each batch for a given locale, sample ~10 existing translations from the same namespace(s) being translated. These serve as tone/style reference for the translator. + +1. Read `src/assets/translations/{locale}/main.json` +2. From the namespace(s) in the current batch, extract entries that are **not** in the batch being translated +3. Prefer entries of similar string length to the batch strings +4. Format as: + ```json + { "dotted.path": { "en": "English source", "{locale}": "Existing translation" } } + ``` +5. Pass as `{EXISTING_TRANSLATIONS_SAMPLE}` in the translator prompt (see Step 5a) + +This grounds the translator in the existing voice and terminology of the project for that locale. + +**Caching**: Load the locale file once at pipeline start and reuse for all batches of that locale. + +## Step 4b: Prepare Locale Bundle + +After loading few-shot context (Step 4), run `prepare-locale.js` **once per locale** to bundle all translation context into a single file. This prevents sub-agents from needing to read any codebase files. + +```bash +node .claude/skills/translate/scripts/prepare-locale.js LOCALE --batches=BATCHES_FILE --term-context=TERM_CONTEXT_FILE --few-shot=FEW_SHOT_FILE +``` + +- `BATCHES_FILE` — path to a JSON file containing an array of batch objects (each batch is `{ "dotted.path": "english value" }`) +- `TERM_CONTEXT_FILE` — path to term-context output from Step 3b (omit if empty) +- `FEW_SHOT_FILE` — path to few-shot context from Step 4 (omit if empty) + +Output: `/tmp/translate-{locale}.json` containing locale rules, glossary, term context, few-shot examples, and all batches in a single file. + +## Step 5: Translation Pipeline (per language, per batch) + +### 5a. Translator Sub-Agent + +Use the **Task tool** with `model: "sonnet"` to translate each batch. + +Prompt template for the translator: + +``` +You are a professional UI translator for a cryptocurrency/DeFi application. +Translate the following English UI strings into {LANGUAGE_NAME} ({LOCALE_CODE}). + +Do NOT read any other files from the codebase. All context you need is provided below. + +Read the locale bundle from /tmp/translate-{LOCALE_CODE}.json and use batch index {BATCH_INDEX} (0-based) as the input to translate. + +LOCALE RULES: +{LOCALE_RULES} + +RULES: +1. INTERPOLATION: Preserve all %{variableName} placeholders exactly as-is. Do not translate variable names inside %{}. +2. TERMINOLOGY: + - NEVER TRANSLATE these terms (keep in English): {NEVER_TRANSLATE_LIST} + - USE APPROVED TRANSLATIONS: {APPROVED_TRANSLATIONS_FOR_THIS_LOCALE} + - When a term below has an established translation in this project, use that same translation unless the context clearly demands a different meaning. +3. Keep translations concise - UI space is limited. Match the approximate length of the English source. +4. Do not add explanations or notes. Return ONLY a JSON object. +5. FORMAT: Preserve HTML entities and markdown in source strings. If a string is a single word that's also a UI label (like "Done", "Cancel"), translate it as a UI action. +6. SOURCE FAITHFULNESS: Do not add information, words, or context not present in the English source. The translation must convey exactly what the English says — nothing more, nothing less. +7. CONCISENESS: UI labels must be short. If the locale naturally produces longer text, prefer shorter synonyms or abbreviations common in that locale's UI conventions. +8. KEY INTEGRITY: Your output keys must EXACTLY match the input keys. Do not rename, rewrite, or substitute any key paths. The output JSON must contain the same set of dotted paths as the input — no additions, no removals, no modifications to key names. + +TERM CONTEXT (how key terms in these strings have been translated elsewhere in this project): +{TERM_CONTEXT} + +REFERENCE TRANSLATIONS (existing translations from this project for tone/style reference): +{EXISTING_TRANSLATIONS_SAMPLE} + +INPUT (JSON object with dotted paths as keys and English strings as values): +{BATCH_JSON} + +OUTPUT: Return a single JSON object with the SAME keys and translated values. Nothing else. +``` + +Where `{LOCALE_RULES}` is loaded from the locale bundle (or `.claude/skills/translate/locales/{locale}.md`). The `{BATCH_INDEX}` is the 0-based index into the `batches` array in the locale bundle file. + +Parse the returned JSON. If it doesn't parse, retry once with "Return ONLY valid JSON, no markdown fences." + +### 5b. Programmatic Validation + +After receiving translator output, run the validation script: + +```bash +node .claude/skills/translate/scripts/validate.js LOCALE SOURCE_JSON TARGET_JSON --term-context=TERM_CONTEXT_FILE +``` + +The script checks: + +0. **Key integrity**: Verify the output key set exactly matches the input key set. Reject any key in the output that doesn't exist in the input ("unexpected key" — translator hallucinated a different key path). Reject any input key missing from the output ("missing key"). Already-rejected keys are skipped by subsequent checks. + +1. **Placeholder integrity**: Extract all `%{...}` tokens from source and target. They must be identical sets (reject if mismatch). Additionally, flag if placeholders appear in different order (grammar may require reordering — flag, not reject). + +2. **Length ratio**: Flag if `target.length / source.length > 3.0` or `< 0.25` (for CJK locales `ja` and `zh`: `> 4.0` and `< 0.15`). + +3. **Empty/whitespace**: Reject if translated value is empty or whitespace-only. + +4. **Untranslated detection**: Flag if target === source AND the source has more than 3 words (single-word labels and brand names are expected to stay the same). + +5. **Wrong-script detection**: For locales `ja`, `zh` — flag strings where >70% of non-whitespace, non-placeholder, non-glossary characters are Latin. For `ru`, `uk` — same threshold. + +6. **Glossary compliance**: Check that "never translate" terms appear unchanged. Check that terms with approved translations use the approved form. + +7. **Term consistency** (if term-context available): For each term where all existing matches agree on a single translation, flag if the new translation uses a different word. + +Output: `{ "rejected": [...], "flagged": [...], "passed": [...] }` + +- **Auto-reject** (send back to translator with error): key integrity mismatch (unexpected/missing keys), empty/whitespace, JSON parse failure, placeholder set mismatch. +- **Flag for reviewer** (pass to reviewer with note): length ratio, untranslated suspicion, wrong-script, glossary violations, term consistency deviations, placeholder reorder. + +### 5c. Reviewer Sub-Agent + +Use the **Task tool** with `model: "sonnet"` for review. + +Invoke the reviewer for **flagged strings plus a 10% random sample of passed strings** as a spot-check. If no strings are flagged and the passed sample is clean, skip the reviewer for this batch. + +Prompt template for the reviewer: + +``` +You are a senior localization reviewer for a cryptocurrency/DeFi application ({LANGUAGE_NAME}). + +Do NOT read any other files from the codebase. All context you need is provided below. + +Review these translations for quality. For each string, respond with either "approved" or a specific issue description. + +LOCALE RULES: +{LOCALE_RULES} + +FOCUS ON (things programmatic validation cannot catch): +1. Naturalness - does it sound natural to a native {LANGUAGE_NAME} speaker? +2. Semantic accuracy - does the translation accurately convey the English meaning? +3. Cultural appropriateness - are there any culturally awkward or inappropriate phrasings? +4. UI appropriateness - translations should be concise enough for UI elements +5. Source faithfulness - verify translation doesn't add information not in the English source + +TERM CONTEXT (how key terms have been translated elsewhere — flag inconsistencies): +{TERM_CONTEXT} + +VALIDATION FLAGS (pay special attention to these): +{VALIDATION_FLAGS_IF_ANY} + +STRINGS TO REVIEW: +{STRINGS_WITH_SOURCE_AND_TRANSLATION} + +OUTPUT: JSON object with dotted paths as keys. Value is either `"approved"` or `"Issue: [one-sentence description]"`. Always prefix issues with "Issue:" for parseability. +``` + +When populating `{VALIDATION_FLAGS_IF_ANY}`: if no flags exist for this batch, insert "(No validation flags for this batch — review all strings normally.)" + +If >80% of strings are approved, only the flagged strings proceed to refinement. + +### 5d. Refiner Sub-Agent (conditional) + +Use the **Task tool** with `model: "sonnet"` only for strings the reviewer flagged. + +Prompt template: + +``` +You are a professional UI translator for a cryptocurrency/DeFi application. +Do NOT read any other files from the codebase. All context you need is provided below. +Fix the following {LANGUAGE_NAME} translations based on reviewer feedback. + +For each string below, you have: the English source, the rejected translation, and the reviewer's feedback. +Produce a corrected translation that addresses the feedback. + +LOCALE RULES: +{LOCALE_RULES} + +RULES: Same as translator rules (interpolation, terminology, conciseness, format, source faithfulness). + +INPUT FORMAT: +{ "dotted.path": { "en": "English source", "translation": "Current translation", "feedback": "Issue: ..." } } + +STRINGS TO FIX: +{STRINGS_WITH_FEEDBACK} + +OUTPUT: JSON object with dotted paths as keys and corrected translations as values. Nothing else. +``` + +Re-validate the refined output programmatically. If it still fails validation after 1 retry, skip it and include in the final report as "needs manual review". + +## Error Handling + +- **JSON parse failure** (translator/refiner): Retry once with "Return ONLY valid JSON, no markdown fences." If retry fails, skip batch, log to summary as "needs manual review." +- **Empty batch after filtering**: Skip without invoking sub-agents. +- **Sub-agent timeout**: Log as "needs manual review", continue with next language/batch. +- **Glossary file missing/malformed**: Log error and exit with clear message. + +## Step 6: Parallel Execution Strategy + +Launch **9 Task sub-agents in parallel** (one per language). Within each language, process batches **sequentially** to maintain terminology consistency across namespaces. + +For each language, the full pipeline is: translate batch → validate → review (if needed) → refine (if needed) → next batch. + +## Step 7: Merge Results + +After all languages complete, merge translations into the existing files. + +For each locale: + +1. Read `src/assets/translations/{locale}/main.json` +2. Deep-merge the new translations into the existing nested structure +3. **Preserve key ordering from the English file** — use the English file as the structural template to determine key order +4. Write back with `JSON.stringify(data, null, 2) + '\n'` (2-space indent, trailing newline — matches existing format per `scripts/translations/utils.ts` `saveJSONFile`) + +Run the merge script: + +```bash +node .claude/skills/translate/scripts/merge.js LOCALE TRANSLATIONS_JSON_OR_FILE +``` + +Replace `LOCALE` with the locale code and pass either a stringified JSON of `{ dottedPath: translatedValue }` pairs or a path to a temp file containing the JSON. For large payloads, prefer the temp file approach. + +## Step 8: Update Marker & Report + +After all merges are complete: + +1. **Write marker file**: + ```bash + git rev-parse HEAD > src/assets/translations/.last-translation-sha + ``` + +2. **Update glossary timestamp** (if glossary was modified during this run): + Update `_meta.lastUpdated` in `src/assets/translations/glossary.json` to today's date. + +3. **Print summary report**: + ``` + === Translation Summary === + SHA marker: + + Strings translated: across languages + Strings skipped (manual review needed): + New glossary terms added: + + Per-language breakdown: + de: translated, skipped + es: translated, skipped + ... + + Skipped strings (need manual review): + - (): + ``` + +## Step 9: Glossary Update (conditional) + +After all translations complete, scan for English terms that appear untranslated (kept as-is) in **7 or more locales**. These are candidates for the glossary never-translate list. + +1. Collect terms that remained in English across most languages +2. Present candidates to the user for confirmation before adding +3. For confirmed terms, add to `src/assets/translations/glossary.json` with value `null` +4. Log additions in the summary report diff --git a/.claude/skills/translate/locales/de.md b/.claude/skills/translate/locales/de.md new file mode 100644 index 00000000000..848f1968dab --- /dev/null +++ b/.claude/skills/translate/locales/de.md @@ -0,0 +1,12 @@ +# German (de) — Formal (Sie) + +- Use formal "Sie" address consistently +- Use verb infinitives for action buttons (e.g., "Einzahlen" not "Einzahlung") +- Compound nouns as one word without hyphens (e.g., "Gasgebuhr" not "Gas-Gebuhr") +- Use established German financial terms +- Translate English adjectives — don't leave English untranslated unless in glossary +- Do not invent compound nouns that don't exist in German — if no established compound exists, use a short phrase or keep the English term +- "claim" in DeFi context = "Einfordern" (not "Anfordern" or "Beanspruchen") +- "dust" stays English (glossary never-translate) — not "Staub" (physical dust) +- Separable verb imperatives with "Sie": the prefix goes to end of clause (e.g., "Fordern Sie Ihre Belohnungen ein") +- Watch for typos in technical terms: "Miner" not "Mincer", "Token" not "Toke" diff --git a/.claude/skills/translate/locales/es.md b/.claude/skills/translate/locales/es.md new file mode 100644 index 00000000000..a1857f0909a --- /dev/null +++ b/.claude/skills/translate/locales/es.md @@ -0,0 +1,7 @@ +# Spanish (es) — Informal (tu) + +- Use informal "tu" address consistently +- Never invent verbs from English (no "tradear", "swapear") +- "trade" verb = "intercambiar", noun = "intercambio" +- Don't use English nouns outside the glossary +- "liquidity pool" = "pool de liquidez" or "fondo de liquidez" — NEVER "piscina de liquidez" (piscina = swimming pool) diff --git a/.claude/skills/translate/locales/fr.md b/.claude/skills/translate/locales/fr.md new file mode 100644 index 00000000000..47c74bc6378 --- /dev/null +++ b/.claude/skills/translate/locales/fr.md @@ -0,0 +1,11 @@ +# French (fr) — Formal (vous) + +- Use formal "vous" address consistently +- "claim" (DeFi) verb = "réclamer", noun = "réclamation" — NEVER "réclame" (= advertisement in French) +- "supported" (feature/chain) = "pris(e) en charge" — not "supportée" (anglicism) +- Use consistent "vous" register: prefer "Veuillez + infinitive" for instructions, no mixed imperatives +- Keep product names exactly as-is: FOXy, rFOX, FOX Token are distinct products — never substitute one for another +- Prefer concise phrasing — French typically runs 30-40% longer than English, so trim filler words +- "seed phrase" = "phrase de récupération" — never literal "phrase de graine" or "phrase de semences" +- "unstake" = "déstaker" — coined French DeFi verb, consistent throughout +- "liquidity pool" = "pool de liquidités" — never "piscine" diff --git a/.claude/skills/translate/locales/ja.md b/.claude/skills/translate/locales/ja.md new file mode 100644 index 00000000000..dadd2f4fd99 --- /dev/null +++ b/.claude/skills/translate/locales/ja.md @@ -0,0 +1,14 @@ +# Japanese (ja) — Polite (です/ます) + +- Short UI labels: use noun form without です/ます +- Prefer native kanji over katakana loanwords (e.g., 入金 not デポジット, 残高不足 not 資金不足です) +- Never use お客様 +- For "X out of Y" → Y件中X件 +- Prefer リスト over リスティング +- For bridge/DeFi "claim" (withdrawal action), use 請求 not 獲得 (which means earn/win) +- Avoid mixing katakana and Japanese in a single compound where it reads unnaturally (e.g., ペンディング中の → 保留中の) +- Reorder %{…} placeholders to match Japanese SOV/postpositional grammar (e.g., "%{count} of %{total}" → "%{total}件中%{count}件") +- "allowance" (token approval) = 承認枠 or 承認額 — NEVER プール (= pool, completely wrong meaning) +- "vault" = ヴォールト — not バルト (typo that changes the meaning entirely) +- "deposit" = always 入金 (kanji form) — consistent with the kanji-over-katakana rule +- "position" (financial) = ポジション — not 位置 (= physical location/coordinates) diff --git a/.claude/skills/translate/locales/pt.md b/.claude/skills/translate/locales/pt.md new file mode 100644 index 00000000000..957caa12670 --- /dev/null +++ b/.claude/skills/translate/locales/pt.md @@ -0,0 +1,9 @@ +# Portuguese — Brazil (pt) — Informal (voce) + +- Use informal "voce" address consistently +- "claim" (DeFi) = "resgatar" — not "reivindicar" (sounds legalistic/formal in Brazilian Portuguese) +- "seed phrase" = "frase de recuperacao" — never literal "frase de sementes" +- Keep English DeFi loanwords where Brazilian crypto community uses them directly: unstaking, restaking, staking +- "liquidity pool" = "pool de liquidez" — never "piscina de liquidez" +- "unstake" action = "remover stake" — not "desbloquear" (unlock) which has different semantics +- "streaming swap" = "troca de streaming" — keep "streaming" as loanword diff --git a/.claude/skills/translate/locales/ru.md b/.claude/skills/translate/locales/ru.md new file mode 100644 index 00000000000..b4fde5fa470 --- /dev/null +++ b/.claude/skills/translate/locales/ru.md @@ -0,0 +1,10 @@ +# Russian (ru) — Formal (vы) + +- Use formal "vы" address consistently +- "staking" = "стейкинг" — NEVER "ставка" (= gambling bet) or "стейкировать" (malformed verb) +- "restaking" = "рестейкинг" — NEVER "переставка" (= rearranging physical objects, catastrophically wrong) +- "unstaking" = "снять со стейкинга" — NEVER native calques that produce meaningless words +- "claim" (DeFi) = "получить" — NEVER "заклеймить" (= to brand/stigmatize, catastrophically wrong) +- "seed phrase" = "сид-фраза" — transliterated loanword, standard in Russian crypto community +- Prefer transliterated English DeFi loanwords over native Russian calques (e.g., "бридж" not "мост" for bridge) +- "liquidity pool" = "пул ликвидности" — "пул" is the standard transliteration diff --git a/.claude/skills/translate/locales/tr.md b/.claude/skills/translate/locales/tr.md new file mode 100644 index 00000000000..cdd87b84786 --- /dev/null +++ b/.claude/skills/translate/locales/tr.md @@ -0,0 +1,7 @@ +# Turkish (tr) — Formal (siz) + +- Use formal "siz" address consistently +- Verify correct vowel harmony in suffixes (e.g., "Parolayı" not "Parolay" — accusative requires buffer vowel) +- "claim" (DeFi) = "talep etmek" — NEVER "yuklemeler" (= uploads, completely wrong) +- Keep DeFi English loanwords where Turkish crypto community uses them: staking, restaking, swap +- When borrowing English terms, use apostrophe for Turkish suffixes (e.g., "Stake'i Kaldır") diff --git a/.claude/skills/translate/locales/uk.md b/.claude/skills/translate/locales/uk.md new file mode 100644 index 00000000000..7322c3b2a0a --- /dev/null +++ b/.claude/skills/translate/locales/uk.md @@ -0,0 +1,10 @@ +# Ukrainian (uk) — Formal (ви) + +- Use formal "ви" address consistently +- "staking" = "стейкiнг" — same transliteration pattern as Russian but with Ukrainian orthography +- "restaking" = "рестейкiнг" — NEVER native calques +- "unstaking" = "зняти зi стейкiнгу" — NEVER meaningless calques like "ненаставлений" +- "claim" (DeFi) = "отримати" — NEVER "заклеймити" (= to brand/stigmatize, same catastrophic error as Russian) +- "seed phrase" = "сiд-фраза" — NEVER literal "фраза-насiння" (= phrase of seeds, meaningless) +- Verify morphological correctness — Ukrainian agglutination rules differ from Russian; incorrect suffixes produce meaningless words +- "liquidity pool" = "пул лiквiдностi" — standard transliteration matching Russian pattern diff --git a/.claude/skills/translate/locales/zh.md b/.claude/skills/translate/locales/zh.md new file mode 100644 index 00000000000..a00a1b60235 --- /dev/null +++ b/.claude/skills/translate/locales/zh.md @@ -0,0 +1,11 @@ +# Chinese — Simplified (zh) — Neutral/formal + +- Use crypto-community terms: 授权 not 批准 (approve), 回滚 not 恢复 (revert) +- Don't add words not in the English source +- "trade" = 交易 (broader); "swap" = 兑换 (token exchange) — do not use 兑换 for "trade" +- Always use full-width Chinese punctuation (。?!) not half-width (.?!) +- Use 您 (formal "you") in error/status messages, not 你 (informal) +- "dust" stays English (glossary never-translate) — not 灰尘 (physical dust) or 小额资金 (small funds) +- "streaming swap" = 流式兑换 — not 流动兑换 (流动 means "flowing/liquid", 流式 means "streaming-style") +- "allowance" (token approval) = 授权额度 — not unrelated terms +- Reinforce: 授权 (authorize) for on-chain approve — skill has violated this existing rule, be strict diff --git a/.claude/skills/translate/scripts/diff.js b/.claude/skills/translate/scripts/diff.js new file mode 100644 index 00000000000..33e234497d1 --- /dev/null +++ b/.claude/skills/translate/scripts/diff.js @@ -0,0 +1,25 @@ +const { execSync } = require('child_process'); +const fs = require('fs'); + +const sha = fs.readFileSync('src/assets/translations/.last-translation-sha', 'utf8').trim(); +const oldContent = execSync('git show ' + sha + ':src/assets/translations/en/main.json', { encoding: 'utf8' }); +const oldStrings = JSON.parse(oldContent); +const newStrings = JSON.parse(fs.readFileSync('src/assets/translations/en/main.json', 'utf8')); + +function findChanges(prev, curr, path) { + const results = []; + for (const key in curr) { + const currentPath = path ? path + '.' + key : key; + const currentValue = curr[key]; + const previousValue = prev?.[key]; + if (typeof currentValue === 'string' && previousValue !== currentValue) { + results.push({ path: currentPath, value: currentValue, status: previousValue ? 'modified' : 'new' }); + } else if (typeof currentValue === 'object' && currentValue !== null) { + results.push(...findChanges(previousValue || {}, currentValue, currentPath)); + } + } + return results; +} + +const changes = findChanges(oldStrings, newStrings); +console.log(JSON.stringify(changes, null, 2)); diff --git a/.claude/skills/translate/scripts/merge.js b/.claude/skills/translate/scripts/merge.js new file mode 100644 index 00000000000..bba7a4c4d0c --- /dev/null +++ b/.claude/skills/translate/scripts/merge.js @@ -0,0 +1,52 @@ +const fs = require('fs'); + +const enKeys = JSON.parse(fs.readFileSync('src/assets/translations/en/main.json', 'utf8')); +const locale = process.argv[2]; +const translationsArg = process.argv[3]; + +let newTranslations; +if (translationsArg && fs.existsSync(translationsArg)) { + newTranslations = JSON.parse(fs.readFileSync(translationsArg, 'utf8')); +} else { + newTranslations = JSON.parse(translationsArg); +} + +const existing = JSON.parse(fs.readFileSync('src/assets/translations/' + locale + '/main.json', 'utf8')); + +function setByPath(obj, path, value) { + const parts = path.split('.'); + let current = obj; + for (let i = 0; i < parts.length - 1; i++) { + if (!current[parts[i]] || typeof current[parts[i]] !== 'object') { + current[parts[i]] = {}; + } + current = current[parts[i]]; + } + current[parts[parts.length - 1]] = value; +} + +function orderLike(template, target) { + if (typeof template !== 'object' || template === null) return target; + const ordered = {}; + for (const key of Object.keys(template)) { + if (key in target) { + if (typeof template[key] === 'object' && template[key] !== null && typeof target[key] === 'object' && target[key] !== null) { + ordered[key] = orderLike(template[key], target[key]); + } else { + ordered[key] = target[key]; + } + } + } + for (const key of Object.keys(target)) { + if (!(key in ordered)) ordered[key] = target[key]; + } + return ordered; +} + +for (const [path, value] of Object.entries(newTranslations)) { + setByPath(existing, path, value); +} + +const ordered = orderLike(enKeys, existing); +fs.writeFileSync('src/assets/translations/' + locale + '/main.json', JSON.stringify(ordered, null, 2) + '\n'); +console.log('Merged ' + Object.keys(newTranslations).length + ' translations into ' + locale); diff --git a/.claude/skills/translate/scripts/missing-keys.js b/.claude/skills/translate/scripts/missing-keys.js new file mode 100644 index 00000000000..c8bc4aae556 --- /dev/null +++ b/.claude/skills/translate/scripts/missing-keys.js @@ -0,0 +1,26 @@ +const fs = require('fs'); +const en = JSON.parse(fs.readFileSync('src/assets/translations/en/main.json', 'utf8')); + +function findMissing(source, target, path) { + const results = []; + for (const key in source) { + const currentPath = path ? path + '.' + key : key; + if (typeof source[key] === 'string') { + if (target?.[key] === undefined) { + results.push({ path: currentPath, value: source[key], status: 'new' }); + } + } else if (typeof source[key] === 'object' && source[key] !== null) { + results.push(...findMissing(source[key], target?.[key] || {}, currentPath)); + } + } + return results; +} + +const locales = ['de','es','fr','ja','pt','ru','tr','uk','zh']; +const result = {}; +for (const locale of locales) { + const target = JSON.parse(fs.readFileSync('src/assets/translations/' + locale + '/main.json', 'utf8')); + const missing = findMissing(en, target); + if (missing.length > 0) result[locale] = missing; +} +console.log(JSON.stringify(result, null, 2)); diff --git a/.claude/skills/translate/scripts/prepare-locale.js b/.claude/skills/translate/scripts/prepare-locale.js new file mode 100644 index 00000000000..d115e8b85fd --- /dev/null +++ b/.claude/skills/translate/scripts/prepare-locale.js @@ -0,0 +1,91 @@ +const fs = require('fs'); + +const LOCALE_META = { + de: { language: 'German', register: 'Formal (Sie)' }, + es: { language: 'Spanish', register: 'Informal (tú)' }, + fr: { language: 'French', register: 'Formal (vous)' }, + ja: { language: 'Japanese', register: 'Polite (です/ます)' }, + pt: { language: 'Portuguese (BR)', register: 'Informal (você)' }, + ru: { language: 'Russian', register: 'Formal (вы)' }, + tr: { language: 'Turkish', register: 'Formal (siz)' }, + uk: { language: 'Ukrainian', register: 'Formal (ви)' }, + zh: { language: 'Chinese (Simplified)', register: 'Neutral/formal' }, +}; + +const locale = process.argv[2]; + +if (!locale || !LOCALE_META[locale]) { + console.error('Usage: node prepare-locale.js --batches= [--term-context=] [--few-shot=] [--output=]'); + console.error('Supported locales: ' + Object.keys(LOCALE_META).join(', ')); + process.exit(1); +} + +let batchesPath; +let termContextPath; +let fewShotPath; +let outputPath = `/tmp/translate-${locale}.json`; + +for (const arg of process.argv.slice(3)) { + if (arg.startsWith('--batches=')) batchesPath = arg.slice('--batches='.length); + if (arg.startsWith('--term-context=')) termContextPath = arg.slice('--term-context='.length); + if (arg.startsWith('--few-shot=')) fewShotPath = arg.slice('--few-shot='.length); + if (arg.startsWith('--output=')) outputPath = arg.slice('--output='.length); +} + +if (!batchesPath) { + console.error('Error: --batches= is required'); + process.exit(1); +} + +const meta = LOCALE_META[locale]; + +const localeRulesPath = `${__dirname}/../locales/${locale}.md`; +const localeRules = fs.existsSync(localeRulesPath) + ? fs.readFileSync(localeRulesPath, 'utf8').trim() + : ''; + +const glossaryPath = 'src/assets/translations/glossary.json'; +if (!fs.existsSync(glossaryPath)) { + console.error('Error: glossary file not found at ' + glossaryPath); + process.exit(1); +} +const glossary = JSON.parse(fs.readFileSync(glossaryPath, 'utf8')); + +const neverTranslate = Object.entries(glossary) + .filter(([key, value]) => key !== '_meta' && value === null) + .map(([key]) => key); + +const approvedTerms = {}; +for (const [term, value] of Object.entries(glossary)) { + if (term === '_meta') continue; + if (typeof value === 'object' && value !== null && value[locale]) { + approvedTerms[term] = value[locale]; + } +} + +const batches = JSON.parse(fs.readFileSync(batchesPath, 'utf8')); + +let termContext = {}; +if (termContextPath && fs.existsSync(termContextPath)) { + termContext = JSON.parse(fs.readFileSync(termContextPath, 'utf8')); +} + +let fewShot = {}; +if (fewShotPath && fs.existsSync(fewShotPath)) { + fewShot = JSON.parse(fs.readFileSync(fewShotPath, 'utf8')); +} + +const bundle = { + locale, + language: meta.language, + register: meta.register, + localeRules, + neverTranslate, + approvedTerms, + termContext, + fewShot, + batches: Array.isArray(batches) ? batches : [batches], +}; + +fs.writeFileSync(outputPath, JSON.stringify(bundle, null, 2) + '\n'); +console.log(`Wrote locale bundle to ${outputPath}`); diff --git a/.claude/skills/translate/scripts/term-context.js b/.claude/skills/translate/scripts/term-context.js new file mode 100644 index 00000000000..e644be9a6a4 --- /dev/null +++ b/.claude/skills/translate/scripts/term-context.js @@ -0,0 +1,127 @@ +const fs = require('fs'); + +const locale = process.argv[2]; +const newStringsArg = process.argv[3]; + +if (!locale || !newStringsArg) { + console.error('Usage: node term-context.js '); + process.exit(1); +} + +let newStrings; +if (fs.existsSync(newStringsArg)) { + newStrings = JSON.parse(fs.readFileSync(newStringsArg, 'utf8')); +} else { + newStrings = JSON.parse(newStringsArg); +} + +// Normalize input: accept both [{ path, value }] array and { path: value } object +const newEntries = Array.isArray(newStrings) + ? newStrings.map(s => ({ path: s.path, value: s.value })) + : Object.entries(newStrings).map(([path, value]) => ({ path, value })); + +const glossary = JSON.parse(fs.readFileSync('src/assets/translations/glossary.json', 'utf8')); +const glossaryTermsLower = new Set( + Object.keys(glossary).filter(k => k !== '_meta').map(t => t.toLowerCase()) +); + +const en = JSON.parse(fs.readFileSync('src/assets/translations/en/main.json', 'utf8')); +const localeFile = `src/assets/translations/${locale}/main.json`; +if (!fs.existsSync(localeFile)) { + console.error(`Locale file not found: ${localeFile}`); + process.exit(1); +} +const localeData = JSON.parse(fs.readFileSync(localeFile, 'utf8')); + +function flatten(obj, prefix) { + const result = {}; + for (const [key, value] of Object.entries(obj)) { + const path = prefix ? `${prefix}.${key}` : key; + if (typeof value === 'string') { + result[path] = value; + } else if (typeof value === 'object' && value !== null) { + Object.assign(result, flatten(value, path)); + } + } + return result; +} + +const flatEn = flatten(en, ''); +const flatLocale = flatten(localeData, ''); + +const stopWords = new Set([ + 'the','a','an','is','are','was','were','be','been','being','have','has','had', + 'do','does','did','will','would','could','should','may','might','shall','can', + 'need','must','to','of','in','for','on','with','at','by','from','as','into', + 'through','during','before','after','above','below','between','out','off','over', + 'under','again','further','then','once','here','there','when','where','why','how', + 'all','both','each','few','more','most','other','some','such','no','not','only', + 'own','same','so','than','too','very','just','because','but','and','or','if', + 'while','about','up','it','its','this','that','these','those','my','your','his', + 'her','our','their','what','which','who','whom','we','you','they','me','him', + 'us','them','any','every','much','many','also','still','already','even','now', + 'get','got','make','made','let','set','put','take','come','go','see','know', + 'want','use','find','give','tell','say','try','keep','show','turn','move','run', + 'work','call','ask','look','new','old','big','small','long','short','high','low', + 'good','bad','great','little','right','left','first','last','next','back','well', + 'way','day','time','year','thing','per','been','please','don','doesn','didn', + 'won','isn','aren','wasn','weren','hasn','haven','hadn','couldn','shouldn', + 'wouldn','can','able','unable' +]); + +const newStringPaths = new Set(newEntries.map(s => s.path)); + +// Extract significant terms from the new English strings +const termSet = new Set(); +for (const { value } of newEntries) { + const cleaned = value.toLowerCase().replace(/%\{\w+\}/g, '').replace(/[^a-z\s-]/g, ' '); + const words = cleaned.split(/\s+/).filter(w => w.length >= 3); + + for (const word of words) { + if (!stopWords.has(word) && !glossaryTermsLower.has(word)) { + termSet.add(word); + } + } + + // Also extract 2-word phrases (catches compound terms like "liquidity pool", "gas fee") + for (let i = 0; i < words.length - 1; i++) { + if (!stopWords.has(words[i]) && !stopWords.has(words[i + 1]) + && !glossaryTermsLower.has(`${words[i]} ${words[i + 1]}`)) { + termSet.add(`${words[i]} ${words[i + 1]}`); + } + } +} + +// For each term, find existing translated strings that contain it in the English source +const MAX_MATCHES_PER_TERM = 3; +const termContext = {}; + +for (const term of termSet) { + const escapedTerm = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const regex = new RegExp(`\\b${escapedTerm}\\b`, 'i'); + const matches = []; + + for (const [path, enValue] of Object.entries(flatEn)) { + if (newStringPaths.has(path)) continue; + if (!flatLocale[path]) continue; + if (!regex.test(enValue)) continue; + + matches.push({ key: path, en: enValue, [locale]: flatLocale[path] }); + if (matches.length >= MAX_MATCHES_PER_TERM) break; + } + + if (matches.length > 0) { + termContext[term] = matches; + } +} + +// Sort: phrases first (they're more specific/useful), then cap total +const sorted = Object.entries(termContext) + .sort(([a], [b]) => { + const aIsPhrase = a.includes(' ') ? 0 : 1; + const bIsPhrase = b.includes(' ') ? 0 : 1; + return aIsPhrase - bIsPhrase; + }) + .slice(0, 30); + +console.log(JSON.stringify(Object.fromEntries(sorted), null, 2)); diff --git a/.claude/skills/translate/scripts/validate.js b/.claude/skills/translate/scripts/validate.js new file mode 100644 index 00000000000..14d3b0c1ae7 --- /dev/null +++ b/.claude/skills/translate/scripts/validate.js @@ -0,0 +1,185 @@ +const fs = require('fs'); + +const locale = process.argv[2]; +const sourceArg = process.argv[3]; +const targetArg = process.argv[4]; + +if (!locale || !sourceArg || !targetArg) { + console.error('Usage: node validate.js [--term-context=] [--glossary=]'); + process.exit(1); +} + +function loadJsonArg(arg) { + if (fs.existsSync(arg)) return JSON.parse(fs.readFileSync(arg, 'utf8')); + return JSON.parse(arg); +} + +const source = loadJsonArg(sourceArg); +const target = loadJsonArg(targetArg); + +let termContextPath; +let glossaryPath = 'src/assets/translations/glossary.json'; + +for (const arg of process.argv.slice(5)) { + if (arg.startsWith('--term-context=')) termContextPath = arg.slice('--term-context='.length); + if (arg.startsWith('--glossary=')) glossaryPath = arg.slice('--glossary='.length); +} + +let glossary = {}; +if (fs.existsSync(glossaryPath)) { + glossary = JSON.parse(fs.readFileSync(glossaryPath, 'utf8')); +} + +let termContext = {}; +if (termContextPath && fs.existsSync(termContextPath)) { + termContext = JSON.parse(fs.readFileSync(termContextPath, 'utf8')); +} + +const CJK_LOCALES = new Set(['ja', 'zh']); +const CYRILLIC_LOCALES = new Set(['ru', 'uk']); +const NON_LATIN_LOCALES = new Set([...CJK_LOCALES, ...CYRILLIC_LOCALES]); + +const rejected = []; +const flagged = []; +const passed = []; + +function extractPlaceholders(str) { + return [...str.matchAll(/%\{(\w+)\}/g)].map(m => m[1]); +} + +function stripPlaceholdersAndGlossary(str) { + let cleaned = str.replace(/%\{\w+\}/g, ''); + for (const term of Object.keys(glossary)) { + if (term === '_meta') continue; + cleaned = cleaned.replace(new RegExp(term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi'), ''); + } + return cleaned.replace(/\s+/g, ''); +} + +function latinRatio(str) { + const cleaned = stripPlaceholdersAndGlossary(str); + if (cleaned.length === 0) return 0; + const latinChars = [...cleaned].filter(c => /[a-zA-Z]/.test(c)).length; + return latinChars / cleaned.length; +} + +// Check 0: Key set validation +const sourceKeys = new Set(Object.keys(source)); +const targetKeys = new Set(Object.keys(target)); + +for (const key of targetKeys) { + if (!sourceKeys.has(key)) { + rejected.push({ path: key, reason: 'unexpected key', details: 'Key not in input batch — translator may have hallucinated a different key path' }); + } +} +for (const key of sourceKeys) { + if (!targetKeys.has(key)) { + rejected.push({ path: key, reason: 'missing key', details: 'Key was in input batch but missing from translator output' }); + } +} + +const rejectedPaths = new Set(rejected.map(r => r.path)); + +for (const path of Object.keys(source)) { + if (rejectedPaths.has(path)) continue; + + const src = source[path]; + const tgt = target[path]; + + // Check 3: Empty/whitespace + if (tgt === undefined || tgt === null || (typeof tgt === 'string' && tgt.trim() === '')) { + rejected.push({ path, reason: 'empty', details: 'Translated value is empty or whitespace-only' }); + continue; + } + + // Check 1: Placeholder integrity + const srcPlaceholders = extractPlaceholders(src); + const tgtPlaceholders = extractPlaceholders(tgt); + const srcSet = [...srcPlaceholders].sort().join(','); + const tgtSet = [...tgtPlaceholders].sort().join(','); + + if (srcSet !== tgtSet) { + rejected.push({ path, reason: 'placeholder mismatch', details: `Source: {${srcSet}} Target: {${tgtSet}}` }); + continue; + } + + let isFlagged = false; + const flags = []; + + // Check 1b: Placeholder order + if (srcPlaceholders.join(',') !== tgtPlaceholders.join(',') && srcSet === tgtSet) { + flags.push({ path, reason: 'placeholder reorder', details: `Source order: ${srcPlaceholders.join(',')} Target order: ${tgtPlaceholders.join(',')}` }); + isFlagged = true; + } + + // Check 2: Length ratio + const ratio = tgt.length / src.length; + const maxRatio = CJK_LOCALES.has(locale) ? 4.0 : 3.0; + const minRatio = CJK_LOCALES.has(locale) ? 0.15 : 0.25; + if (ratio > maxRatio || ratio < minRatio) { + flags.push({ path, reason: 'length ratio', details: `Ratio: ${ratio.toFixed(2)} (threshold: ${minRatio}-${maxRatio})` }); + isFlagged = true; + } + + // Check 4: Untranslated detection + const wordCount = src.split(/\s+/).length; + if (tgt === src && wordCount > 3) { + flags.push({ path, reason: 'untranslated', details: `Target identical to source (${wordCount} words)` }); + isFlagged = true; + } + + // Check 5: Wrong-script detection + if (NON_LATIN_LOCALES.has(locale)) { + const lr = latinRatio(tgt); + if (lr > 0.7) { + flags.push({ path, reason: 'wrong script', details: `${(lr * 100).toFixed(0)}% Latin characters for ${locale} locale` }); + isFlagged = true; + } + } + + // Check 6: Glossary compliance + for (const [term, value] of Object.entries(glossary)) { + if (term === '_meta') continue; + const termRegex = new RegExp(term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi'); + + if (value === null) { + // Never-translate term: must appear unchanged if present in source + if (termRegex.test(src) && !new RegExp(term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).test(tgt)) { + flags.push({ path, reason: 'glossary never-translate', details: `"${term}" should remain in English` }); + isFlagged = true; + } + } else if (typeof value === 'object' && value[locale]) { + // Approved translation: check it's used + if (termRegex.test(src) && !tgt.includes(value[locale])) { + flags.push({ path, reason: 'glossary approved translation', details: `"${term}" should be "${value[locale]}" in ${locale}` }); + isFlagged = true; + } + } + } + + // Check 7: Term consistency + for (const [term, matches] of Object.entries(termContext)) { + if (!matches || matches.length === 0) continue; + const translations = matches.map(m => m[locale]).filter(Boolean); + if (translations.length === 0) continue; + + // Only flag if all existing uses agree on a single translation for the term + const unique = [...new Set(translations)]; + if (unique.length !== 1) continue; + + const established = unique[0]; + const termRegex = new RegExp(term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i'); + if (termRegex.test(src) && !tgt.includes(established)) { + flags.push({ path, reason: 'term consistency', details: `"${term}" is typically "${established}" but not found in translation` }); + isFlagged = true; + } + } + + if (isFlagged) { + flagged.push(...flags); + } else { + passed.push(path); + } +} + +console.log(JSON.stringify({ rejected, flagged, passed }, null, 2)); diff --git a/.gitignore b/.gitignore index 3575f2e1649..7b1cdfa1a00 100644 --- a/.gitignore +++ b/.gitignore @@ -51,3 +51,6 @@ yarn-error.log* .idea/ .playwright-mcp/ .sisyphus/ + +# translation benchmark data +scripts/translations/benchmark/ diff --git a/src/assets/translations/glossary.json b/src/assets/translations/glossary.json new file mode 100644 index 00000000000..51b5331c9bf --- /dev/null +++ b/src/assets/translations/glossary.json @@ -0,0 +1,304 @@ +{ + "_meta": { + "description": "Crypto/DeFi terminology glossary for translation. null = never translate (keep in English). Object with locale keys = use approved translation.", + "lastUpdated": "2026-02-20" + }, + "Bitcoin": null, + "BTC": null, + "Ethereum": null, + "ETH": null, + "ShapeShift": null, + "ShapeShift DAO": null, + "FOX": null, + "FOX Token": null, + "rFOX": null, + "THORChain": null, + "RUNE": null, + "Cosmos": null, + "ATOM": null, + "Osmosis": null, + "OSMO": null, + "Arbitrum": null, + "Avalanche": null, + "AVAX": null, + "Base": null, + "BNB Smart Chain": null, + "BNB": null, + "Gnosis": null, + "Optimism": null, + "Polygon": null, + "Solana": null, + "SOL": null, + "DeFi": null, + "NFT": null, + "NFTs": null, + "LP": null, + "DEX": null, + "AMM": null, + "UTXO": null, + "EVM": null, + "ERC-20": null, + "WalletConnect": null, + "MetaMask": null, + "Ledger": null, + "KeepKey": null, + "Coinbase": null, + "XDEFI": null, + "Keplr": null, + "Phantom": null, + "GridPlus": null, + "Uniswap": null, + "SushiSwap": null, + "Aave": null, + "Compound": null, + "Yearn": null, + "Curve": null, + "MayaChain": null, + "CowSwap": null, + "Jupiter": null, + "Chainflip": null, + "FOXy": null, + "staking": { + "de": "Staking", + "es": "staking", + "fr": "staking", + "ja": "ステーキング", + "pt": "staking", + "ru": "стейкинг", + "tr": "staking", + "uk": "стейкінг", + "zh": "质押" + }, + "blockchain": { + "de": "Blockchain", + "es": "blockchain", + "fr": "blockchain", + "ja": "ブロックチェーン", + "pt": "blockchain", + "ru": "блокчейн", + "tr": "blok zinciri", + "uk": "блокчейн", + "zh": "区块链" + }, + "wallet": { + "de": "Wallet", + "es": "billetera", + "fr": "portefeuille", + "ja": "ウォレット", + "pt": "carteira", + "ru": "кошелёк", + "tr": "cüzdan", + "uk": "гаманець", + "zh": "钱包" + }, + "token": { + "de": "Token", + "es": "token", + "fr": "jeton", + "ja": "トークン", + "pt": "token", + "ru": "токен", + "tr": "token", + "uk": "токен", + "zh": "代币" + }, + "swap": { + "de": "Swap", + "es": "swap", + "fr": "swap", + "ja": "スワップ", + "pt": "swap", + "ru": "своп", + "tr": "takas", + "uk": "своп", + "zh": "兑换" + }, + "gas": { + "de": "Gas", + "es": "gas", + "fr": "gas", + "ja": "ガス", + "pt": "gas", + "ru": "газ", + "tr": "gas", + "uk": "газ", + "zh": "Gas" + }, + "airdrop": { + "de": "Airdrop", + "es": "airdrop", + "fr": "airdrop", + "ja": "エアドロップ", + "pt": "airdrop", + "ru": "аирдроп", + "tr": "airdrop", + "uk": "аірдроп", + "zh": "空投" + }, + "bridge": { + "de": "Bridge", + "es": "bridge", + "fr": "bridge", + "ja": "ブリッジ", + "pt": "bridge", + "ru": "бридж", + "tr": "köprü", + "uk": "бридж", + "zh": "跨链桥" + }, + "slippage": { + "de": "Slippage", + "es": "deslizamiento", + "fr": "glissement", + "ja": "スリッページ", + "pt": "deslizamento", + "ru": "проскальзывание", + "tr": "kayma", + "uk": "прослизання", + "zh": "滑点" + }, + "impermanent loss": { + "de": "impermanenter Verlust", + "es": "pérdida impermanente", + "fr": "perte impermanente", + "ja": "インパーマネントロス", + "pt": "perda impermanente", + "ru": "непостоянные потери", + "tr": "geçici kayıp", + "uk": "непостійні втрати", + "zh": "无常损失" + }, + "claim (DeFi rewards)": { + "de": "Einfordern", + "es": "reclamar", + "fr": "réclamer", + "ja": "請求", + "pt": "resgatar", + "ru": "получить", + "tr": "talep etmek", + "uk": "отримати", + "zh": "领取" + }, + "dust (crypto)": null, + "trade (action)": { + "de": "Handeln", + "es": "intercambiar", + "fr": "échanger", + "ja": "取引", + "pt": "negociar", + "ru": "торговать", + "tr": "işlem yapmak", + "uk": "торгувати", + "zh": "交易" + }, + "Loan to Value": { + "de": "Beleihungswert", + "es": "relación préstamo-valor", + "fr": "ratio prêt-valeur", + "ja": "LTV", + "pt": "relação empréstimo-valor", + "ru": "соотношение кредита к стоимости", + "tr": "kredi-değer oranı", + "uk": "співвідношення кредиту до вартості", + "zh": "贷款价值比" + }, + "approve (on-chain)": { + "de": "Genehmigen", + "es": "aprobar", + "fr": "approuver", + "ja": "承認", + "pt": "aprovar", + "ru": "одобрить", + "tr": "onaylamak", + "uk": "схвалити", + "zh": "授权" + }, + "revert (transaction)": { + "de": "rückgängig machen", + "es": "revertir", + "fr": "annuler", + "ja": "リバート", + "pt": "reverter", + "ru": "откатить", + "tr": "geri almak", + "uk": "скасувати", + "zh": "回滚" + }, + "deposit (funds)": { + "de": "Einzahlen", + "es": "depositar", + "fr": "déposer", + "ja": "入金", + "pt": "depositar", + "ru": "внести", + "tr": "yatırmak", + "uk": "внести", + "zh": "存入" + }, + "insufficient funds": { + "de": "unzureichendes Guthaben", + "es": "fondos insuficientes", + "fr": "fonds insuffisants", + "ja": "残高不足", + "pt": "saldo insuficiente", + "ru": "недостаточно средств", + "tr": "yetersiz bakiye", + "uk": "недостатньо коштів", + "zh": "余额不足" + }, + "seed phrase": { + "de": "Seed-Phrase", + "es": "frase semilla", + "fr": "phrase de récupération", + "ja": "シードフレーズ", + "pt": "frase de recuperação", + "ru": "сид-фраза", + "tr": "tohum ifadesi", + "uk": "сід-фраза", + "zh": "助记词" + }, + "streaming swap": { + "de": "Streaming-Tausch", + "es": "intercambio streaming", + "fr": "échange en streaming", + "ja": "ストリーミングスワップ", + "pt": "troca de streaming", + "ru": "потоковый обмен", + "tr": "akış takası", + "uk": "потоковий обмін", + "zh": "流式兑换" + }, + "unstake": { + "de": "Staking beenden", + "es": "retirar del staking", + "fr": "déstaker", + "ja": "ステーク解除", + "pt": "remover stake", + "ru": "снять со стейкинга", + "tr": "stake'i kaldırmak", + "uk": "зняти зі стейкінгу", + "zh": "取消质押" + }, + "restake": { + "de": "Wiederveranlagung", + "es": "restaking", + "fr": "restaking", + "ja": "再ステーキング", + "pt": "restaking", + "ru": "рестейкинг", + "tr": "restaking", + "uk": "рестейкінг", + "zh": "再质押" + }, + "liquidity pool": { + "de": "Liquidity Pool", + "es": "pool de liquidez", + "fr": "pool de liquidités", + "ja": "流動性プール", + "pt": "pool de liquidez", + "ru": "пул ликвидности", + "tr": "likidite havuzu", + "uk": "пул ліквідності", + "zh": "流动资金池" + } +} From e71e153a7f0f7b8f8f728215bf7f670d9f8cab58 Mon Sep 17 00:00:00 2001 From: Jibles Date: Sun, 22 Feb 2026 08:01:37 +0700 Subject: [PATCH 02/10] feat: self-contained per-language translation agents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refactor translation pipeline so each per-language sub-agent owns its full lifecycle (translate → validate → retry → review → refine → merge → verify) instead of the orchestrator managing all steps across 9 languages. Reduces orchestrator to a lightweight coordinator that spawns agents and reads status files. - Extract shared script-detection utilities into script-utils.js - Refactor validate.js to import from script-utils.js (no behavior change) - Add validate-file.js for post-merge full-file validation (JSON validity, key completeness, aggregate script ratio, regression detection) - Simplify merge.js: remove duplicate script-validation, add pre-merge backup for rollback support - Rewrite SKILL.md Steps 5-8 for self-contained language agent architecture Co-Authored-By: Claude Opus 4.6 --- .claude/skills/translate/SKILL.md | 230 +++++++++--------- .claude/skills/translate/scripts/merge.js | 11 +- .../skills/translate/scripts/script-utils.js | 74 ++++++ .../skills/translate/scripts/validate-file.js | 130 ++++++++++ .claude/skills/translate/scripts/validate.js | 70 +++--- 5 files changed, 359 insertions(+), 156 deletions(-) create mode 100644 .claude/skills/translate/scripts/script-utils.js create mode 100644 .claude/skills/translate/scripts/validate-file.js diff --git a/.claude/skills/translate/SKILL.md b/.claude/skills/translate/SKILL.md index 6877957273a..5d19199df3f 100644 --- a/.claude/skills/translate/SKILL.md +++ b/.claude/skills/translate/SKILL.md @@ -158,219 +158,213 @@ node .claude/skills/translate/scripts/prepare-locale.js LOCALE --batches=BATCHES Output: `/tmp/translate-{locale}.json` containing locale rules, glossary, term context, few-shot examples, and all batches in a single file. -## Step 5: Translation Pipeline (per language, per batch) +## Step 5: Spawn Self-Contained Language Agents -### 5a. Translator Sub-Agent +Launch **9 Task sub-agents in parallel** (one per language) using the **Task tool** with `model: "sonnet"`. Each language agent owns its entire lifecycle: translate → validate → retry → review → refine → merge → verify. -Use the **Task tool** with `model: "sonnet"` to translate each batch. +The orchestrator's only job after spawning is to read status files and compile the report (Step 6). -Prompt template for the translator: +### Language Agent Prompt -``` -You are a professional UI translator for a cryptocurrency/DeFi application. -Translate the following English UI strings into {LANGUAGE_NAME} ({LOCALE_CODE}). - -Do NOT read any other files from the codebase. All context you need is provided below. - -Read the locale bundle from /tmp/translate-{LOCALE_CODE}.json and use batch index {BATCH_INDEX} (0-based) as the input to translate. +For each locale, spawn a Task with the following prompt (substituting `{LOCALE_CODE}` and `{LANGUAGE_NAME}`): -LOCALE RULES: -{LOCALE_RULES} - -RULES: -1. INTERPOLATION: Preserve all %{variableName} placeholders exactly as-is. Do not translate variable names inside %{}. -2. TERMINOLOGY: - - NEVER TRANSLATE these terms (keep in English): {NEVER_TRANSLATE_LIST} - - USE APPROVED TRANSLATIONS: {APPROVED_TRANSLATIONS_FOR_THIS_LOCALE} - - When a term below has an established translation in this project, use that same translation unless the context clearly demands a different meaning. -3. Keep translations concise - UI space is limited. Match the approximate length of the English source. -4. Do not add explanations or notes. Return ONLY a JSON object. -5. FORMAT: Preserve HTML entities and markdown in source strings. If a string is a single word that's also a UI label (like "Done", "Cancel"), translate it as a UI action. -6. SOURCE FAITHFULNESS: Do not add information, words, or context not present in the English source. The translation must convey exactly what the English says — nothing more, nothing less. -7. CONCISENESS: UI labels must be short. If the locale naturally produces longer text, prefer shorter synonyms or abbreviations common in that locale's UI conventions. -8. KEY INTEGRITY: Your output keys must EXACTLY match the input keys. Do not rename, rewrite, or substitute any key paths. The output JSON must contain the same set of dotted paths as the input — no additions, no removals, no modifications to key names. - -TERM CONTEXT (how key terms in these strings have been translated elsewhere in this project): -{TERM_CONTEXT} - -REFERENCE TRANSLATIONS (existing translations from this project for tone/style reference): -{EXISTING_TRANSLATIONS_SAMPLE} - -INPUT (JSON object with dotted paths as keys and English strings as values): -{BATCH_JSON} - -OUTPUT: Return a single JSON object with the SAME keys and translated values. Nothing else. ``` +You are a self-contained translation agent for {LANGUAGE_NAME} ({LOCALE_CODE}) in a cryptocurrency/DeFi application. -Where `{LOCALE_RULES}` is loaded from the locale bundle (or `.claude/skills/translate/locales/{locale}.md`). The `{BATCH_INDEX}` is the 0-based index into the `batches` array in the locale bundle file. +You own the full translation lifecycle for your locale. Do NOT read any codebase source files — all context is in the locale bundle. -Parse the returned JSON. If it doesn't parse, retry once with "Return ONLY valid JSON, no markdown fences." +## Your Locale Bundle -### 5b. Programmatic Validation +Read `/tmp/translate-{LOCALE_CODE}.json`. It contains: +- `locale`, `language`, `register` — your target locale metadata +- `localeRules` — locale-specific translation rules (follow these precisely) +- `neverTranslate` — terms that must remain in English +- `approvedTerms` — terms with mandatory translations for your locale +- `termContext` — how key terms have been translated elsewhere in this project +- `fewShot` — reference translations for tone/style +- `batches` — array of batch objects to translate (each batch is `{ "dotted.path": "english value" }`) -After receiving translator output, run the validation script: +## Per-Batch Pipeline (process batches sequentially, 0-indexed) -```bash -node .claude/skills/translate/scripts/validate.js LOCALE SOURCE_JSON TARGET_JSON --term-context=TERM_CONTEXT_FILE -``` - -The script checks: - -0. **Key integrity**: Verify the output key set exactly matches the input key set. Reject any key in the output that doesn't exist in the input ("unexpected key" — translator hallucinated a different key path). Reject any input key missing from the output ("missing key"). Already-rejected keys are skipped by subsequent checks. - -1. **Placeholder integrity**: Extract all `%{...}` tokens from source and target. They must be identical sets (reject if mismatch). Additionally, flag if placeholders appear in different order (grammar may require reordering — flag, not reject). +For each batch in the `batches` array: -2. **Length ratio**: Flag if `target.length / source.length > 3.0` or `< 0.25` (for CJK locales `ja` and `zh`: `> 4.0` and `< 0.15`). +### 1. Translate -3. **Empty/whitespace**: Reject if translated value is empty or whitespace-only. +Translate all strings in the batch from English to {LANGUAGE_NAME}. -4. **Untranslated detection**: Flag if target === source AND the source has more than 3 words (single-word labels and brand names are expected to stay the same). +RULES: +1. INTERPOLATION: Preserve all %{variableName} placeholders exactly as-is. Do not translate variable names inside %{}. +2. TERMINOLOGY: + - NEVER TRANSLATE terms in `neverTranslate` (keep in English) + - USE APPROVED TRANSLATIONS from `approvedTerms` for your locale + - When a term in `termContext` has an established translation, use it unless the context clearly demands a different meaning +3. Keep translations concise — UI space is limited. Match the approximate length of the English source. +4. FORMAT: Preserve HTML entities and markdown. If a string is a single word that's also a UI label (like "Done", "Cancel"), translate it as a UI action. +5. SOURCE FAITHFULNESS: Do not add information not present in the English source. +6. CONCISENESS: Prefer shorter synonyms or abbreviations common in {LANGUAGE_NAME} UI conventions. +7. KEY INTEGRITY: Output keys must EXACTLY match input keys. No additions, removals, or modifications to key names. -5. **Wrong-script detection**: For locales `ja`, `zh` — flag strings where >70% of non-whitespace, non-placeholder, non-glossary characters are Latin. For `ru`, `uk` — same threshold. +Use the `fewShot` examples from the bundle as tone/style reference. -6. **Glossary compliance**: Check that "never translate" terms appear unchanged. Check that terms with approved translations use the approved form. +### 2. Validate -7. **Term consistency** (if term-context available): For each term where all existing matches agree on a single translation, flag if the new translation uses a different word. +Write the source batch to `/tmp/batch-{LOCALE_CODE}-{BATCH_IDX}-source.json` and your translation to `/tmp/batch-{LOCALE_CODE}-{BATCH_IDX}-target.json`, then run: -Output: `{ "rejected": [...], "flagged": [...], "passed": [...] }` +```bash +node .claude/skills/translate/scripts/validate.js {LOCALE_CODE} /tmp/batch-{LOCALE_CODE}-{BATCH_IDX}-source.json /tmp/batch-{LOCALE_CODE}-{BATCH_IDX}-target.json +``` -- **Auto-reject** (send back to translator with error): key integrity mismatch (unexpected/missing keys), empty/whitespace, JSON parse failure, placeholder set mismatch. -- **Flag for reviewer** (pass to reviewer with note): length ratio, untranslated suspicion, wrong-script, glossary violations, term consistency deviations, placeholder reorder. +This outputs `{ rejected, flagged, passed }`. -### 5c. Reviewer Sub-Agent +### 3. Retry Rejected Strings -Use the **Task tool** with `model: "sonnet"` for review. +For any strings in `rejected`: re-translate them incorporating the rejection reason as feedback. Run validation again. Retry up to 2 times total. Strings that still fail after 2 retries become "manual review" items. -Invoke the reviewer for **flagged strings plus a 10% random sample of passed strings** as a spot-check. If no strings are flagged and the passed sample is clean, skip the reviewer for this batch. +### 4. Review (spawn fresh sub-agent) -Prompt template for the reviewer: +Collect flagged strings plus a 10% random sample of passed strings. If there are any strings to review, spawn a **separate reviewer sub-agent** using the Task tool (model: sonnet) with this prompt: ``` You are a senior localization reviewer for a cryptocurrency/DeFi application ({LANGUAGE_NAME}). - Do NOT read any other files from the codebase. All context you need is provided below. Review these translations for quality. For each string, respond with either "approved" or a specific issue description. LOCALE RULES: -{LOCALE_RULES} +{LOCALE_RULES_FROM_BUNDLE} -FOCUS ON (things programmatic validation cannot catch): +FOCUS ON: 1. Naturalness - does it sound natural to a native {LANGUAGE_NAME} speaker? 2. Semantic accuracy - does the translation accurately convey the English meaning? 3. Cultural appropriateness - are there any culturally awkward or inappropriate phrasings? 4. UI appropriateness - translations should be concise enough for UI elements 5. Source faithfulness - verify translation doesn't add information not in the English source -TERM CONTEXT (how key terms have been translated elsewhere — flag inconsistencies): -{TERM_CONTEXT} +TERM CONTEXT: +{TERM_CONTEXT_FROM_BUNDLE} -VALIDATION FLAGS (pay special attention to these): -{VALIDATION_FLAGS_IF_ANY} +VALIDATION FLAGS: +{FLAGS_FOR_FLAGGED_STRINGS_OR_NONE} -STRINGS TO REVIEW: -{STRINGS_WITH_SOURCE_AND_TRANSLATION} +STRINGS TO REVIEW (JSON: { "path": { "en": "source", "translation": "target" } }): +{STRINGS_TO_REVIEW} -OUTPUT: JSON object with dotted paths as keys. Value is either `"approved"` or `"Issue: [one-sentence description]"`. Always prefix issues with "Issue:" for parseability. +OUTPUT: JSON object with dotted paths as keys. Value is either "approved" or "Issue: [one-sentence description]". ``` -When populating `{VALIDATION_FLAGS_IF_ANY}`: if no flags exist for this batch, insert "(No validation flags for this batch — review all strings normally.)" - -If >80% of strings are approved, only the flagged strings proceed to refinement. - -### 5d. Refiner Sub-Agent (conditional) - -Use the **Task tool** with `model: "sonnet"` only for strings the reviewer flagged. +### 5. Refine (spawn fresh sub-agent, conditional) -Prompt template: +If the reviewer flagged any strings, spawn a **separate refiner sub-agent** using the Task tool (model: sonnet): ``` You are a professional UI translator for a cryptocurrency/DeFi application. Do NOT read any other files from the codebase. All context you need is provided below. Fix the following {LANGUAGE_NAME} translations based on reviewer feedback. -For each string below, you have: the English source, the rejected translation, and the reviewer's feedback. -Produce a corrected translation that addresses the feedback. - LOCALE RULES: -{LOCALE_RULES} +{LOCALE_RULES_FROM_BUNDLE} -RULES: Same as translator rules (interpolation, terminology, conciseness, format, source faithfulness). +RULES: Preserve %{placeholders}, use approved terminology, be concise, be faithful to source. -INPUT FORMAT: -{ "dotted.path": { "en": "English source", "translation": "Current translation", "feedback": "Issue: ..." } } +INPUT: { "dotted.path": { "en": "source", "translation": "current", "feedback": "Issue: ..." } } STRINGS TO FIX: {STRINGS_WITH_FEEDBACK} -OUTPUT: JSON object with dotted paths as keys and corrected translations as values. Nothing else. +OUTPUT: JSON object with dotted paths as keys and corrected translations as values. ``` -Re-validate the refined output programmatically. If it still fails validation after 1 retry, skip it and include in the final report as "needs manual review". +Re-validate refined output. If it still fails after 1 retry, mark as "manual review". -## Error Handling +### 6. Accumulate -- **JSON parse failure** (translator/refiner): Retry once with "Return ONLY valid JSON, no markdown fences." If retry fails, skip batch, log to summary as "needs manual review." -- **Empty batch after filtering**: Skip without invoking sub-agents. -- **Sub-agent timeout**: Log as "needs manual review", continue with next language/batch. -- **Glossary file missing/malformed**: Log error and exit with clear message. +After processing all batches, combine all passing translations into a single object. -## Step 6: Parallel Execution Strategy +## Post-Batch: Merge & Verify -Launch **9 Task sub-agents in parallel** (one per language). Within each language, process batches **sequentially** to maintain terminology consistency across namespaces. +After all batches are complete: -For each language, the full pipeline is: translate batch → validate → review (if needed) → refine (if needed) → next batch. +1. Write accumulated translations to `/tmp/translations-{LOCALE_CODE}-final.json` -## Step 7: Merge Results +2. Run merge (which creates a pre-merge backup automatically): + ```bash + node .claude/skills/translate/scripts/merge.js {LOCALE_CODE} /tmp/translations-{LOCALE_CODE}-final.json + ``` -After all languages complete, merge translations into the existing files. +3. Run post-merge validation: + ```bash + node .claude/skills/translate/scripts/validate-file.js {LOCALE_CODE} --pre-merge=/tmp/pre-merge-{LOCALE_CODE}.json + ``` -For each locale: +4. If validate-file reports `valid: false`: + - Restore the pre-merge backup: copy `/tmp/pre-merge-{LOCALE_CODE}.json` back to `src/assets/translations/{LOCALE_CODE}/main.json` + - Mark locale as "failed" in status -1. Read `src/assets/translations/{locale}/main.json` -2. Deep-merge the new translations into the existing nested structure -3. **Preserve key ordering from the English file** — use the English file as the structural template to determine key order -4. Write back with `JSON.stringify(data, null, 2) + '\n'` (2-space indent, trailing newline — matches existing format per `scripts/translations/utils.ts` `saveJSONFile`) +5. Write status to `/tmp/translate-status-{LOCALE_CODE}.json`: + ```json + { + "locale": "{LOCALE_CODE}", + "status": "success" | "failed", + "translated": , + "manualReview": [{ "path": "...", "reason": "..." }], + "errors": ["..."] + } + ``` -Run the merge script: +## Error Handling -```bash -node .claude/skills/translate/scripts/merge.js LOCALE TRANSLATIONS_JSON_OR_FILE +- **JSON parse failure** from your own translation: retry once with stricter instructions +- **Empty batch**: skip without processing +- **Sub-agent (reviewer/refiner) failure**: log as "manual review", continue with next batch +- Strings that fail all retries: include in `manualReview` array in status file ``` -Replace `LOCALE` with the locale code and pass either a stringified JSON of `{ dottedPath: translatedValue }` pairs or a path to a temp file containing the JSON. For large payloads, prefer the temp file approach. +### Temp File Conventions + +All files are namespaced by locale — zero overlap between parallel agents: + +| File | Writer | Reader | +|------|--------|--------| +| `/tmp/translate-{locale}.json` | orchestrator | language agent | +| `/tmp/batch-{locale}-{idx}-source.json` | language agent | validate.js | +| `/tmp/batch-{locale}-{idx}-target.json` | language agent | validate.js | +| `/tmp/translations-{locale}-final.json` | language agent | merge.js | +| `/tmp/pre-merge-{locale}.json` | merge.js | language agent (rollback), validate-file.js | +| `/tmp/translate-status-{locale}.json` | language agent | orchestrator | + +## Step 6: Update Marker & Report -## Step 8: Update Marker & Report +After all 9 language agents complete, read their status files and compile results. -After all merges are complete: +1. **Read status files**: Read `/tmp/translate-status-{locale}.json` for each locale. If a status file is missing, report that locale as "no response" (agent may have crashed — locale file is unchanged). -1. **Write marker file**: +2. **Write marker file** (only if at least one locale succeeded): ```bash git rev-parse HEAD > src/assets/translations/.last-translation-sha ``` -2. **Update glossary timestamp** (if glossary was modified during this run): +3. **Update glossary timestamp** (if glossary was modified during this run): Update `_meta.lastUpdated` in `src/assets/translations/glossary.json` to today's date. -3. **Print summary report**: +4. **Print summary report**: ``` === Translation Summary === SHA marker: Strings translated: across languages Strings skipped (manual review needed): - New glossary terms added: + Locales failed (rolled back): Per-language breakdown: - de: translated, skipped - es: translated, skipped + de: translated, skipped [success|failed|no response] + es: translated, skipped [success|failed|no response] ... Skipped strings (need manual review): - (): ``` -## Step 9: Glossary Update (conditional) +## Step 7: Glossary Update (conditional) After all translations complete, scan for English terms that appear untranslated (kept as-is) in **7 or more locales**. These are candidates for the glossary never-translate list. diff --git a/.claude/skills/translate/scripts/merge.js b/.claude/skills/translate/scripts/merge.js index bba7a4c4d0c..99048c1c790 100644 --- a/.claude/skills/translate/scripts/merge.js +++ b/.claude/skills/translate/scripts/merge.js @@ -11,7 +11,12 @@ if (translationsArg && fs.existsSync(translationsArg)) { newTranslations = JSON.parse(translationsArg); } -const existing = JSON.parse(fs.readFileSync('src/assets/translations/' + locale + '/main.json', 'utf8')); +const localeFilePath = 'src/assets/translations/' + locale + '/main.json'; +const existing = JSON.parse(fs.readFileSync(localeFilePath, 'utf8')); + +// Pre-merge backup for rollback support +const backupPath = `/tmp/pre-merge-${locale}.json`; +fs.writeFileSync(backupPath, JSON.stringify(existing, null, 2) + '\n'); function setByPath(obj, path, value) { const parts = path.split('.'); @@ -48,5 +53,5 @@ for (const [path, value] of Object.entries(newTranslations)) { } const ordered = orderLike(enKeys, existing); -fs.writeFileSync('src/assets/translations/' + locale + '/main.json', JSON.stringify(ordered, null, 2) + '\n'); -console.log('Merged ' + Object.keys(newTranslations).length + ' translations into ' + locale); +fs.writeFileSync(localeFilePath, JSON.stringify(ordered, null, 2) + '\n'); +console.log('Merged ' + Object.keys(newTranslations).length + ' translations into ' + locale + ' (backup: ' + backupPath + ')'); diff --git a/.claude/skills/translate/scripts/script-utils.js b/.claude/skills/translate/scripts/script-utils.js new file mode 100644 index 00000000000..43c4ef10095 --- /dev/null +++ b/.claude/skills/translate/scripts/script-utils.js @@ -0,0 +1,74 @@ +const fs = require('fs'); + +const CJK_LOCALES = new Set(['ja', 'zh']); +const CYRILLIC_LOCALES = new Set(['ru', 'uk']); +const NON_LATIN_LOCALES = new Set([...CJK_LOCALES, ...CYRILLIC_LOCALES]); + +function extractPlaceholders(str) { + return [...str.matchAll(/%\{(\w+)\}/g)].map(m => m[1]); +} + +function stripPlaceholdersAndGlossary(str, glossaryTerms) { + let cleaned = str.replace(/%\{\w+\}/g, ''); + for (const term of glossaryTerms) { + cleaned = cleaned.replace(new RegExp(term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi'), ''); + } + return cleaned.replace(/\s+/g, ''); +} + +function latinRatio(str, glossaryTerms) { + const cleaned = stripPlaceholdersAndGlossary(str, glossaryTerms); + if (cleaned.length === 0) return 0; + const latinChars = [...cleaned].filter(c => /[a-zA-Z]/.test(c)).length; + return latinChars / cleaned.length; +} + +function cyrillicRatio(str, glossaryTerms) { + const cleaned = stripPlaceholdersAndGlossary(str, glossaryTerms); + if (cleaned.length === 0) return 0; + const cyrillicChars = [...cleaned].filter(c => /[\u0400-\u04FF]/.test(c)).length; + return cyrillicChars / cleaned.length; +} + +function cjkRatio(str, glossaryTerms) { + const cleaned = stripPlaceholdersAndGlossary(str, glossaryTerms); + if (cleaned.length === 0) return 0; + const cjkChars = [...cleaned].filter(c => /[\u4E00-\u9FFF\u3040-\u309F\u30A0-\u30FF\uFF66-\uFF9F]/.test(c)).length; + return cjkChars / cleaned.length; +} + +function loadGlossary(glossaryPath) { + if (!fs.existsSync(glossaryPath)) return {}; + return JSON.parse(fs.readFileSync(glossaryPath, 'utf8')); +} + +function glossaryTerms(glossary) { + return Object.keys(glossary).filter(k => k !== '_meta'); +} + +function flattenJson(obj, prefix) { + const result = {}; + for (const [key, value] of Object.entries(obj)) { + const path = prefix ? `${prefix}.${key}` : key; + if (typeof value === 'string') { + result[path] = value; + } else if (typeof value === 'object' && value !== null) { + Object.assign(result, flattenJson(value, path)); + } + } + return result; +} + +module.exports = { + CJK_LOCALES, + CYRILLIC_LOCALES, + NON_LATIN_LOCALES, + extractPlaceholders, + stripPlaceholdersAndGlossary, + latinRatio, + cyrillicRatio, + cjkRatio, + loadGlossary, + glossaryTerms, + flattenJson, +}; diff --git a/.claude/skills/translate/scripts/validate-file.js b/.claude/skills/translate/scripts/validate-file.js new file mode 100644 index 00000000000..c50c54985b8 --- /dev/null +++ b/.claude/skills/translate/scripts/validate-file.js @@ -0,0 +1,130 @@ +const fs = require('fs'); +const { + CJK_LOCALES, + CYRILLIC_LOCALES, + NON_LATIN_LOCALES, + latinRatio, + cyrillicRatio, + cjkRatio, + loadGlossary, + glossaryTerms: getGlossaryTerms, + flattenJson, +} = require('./script-utils'); + +const locale = process.argv[2]; + +if (!locale) { + console.error('Usage: node validate-file.js [--pre-merge=] [--glossary=]'); + process.exit(1); +} + +let preMergePath; +let glossaryPath = 'src/assets/translations/glossary.json'; + +for (const arg of process.argv.slice(3)) { + if (arg.startsWith('--pre-merge=')) preMergePath = arg.slice('--pre-merge='.length); + if (arg.startsWith('--glossary=')) glossaryPath = arg.slice('--glossary='.length); +} + +const glossary = loadGlossary(glossaryPath); +const terms = getGlossaryTerms(glossary); + +const errors = []; +const warnings = []; + +// Check 1: JSON validity — read and parse the locale file +const localeFilePath = `src/assets/translations/${locale}/main.json`; +let localeData; +try { + const raw = fs.readFileSync(localeFilePath, 'utf8'); + localeData = JSON.parse(raw); +} catch (e) { + errors.push(`JSON parse error: ${e.message}`); + console.log(JSON.stringify({ valid: false, errors, warnings })); + process.exit(0); +} + +// Flatten for inspection +const flatLocale = flattenJson(localeData, ''); + +// Check 2: Key completeness — every English key should exist in locale +let enData; +try { + enData = JSON.parse(fs.readFileSync('src/assets/translations/en/main.json', 'utf8')); +} catch (e) { + errors.push(`Cannot read English file: ${e.message}`); + console.log(JSON.stringify({ valid: false, errors, warnings })); + process.exit(0); +} + +const flatEn = flattenJson(enData, ''); +const missingKeys = []; +for (const key of Object.keys(flatEn)) { + if (!(key in flatLocale)) { + missingKeys.push(key); + } +} +if (missingKeys.length > 0) { + warnings.push(`${missingKeys.length} English keys missing from ${locale} (expected for incremental translation)`); +} + +// Check 3: Aggregate script ratio for non-Latin locales +if (NON_LATIN_LOCALES.has(locale)) { + let checked = 0; + let failed = 0; + + for (const value of Object.values(flatLocale)) { + if (typeof value !== 'string') continue; + const cleaned = value.replace(/%\{\w+\}/g, '').replace(/\s+/g, ''); + if (cleaned.length <= 3) continue; + + checked++; + + const lr = latinRatio(value, terms); + if (lr > 0.7) { failed++; continue; } + + if (CYRILLIC_LOCALES.has(locale)) { + const cr = cyrillicRatio(value, terms); + if (cr < 0.3) { failed++; continue; } + } + if (CJK_LOCALES.has(locale)) { + const cr = cjkRatio(value, terms); + if (cr < 0.3) { failed++; continue; } + } + } + + if (checked > 0) { + const failRate = failed / checked; + if (failRate > 0.05) { + errors.push(`${failed}/${checked} strings (${(failRate * 100).toFixed(1)}%) fail script detection for ${locale} — exceeds 5% threshold`); + } else if (failRate > 0.02) { + warnings.push(`${failed}/${checked} strings (${(failRate * 100).toFixed(1)}%) fail script detection for ${locale} — below error threshold but notable`); + } + } +} + +// Check 4: No regression — if pre-merge backup provided, check no keys were deleted or corrupted +if (preMergePath && fs.existsSync(preMergePath)) { + let preMergeData; + try { + preMergeData = JSON.parse(fs.readFileSync(preMergePath, 'utf8')); + } catch (e) { + warnings.push(`Could not parse pre-merge backup: ${e.message}`); + } + + if (preMergeData) { + const flatPreMerge = flattenJson(preMergeData, ''); + const deletedKeys = []; + for (const key of Object.keys(flatPreMerge)) { + if (!(key in flatLocale)) { + deletedKeys.push(key); + } + } + if (deletedKeys.length > 0) { + errors.push(`${deletedKeys.length} existing keys were deleted during merge: ${deletedKeys.slice(0, 5).join(', ')}${deletedKeys.length > 5 ? '...' : ''}`); + } + } +} + +const valid = errors.length === 0; +console.log(JSON.stringify({ valid, errors, warnings }, null, 2)); diff --git a/.claude/skills/translate/scripts/validate.js b/.claude/skills/translate/scripts/validate.js index 14d3b0c1ae7..8a4caeff720 100644 --- a/.claude/skills/translate/scripts/validate.js +++ b/.claude/skills/translate/scripts/validate.js @@ -1,4 +1,16 @@ const fs = require('fs'); +const { + CJK_LOCALES, + CYRILLIC_LOCALES, + NON_LATIN_LOCALES, + extractPlaceholders, + stripPlaceholdersAndGlossary, + latinRatio, + cyrillicRatio, + cjkRatio, + loadGlossary, + glossaryTerms: getGlossaryTerms, +} = require('./script-utils'); const locale = process.argv[2]; const sourceArg = process.argv[3]; @@ -25,44 +37,18 @@ for (const arg of process.argv.slice(5)) { if (arg.startsWith('--glossary=')) glossaryPath = arg.slice('--glossary='.length); } -let glossary = {}; -if (fs.existsSync(glossaryPath)) { - glossary = JSON.parse(fs.readFileSync(glossaryPath, 'utf8')); -} +const glossary = loadGlossary(glossaryPath); +const terms = getGlossaryTerms(glossary); let termContext = {}; if (termContextPath && fs.existsSync(termContextPath)) { termContext = JSON.parse(fs.readFileSync(termContextPath, 'utf8')); } -const CJK_LOCALES = new Set(['ja', 'zh']); -const CYRILLIC_LOCALES = new Set(['ru', 'uk']); -const NON_LATIN_LOCALES = new Set([...CJK_LOCALES, ...CYRILLIC_LOCALES]); - const rejected = []; const flagged = []; const passed = []; -function extractPlaceholders(str) { - return [...str.matchAll(/%\{(\w+)\}/g)].map(m => m[1]); -} - -function stripPlaceholdersAndGlossary(str) { - let cleaned = str.replace(/%\{\w+\}/g, ''); - for (const term of Object.keys(glossary)) { - if (term === '_meta') continue; - cleaned = cleaned.replace(new RegExp(term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi'), ''); - } - return cleaned.replace(/\s+/g, ''); -} - -function latinRatio(str) { - const cleaned = stripPlaceholdersAndGlossary(str); - if (cleaned.length === 0) return 0; - const latinChars = [...cleaned].filter(c => /[a-zA-Z]/.test(c)).length; - return latinChars / cleaned.length; -} - // Check 0: Key set validation const sourceKeys = new Set(Object.keys(source)); const targetKeys = new Set(Object.keys(target)); @@ -128,12 +114,29 @@ for (const path of Object.keys(source)) { isFlagged = true; } - // Check 5: Wrong-script detection + // Check 5: Wrong-script detection (auto-reject, not advisory) if (NON_LATIN_LOCALES.has(locale)) { - const lr = latinRatio(tgt); + const lr = latinRatio(tgt, terms); if (lr > 0.7) { - flags.push({ path, reason: 'wrong script', details: `${(lr * 100).toFixed(0)}% Latin characters for ${locale} locale` }); - isFlagged = true; + rejected.push({ path, reason: 'wrong script', details: `${(lr * 100).toFixed(0)}% Latin characters for ${locale} locale` }); + continue; + } + const cleaned = stripPlaceholdersAndGlossary(tgt, terms); + if (cleaned.length > 3) { + if (CYRILLIC_LOCALES.has(locale)) { + const cr = cyrillicRatio(tgt, terms); + if (cr < 0.3) { + rejected.push({ path, reason: 'wrong script', details: `Only ${(cr * 100).toFixed(0)}% Cyrillic characters for ${locale} locale` }); + continue; + } + } + if (CJK_LOCALES.has(locale)) { + const cr = cjkRatio(tgt, terms); + if (cr < 0.3) { + rejected.push({ path, reason: 'wrong script', details: `Only ${(cr * 100).toFixed(0)}% CJK characters for ${locale} locale` }); + continue; + } + } } } @@ -143,13 +146,11 @@ for (const path of Object.keys(source)) { const termRegex = new RegExp(term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi'); if (value === null) { - // Never-translate term: must appear unchanged if present in source if (termRegex.test(src) && !new RegExp(term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).test(tgt)) { flags.push({ path, reason: 'glossary never-translate', details: `"${term}" should remain in English` }); isFlagged = true; } } else if (typeof value === 'object' && value[locale]) { - // Approved translation: check it's used if (termRegex.test(src) && !tgt.includes(value[locale])) { flags.push({ path, reason: 'glossary approved translation', details: `"${term}" should be "${value[locale]}" in ${locale}` }); isFlagged = true; @@ -163,7 +164,6 @@ for (const path of Object.keys(source)) { const translations = matches.map(m => m[locale]).filter(Boolean); if (translations.length === 0) continue; - // Only flag if all existing uses agree on a single translation for the term const unique = [...new Set(translations)]; if (unique.length !== 1) continue; From 66d57586c9cae64685cc410e0c3ccf116ad5d5fa Mon Sep 17 00:00:00 2001 From: Jibles Date: Mon, 23 Feb 2026 06:22:50 +0700 Subject: [PATCH 03/10] small cleanup fixes --- .beads/pr-context.jsonl | 7 --- .claude/skills/translate/SKILL.md | 19 +++++-- .claude/skills/translate/locales/de.md | 4 +- .claude/skills/translate/locales/ru.md | 1 + .claude/skills/translate/locales/uk.md | 1 + .../translate/scripts/prepare-locale.js | 32 +++++++++++- .../skills/translate/scripts/script-utils.js | 46 +++++----------- .../skills/translate/scripts/validate-file.js | 52 +------------------ .claude/skills/translate/scripts/validate.js | 47 +++-------------- .gitattributes | 3 ++ src/assets/translations/glossary.json | 12 ++--- 11 files changed, 82 insertions(+), 142 deletions(-) diff --git a/.beads/pr-context.jsonl b/.beads/pr-context.jsonl index b46eb353c9f..e69de29bb2d 100644 --- a/.beads/pr-context.jsonl +++ b/.beads/pr-context.jsonl @@ -1,7 +0,0 @@ -{"id":"shapeshiftweb-33d","title":"bugfix pass: wrapped assets, popular assets, chain icons for second-class EVM chains","description":"Sequential bugfix pass from Mantle to Mode. For each PR: checkout, fix issues, merge previous, build/regen/lint/typecheck, commit and push. NEVER force push. NEVER merge PRs. gh api read-only (PR body + comments only). Key fixes: (1) Generalize wrapped native asset detection from Berachain to all chains via chainId-\u003ewrappedNativeAddress mapping, (2) Fix popular assets availability (Cronos only has 2, Linea too), (3) Fix Linea perma-loading asset icon, (4) Sanity check brand chain icons for each chain, (5) Update second-class-evm-chain contract with learnings, (6) Create append-only check skill to prevent closing brace issues in future PRs.","status":"open","priority":2,"issue_type":"epic","owner":"17035424+gomesalexandre@users.noreply.github.com","created_at":"2026-02-20T15:05:00Z","created_by":"gomes","updated_at":"2026-02-20T15:05:00Z"} -{"id":"shapeshiftweb-33d.1","title":"Mantle (#11905): generalize wrapped native detection, fix popular assets, fix Linea icon, update contract, create append-only skill","description":"gh pr checkout 11905. (1) Generalize Berachain's WBERA burn detection to all second-class chains via WRAPPED_NATIVE_BY_CHAIN_ID mapping in SecondClassEvmAdapter.ts - add WMNT address for Mantle. (2) Investigate + fix popular assets issue (compare against happy chains like Scroll/Ink). (3) Fix Linea perma-loading asset icon. (4) Update .claude/contracts/second-class-evm-chain.md with wrapped native + popular assets learnings. (5) Create append-only check skill to prevent closing brace issues. (6) Sanity check Mantle chain icon. Build/regen/lint/typecheck/commit/push. NEVER force push. NEVER merge PR.","status":"in-progress","priority":2,"issue_type":"task","owner":"17035424+gomesalexandre@users.noreply.github.com","created_at":"2026-02-20T15:05:20Z","created_by":"gomes","updated_at":"2026-02-20T15:05:51Z","dependencies":[{"issue_id":"shapeshiftweb-33d.1","depends_on_id":"shapeshiftweb-33d","type":"parent-child","created_at":"2026-02-20T16:05:20Z","created_by":"gomes","metadata":"{}"}]} -{"id":"shapeshiftweb-33d.2","title":"Cronos (#11910): ensure wrapped asset fix for WCRO, fix popular assets","description":"gh pr checkout 11910. Merge Mantle branch. Ensure WCRO address in WRAPPED_NATIVE_BY_CHAIN_ID. Fix popular assets (only 2 currently). Sanity check chain icon. Build/regen/lint/typecheck/commit/push. NEVER force push. NEVER merge PR.","status":"open","priority":2,"issue_type":"task","owner":"17035424+gomesalexandre@users.noreply.github.com","created_at":"2026-02-20T15:05:20Z","created_by":"gomes","updated_at":"2026-02-20T15:05:20Z","dependencies":[{"issue_id":"shapeshiftweb-33d.2","depends_on_id":"shapeshiftweb-33d","type":"parent-child","created_at":"2026-02-20T16:05:20Z","created_by":"gomes","metadata":"{}"},{"issue_id":"shapeshiftweb-33d.2","depends_on_id":"shapeshiftweb-33d.1","type":"blocks","created_at":"2026-02-20T16:05:20Z","created_by":"gomes","metadata":"{}"}]} -{"id":"shapeshiftweb-33d.3","title":"Sonic (#11923): merge Cronos, ensure no wrapped/popular bugs, sanity check icon","description":"gh pr checkout 11923. Merge Cronos branch. Check if Sonic has wrapped native pattern (likely WSONIC). Sanity check popular assets + chain icon. Build/regen/lint/typecheck/commit/push. NEVER force push. NEVER merge PR.","status":"open","priority":2,"issue_type":"task","owner":"17035424+gomesalexandre@users.noreply.github.com","created_at":"2026-02-20T15:05:21Z","created_by":"gomes","updated_at":"2026-02-20T15:05:21Z","dependencies":[{"issue_id":"shapeshiftweb-33d.3","depends_on_id":"shapeshiftweb-33d","type":"parent-child","created_at":"2026-02-20T16:05:20Z","created_by":"gomes","metadata":"{}"},{"issue_id":"shapeshiftweb-33d.3","depends_on_id":"shapeshiftweb-33d.2","type":"blocks","created_at":"2026-02-20T16:05:20Z","created_by":"gomes","metadata":"{}"}]} -{"id":"shapeshiftweb-33d.4","title":"Unichain (#11924): merge Sonic, ensure no wrapped/popular bugs, sanity check icon","description":"gh pr checkout 11924. Merge Sonic branch. Check wrapped native pattern (WETH on Unichain). Sanity check popular assets + chain icon. Build/regen/lint/typecheck/commit/push. NEVER force push. NEVER merge PR.","status":"open","priority":2,"issue_type":"task","owner":"17035424+gomesalexandre@users.noreply.github.com","created_at":"2026-02-20T15:05:21Z","created_by":"gomes","updated_at":"2026-02-20T15:05:21Z","dependencies":[{"issue_id":"shapeshiftweb-33d.4","depends_on_id":"shapeshiftweb-33d","type":"parent-child","created_at":"2026-02-20T16:05:20Z","created_by":"gomes","metadata":"{}"},{"issue_id":"shapeshiftweb-33d.4","depends_on_id":"shapeshiftweb-33d.3","type":"blocks","created_at":"2026-02-20T16:05:20Z","created_by":"gomes","metadata":"{}"}]} -{"id":"shapeshiftweb-33d.5","title":"BOB (#11925): merge Unichain, ensure no wrapped/popular bugs, sanity check icon","description":"gh pr checkout 11925. Merge Unichain branch. Check wrapped native pattern (WETH on BOB). Sanity check popular assets + chain icon. Build/regen/lint/typecheck/commit/push. NEVER force push. NEVER merge PR.","status":"open","priority":2,"issue_type":"task","owner":"17035424+gomesalexandre@users.noreply.github.com","created_at":"2026-02-20T15:05:21Z","created_by":"gomes","updated_at":"2026-02-20T15:05:21Z","dependencies":[{"issue_id":"shapeshiftweb-33d.5","depends_on_id":"shapeshiftweb-33d","type":"parent-child","created_at":"2026-02-20T16:05:21Z","created_by":"gomes","metadata":"{}"},{"issue_id":"shapeshiftweb-33d.5","depends_on_id":"shapeshiftweb-33d.4","type":"blocks","created_at":"2026-02-20T16:05:21Z","created_by":"gomes","metadata":"{}"}]} -{"id":"shapeshiftweb-33d.6","title":"Mode (#11926): merge BOB, ensure no wrapped/popular bugs, sanity check icon","description":"gh pr checkout 11926. Merge BOB branch. Check wrapped native pattern (WETH on Mode). Sanity check popular assets + chain icon. Build/regen/lint/typecheck/commit/push. NEVER force push. NEVER merge PR.","status":"open","priority":2,"issue_type":"task","owner":"17035424+gomesalexandre@users.noreply.github.com","created_at":"2026-02-20T15:05:21Z","created_by":"gomes","updated_at":"2026-02-20T15:05:21Z","dependencies":[{"issue_id":"shapeshiftweb-33d.6","depends_on_id":"shapeshiftweb-33d","type":"parent-child","created_at":"2026-02-20T16:05:21Z","created_by":"gomes","metadata":"{}"},{"issue_id":"shapeshiftweb-33d.6","depends_on_id":"shapeshiftweb-33d.5","type":"blocks","created_at":"2026-02-20T16:05:21Z","created_by":"gomes","metadata":"{}"}]} diff --git a/.claude/skills/translate/SKILL.md b/.claude/skills/translate/SKILL.md index 5d19199df3f..be7a6fcc909 100644 --- a/.claude/skills/translate/SKILL.md +++ b/.claude/skills/translate/SKILL.md @@ -182,7 +182,10 @@ Read `/tmp/translate-{LOCALE_CODE}.json`. It contains: - `approvedTerms` — terms with mandatory translations for your locale - `termContext` — how key terms have been translated elsewhere in this project - `fewShot` — reference translations for tone/style -- `batches` — array of batch objects to translate (each batch is `{ "dotted.path": "english value" }`) +- `batches` — array of batch objects, each containing: + - `strings` — the key-value pairs to translate (`{ "dotted.path": "english value" }`) + - `relevantNeverTranslate` — never-translate terms that appear in this batch's strings + - `relevantApprovedTerms` — approved translations relevant to this batch ## Per-Batch Pipeline (process batches sequentially, 0-indexed) @@ -190,25 +193,31 @@ For each batch in the `batches` array: ### 1. Translate -Translate all strings in the batch from English to {LANGUAGE_NAME}. +Translate all strings in `batch.strings` from English to {LANGUAGE_NAME}. + +GLOSSARY REMINDER for this batch: +- Never translate these terms (keep in English): {batch.relevantNeverTranslate} +- Use these approved translations: {batch.relevantApprovedTerms} RULES: 1. INTERPOLATION: Preserve all %{variableName} placeholders exactly as-is. Do not translate variable names inside %{}. 2. TERMINOLOGY: - - NEVER TRANSLATE terms in `neverTranslate` (keep in English) - - USE APPROVED TRANSLATIONS from `approvedTerms` for your locale + - NEVER TRANSLATE terms in `relevantNeverTranslate` for this batch (keep in English) + - USE APPROVED TRANSLATIONS from `relevantApprovedTerms` for this batch + - Also reference the full `neverTranslate` and `approvedTerms` in the bundle as fallback - When a term in `termContext` has an established translation, use it unless the context clearly demands a different meaning 3. Keep translations concise — UI space is limited. Match the approximate length of the English source. 4. FORMAT: Preserve HTML entities and markdown. If a string is a single word that's also a UI label (like "Done", "Cancel"), translate it as a UI action. 5. SOURCE FAITHFULNESS: Do not add information not present in the English source. 6. CONCISENESS: Prefer shorter synonyms or abbreviations common in {LANGUAGE_NAME} UI conventions. 7. KEY INTEGRITY: Output keys must EXACTLY match input keys. No additions, removals, or modifications to key names. +8. TAG KEYS: If the key path contains `.tags.`, the value is likely a short label or abbreviation. Preserve abbreviations as-is without expanding them. Check the `tagKeys` array in the bundle to identify these keys. Use the `fewShot` examples from the bundle as tone/style reference. ### 2. Validate -Write the source batch to `/tmp/batch-{LOCALE_CODE}-{BATCH_IDX}-source.json` and your translation to `/tmp/batch-{LOCALE_CODE}-{BATCH_IDX}-target.json`, then run: +Write the source batch (`batch.strings`) to `/tmp/batch-{LOCALE_CODE}-{BATCH_IDX}-source.json` and your translation to `/tmp/batch-{LOCALE_CODE}-{BATCH_IDX}-target.json`, then run: ```bash node .claude/skills/translate/scripts/validate.js {LOCALE_CODE} /tmp/batch-{LOCALE_CODE}-{BATCH_IDX}-source.json /tmp/batch-{LOCALE_CODE}-{BATCH_IDX}-target.json diff --git a/.claude/skills/translate/locales/de.md b/.claude/skills/translate/locales/de.md index 848f1968dab..c7187b31c3b 100644 --- a/.claude/skills/translate/locales/de.md +++ b/.claude/skills/translate/locales/de.md @@ -2,10 +2,12 @@ - Use formal "Sie" address consistently - Use verb infinitives for action buttons (e.g., "Einzahlen" not "Einzahlung") -- Compound nouns as one word without hyphens (e.g., "Gasgebuhr" not "Gas-Gebuhr") +- German compound nouns are ALWAYS written as one word or hyphenated, NEVER as separate words (e.g., "Verkaufsvorschau" not "Verkauf vorschau", "Gasgebühr" not "Gas Gebühr") - Use established German financial terms - Translate English adjectives — don't leave English untranslated unless in glossary - Do not invent compound nouns that don't exist in German — if no established compound exists, use a short phrase or keep the English term +- For preview/overview labels, use compound forms: Vorschau (preview), Übersicht (overview) +- Action buttons use infinitive form (Einzahlen, Handeln, Einfordern), not noun form (Einzahlung, Handel) - "claim" in DeFi context = "Einfordern" (not "Anfordern" or "Beanspruchen") - "dust" stays English (glossary never-translate) — not "Staub" (physical dust) - Separable verb imperatives with "Sie": the prefix goes to end of clause (e.g., "Fordern Sie Ihre Belohnungen ein") diff --git a/.claude/skills/translate/locales/ru.md b/.claude/skills/translate/locales/ru.md index b4fde5fa470..b78093d9ab0 100644 --- a/.claude/skills/translate/locales/ru.md +++ b/.claude/skills/translate/locales/ru.md @@ -8,3 +8,4 @@ - "seed phrase" = "сид-фраза" — transliterated loanword, standard in Russian crypto community - Prefer transliterated English DeFi loanwords over native Russian calques (e.g., "бридж" not "мост" for bridge) - "liquidity pool" = "пул ликвидности" — "пул" is the standard transliteration +- "dust" (in crypto context, meaning tiny leftover amounts) must stay in English — do NOT translate as "пыль" diff --git a/.claude/skills/translate/locales/uk.md b/.claude/skills/translate/locales/uk.md index 7322c3b2a0a..1e6c4a9ae31 100644 --- a/.claude/skills/translate/locales/uk.md +++ b/.claude/skills/translate/locales/uk.md @@ -8,3 +8,4 @@ - "seed phrase" = "сiд-фраза" — NEVER literal "фраза-насiння" (= phrase of seeds, meaningless) - Verify morphological correctness — Ukrainian agglutination rules differ from Russian; incorrect suffixes produce meaningless words - "liquidity pool" = "пул лiквiдностi" — standard transliteration matching Russian pattern +- "dust" (in crypto context, meaning tiny leftover amounts) must stay in English — do NOT translate as "пил" diff --git a/.claude/skills/translate/scripts/prepare-locale.js b/.claude/skills/translate/scripts/prepare-locale.js index d115e8b85fd..6e431aa29cf 100644 --- a/.claude/skills/translate/scripts/prepare-locale.js +++ b/.claude/skills/translate/scripts/prepare-locale.js @@ -75,6 +75,35 @@ if (fewShotPath && fs.existsSync(fewShotPath)) { fewShot = JSON.parse(fs.readFileSync(fewShotPath, 'utf8')); } +const batchArray = Array.isArray(batches) ? batches : [batches]; +const tagKeys = batchArray.flatMap(batch => Object.keys(batch).filter(k => k.includes('.tags.'))); + +function filterGlossaryForBatch(batch) { + const batchText = Object.values(batch).join(' ').toLowerCase(); + + const relevantNeverTranslate = neverTranslate.filter(term => + batchText.includes(term.toLowerCase()) + ); + + const relevantApprovedTerms = {}; + for (const [term, translation] of Object.entries(approvedTerms)) { + if (batchText.includes(term.toLowerCase())) { + relevantApprovedTerms[term] = translation; + } + } + + return { relevantNeverTranslate, relevantApprovedTerms }; +} + +const batchesWithGlossary = batchArray.map(batch => { + const { relevantNeverTranslate, relevantApprovedTerms } = filterGlossaryForBatch(batch); + return { + strings: batch, + relevantNeverTranslate, + relevantApprovedTerms, + }; +}); + const bundle = { locale, language: meta.language, @@ -84,7 +113,8 @@ const bundle = { approvedTerms, termContext, fewShot, - batches: Array.isArray(batches) ? batches : [batches], + tagKeys, + batches: batchesWithGlossary, }; fs.writeFileSync(outputPath, JSON.stringify(bundle, null, 2) + '\n'); diff --git a/.claude/skills/translate/scripts/script-utils.js b/.claude/skills/translate/scripts/script-utils.js index 43c4ef10095..75d38628fd2 100644 --- a/.claude/skills/translate/scripts/script-utils.js +++ b/.claude/skills/translate/scripts/script-utils.js @@ -1,40 +1,26 @@ const fs = require('fs'); const CJK_LOCALES = new Set(['ja', 'zh']); -const CYRILLIC_LOCALES = new Set(['ru', 'uk']); -const NON_LATIN_LOCALES = new Set([...CJK_LOCALES, ...CYRILLIC_LOCALES]); function extractPlaceholders(str) { return [...str.matchAll(/%\{(\w+)\}/g)].map(m => m[1]); } -function stripPlaceholdersAndGlossary(str, glossaryTerms) { - let cleaned = str.replace(/%\{\w+\}/g, ''); - for (const term of glossaryTerms) { - cleaned = cleaned.replace(new RegExp(term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi'), ''); - } - return cleaned.replace(/\s+/g, ''); -} +const INFLECTED_LOCALES = new Set(['de', 'es', 'fr', 'pt', 'ru', 'tr', 'uk']); -function latinRatio(str, glossaryTerms) { - const cleaned = stripPlaceholdersAndGlossary(str, glossaryTerms); - if (cleaned.length === 0) return 0; - const latinChars = [...cleaned].filter(c => /[a-zA-Z]/.test(c)).length; - return latinChars / cleaned.length; -} +function stemMatch(target, approved, locale) { + if (!INFLECTED_LOCALES.has(locale)) { + return target.toLowerCase().includes(approved.toLowerCase()); + } -function cyrillicRatio(str, glossaryTerms) { - const cleaned = stripPlaceholdersAndGlossary(str, glossaryTerms); - if (cleaned.length === 0) return 0; - const cyrillicChars = [...cleaned].filter(c => /[\u0400-\u04FF]/.test(c)).length; - return cyrillicChars / cleaned.length; -} + const targetLower = target.toLowerCase(); + const words = approved.split(/\s+/); -function cjkRatio(str, glossaryTerms) { - const cleaned = stripPlaceholdersAndGlossary(str, glossaryTerms); - if (cleaned.length === 0) return 0; - const cjkChars = [...cleaned].filter(c => /[\u4E00-\u9FFF\u3040-\u309F\u30A0-\u30FF\uFF66-\uFF9F]/.test(c)).length; - return cjkChars / cleaned.length; + return words.every(word => { + const minLen = Math.max(3, Math.ceil(word.length * 0.7)); + const stem = word.slice(0, minLen).toLowerCase(); + return targetLower.includes(stem); + }); } function loadGlossary(glossaryPath) { @@ -61,13 +47,9 @@ function flattenJson(obj, prefix) { module.exports = { CJK_LOCALES, - CYRILLIC_LOCALES, - NON_LATIN_LOCALES, + INFLECTED_LOCALES, extractPlaceholders, - stripPlaceholdersAndGlossary, - latinRatio, - cyrillicRatio, - cjkRatio, + stemMatch, loadGlossary, glossaryTerms, flattenJson, diff --git a/.claude/skills/translate/scripts/validate-file.js b/.claude/skills/translate/scripts/validate-file.js index c50c54985b8..16c3a37fdb4 100644 --- a/.claude/skills/translate/scripts/validate-file.js +++ b/.claude/skills/translate/scripts/validate-file.js @@ -1,34 +1,21 @@ const fs = require('fs'); const { - CJK_LOCALES, - CYRILLIC_LOCALES, - NON_LATIN_LOCALES, - latinRatio, - cyrillicRatio, - cjkRatio, - loadGlossary, - glossaryTerms: getGlossaryTerms, flattenJson, } = require('./script-utils'); const locale = process.argv[2]; if (!locale) { - console.error('Usage: node validate-file.js [--pre-merge=] [--glossary=]'); + console.error('Usage: node validate-file.js [--pre-merge=]'); process.exit(1); } let preMergePath; -let glossaryPath = 'src/assets/translations/glossary.json'; for (const arg of process.argv.slice(3)) { if (arg.startsWith('--pre-merge=')) preMergePath = arg.slice('--pre-merge='.length); - if (arg.startsWith('--glossary=')) glossaryPath = arg.slice('--glossary='.length); } -const glossary = loadGlossary(glossaryPath); -const terms = getGlossaryTerms(glossary); - const errors = []; const warnings = []; @@ -68,42 +55,7 @@ if (missingKeys.length > 0) { warnings.push(`${missingKeys.length} English keys missing from ${locale} (expected for incremental translation)`); } -// Check 3: Aggregate script ratio for non-Latin locales -if (NON_LATIN_LOCALES.has(locale)) { - let checked = 0; - let failed = 0; - - for (const value of Object.values(flatLocale)) { - if (typeof value !== 'string') continue; - const cleaned = value.replace(/%\{\w+\}/g, '').replace(/\s+/g, ''); - if (cleaned.length <= 3) continue; - - checked++; - - const lr = latinRatio(value, terms); - if (lr > 0.7) { failed++; continue; } - - if (CYRILLIC_LOCALES.has(locale)) { - const cr = cyrillicRatio(value, terms); - if (cr < 0.3) { failed++; continue; } - } - if (CJK_LOCALES.has(locale)) { - const cr = cjkRatio(value, terms); - if (cr < 0.3) { failed++; continue; } - } - } - - if (checked > 0) { - const failRate = failed / checked; - if (failRate > 0.05) { - errors.push(`${failed}/${checked} strings (${(failRate * 100).toFixed(1)}%) fail script detection for ${locale} — exceeds 5% threshold`); - } else if (failRate > 0.02) { - warnings.push(`${failed}/${checked} strings (${(failRate * 100).toFixed(1)}%) fail script detection for ${locale} — below error threshold but notable`); - } - } -} - -// Check 4: No regression — if pre-merge backup provided, check no keys were deleted or corrupted +// Check 3: No regression — if pre-merge backup provided, check no keys were deleted or corrupted if (preMergePath && fs.existsSync(preMergePath)) { let preMergeData; try { diff --git a/.claude/skills/translate/scripts/validate.js b/.claude/skills/translate/scripts/validate.js index 8a4caeff720..7cebd312248 100644 --- a/.claude/skills/translate/scripts/validate.js +++ b/.claude/skills/translate/scripts/validate.js @@ -1,15 +1,9 @@ const fs = require('fs'); const { CJK_LOCALES, - CYRILLIC_LOCALES, - NON_LATIN_LOCALES, extractPlaceholders, - stripPlaceholdersAndGlossary, - latinRatio, - cyrillicRatio, - cjkRatio, + stemMatch, loadGlossary, - glossaryTerms: getGlossaryTerms, } = require('./script-utils'); const locale = process.argv[2]; @@ -38,7 +32,6 @@ for (const arg of process.argv.slice(5)) { } const glossary = loadGlossary(glossaryPath); -const terms = getGlossaryTerms(glossary); let termContext = {}; if (termContextPath && fs.existsSync(termContextPath)) { @@ -114,51 +107,25 @@ for (const path of Object.keys(source)) { isFlagged = true; } - // Check 5: Wrong-script detection (auto-reject, not advisory) - if (NON_LATIN_LOCALES.has(locale)) { - const lr = latinRatio(tgt, terms); - if (lr > 0.7) { - rejected.push({ path, reason: 'wrong script', details: `${(lr * 100).toFixed(0)}% Latin characters for ${locale} locale` }); - continue; - } - const cleaned = stripPlaceholdersAndGlossary(tgt, terms); - if (cleaned.length > 3) { - if (CYRILLIC_LOCALES.has(locale)) { - const cr = cyrillicRatio(tgt, terms); - if (cr < 0.3) { - rejected.push({ path, reason: 'wrong script', details: `Only ${(cr * 100).toFixed(0)}% Cyrillic characters for ${locale} locale` }); - continue; - } - } - if (CJK_LOCALES.has(locale)) { - const cr = cjkRatio(tgt, terms); - if (cr < 0.3) { - rejected.push({ path, reason: 'wrong script', details: `Only ${(cr * 100).toFixed(0)}% CJK characters for ${locale} locale` }); - continue; - } - } - } - } - - // Check 6: Glossary compliance + // Check 5: Glossary compliance for (const [term, value] of Object.entries(glossary)) { if (term === '_meta') continue; - const termRegex = new RegExp(term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi'); + const termRegex = new RegExp('\\b' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\b', 'gi'); if (value === null) { - if (termRegex.test(src) && !new RegExp(term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).test(tgt)) { - flags.push({ path, reason: 'glossary never-translate', details: `"${term}" should remain in English` }); + if (termRegex.test(src) && !new RegExp('\\b' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\b', 'i').test(tgt)) { + flags.push({ path, reason: 'glossary never-translate', details: `"${term}" should stay in English` }); isFlagged = true; } } else if (typeof value === 'object' && value[locale]) { - if (termRegex.test(src) && !tgt.includes(value[locale])) { + if (termRegex.test(src) && !stemMatch(tgt, value[locale], locale)) { flags.push({ path, reason: 'glossary approved translation', details: `"${term}" should be "${value[locale]}" in ${locale}` }); isFlagged = true; } } } - // Check 7: Term consistency + // Check 6: Term consistency for (const [term, matches] of Object.entries(termContext)) { if (!matches || matches.length === 0) continue; const translations = matches.map(m => m[locale]).filter(Boolean); diff --git a/.gitattributes b/.gitattributes index 937c0eb3795..26e4a840bc0 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,5 @@ /.yarn/releases/** binary /.yarn/plugins/** binary + +# Use bd merge for beads JSONL files +.beads/issues.jsonl merge=beads diff --git a/src/assets/translations/glossary.json b/src/assets/translations/glossary.json index 51b5331c9bf..6bcb7899d71 100644 --- a/src/assets/translations/glossary.json +++ b/src/assets/translations/glossary.json @@ -168,7 +168,7 @@ "uk": "непостійні втрати", "zh": "无常损失" }, - "claim (DeFi rewards)": { + "claim": { "de": "Einfordern", "es": "reclamar", "fr": "réclamer", @@ -179,8 +179,8 @@ "uk": "отримати", "zh": "领取" }, - "dust (crypto)": null, - "trade (action)": { + "dust": null, + "trade": { "de": "Handeln", "es": "intercambiar", "fr": "échanger", @@ -202,7 +202,7 @@ "uk": "співвідношення кредиту до вартості", "zh": "贷款价值比" }, - "approve (on-chain)": { + "approve": { "de": "Genehmigen", "es": "aprobar", "fr": "approuver", @@ -213,7 +213,7 @@ "uk": "схвалити", "zh": "授权" }, - "revert (transaction)": { + "revert": { "de": "rückgängig machen", "es": "revertir", "fr": "annuler", @@ -224,7 +224,7 @@ "uk": "скасувати", "zh": "回滚" }, - "deposit (funds)": { + "deposit": { "de": "Einzahlen", "es": "depositar", "fr": "déposer", From f4a6ee2094be68281f16d24dd8c4e915c3ecb205 Mon Sep 17 00:00:00 2001 From: Jibles Date: Mon, 23 Feb 2026 18:54:15 +0700 Subject: [PATCH 04/10] feat: add missing i18n translations across 9 languages Translate 11 missing English strings into de, es, fr, ja, pt, ru, tr, uk, zh using the new /translate Claude Code skill. Covers RFOX FAQ entries, action center failure messages, and yield cooldown notices. Also fixes merge.js to only add new keys by default, never overwriting existing translations. A --force flag is available for intentional re-translation of changed English strings. Co-Authored-By: Claude Opus 4.6 --- .claude/skills/translate/SKILL.md | 2 +- .claude/skills/translate/scripts/merge.js | 22 +++++++++++++- src/assets/translations/.last-translation-sha | 1 + src/assets/translations/de/main.json | 30 +++++++++++++------ src/assets/translations/es/main.json | 30 +++++++++++++------ src/assets/translations/fr/main.json | 30 +++++++++++++------ src/assets/translations/ja/main.json | 30 +++++++++++++------ src/assets/translations/pt/main.json | 30 +++++++++++++------ src/assets/translations/ru/main.json | 30 +++++++++++++------ src/assets/translations/tr/main.json | 30 +++++++++++++------ src/assets/translations/uk/main.json | 30 +++++++++++++------ src/assets/translations/zh/main.json | 30 +++++++++++++------ 12 files changed, 212 insertions(+), 83 deletions(-) create mode 100644 src/assets/translations/.last-translation-sha diff --git a/.claude/skills/translate/SKILL.md b/.claude/skills/translate/SKILL.md index be7a6fcc909..1c8c34be235 100644 --- a/.claude/skills/translate/SKILL.md +++ b/.claude/skills/translate/SKILL.md @@ -295,7 +295,7 @@ After all batches are complete: 1. Write accumulated translations to `/tmp/translations-{LOCALE_CODE}-final.json` -2. Run merge (which creates a pre-merge backup automatically): +2. Run merge (which creates a pre-merge backup automatically). By default, merge **only adds new keys** — existing translations are never overwritten. Pass `--force` only when re-translating changed English strings: ```bash node .claude/skills/translate/scripts/merge.js {LOCALE_CODE} /tmp/translations-{LOCALE_CODE}-final.json ``` diff --git a/.claude/skills/translate/scripts/merge.js b/.claude/skills/translate/scripts/merge.js index 99048c1c790..b17050a0df3 100644 --- a/.claude/skills/translate/scripts/merge.js +++ b/.claude/skills/translate/scripts/merge.js @@ -18,6 +18,18 @@ const existing = JSON.parse(fs.readFileSync(localeFilePath, 'utf8')); const backupPath = `/tmp/pre-merge-${locale}.json`; fs.writeFileSync(backupPath, JSON.stringify(existing, null, 2) + '\n'); +const forceOverwrite = process.argv.includes('--force'); + +function getByPath(obj, path) { + const parts = path.split('.'); + let current = obj; + for (const part of parts) { + if (current == null || typeof current !== 'object') return undefined; + current = current[part]; + } + return current; +} + function setByPath(obj, path, value) { const parts = path.split('.'); let current = obj; @@ -48,10 +60,18 @@ function orderLike(template, target) { return ordered; } +let added = 0; +let skipped = 0; for (const [path, value] of Object.entries(newTranslations)) { + const existingValue = getByPath(existing, path); + if (existingValue !== undefined && !forceOverwrite) { + skipped++; + continue; + } setByPath(existing, path, value); + added++; } const ordered = orderLike(enKeys, existing); fs.writeFileSync(localeFilePath, JSON.stringify(ordered, null, 2) + '\n'); -console.log('Merged ' + Object.keys(newTranslations).length + ' translations into ' + locale + ' (backup: ' + backupPath + ')'); +console.log('Merged ' + added + ' new translations into ' + locale + ' (' + skipped + ' existing skipped, backup: ' + backupPath + ')'); diff --git a/src/assets/translations/.last-translation-sha b/src/assets/translations/.last-translation-sha new file mode 100644 index 00000000000..c01be5c78cb --- /dev/null +++ b/src/assets/translations/.last-translation-sha @@ -0,0 +1 @@ +410b0e2ae72aa1006fa79543536954be0d0ab7a2 diff --git a/src/assets/translations/de/main.json b/src/assets/translations/de/main.json index d389ec468bb..2a34ae50f33 100644 --- a/src/assets/translations/de/main.json +++ b/src/assets/translations/de/main.json @@ -2589,13 +2589,16 @@ "body": "rFOX ist ein Begriff, der die Vorteile und Belohnungen beschreibt, die Sie erhalten, wenn Sie Ihre FOX-Token auf Arbitrum einsetzen. Derzeit sperren und entsperren Sie Vanilla-FOX-Token (auf Arbitrum) für das Einsetzen von Emissionen." }, "why": { - "title": "Warum sollten Sie Ihren FOX staken?" + "title": "Warum sollten Sie Ihren FOX staken?", + "body": "Wenn Sie Ihre FOX einsetzen, können Sie regelmäßige USDC-Auszahlungen aus der DAO-Schatzkammer erhalten, was einen stetigen Strom an passivem Einkommen in USDC pro Epoche bietet. Darüber hinaus wird ein Teil der DAO-Einnahmen dazu verwendet, das gesamte FOX-Angebot durch Token-Verbrennungen zu reduzieren. Der Staking-Prozess erfordert eine Mindest-Unstaking-Periode von 28 Tagen, was Engagement und Stabilität innerhalb der Community fördert." }, "stake": { - "title": "Wie stake ich meinen FOX?" + "title": "Wie stake ich meinen FOX?", + "body": "Um Ihre FOX einzusetzen, nutzen Sie die Staking-Oberfläche, um Ihre FOX-Token in den Staking-Vertrag zu sperren. Nach dem Einsetzen beginnen Sie, USDC-Belohnungen pro Block als Teil Ihrer Staking-Vorteile zu verdienen." }, "unstake": { - "title": "Was passiert, wenn ich mein Staking zurücknehme?" + "title": "Was passiert, wenn ich mein Staking zurücknehme?", + "body": "Wenn Sie das Staking beenden möchten, gilt eine Mindest-Unstaking-Periode von 28 Tagen. Während dieser Zeit bleibt Ihr FOX gesperrt, und Sie erhalten keine USDC-Emissionen mehr. Nach der Unstaking-Periode können Sie Ihre regulären FOX-Token entsperren und abrufen." }, "cooldown": { "title": "Wie lange ist die Abkühlungsphase?", @@ -2606,7 +2609,8 @@ "body": "Ja, Sie können mehrere Beträge aufheben. Für jede Aufhebungsaktion gilt eine Abkühlungsphase von 28 Tagen." }, "connect": { - "title": "Welcher Zusammenhang besteht zwischen den insgesamt erhobenen Gebühren, dem gesamten FOX-Einsatz, dem Emissionspool und der FOX-Verbrennungsmenge?" + "title": "Welcher Zusammenhang besteht zwischen den insgesamt erhobenen Gebühren, dem gesamten FOX-Einsatz, dem Emissionspool und der FOX-Verbrennungsmenge?", + "body": "Die vom DAO erhobenen Gebühren werden in nativen Vermögenswerten pro Swapper berechnet. Ein Teil dieses Einkommens wird umgewandelt und als Belohnungen an FOX-Staker verteilt, während ein anderer Teil zur Reduzierung des gesamten FOX-Angebots durch Token-Verbrennungen verwendet wird. Die Gesamtmenge der eingesetzten FOX beeinflusst die Verteilung der Belohnungen, da diese pro rata auf Basis der eingesetzten FOX-Menge verteilt werden. Der Emissionspool stellt die Menge an FOX dar, die für Staking-Belohnungen verfügbar ist, und die FOX-Verbrennungsmenge ist der Anteil an FOX, der gekauft und verbrannt wird." } }, "totals": "rFOX Summen", @@ -2836,15 +2840,23 @@ }, "deposit": { "pending": "Ihre Einzahlung von %{amount} %{symbol} wird bearbeitet.", - "complete": "Ihre Einzahlung von %{amount} %{symbol} ist abgeschlossen." + "complete": "Ihre Einzahlung von %{amount} %{symbol} ist abgeschlossen.", + "failed": "Ihre Einzahlung von %{amount} %{symbol} ist fehlgeschlagen." }, "withdrawal": { "pending": "Ihre Auszahlung von %{amount} %{symbol} wird bearbeitet.", - "complete": "Ihre Auszahlung von %{amount} %{symbol} ist abgeschlossen." + "complete": "Ihre Auszahlung von %{amount} %{symbol} ist abgeschlossen.", + "failed": "Ihre Auszahlung von %{amount} %{symbol} ist fehlgeschlagen." }, "claim": { "pending": "Ihr Anspruch in Höhe von %{amount} %{symbol} wird bearbeitet.", - "complete": "Ihr Anspruch in Höhe von %{amount} %{symbol} ist vollständig." + "complete": "Ihr Anspruch in Höhe von %{amount} %{symbol} ist vollständig.", + "failed": "Das Einfordern von %{amount} %{symbol} ist fehlgeschlagen." + }, + "yield": { + "unstakeAvailableIn": "Das Beenden des Stakings für Ihr %{symbol} ist in %{duration} verfügbar.", + "unstakeReady": "Das Beenden des Stakings für Ihr %{symbol} ist bereit zum Einfordern.", + "unstakeClaimed": "Das Beenden des Stakings für Ihr %{symbol} wurde eingefordert." }, "bridge": { "processing": "Ihr Wechsel von %{sellAmountAndSymbol}.%{sellChainShortName} zu %{buyAmountAndSymbol}.%{buyChainShortName} wird verarbeitet.", @@ -2895,8 +2907,7 @@ "yieldXYZ": { "pageTitle": "Erträge", "pageSubtitle": "Ertragspotenziale über mehrere Ketten hinweg erkennen und nutzen", - "actions": { - }, + "actions": {}, "yield": "Ertrag", "apy": "APY", "apr": "APR", @@ -2988,6 +2999,7 @@ "providers": "Anbieter", "successStaked": "Sie haben erfolgreich %{amount} %{symbol} gestaked.", "successUnstaked": "Sie haben %{amount} %{symbol} erfolgreich freigegeben.", + "cooldownNotice": "Ihre Auszahlung ist in %{cooldownDuration} verfügbar.", "successDeposited": "Sie haben erfolgreich %{amount} %{symbol} eingezahlt.", "successWithdrawn": "Sie haben erfolgreich %{amount} %{symbol} abgehoben.", "successClaim": "Sie haben erfolgreich %{amount} %{symbol} beansprucht.", diff --git a/src/assets/translations/es/main.json b/src/assets/translations/es/main.json index fa8c77d39fa..4c2701465a7 100644 --- a/src/assets/translations/es/main.json +++ b/src/assets/translations/es/main.json @@ -2589,13 +2589,16 @@ "body": "rFOX término utilizado para describir beneficios y recompensas que recibe al hacer Staking de sus FOX en Arbitrum. Actualmente, bloquea y desbloquea tokens vanilla FOX (en Arbitrum) para emisiones Staking." }, "why": { - "title": "¿Por qué hacer Staking de FOX?" + "title": "¿Por qué hacer Staking de FOX?", + "body": "Hacer staking de tus FOX te permite ganar pagos regulares de USDC del tesoro de la DAO, proporcionando un flujo constante de ingresos pasivos en USDC por época. Además, una parte de los ingresos de la DAO se destinará a reducir el suministro total de FOX mediante quema de tokens. El proceso de staking requiere un período mínimo de 28 días para retirar del staking, fomentando el compromiso y la estabilidad dentro de la comunidad." }, "stake": { - "title": "¿Cómo hago Staking FOX?" + "title": "¿Cómo hago Staking FOX?", + "body": "Para hacer staking de tus FOX, usa la interfaz de staking para bloquear tus tokens FOX en el contrato de staking. Una vez en staking, comienzas a ganar recompensas en USDC por bloque como parte de tus beneficios de staking." }, "unstake": { - "title": "¿Qué pasa cuando dejo de hacer Staking?" + "title": "¿Qué pasa cuando dejo de hacer Staking?", + "body": "Cuando decides retirar del staking, hay un período mínimo de 28 días. Durante este tiempo, tus FOX permanecen bloqueados y dejas de recibir emisiones de USDC. Tras el período de retiro del staking, puedes desbloquear y recuperar tus tokens FOX regulares." }, "cooldown": { "title": "¿Cuánto dura el período de recuperación?", @@ -2606,7 +2609,8 @@ "body": "Sí, puedes tener múltiples cantidades en recuperación. Cada acción tendrá su propio período de 28 días." }, "connect": { - "title": "¿Cómo se relacionan las tarifas totales recaudadas, total Staking FOX, emisiones y cantidad quemado?" + "title": "¿Cómo se relacionan las tarifas totales recaudadas, total Staking FOX, emisiones y cantidad quemado?", + "body": "Las tarifas recaudadas por la DAO están en activos nativos por swapper. Una parte de estos ingresos se convierte y distribuye a los que tienen FOX en staking como recompensas, mientras que otra parte se destina a reducir el suministro total de FOX mediante quema de tokens. El total de FOX en staking influye en la distribución de recompensas, ya que se distribuyen de forma proporcional según la cantidad de FOX en staking. El pool de emisiones representa la cantidad de FOX disponible para recompensas de staking, y la cantidad de quema de FOX es la porción de FOX comprada y quemada." } }, "totals": "rFOX Totales", @@ -2836,15 +2840,23 @@ }, "deposit": { "pending": "Su depósito de %{amount} %{symbol} procesando.", - "complete": "Su depósito de %{amount} %{symbol} completado." + "complete": "Su depósito de %{amount} %{symbol} completado.", + "failed": "Tu depósito de %{amount} %{symbol} ha fallado." }, "withdrawal": { "pending": "Su retiro de %{amount} %{symbol} procesando.", - "complete": "Su retiro de %{amount} %{symbol} completado." + "complete": "Su retiro de %{amount} %{symbol} completado.", + "failed": "Tu retiro de %{amount} %{symbol} ha fallado." }, "claim": { "pending": "Su reclamo de %{amount} %{symbol} procesando.", - "complete": "Su reclamo de %{amount} %{symbol} está completo." + "complete": "Su reclamo de %{amount} %{symbol} está completo.", + "failed": "Tu reclamación de %{amount} %{symbol} ha fallado." + }, + "yield": { + "unstakeAvailableIn": "Tu retiro del staking de %{symbol} estará disponible en %{duration}.", + "unstakeReady": "Tu retiro del staking de %{symbol} está listo para reclamar.", + "unstakeClaimed": "Tu retiro del staking de %{symbol} fue reclamado." }, "bridge": { "processing": "Su bridge de %{sellAmountAndSymbol}.%{sellChainShortName} a %{buyAmountAndSymbol}.%{buyChainShortName} se está procesando.", @@ -2895,8 +2907,7 @@ "yieldXYZ": { "pageTitle": "Rendimientos", "pageSubtitle": "Descubra y gestione oportunidades de rendimiento en múltiples cadenas", - "actions": { - }, + "actions": {}, "yield": "Rendimiento", "apy": "APY", "apr": "APR", @@ -2988,6 +2999,7 @@ "providers": "Proveedores", "successStaked": "Has staked con éxito %{amount} %{symbol}", "successUnstaked": "Has retirado staking exitosamente %{amount} %{symbol}", + "cooldownNotice": "Tu retiro estará disponible en %{cooldownDuration}.", "successDeposited": "Depositó exitosamente %{amount} %{symbol}", "successWithdrawn": "Retiraste exitosamente %{amount} %{symbol}", "successClaim": "Reclamaste exitosamente %{amount} %{symbol}", diff --git a/src/assets/translations/fr/main.json b/src/assets/translations/fr/main.json index 7c7abcaca7f..5a9a9abb1f7 100644 --- a/src/assets/translations/fr/main.json +++ b/src/assets/translations/fr/main.json @@ -2589,13 +2589,16 @@ "body": "rFOX est un terme utilisé pour décrire les avantages et les récompenses que vous recevez lorsque vous stakez vos jetons FOX sur Arbitrum. Actuellement, vous verrouillez et déverrouillez les jetons « vanilla » FOX (sur Arbitrum) pour les émissions de staking." }, "why": { - "title": "Pourquoi staker vos FOX ?" + "title": "Pourquoi staker vos FOX ?", + "body": "Staker votre FOX vous permet de percevoir des versements réguliers en USDC depuis la trésorerie de la DAO, vous offrant un flux constant de revenus passifs en USDC par époque. De plus, une partie des revenus de la DAO sera allouée à la réduction de l'offre globale de FOX par le biais de brûlages de jetons. Le processus de staking requiert une période minimale de déstaking de 28 jours, ce qui encourage l'engagement et la stabilité au sein de la communauté." }, "stake": { - "title": "Comment staker mes FOX ?" + "title": "Comment staker mes FOX ?", + "body": "Pour staker votre FOX, utilisez l'interface de staking afin de verrouiller vos jetons FOX dans le contrat de staking. Une fois staké, vous commencez à gagner des récompenses en USDC par bloc dans le cadre de vos avantages de staking." }, "unstake": { - "title": "Que se passe-t-il lorsque je déstake ?" + "title": "Que se passe-t-il lorsque je déstake ?", + "body": "Lorsque vous décidez de déstaker, une période minimale de déstaking de 28 jours s'applique. Durant cette période, votre FOX reste verrouillé et vous cessez de recevoir des émissions en USDC. Après la période de déstaking, vous pouvez déverrouiller et récupérer vos jetons FOX." }, "cooldown": { "title": "Quelle est la durée de la période de repos ?", @@ -2606,7 +2609,8 @@ "body": "Oui, vous pouvez avoir plusieurs montants en cours de déstaking. Chaque action de déstaking aura sa propre période de repos de 28 jours." }, "connect": { - "title": "Quel est le rapport entre le total de frais collectés, le total staké en FOX, le pool d'émissions et le montant de FOX brûlés ?" + "title": "Quel est le rapport entre le total de frais collectés, le total staké en FOX, le pool d'émissions et le montant de FOX brûlés ?", + "body": "Les frais collectés par la DAO sont exprimés en actifs natifs par swapper. Une partie de ces revenus est convertie et distribuée aux stakers de FOX sous forme de récompenses, tandis qu'une autre partie est allouée à la réduction de l'offre globale de FOX par le biais de brûlages de jetons. Le total de FOX staké influence la distribution des récompenses, celles-ci étant distribuées au prorata du montant de FOX staké. Le pool d'émissions représente la quantité de FOX disponible pour les récompenses de staking, et le montant de FOX brûlés correspond à la part de FOX achetée et brûlée." } }, "totals": "Totaux rFOX", @@ -2836,15 +2840,23 @@ }, "deposit": { "pending": "Votre dépôt de %{amount} %{symbol} est en cours de traitement.", - "complete": "Votre dépôt de %{amount} %{symbol} est terminé." + "complete": "Votre dépôt de %{amount} %{symbol} est terminé.", + "failed": "Votre dépôt de %{amount} %{symbol} a échoué." }, "withdrawal": { "pending": "Votre retrait de %{amount} %{symbol} est en cours de traitement.", - "complete": "Votre retrait de %{amount} %{symbol} est terminé." + "complete": "Votre retrait de %{amount} %{symbol} est terminé.", + "failed": "Votre retrait de %{amount} %{symbol} a échoué." }, "claim": { "pending": "Votre réclamation de %{amount} %{symbol} est en cours de traitement.", - "complete": "Votre réclamation de %{amount} %{symbol} est terminée." + "complete": "Votre réclamation de %{amount} %{symbol} est terminée.", + "failed": "Votre réclamation de %{amount} %{symbol} a échoué." + }, + "yield": { + "unstakeAvailableIn": "Votre déstaking de %{symbol} sera disponible dans %{duration}.", + "unstakeReady": "Votre déstaking de %{symbol} est prêt à être réclamé.", + "unstakeClaimed": "Votre déstaking de %{symbol} a été réclamé." }, "bridge": { "processing": "Votre bridge de %{sellAmountAndSymbol}.%{sellChainShortName} vers %{buyAmountAndSymbol}.%{buyChainShortName} est en cours de traitement.", @@ -2895,8 +2907,7 @@ "yieldXYZ": { "pageTitle": "Rendements", "pageSubtitle": "Découvrez et gérez les opportunités de rendements sur plusieurs chaînes.", - "actions": { - }, + "actions": {}, "yield": "Rendement", "apy": "APY", "apr": "APR", @@ -2988,6 +2999,7 @@ "providers": "Fournisseurs", "successStaked": "Vous avez staké %{amount} %{symbol} avec succès", "successUnstaked": "Vous avez unstaké %{amount} %{symbol} avec succès", + "cooldownNotice": "Votre retrait sera disponible dans %{cooldownDuration}.", "successDeposited": "Vous avez déposé %{amount} %{symbol} avec succès", "successWithdrawn": "Vous avez retiré %{amount} %{symbol} avec succès", "successClaim": "Vous avez réclamé %{amount} %{symbol} avec succès", diff --git a/src/assets/translations/ja/main.json b/src/assets/translations/ja/main.json index 3aa597303d8..008f82da672 100644 --- a/src/assets/translations/ja/main.json +++ b/src/assets/translations/ja/main.json @@ -2589,13 +2589,16 @@ "body": "rFOX は、Arbitrum で FOX トークンをステークしたときに受け取る利益と報酬を表すために使用される用語です。現在、ステーク発行のために、(Arbitrum 上の)バニラ FOXトークンをロックおよびロック解除します。" }, "why": { - "title": "FOXをステークする理由は何?" + "title": "FOXをステークする理由は何?", + "body": "FOXをステーキングすることで、DAOの財務からのUSDC定期配当を獲得でき、エポックごとにUSDCによる安定した受動的収入を得られます。また、DAO収益の一部はトークンバーンを通じてFOXの総供給量を削減するために充当されます。ステーキングには最低28日間のステーク解除期間が必要で、コミュニティ内でのコミットメントと安定性を促します。" }, "stake": { - "title": "FOXをステークするにはどうすればいいか?" + "title": "FOXをステークするにはどうすればいいか?", + "body": "FOXをステーキングするには、ステーキングインターフェースを使用してFOXトークンをステーキングコントラクトにロックします。ステーキング後、ステーキング特典の一環としてブロックごとにUSDC報酬の獲得が始まります。" }, "unstake": { - "title": "ステークを解除するとどうなるか?" + "title": "ステークを解除するとどうなるか?", + "body": "ステークを解除する際は、最低28日間のステーク解除期間があります。この期間中、FOXはロックされたままとなり、USDCエミッションの受け取りが停止されます。ステーク解除期間が終了すると、通常のFOXトークンのロックを解除して回収できます。" }, "cooldown": { "title": "クールダウン期間はどれくらいか?", @@ -2606,7 +2609,8 @@ "body": "はい、ステーキング解除量は複数設定できます。ステーキング解除アクションごとに 28 日間のクールダウン期間があります。" }, "connect": { - "title": "徴収された合計手数料、ステークされた FOX の合計、発行プール、FOX バーン量はそれぞれどのように関連していますか?" + "title": "徴収された合計手数料、ステークされた FOX の合計、発行プール、FOX バーン量はそれぞれどのように関連していますか?", + "body": "DAOが収集する手数料は、スワッパーごとにネイティブ資産で支払われます。この収益の一部はFOXステーカーへの報酬として変換・配分され、別の一部はトークンバーンを通じてFOXの総供給量を削減するために充当されます。ステーキングされたFOXの総量が報酬の配分に影響し、報酬はステーキングされたFOXの量に基づいてプロラータで配分されます。エミッションプールはステーキング報酬として利用可能なFOXの量を表し、FOXバーン量は購入してバーンされたFOXの量です。" } }, "totals": "rFOX の合計", @@ -2836,15 +2840,23 @@ }, "deposit": { "pending": "%{amount} %{symbol} の入金を処理中です。", - "complete": "%{amount} %{symbol} の入金が完了しました。" + "complete": "%{amount} %{symbol} の入金が完了しました。", + "failed": "%{amount} %{symbol} の入金が失敗しました。" }, "withdrawal": { "pending": "%{amount} %{symbol} の出金を処理中です。", - "complete": "%{amount} %{symbol} の出金が完了しました。" + "complete": "%{amount} %{symbol} の出金が完了しました。", + "failed": "%{amount} %{symbol} の出金が失敗しました。" }, "claim": { "pending": "%{amount} %{symbol} の請求を処理中です。", - "complete": "%{amount} %{symbol} の請求が完了しました。" + "complete": "%{amount} %{symbol} の請求が完了しました。", + "failed": "%{amount} %{symbol} の請求が失敗しました。" + }, + "yield": { + "unstakeAvailableIn": "%{symbol} のステーク解除は %{duration} 後に利用可能になります。", + "unstakeReady": "%{symbol} のステーク解除の請求が可能になりました。", + "unstakeClaimed": "%{symbol} のステーク解除が請求されました。" }, "bridge": { "processing": "%{sellAmountAndSymbol}.%{sellChainShortName} から %{buyAmountAndSymbol}.%{buyChainShortName} へのブリッジが処理中です。", @@ -2895,8 +2907,7 @@ "yieldXYZ": { "pageTitle": "利回り", "pageSubtitle": "複数のチェーンにわたる収益機会の発見と管理", - "actions": { - }, + "actions": {}, "yield": "利回り", "apy": "年利", "apr": "APR", @@ -2988,6 +2999,7 @@ "providers": "プロバイダー", "successStaked": "%{amount} %{symbol} をステークしました", "successUnstaked": "%{amount} %{symbol} のステーク解除に成功しました", + "cooldownNotice": "出金は %{cooldownDuration} 後に利用可能になります。", "successDeposited": "%{amount} %{symbol} を入金しました", "successWithdrawn": "%{amount} %{symbol} の引き出しに成功しました", "successClaim": "%{amount} %{symbol} の請求に成功しました", diff --git a/src/assets/translations/pt/main.json b/src/assets/translations/pt/main.json index 742d7670739..5a04cc0b8b2 100644 --- a/src/assets/translations/pt/main.json +++ b/src/assets/translations/pt/main.json @@ -2589,13 +2589,16 @@ "body": "rFOX é um termo usado para descrever os benefícios e recompensas que você recebe ao fazer stake dos seus tokens FOX no Arbitrum. Atualmente, você os trava e desbloqueia tokens vanilla FOX (no Arbitrum) para emissões de staking." }, "why": { - "title": "Por que fazer o stake de FOX?" + "title": "Por que fazer o stake de FOX?", + "body": "Fazer staking do seu FOX permite que você receba pagamentos regulares em USDC do tesouro da DAO, proporcionando um fluxo constante de renda passiva em USDC por época. Além disso, uma parte da receita da DAO será alocada para reduzir a oferta total de FOX por meio de queimas de tokens. O processo de staking requer um período mínimo de unstaking de 28 dias, incentivando o comprometimento e a estabilidade dentro da comunidade." }, "stake": { - "title": "Como faço para realizar stake de FOX?" + "title": "Como faço para realizar stake de FOX?", + "body": "Para fazer staking do seu FOX, você usa a interface de staking para bloquear seus tokens FOX no contrato de staking. Uma vez em staking, você começa a ganhar recompensas em USDC por bloco como parte dos seus benefícios de staking." }, "unstake": { - "title": "O que acontece quando realizo o unstake?" + "title": "O que acontece quando realizo o unstake?", + "body": "Quando você decidir remover stake, há um período mínimo de unstaking de 28 dias. Durante esse tempo, seu FOX permanece bloqueado e você deixa de receber emissões de USDC. Após o período de unstaking, você pode desbloquear e resgatar seus tokens FOX regulares." }, "cooldown": { "title": "Quanto tempo dura o período de espera?", @@ -2606,7 +2609,8 @@ "body": "Sim, você pode ter vários valores em unstaking. Cada ação terá seu próprio período de espera de 28 dias." }, "connect": { - "title": "Como o total de taxas cobradas, o total de FOX em stake, a pool de emissões e o valor queimado da FOX se conectam?" + "title": "Como o total de taxas cobradas, o total de FOX em stake, a pool de emissões e o valor queimado da FOX se conectam?", + "body": "As taxas coletadas pela DAO estão em ativos nativos por swapper. Uma parte dessa receita é convertida e distribuída aos stakers de FOX como recompensas, enquanto outra parte é alocada para reduzir a oferta total de FOX por meio de queimas de tokens. O total de FOX em stake influencia a distribuição de recompensas, pois as recompensas são distribuídas proporcionalmente com base na quantidade de FOX em stake. A pool de emissões representa a quantidade de FOX disponível para recompensas de staking, e o valor de queima de FOX é a parcela de FOX comprada e queimada." } }, "totals": "Totais de rFOX", @@ -2836,15 +2840,23 @@ }, "deposit": { "pending": "Seu depósito de %{amount} %{symbol} está sendo processado.", - "complete": "Seu depósito de %{amount} %{symbol} foi concluído." + "complete": "Seu depósito de %{amount} %{symbol} foi concluído.", + "failed": "Seu depósito de %{amount} %{symbol} falhou." }, "withdrawal": { "pending": "Seu saque de %{amount} %{symbol} está sendo processado.", - "complete": "Seu saque de %{amount} %{symbol} foi concluído." + "complete": "Seu saque de %{amount} %{symbol} foi concluído.", + "failed": "Seu saque de %{amount} %{symbol} falhou." }, "claim": { "pending": "Seu resgate de %{amount} %{symbol} está sendo processado.", - "complete": "Seu resgate de %{amount} %{symbol} foi concluído." + "complete": "Seu resgate de %{amount} %{symbol} foi concluído.", + "failed": "Seu resgate de %{amount} %{symbol} falhou." + }, + "yield": { + "unstakeAvailableIn": "A remoção de stake do seu %{symbol} estará disponível em %{duration}.", + "unstakeReady": "A remoção de stake do seu %{symbol} está pronta para resgatar.", + "unstakeClaimed": "A remoção de stake do seu %{symbol} foi resgatada." }, "bridge": { "processing": "Sua ponte de %{sellAmountAndSymbol}.%{sellChainShortName} to %{buyAmountAndSymbol}.%{buyChainShortName} está sendo processada.", @@ -2895,8 +2907,7 @@ "yieldXYZ": { "pageTitle": "Yields", "pageSubtitle": "Descubra e gerencie oportunidades de rendimento em várias redes.", - "actions": { - }, + "actions": {}, "yield": "Yield", "apy": "APY", "apr": "APR", @@ -2988,6 +2999,7 @@ "providers": "Fornecedores", "successStaked": "Você stakou com sucesso %{amount} %{symbol}", "successUnstaked": "You unstakou com sucesso %{amount} %{symbol}", + "cooldownNotice": "Seu saque estará disponível em %{cooldownDuration}.", "successDeposited": "Você depositou com sucesso %{amount} %{symbol}", "successWithdrawn": "Você sacou %{amount} %{symbol} com sucesso.", "successClaim": "Você sacou com sucesso %{amount} %{symbol}", diff --git a/src/assets/translations/ru/main.json b/src/assets/translations/ru/main.json index 3136f6ebf14..3b018a8c13e 100644 --- a/src/assets/translations/ru/main.json +++ b/src/assets/translations/ru/main.json @@ -2589,13 +2589,16 @@ "body": "rFOX - это термин, используемый для описания преимуществ и вознаграждений, которые вы получаете, когда стейкаете свои токены FOX на Arbitrum. В настоящее время вы блокируете и разблокируете токены vanilla FOX (на Арбитруме), чтобы застейкать." }, "why": { - "title": "Зачем стейкать свой FOX?" + "title": "Зачем стейкать свой FOX?", + "body": "Стейкинг вашего FOX позволяет вам получать регулярные выплаты USDC из казны DAO, обеспечивая стабильный поток пассивного дохода в USDC за каждую эпоху. Кроме того, часть доходов DAO будет направлена на сокращение общего предложения FOX посредством сжигания токенов. Процесс стейкинга требует минимального периода снятия со стейкинга в 28 дней, что поощряет приверженность и стабильность в сообществе." }, "stake": { - "title": "Как стейкать мои FOX?" + "title": "Как стейкать мои FOX?", + "body": "Чтобы застейкать свои FOX, используйте интерфейс стейкинга для блокировки токенов FOX в контракте стейкинга. После стейкинга вы начинаете получать вознаграждения в USDC за каждый блок в рамках преимуществ стейкинга." }, "unstake": { - "title": "Что произойдет, если я сниму со стейкинга?" + "title": "Что произойдет, если я сниму со стейкинга?", + "body": "Когда вы решите снять токены со стейкинга, существует минимальный период в 28 дней. В течение этого времени ваши FOX остаются заблокированными, и вы перестаёте получать эмиссии USDC. После окончания периода снятия со стейкинга вы можете разблокировать и вернуть свои обычные токены FOX." }, "cooldown": { "title": "Как долго длится период снятия со стейкинга?", @@ -2606,7 +2609,8 @@ "body": "Да, у вас может быть несколько сумм разблокировки. У каждого действия по разблокировке будет свой 28-дневный период охлаждения." }, "connect": { - "title": "Как связаны между собой общая сумма собранных комиссий, общая сумма стейкинга FOX, пул и сумма сжигания FOX?" + "title": "Как связаны между собой общая сумма собранных комиссий, общая сумма стейкинга FOX, пул и сумма сжигания FOX?", + "body": "Комиссии, собранные DAO, — это нативные активы в расчёте на каждого свопера. Часть этого дохода конвертируется и распределяется между стейкерами FOX в виде вознаграждений, а другая часть направляется на сокращение общего предложения FOX посредством сжигания токенов. Общий объём застейканных FOX влияет на распределение вознаграждений, поскольку вознаграждения распределяются пропорционально количеству застейканных FOX. Пул эмиссий представляет собой количество FOX, доступное для вознаграждений за стейкинг, а сумма сжигания FOX — это часть FOX, купленная и сожжённая." } }, "totals": "Всего rFOX ", @@ -2836,15 +2840,23 @@ }, "deposit": { "pending": "Ваш депозит в размере %{amount} %{symbol} находится в обработке.", - "complete": "Ваш депозит в размере %{amount} %{symbol} завершен." + "complete": "Ваш депозит в размере %{amount} %{symbol} завершен.", + "failed": "Не удалось внести %{amount} %{symbol}." }, "withdrawal": { "pending": "Ваш вывод средств в размере %{amount} %{symbol} находится в процессе обработки.", - "complete": "Ваш вывод средств в размере %{amount} %{symbol} завершен." + "complete": "Ваш вывод средств в размере %{amount} %{symbol} завершен.", + "failed": "Ваш вывод средств в размере %{amount} %{symbol} не выполнен." }, "claim": { "pending": "Ваше требование на сумму %{amount} %{symbol} находится в обработке.", - "complete": "Ваше требование на %{amount} %{symbol} выполнено." + "complete": "Ваше требование на %{amount} %{symbol} выполнено.", + "failed": "Не удалось получить %{amount} %{symbol}." + }, + "yield": { + "unstakeAvailableIn": "Снятие со стейкинга %{symbol} будет доступно через %{duration}.", + "unstakeReady": "Снятие со стейкинга %{symbol} готово — можно получить.", + "unstakeClaimed": "Снятие со стейкинга %{symbol} выполнено." }, "bridge": { "processing": "Ваш перевод %{sellAmountAndSymbol}.%{sellChainShortName} в %{buyAmountAndSymbol}.%{buyChainShortName} обрабатывается.", @@ -2895,8 +2907,7 @@ "yieldXYZ": { "pageTitle": "Прибыль", "pageSubtitle": "Обнаруживайте и управляйте возможностями получения дохода в нескольких сетях", - "actions": { - }, + "actions": {}, "yield": "Доходность", "apy": "APY", "apr": "APR", @@ -2988,6 +2999,7 @@ "providers": "Поставщики", "successStaked": "Вы успешно застейкали %{amount} %{symbol}", "successUnstaked": "Вы успешно сняли стейкинг %{amount} %{symbol}", + "cooldownNotice": "Ваш вывод будет доступен через %{cooldownDuration}.", "successDeposited": "Вы успешно внесли депозит %{amount} %{symbol}", "successWithdrawn": "Вы успешно сняли деньги %{amount} %{symbol}", "successClaim": "Вы успешно получили %{amount} %{symbol}", diff --git a/src/assets/translations/tr/main.json b/src/assets/translations/tr/main.json index 26abd3e2818..797e840ea75 100644 --- a/src/assets/translations/tr/main.json +++ b/src/assets/translations/tr/main.json @@ -2589,13 +2589,16 @@ "body": "rFOX, FOX tokenlarınızı Arbitrum'da stake ettiğinizde elde ettiğiniz avantajları ve ödülleri tanımlamak için kullanılan bir terimdir. Şu anda, emisyonları stake etmek için vanilya FOX (Arbitrum'da) tokenlerini kilitliyor ve kilidini açıyorsunuz." }, "why": { - "title": "Neden FOX'unuzu stake etmelisiniz?" + "title": "Neden FOX'unuzu stake etmelisiniz?", + "body": "FOX'unuzu stake etmek, DAO'nun hazinesinden dönem başına USDC cinsinden düzenli ödemeler kazanmanıza olanak tanır ve dönem başına USDC'de istikrarlı bir pasif gelir akışı sağlar. Ayrıca, DAO gelirinin bir kısmı token yakma yoluyla genel FOX arzını azaltmaya tahsis edilecektir. Staking süreci, topluluk içinde bağlılığı ve istikrarı teşvik eden minimum 28 günlük bir stake kaldırma süresini gerektirir." }, "stake": { - "title": "FOX'umu nasıl stake ederim?" + "title": "FOX'umu nasıl stake ederim?", + "body": "FOX'unuzu stake etmek için, FOX tokenlarınızı staking sözleşmesine kilitlemek üzere staking arayüzünü kullanırsınız. Stake edildikten sonra, staking avantajlarınızın bir parçası olarak blok başına USDC ödülleri kazanmaya başlarsınız." }, "unstake": { - "title": "Stake kaldırdığımda ne olur?" + "title": "Stake kaldırdığımda ne olur?", + "body": "Stake'i kaldırmaya karar verdiğinizde, minimum 28 günlük bir stake kaldırma süresi vardır. Bu süre zarfında FOX'unuz kilitli kalır ve USDC emisyonları almayı bırakırsınız. Stake kaldırma süresinin ardından düzenli FOX tokenlarınızın kilidini açabilir ve geri alabilirsiniz." }, "cooldown": { "title": "Soğuma süresi ne kadardır?", @@ -2606,7 +2609,8 @@ "body": "Evet, birden fazla stake etme tutarınız olabilir. Her bir stake kaldırma eyleminin kendine ait 28 günlük bekleme süresi olacaktır." }, "connect": { - "title": "Toplanan toplam ücretler, toplam FOX stake miktarı, emisyon havuzu ve FOX yakma miktarı nasıl birbirine bağlanır?" + "title": "Toplanan toplam ücretler, toplam FOX stake miktarı, emisyon havuzu ve FOX yakma miktarı nasıl birbirine bağlanır?", + "body": "DAO tarafından toplanan ücretler, takas başına yerel varlıklar cinsindedir. Bu gelirin bir kısmı dönüştürülerek FOX staker'larına ödül olarak dağıtılırken, bir kısmı token yakma yoluyla genel FOX arzını azaltmaya tahsis edilir. Stake edilen toplam FOX, ödüllerin dağıtımını etkiler; ödüller, stake edilen FOX miktarına göre orantılı olarak dağıtılır. Emisyon havuzu, staking ödülleri için mevcut FOX miktarını temsil eder ve FOX yakma miktarı, satın alınıp yakılan FOX miktarıdır." } }, "totals": "rFOX Toplamı", @@ -2836,15 +2840,23 @@ }, "deposit": { "pending": "%{amount} %{symbol} tutarındaki yatırımınız işleniyor.", - "complete": "%{amount} %{symbol} tutarındaki yatırımınız tamamlandı." + "complete": "%{amount} %{symbol} tutarındaki yatırımınız tamamlandı.", + "failed": "%{amount} %{symbol} yatırma işleminiz başarısız oldu." }, "withdrawal": { "pending": "%{amount} %{symbol} tutarındaki çekim işleminiz işleniyor.", - "complete": "%{amount} %{symbol} tutarındaki çekim işleminiz tamamlandı." + "complete": "%{amount} %{symbol} tutarındaki çekim işleminiz tamamlandı.", + "failed": "%{amount} %{symbol} çekme işleminiz başarısız oldu." }, "claim": { "pending": "%{amount} %{symbol} tutarındaki talebiniz işleniyor.", - "complete": "%{amount} %{symbol} tutarındaki talebiniz tamamlandı." + "complete": "%{amount} %{symbol} tutarındaki talebiniz tamamlandı.", + "failed": "%{amount} %{symbol} talep işleminiz başarısız oldu." + }, + "yield": { + "unstakeAvailableIn": "%{symbol} stake kaldırma işleminiz %{duration} içinde kullanılabilir olacak.", + "unstakeReady": "%{symbol} stake kaldırma işleminiz talep edilmeye hazır.", + "unstakeClaimed": "%{symbol} stake kaldırma işleminiz talep edildi." }, "bridge": { "processing": "%{sellAmountAndSymbol}.%{sellChainShortName} ile %{buyAmountAndSymbol}.%{buyChainShortName} arasındaki köprünüz işleniyor.", @@ -2895,8 +2907,7 @@ "yieldXYZ": { "pageTitle": "Getiriler", "pageSubtitle": "Birden fazla zincirde getiri fırsatlarını keşfedin ve yönetin.", - "actions": { - }, + "actions": {}, "yield": "Getiri", "apy": "APY", "apr": "APR", @@ -2988,6 +2999,7 @@ "providers": "Sağlayıcılar", "successStaked": "%{amount} %{symbol} tutarındaki stake tutarınızı başarıyla yatırdınız.", "successUnstaked": "%{amount} %{symbol} tutarındaki yatırımınızı başarıyla geri çektiniz.", + "cooldownNotice": "Çekme işleminiz %{cooldownDuration} içinde kullanılabilir olacak.", "successDeposited": "%{amount} %{symbol} tutarını başarıyla yatırdınız.", "successWithdrawn": "%{amount} %{symbol} tutarındaki çekme işleminiz başarıyla tamamlandı.", "successClaim": "%{amount} %{symbol} tutarındaki talebinizi başarıyla karşıladınız.", diff --git a/src/assets/translations/uk/main.json b/src/assets/translations/uk/main.json index 881d52aae23..518726e2398 100644 --- a/src/assets/translations/uk/main.json +++ b/src/assets/translations/uk/main.json @@ -2589,13 +2589,16 @@ "body": "rFOX - це термін, який використовується для опису переваг і винагород, які ви отримуєте, коли ви стейкаєте свої токени FOX на Arbitrum. Наразі ви блокуєте і розблоковуєте токени vanilla FOX (на Arbitrum) для стейкінгу." }, "why": { - "title": "Чому стейкати ваш FOX?" + "title": "Чому стейкати ваш FOX?", + "body": "Стейкінг вашого FOX дозволяє вам отримувати регулярні виплати USDC зі скарбниці DAO, забезпечуючи стабільний потік пасивного доходу в USDC за кожну епоху. Крім того, частина доходу DAO буде спрямована на скорочення загальної пропозиції FOX через спалювання токенів. Процес стейкінгу вимагає мінімального 28-денного терміну для зняття зі стейкінгу, заохочуючи прихильність і стабільність у спільноті." }, "stake": { - "title": "Як я можу стейкати мої FOX?" + "title": "Як я можу стейкати мої FOX?", + "body": "Щоб застейкати FOX, ви використовуєте інтерфейс стейкінгу для блокування ваших токенів FOX у контракті стейкінгу. Після стейкінгу ви починаєте заробляти винагороди USDC за кожен блок як частину переваг стейкінгу." }, "unstake": { - "title": "Що станеться коли я виведу зі стейкінгу?" + "title": "Що станеться коли я виведу зі стейкінгу?", + "body": "Коли ви вирішите зняти зі стейкінгу, існує мінімальний 28-денний період очікування. Протягом цього часу ваш FOX залишається заблокованим, і ви перестаєте отримувати емісії USDC. Після завершення терміну зняття зі стейкінгу ви можете розблокувати та отримати свої звичайні токени FOX." }, "cooldown": { "title": "Як довго триває період зняття з стейкінгу?", @@ -2606,7 +2609,8 @@ "body": "Так, ви можете виконати декілька дій зі зняття стейкінгу. Кожна дія зі зняття стейкінгу матиме власний 28-денний період затишшя." }, "connect": { - "title": "як пов'язані між собою загальна сума зібраних комісій, загальна сума в стейкінгу FOX, пул монет та кількість спалених FOX?" + "title": "як пов'язані між собою загальна сума зібраних комісій, загальна сума в стейкінгу FOX, пул монет та кількість спалених FOX?", + "body": "Комісії, зібрані DAO, надходять у нативних активах від кожного свопера. Частина цього доходу конвертується та розподіляється між стейкерами FOX як винагороди, тоді як інша частина спрямовується на скорочення загальної пропозиції FOX через спалювання токенів. Загальна кількість застейканого FOX впливає на розподіл винагород, оскільки винагороди розподіляються пропорційно відповідно до обсягу застейканого FOX. Пул емісій представляє кількість FOX, доступного для винагород за стейкінг, а сума спалювання FOX — це частина FOX, придбаного та спаленого." } }, "totals": "Всього rFOX ", @@ -2836,15 +2840,23 @@ }, "deposit": { "pending": "Ваш депозит у розмірі %{amount} %{symbol} обробляється.", - "complete": "Ваш депозит у розмірі %{amount} %{symbol} завершено." + "complete": "Ваш депозит у розмірі %{amount} %{symbol} завершено.", + "failed": "Не вдалося внести %{amount} %{symbol}." }, "withdrawal": { "pending": "Ваш запит на виведення %{amount} %{symbol} обробляється.", - "complete": "Ваше виведення %{amount} %{symbol} завершено." + "complete": "Ваше виведення %{amount} %{symbol} завершено.", + "failed": "Ваше виведення коштів у розмірі %{amount} %{symbol} не вдалося." }, "claim": { "pending": "Ваша вимога на суму %{amount} %{symbol} обробляється.", - "complete": "Ваша вимога на суму %{amount} %{symbol} виконана." + "complete": "Ваша вимога на суму %{amount} %{symbol} виконана.", + "failed": "Ваш запит на отримання %{amount} %{symbol} не вдався." + }, + "yield": { + "unstakeAvailableIn": "Зняття зі стейкінгу %{symbol} буде доступне через %{duration}.", + "unstakeReady": "Ваше зняття зі стейкінгу %{symbol} готове до отримання.", + "unstakeClaimed": "Ваше зняття зі стейкінгу %{symbol} було отримано." }, "bridge": { "processing": "Ваш переказ з %{sellAmountAndSymbol}.%{sellChainShortName} на %{buyAmountAndSymbol}.%{buyChainShortName} обробляється.", @@ -2895,8 +2907,7 @@ "yieldXYZ": { "pageTitle": "Дохідність", "pageSubtitle": "Відкривайте та керуйте можливостями отримання прибутку в декількох мережах", - "actions": { - }, + "actions": {}, "yield": "Прибутковість", "apy": "APY", "apr": "APR", @@ -2988,6 +2999,7 @@ "providers": "Постачальники", "successStaked": "Ви успішно застейкали %{amount} %{symbol}", "successUnstaked": "Ви успішно зняли зі стейкінгу %{amount} %{symbol}", + "cooldownNotice": "Ваше виведення коштів буде доступне через %{cooldownDuration}.", "successDeposited": "Ви успішно внесли %{amount} %{symbol}", "successWithdrawn": "Ви успішно вивели %{amount} %{symbol}", "successClaim": "Ви успішно отримали %{amount} %{symbol}", diff --git a/src/assets/translations/zh/main.json b/src/assets/translations/zh/main.json index 0436d6bbd65..ca02b067f50 100644 --- a/src/assets/translations/zh/main.json +++ b/src/assets/translations/zh/main.json @@ -2589,13 +2589,16 @@ "body": "rFOX 是一个术语,用于描述当您将 FOX 代币质押在 Arbitrum 上时所获得的利益和奖励。目前,您能够将原生 FOX(在 Arbitrum 上)代币进行锁仓和解锁,以获得质押奖励。" }, "why": { - "title": "为什么要质押你的 FOX?" + "title": "为什么要质押你的 FOX?", + "body": "质押您的 FOX 可让您从 DAO 的国库中定期获得 USDC 支付,每个时段为您提供稳定的被动收入(以 USDC 计)。此外,DAO 收入的一部分将通过代币销毁用于减少 FOX 的整体供应量。质押过程要求最少 28 天的取消质押期,以鼓励社区成员的承诺与稳定性。" }, "stake": { - "title": "我该如何质押我的 FOX?" + "title": "我该如何质押我的 FOX?", + "body": "要质押您的 FOX,请使用质押界面将您的 FOX 代币锁定到质押合约中。质押完成后,您将开始按区块获取 USDC 奖励,作为质押收益的一部分。" }, "unstake": { - "title": "当我取消质押时会发生什么?" + "title": "当我取消质押时会发生什么?", + "body": "当您决定取消质押时,须经过至少 28 天的取消质押期。在此期间,您的 FOX 保持锁定状态,您将停止接收 USDC 排放奖励。取消质押期结束后,您可以解锁并取回您的普通 FOX 代币。" }, "cooldown": { "title": "冷却期有多长?", @@ -2606,7 +2609,8 @@ "body": "是的,你可以有多个取消质押金额。每个取消质押操作都有各自的 28 天冷却期。" }, "connect": { - "title": "收取的总费用、FOX 总质押量、排放池和 FOX 销毁量有何关联?" + "title": "收取的总费用、FOX 总质押量、排放池和 FOX 销毁量有何关联?", + "body": "DAO 收取的费用以每个兑换者的原生资产计算。该收入的一部分被转换后作为奖励分配给 FOX 质押者,另一部分则通过代币销毁用于减少 FOX 的整体供应量。FOX 总质押量影响奖励的分配,奖励按质押的 FOX 数量按比例分配。排放池代表可用于质押奖励的 FOX 数量,FOX 销毁量则是被购买并销毁的 FOX 份额。" } }, "totals": "rFOX 总计", @@ -2836,15 +2840,23 @@ }, "deposit": { "pending": "您存入的 %{amount} %{symbol} 正在处理中。", - "complete": "您存入的 %{amount} %{symbol} 已完成。" + "complete": "您存入的 %{amount} %{symbol} 已完成。", + "failed": "您存入 %{amount} %{symbol} 的操作失败。" }, "withdrawal": { "pending": "您提取的 %{amount} %{symbol} 正在处理中。", - "complete": "您提取的 %{amount} %{symbol} 已完成。" + "complete": "您提取的 %{amount} %{symbol} 已完成。", + "failed": "您提取 %{amount} %{symbol} 的操作失败。" }, "claim": { "pending": "您领取的 %{amount} %{symbol} 正在处理中。", - "complete": "您领取的 %{amount} %{symbol} 已完成。" + "complete": "您领取的 %{amount} %{symbol} 已完成。", + "failed": "您领取 %{amount} %{symbol} 的操作失败。" + }, + "yield": { + "unstakeAvailableIn": "您的 %{symbol} 取消质押将在 %{duration} 后可用。", + "unstakeReady": "您的 %{symbol} 取消质押已可领取。", + "unstakeClaimed": "您的 %{symbol} 取消质押已成功领取。" }, "bridge": { "processing": "您从 %{sellAmountAndSymbol}.%{sellChainShortName} 到 %{buyAmountAndSymbol}.%{buyChainShortName} 的跨链交易正在处理中。", @@ -2895,8 +2907,7 @@ "yieldXYZ": { "pageTitle": "收益", "pageSubtitle": "发现并管理跨多条链的收益机会", - "actions": { - }, + "actions": {}, "yield": "收益", "apy": "APY", "apr": "APR", @@ -2988,6 +2999,7 @@ "providers": "提供者", "successStaked": "您已成功质押 %{amount} %{symbol}", "successUnstaked": "您已成功取消质押 %{amount} %{symbol}", + "cooldownNotice": "您的提款将在 %{cooldownDuration} 后可用。", "successDeposited": "您已成功充值 %{amount} %{symbol}", "successWithdrawn": "您已成功提现 %{amount} %{symbol}", "successClaim": "您已成功领取 %{amount} %{symbol}", From 6e87928ac3b7b15e4554694ceb9165617166c901 Mon Sep 17 00:00:00 2001 From: Jibles Date: Tue, 24 Feb 2026 06:18:58 +0700 Subject: [PATCH 05/10] hardening based on review --- .beads/pr-context.jsonl | 4 ++++ .claude/skills/translate/scripts/diff.js | 12 ++++++++++-- .claude/skills/translate/scripts/merge.js | 12 +++++++++++- 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/.beads/pr-context.jsonl b/.beads/pr-context.jsonl index e69de29bb2d..ec4cf00c824 100644 --- a/.beads/pr-context.jsonl +++ b/.beads/pr-context.jsonl @@ -0,0 +1,4 @@ +{"id":"shapeshiftWeb-2f09","title":"Sanity check Ink + Scroll regen data","description":"Verify generatedAssetData.json has entries for both eip155:534352 (Scroll) and eip155:57073 (Ink). Verify relatedAssetIndex.json has inkAssetId in ETH related array. Verify no regressions. Run review-second-class-evm skill.","status":"closed","priority":1,"issue_type":"task","owner":"contact@0xgom.es","created_at":"2026-02-19T13:51:24.013329+01:00","created_by":"gomes-bot","updated_at":"2026-02-19T17:01:37.198079+01:00","closed_at":"2026-02-19T17:01:37.198079+01:00","close_reason":"Popular assets + market data verified working after cache clear. All ink fixes merged, PR #11960 opened.","dependencies":[{"issue_id":"shapeshiftWeb-2f09","depends_on_id":"shapeshiftWeb-cgtg","type":"blocks","created_at":"2026-02-19T13:51:48.716437+01:00","created_by":"gomes-bot"}]} +{"id":"shapeshiftWeb-4uq9","title":"Checkout + merge-fix Ink PR #11904","description":"Checkout Ink PR, extract regen data before merge, merge origin/develop with -X theirs to resolve all conflicts in favor of develop. Result: branch has Ink code changes but develop's generated files.","status":"closed","priority":1,"issue_type":"task","owner":"contact@0xgom.es","created_at":"2026-02-19T13:50:52.705351+01:00","created_by":"gomes-bot","updated_at":"2026-02-19T13:53:13.843624+01:00","closed_at":"2026-02-19T13:53:13.843624+01:00","close_reason":"Merged origin/develop with -X theirs, all conflicts resolved"} +{"id":"shapeshiftWeb-cgtg","title":"Cherry-pick Ink regen data into develop generated files","description":"Extract Ink (eip155:57073) entries from saved PR generated files. Merge into develop's generatedAssetData.json, relatedAssetIndex.json. Create coingecko adapter. Bump clearAssets migration. Regenerate manifest hashes + brotli/gzip compression.","status":"closed","priority":1,"issue_type":"task","owner":"contact@0xgom.es","created_at":"2026-02-19T13:51:03.136273+01:00","created_by":"gomes-bot","updated_at":"2026-02-19T13:56:30.878339+01:00","closed_at":"2026-02-19T13:56:30.878339+01:00","close_reason":"Added coingecko adapter, index.ts import/export, migration bump 293. User will run yarn generate:asset-data for actual regen.","dependencies":[{"issue_id":"shapeshiftWeb-cgtg","depends_on_id":"shapeshiftWeb-4uq9","type":"blocks","created_at":"2026-02-19T13:51:38.351227+01:00","created_by":"gomes-bot"}]} +{"id":"shapeshiftWeb-l6zn","title":"Add Ink native to ETH related asset index + recompress","status":"closed","priority":1,"issue_type":"bug","owner":"contact@0xgom.es","created_at":"2026-02-19T16:09:45.315585+01:00","created_by":"gomes-bot","updated_at":"2026-02-19T16:12:14.214097+01:00","closed_at":"2026-02-19T16:12:14.214097+01:00","close_reason":"Closed"} diff --git a/.claude/skills/translate/scripts/diff.js b/.claude/skills/translate/scripts/diff.js index 33e234497d1..48c9072455c 100644 --- a/.claude/skills/translate/scripts/diff.js +++ b/.claude/skills/translate/scripts/diff.js @@ -1,8 +1,16 @@ const { execSync } = require('child_process'); const fs = require('fs'); -const sha = fs.readFileSync('src/assets/translations/.last-translation-sha', 'utf8').trim(); -const oldContent = execSync('git show ' + sha + ':src/assets/translations/en/main.json', { encoding: 'utf8' }); +try { + const sha = fs.readFileSync('src/assets/translations/.last-translation-sha', 'utf8').trim(); + if (!/^[0-9a-f]{7,40}$/.test(sha)) { + throw new Error(`Invalid SHA format: "${sha}"`); + } + var oldContent = execSync(`git show "${sha}":src/assets/translations/en/main.json`, { encoding: 'utf8' }); +} catch (err) { + console.log(JSON.stringify({ error: err.message })); + process.exit(1); +} const oldStrings = JSON.parse(oldContent); const newStrings = JSON.parse(fs.readFileSync('src/assets/translations/en/main.json', 'utf8')); diff --git a/.claude/skills/translate/scripts/merge.js b/.claude/skills/translate/scripts/merge.js index b17050a0df3..d3b9255e1cc 100644 --- a/.claude/skills/translate/scripts/merge.js +++ b/.claude/skills/translate/scripts/merge.js @@ -1,11 +1,21 @@ const fs = require('fs'); +const ALLOWED_LOCALES = ['de', 'es', 'fr', 'ja', 'pt', 'ru', 'tr', 'uk', 'zh']; + const enKeys = JSON.parse(fs.readFileSync('src/assets/translations/en/main.json', 'utf8')); const locale = process.argv[2]; +if (!ALLOWED_LOCALES.includes(locale)) { + console.error(`Invalid locale "${locale}". Allowed: ${ALLOWED_LOCALES.join(', ')}`); + process.exit(1); +} const translationsArg = process.argv[3]; let newTranslations; -if (translationsArg && fs.existsSync(translationsArg)) { +if (translationsArg && (translationsArg.includes('/') || translationsArg.endsWith('.json'))) { + if (!fs.existsSync(translationsArg)) { + console.error(`File not found: ${translationsArg}`); + process.exit(1); + } newTranslations = JSON.parse(fs.readFileSync(translationsArg, 'utf8')); } else { newTranslations = JSON.parse(translationsArg); From 41fc67d7ba75ce17bde9e0f9aef0d9acdda568de Mon Sep 17 00:00:00 2001 From: Jibles Date: Tue, 24 Feb 2026 10:44:21 +0700 Subject: [PATCH 06/10] fix: address CodeRabbit findings and French elision rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix glossary key mismatch in compile-report.js (disambiguated keys didn't match actual glossary.json keys, silently skipping 4 checks) - Fix mixed Latin/Cyrillic in ru.md locale guide (vы → вы) - Fix fragile file-path detection in merge.js (use fs.existsSync instead of includes('/'), add missing-arg guard and JSON.parse try/catch) - Add try/catch in missing-keys.js for corrupt/missing locale files - Add French elision rule to fr.md: use "de" when numeric %{amount} buffers the symbol, use "en" when symbol placeholder is directly after the preposition (avoids runtime elision ambiguity) - Retranslate French yield/unstake strings applying the new rule: "déstaking de %{symbol}" → "déstake en %{symbol}" Co-Authored-By: Claude Opus 4.6 --- .../scripts/compile-report.js | 10 +++++----- .claude/skills/translate/locales/fr.md | 3 +++ .claude/skills/translate/locales/ru.md | 4 ++-- .claude/skills/translate/scripts/merge.js | 18 ++++++++++++------ .../skills/translate/scripts/missing-keys.js | 10 +++++++--- src/assets/translations/fr/main.json | 6 +++--- 6 files changed, 32 insertions(+), 19 deletions(-) diff --git a/.claude/skills/benchmark-translate/scripts/compile-report.js b/.claude/skills/benchmark-translate/scripts/compile-report.js index ca65ec28aa1..11f51d402cd 100644 --- a/.claude/skills/benchmark-translate/scripts/compile-report.js +++ b/.claude/skills/benchmark-translate/scripts/compile-report.js @@ -25,13 +25,13 @@ const LOCALE_INFO = { } const GLOSSARY_TARGET_TERMS = [ - { englishPattern: 'dust', glossaryKey: 'dust (crypto)', isNeverTranslate: true }, - { englishPattern: 'claim', glossaryKey: 'claim (DeFi rewards)', isNeverTranslate: false }, - { englishPattern: 'trade', glossaryKey: 'trade (action)', isNeverTranslate: false }, + { englishPattern: 'dust', glossaryKey: 'dust', isNeverTranslate: true }, + { englishPattern: 'claim', glossaryKey: 'claim', isNeverTranslate: false }, + { englishPattern: 'trade', glossaryKey: 'trade', isNeverTranslate: false }, { englishPattern: 'impermanent loss', glossaryKey: 'impermanent loss', isNeverTranslate: false }, - { englishPattern: 'approve', glossaryKey: 'approve (on-chain)', isNeverTranslate: false }, + { englishPattern: 'approve', glossaryKey: 'approve', isNeverTranslate: false }, { englishPattern: 'seed phrase', glossaryKey: 'seed phrase', isNeverTranslate: false }, - { englishPattern: 'deposit', glossaryKey: 'deposit (funds)', isNeverTranslate: false }, + { englishPattern: 'deposit', glossaryKey: 'deposit', isNeverTranslate: false }, { englishPattern: 'staking', glossaryKey: 'staking', isNeverTranslate: false }, { englishPattern: 'swap', glossaryKey: 'swap', isNeverTranslate: false }, { englishPattern: 'wallet', glossaryKey: 'wallet', isNeverTranslate: false }, diff --git a/.claude/skills/translate/locales/fr.md b/.claude/skills/translate/locales/fr.md index 47c74bc6378..20734fa4caf 100644 --- a/.claude/skills/translate/locales/fr.md +++ b/.claude/skills/translate/locales/fr.md @@ -9,3 +9,6 @@ - "seed phrase" = "phrase de récupération" — never literal "phrase de graine" or "phrase de semences" - "unstake" = "déstaker" — coined French DeFi verb, consistent throughout - "liquidity pool" = "pool de liquidités" — never "piscine" +- French elision with dynamic placeholders: "de" requires elision before vowels ("d'ETH") but placeholders resolve at runtime to unknown values. Two rules: + - When a numeric %{amount} precedes the symbol, "de" is safe — digits prevent elision (e.g. "dépôt de %{amount} %{symbol}" → "dépôt de 1,5 ETH" is correct French) + - When an asset/symbol placeholder appears directly after the preposition with no number buffer, use "en" (denominated in) instead of "de" (e.g. "montant en %{symbol}" not "montant de %{symbol}", "déstake en %{symbol}" not "déstake de %{symbol}") diff --git a/.claude/skills/translate/locales/ru.md b/.claude/skills/translate/locales/ru.md index b78093d9ab0..bff2f0d9da0 100644 --- a/.claude/skills/translate/locales/ru.md +++ b/.claude/skills/translate/locales/ru.md @@ -1,6 +1,6 @@ -# Russian (ru) — Formal (vы) +# Russian (ru) — Formal (вы) -- Use formal "vы" address consistently +- Use formal "вы" address consistently - "staking" = "стейкинг" — NEVER "ставка" (= gambling bet) or "стейкировать" (malformed verb) - "restaking" = "рестейкинг" — NEVER "переставка" (= rearranging physical objects, catastrophically wrong) - "unstaking" = "снять со стейкинга" — NEVER native calques that produce meaningless words diff --git a/.claude/skills/translate/scripts/merge.js b/.claude/skills/translate/scripts/merge.js index d3b9255e1cc..3790d2b9528 100644 --- a/.claude/skills/translate/scripts/merge.js +++ b/.claude/skills/translate/scripts/merge.js @@ -10,15 +10,21 @@ if (!ALLOWED_LOCALES.includes(locale)) { } const translationsArg = process.argv[3]; +if (!translationsArg) { + console.error('Usage: node merge.js [--force]'); + process.exit(1); +} + let newTranslations; -if (translationsArg && (translationsArg.includes('/') || translationsArg.endsWith('.json'))) { - if (!fs.existsSync(translationsArg)) { - console.error(`File not found: ${translationsArg}`); - process.exit(1); - } +if (fs.existsSync(translationsArg)) { newTranslations = JSON.parse(fs.readFileSync(translationsArg, 'utf8')); } else { - newTranslations = JSON.parse(translationsArg); + try { + newTranslations = JSON.parse(translationsArg); + } catch (e) { + console.error(`Invalid JSON argument: ${e.message}`); + process.exit(1); + } } const localeFilePath = 'src/assets/translations/' + locale + '/main.json'; diff --git a/.claude/skills/translate/scripts/missing-keys.js b/.claude/skills/translate/scripts/missing-keys.js index c8bc4aae556..2425c02f266 100644 --- a/.claude/skills/translate/scripts/missing-keys.js +++ b/.claude/skills/translate/scripts/missing-keys.js @@ -19,8 +19,12 @@ function findMissing(source, target, path) { const locales = ['de','es','fr','ja','pt','ru','tr','uk','zh']; const result = {}; for (const locale of locales) { - const target = JSON.parse(fs.readFileSync('src/assets/translations/' + locale + '/main.json', 'utf8')); - const missing = findMissing(en, target); - if (missing.length > 0) result[locale] = missing; + try { + const target = JSON.parse(fs.readFileSync('src/assets/translations/' + locale + '/main.json', 'utf8')); + const missing = findMissing(en, target); + if (missing.length > 0) result[locale] = missing; + } catch (e) { + result[locale] = [{ error: e.message }]; + } } console.log(JSON.stringify(result, null, 2)); diff --git a/src/assets/translations/fr/main.json b/src/assets/translations/fr/main.json index 5a9a9abb1f7..f954470f1a1 100644 --- a/src/assets/translations/fr/main.json +++ b/src/assets/translations/fr/main.json @@ -2854,9 +2854,9 @@ "failed": "Votre réclamation de %{amount} %{symbol} a échoué." }, "yield": { - "unstakeAvailableIn": "Votre déstaking de %{symbol} sera disponible dans %{duration}.", - "unstakeReady": "Votre déstaking de %{symbol} est prêt à être réclamé.", - "unstakeClaimed": "Votre déstaking de %{symbol} a été réclamé." + "unstakeAvailableIn": "Votre déstake en %{symbol} sera disponible dans %{duration}.", + "unstakeReady": "Votre déstake en %{symbol} est prêt à être réclamé.", + "unstakeClaimed": "Votre déstake en %{symbol} a été réclamé." }, "bridge": { "processing": "Votre bridge de %{sellAmountAndSymbol}.%{sellChainShortName} vers %{buyAmountAndSymbol}.%{buyChainShortName} est en cours de traitement.", From e7f7ef32c85b4043893d59f4c6a5e33e64a6ecde Mon Sep 17 00:00:00 2001 From: Jibles Date: Tue, 24 Feb 2026 11:02:07 +0700 Subject: [PATCH 07/10] feat: add Ukrainian and Turkish placeholder preposition/suffix rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ukrainian: в/у and з/із/зі preposition alternation rules for dynamic placeholders where runtime values are unknown at translation time. Turkish: vowel harmony rules for dynamic placeholders — prefer postpositions over direct suffixes on placeholders since crypto symbols span all vowel classes. Co-Authored-By: Claude Opus 4.6 --- .claude/skills/translate/locales/tr.md | 3 +++ .claude/skills/translate/locales/uk.md | 3 +++ 2 files changed, 6 insertions(+) diff --git a/.claude/skills/translate/locales/tr.md b/.claude/skills/translate/locales/tr.md index cdd87b84786..fd922d20a33 100644 --- a/.claude/skills/translate/locales/tr.md +++ b/.claude/skills/translate/locales/tr.md @@ -5,3 +5,6 @@ - "claim" (DeFi) = "talep etmek" — NEVER "yuklemeler" (= uploads, completely wrong) - Keep DeFi English loanwords where Turkish crypto community uses them: staking, restaking, swap - When borrowing English terms, use apostrophe for Turkish suffixes (e.g., "Stake'i Kaldır") +- Vowel harmony with dynamic placeholders: Turkish suffixes change form based on the last vowel of the preceding word, but %{placeholder} values are unknown at translation time. Since crypto symbols span all vowel classes (ETH=front, AVAX=back-unrounded, FOX=back-rounded), any static suffix will be wrong for some symbols. Two rules: + - Avoid attaching case suffixes directly to %{placeholder} — restructure using postpositions instead (e.g. "%{symbol} için" not "%{symbol}'ı", "%{symbol} üzerinde" not "%{symbol}'de") + - When a suffix on a placeholder is unavoidable, use front-unrounded harmony (e/i/in/den) as the default — this matches the majority of common crypto symbols (ETH, BTC, USDC, USDT) diff --git a/.claude/skills/translate/locales/uk.md b/.claude/skills/translate/locales/uk.md index 1e6c4a9ae31..cc8a21456c2 100644 --- a/.claude/skills/translate/locales/uk.md +++ b/.claude/skills/translate/locales/uk.md @@ -9,3 +9,6 @@ - Verify morphological correctness — Ukrainian agglutination rules differ from Russian; incorrect suffixes produce meaningless words - "liquidity pool" = "пул лiквiдностi" — standard transliteration matching Russian pattern - "dust" (in crypto context, meaning tiny leftover amounts) must stay in English — do NOT translate as "пил" +- Preposition alternation before dynamic placeholders: Ukrainian alternates в/у and з/із/зі based on surrounding sounds, but %{placeholder} values are unknown at translation time. Two rules: + - Always use "у" (not "в") before %{placeholder} — "у" is the safer default before unknown values (e.g. "у %{opportunity}" not "в %{opportunity}") + - Always use "з" (not "із" or "зі") before %{placeholder} — reserve "зі" only for static text before known consonant clusters (e.g. "з %{opportunity}" not "із %{opportunity}") From 71dbb57f5a5e53e9ce777ae0f9f064a0b71fd98f Mon Sep 17 00:00:00 2001 From: Jibles Date: Tue, 24 Feb 2026 19:14:12 +0700 Subject: [PATCH 08/10] feat: translation pipeline improvements from benchmark findings - Add register examples to all 9 locale files (de, es, fr, ja, pt, ru, tr, uk, zh) with correct/incorrect pairs for non-pronoun register markers - Add register consistency as 6th reviewer focus in SKILL.md - Add "Multichain Snap" and "Snap" to glossary never-translate list - Fix 2 broken community translations across all 9 locales (stale multiChain.body, missing %{symbol} in getAssets.about) - Update compile-report.js to use stemMatch instead of raw .includes() for glossary metrics - Improve stemMatch with language-aware morphological matching (suffix stripping, Levenshtein distance, CJK character overlap) Co-Authored-By: Claude Opus 4.6 --- .../scripts/compile-report.js | 12 ++- .claude/skills/translate/SKILL.md | 1 + .claude/skills/translate/locales/de.md | 3 + .claude/skills/translate/locales/es.md | 5 +- .claude/skills/translate/locales/fr.md | 3 + .claude/skills/translate/locales/ja.md | 3 + .claude/skills/translate/locales/pt.md | 7 +- .claude/skills/translate/locales/ru.md | 3 + .claude/skills/translate/locales/tr.md | 3 + .claude/skills/translate/locales/uk.md | 3 + .claude/skills/translate/locales/zh.md | 1 + .../translate/scripts/prepare-locale.js | 6 +- .../skills/translate/scripts/script-utils.js | 92 +++++++++++++++++-- .claude/skills/translate/scripts/validate.js | 7 +- src/assets/translations/de/main.json | 4 +- src/assets/translations/es/main.json | 4 +- src/assets/translations/fr/main.json | 4 +- src/assets/translations/glossary.json | 24 ++--- src/assets/translations/ja/main.json | 4 +- src/assets/translations/pt/main.json | 4 +- src/assets/translations/ru/main.json | 4 +- src/assets/translations/tr/main.json | 4 +- src/assets/translations/uk/main.json | 4 +- src/assets/translations/zh/main.json | 4 +- 24 files changed, 159 insertions(+), 50 deletions(-) diff --git a/.claude/skills/benchmark-translate/scripts/compile-report.js b/.claude/skills/benchmark-translate/scripts/compile-report.js index 11f51d402cd..c20cf5c344f 100644 --- a/.claude/skills/benchmark-translate/scripts/compile-report.js +++ b/.claude/skills/benchmark-translate/scripts/compile-report.js @@ -1,6 +1,8 @@ const fs = require('fs') const path = require('path') +const { stemMatch } = require('../../translate/scripts/script-utils') + const TRANSLATIONS_DIR = path.resolve(__dirname, '../../../../src/assets/translations') const BENCHMARK_DIR = path.resolve(__dirname, '../../../../scripts/translations/benchmark') const GT_PATH = path.join(BENCHMARK_DIR, 'ground-truth.json') @@ -99,8 +101,9 @@ function checkGlossaryCorrectness(english, skill, locale) { } else { const entry = glossary[term.glossaryKey] if (entry && typeof entry === 'object' && entry[locale]) { - const has = skill.toLowerCase().includes(entry[locale].toLowerCase()) - matched.push({ passed: has, reason: has ? `"${term.englishPattern}" → "${entry[locale]}"` : `"${term.englishPattern}" should be "${entry[locale]}"` }) + const has = stemMatch(skill, entry[locale], locale) + const display = Array.isArray(entry[locale]) ? entry[locale][0] : entry[locale] + matched.push({ passed: has, reason: has ? `"${term.englishPattern}" → "${display}"` : `"${term.englishPattern}" should be "${display}"` }) } } } @@ -128,8 +131,9 @@ function validate(english, skill, locale) { } for (const { term, approved } of (approvedByLocale[locale] || [])) { const tl = term.toLowerCase() - if (english.toLowerCase().includes(tl) && !skill.toLowerCase().includes(approved.toLowerCase())) { - glossaryViolations.push(`"${term}" should be "${approved}"`) + if (english.toLowerCase().includes(tl) && !stemMatch(skill, approved, locale)) { + const display = Array.isArray(approved) ? approved[0] : approved + glossaryViolations.push(`"${term}" should be "${display}"`) } } diff --git a/.claude/skills/translate/SKILL.md b/.claude/skills/translate/SKILL.md index 1c8c34be235..8bfe3949254 100644 --- a/.claude/skills/translate/SKILL.md +++ b/.claude/skills/translate/SKILL.md @@ -248,6 +248,7 @@ FOCUS ON: 3. Cultural appropriateness - are there any culturally awkward or inappropriate phrasings? 4. UI appropriateness - translations should be concise enough for UI elements 5. Source faithfulness - verify translation doesn't add information not in the English source +6. Register consistency — verify ALL address forms, verb conjugations, imperatives, and possessives match the declared register. Check beyond just pronouns: verb forms, possessives, and sentence endings must all be consistent. TERM CONTEXT: {TERM_CONTEXT_FROM_BUNDLE} diff --git a/.claude/skills/translate/locales/de.md b/.claude/skills/translate/locales/de.md index c7187b31c3b..26fb7cf1266 100644 --- a/.claude/skills/translate/locales/de.md +++ b/.claude/skills/translate/locales/de.md @@ -1,6 +1,9 @@ # German (de) — Formal (Sie) - Use formal "Sie" address consistently +- Register violations beyond pronouns: imperative verb forms ("Folge" is du-form, use "Folgen Sie"); possessives ("dein" is du-form, use "Ihr/Ihre") +- WRONG: "Folge den Anweisungen auf deinem Gerät" (du-imperative + du-possessive) +- RIGHT: "Folgen Sie den Anweisungen auf Ihrem Gerät" (Sie-imperative + Sie-possessive) - Use verb infinitives for action buttons (e.g., "Einzahlen" not "Einzahlung") - German compound nouns are ALWAYS written as one word or hyphenated, NEVER as separate words (e.g., "Verkaufsvorschau" not "Verkauf vorschau", "Gasgebühr" not "Gas Gebühr") - Use established German financial terms diff --git a/.claude/skills/translate/locales/es.md b/.claude/skills/translate/locales/es.md index a1857f0909a..7293c25405e 100644 --- a/.claude/skills/translate/locales/es.md +++ b/.claude/skills/translate/locales/es.md @@ -1,6 +1,9 @@ # Spanish (es) — Informal (tu) -- Use informal "tu" address consistently +- Use informal "tú" address consistently +- Register violations beyond pronouns: verb conjugation ("habla" is usted-form, use "hablas" for tú); imperatives ("hable" is formal, use "habla" for tú) +- WRONG: "Conecte su billetera" (usted imperative + formal possessive) +- RIGHT: "Conecta tu billetera" (tú imperative + informal possessive) - Never invent verbs from English (no "tradear", "swapear") - "trade" verb = "intercambiar", noun = "intercambio" - Don't use English nouns outside the glossary diff --git a/.claude/skills/translate/locales/fr.md b/.claude/skills/translate/locales/fr.md index 20734fa4caf..1abe2944b94 100644 --- a/.claude/skills/translate/locales/fr.md +++ b/.claude/skills/translate/locales/fr.md @@ -1,6 +1,9 @@ # French (fr) — Formal (vous) - Use formal "vous" address consistently +- Register violations beyond pronouns: verb endings ("tu peux" vs "vous pouvez"); imperatives ("connecte" is tu-form, use "connectez" for vous) +- WRONG: "Connecte ton portefeuille" (tu-imperative + tu-possessive) +- RIGHT: "Connectez votre portefeuille" (vous-imperative + vous-possessive) - "claim" (DeFi) verb = "réclamer", noun = "réclamation" — NEVER "réclame" (= advertisement in French) - "supported" (feature/chain) = "pris(e) en charge" — not "supportée" (anglicism) - Use consistent "vous" register: prefer "Veuillez + infinitive" for instructions, no mixed imperatives diff --git a/.claude/skills/translate/locales/ja.md b/.claude/skills/translate/locales/ja.md index dadd2f4fd99..0450410655b 100644 --- a/.claude/skills/translate/locales/ja.md +++ b/.claude/skills/translate/locales/ja.md @@ -1,6 +1,9 @@ # Japanese (ja) — Polite (です/ます) - Short UI labels: use noun form without です/ます +- Register violations: sentence endings (casual だ/ない vs polite です/ません); verb forms (casual ~てる vs polite ~ています) +- WRONG: "トランザクションが失敗した" (casual past tense) +- RIGHT: "トランザクションが失敗しました" (polite past tense) - Prefer native kanji over katakana loanwords (e.g., 入金 not デポジット, 残高不足 not 資金不足です) - Never use お客様 - For "X out of Y" → Y件中X件 diff --git a/.claude/skills/translate/locales/pt.md b/.claude/skills/translate/locales/pt.md index 957caa12670..449ea97b111 100644 --- a/.claude/skills/translate/locales/pt.md +++ b/.claude/skills/translate/locales/pt.md @@ -1,6 +1,9 @@ -# Portuguese — Brazil (pt) — Informal (voce) +# Portuguese — Brazil (pt) — Informal (você) -- Use informal "voce" address consistently +- Use informal "você" address consistently +- Register violations: mixing tu-forms with você. Você uses third-person conjugation (fala, é), not second-person (falas, és) +- WRONG: "Tu precisas conectar a carteira" (tu conjugation) +- RIGHT: "Você precisa conectar a carteira" (você conjugation) - "claim" (DeFi) = "resgatar" — not "reivindicar" (sounds legalistic/formal in Brazilian Portuguese) - "seed phrase" = "frase de recuperacao" — never literal "frase de sementes" - Keep English DeFi loanwords where Brazilian crypto community uses them directly: unstaking, restaking, staking diff --git a/.claude/skills/translate/locales/ru.md b/.claude/skills/translate/locales/ru.md index bff2f0d9da0..533185074a7 100644 --- a/.claude/skills/translate/locales/ru.md +++ b/.claude/skills/translate/locales/ru.md @@ -1,6 +1,9 @@ # Russian (ru) — Formal (вы) - Use formal "вы" address consistently +- Register violations beyond pronouns: imperative forms (ты-imperative "делай" vs вы-imperative "делайте"); possessives ("твой" is ты-form, use "ваш") +- WRONG: "Подключи свой кошелёк" (ты-imperative) +- RIGHT: "Подключите ваш кошелёк" (вы-imperative + вы-possessive) - "staking" = "стейкинг" — NEVER "ставка" (= gambling bet) or "стейкировать" (malformed verb) - "restaking" = "рестейкинг" — NEVER "переставка" (= rearranging physical objects, catastrophically wrong) - "unstaking" = "снять со стейкинга" — NEVER native calques that produce meaningless words diff --git a/.claude/skills/translate/locales/tr.md b/.claude/skills/translate/locales/tr.md index fd922d20a33..3287b4309fb 100644 --- a/.claude/skills/translate/locales/tr.md +++ b/.claude/skills/translate/locales/tr.md @@ -1,6 +1,9 @@ # Turkish (tr) — Formal (siz) - Use formal "siz" address consistently +- Register violations beyond pronouns: verb suffixes (sen-form "-sın/-sin" vs siz-form "-sınız/-siniz"); possessives ("senin" is informal, use "sizin") +- WRONG: "Cüzdanını bağla" (sen-imperative + sen-possessive) +- RIGHT: "Cüzdanınızı bağlayınız" (siz-imperative + siz-possessive) - Verify correct vowel harmony in suffixes (e.g., "Parolayı" not "Parolay" — accusative requires buffer vowel) - "claim" (DeFi) = "talep etmek" — NEVER "yuklemeler" (= uploads, completely wrong) - Keep DeFi English loanwords where Turkish crypto community uses them: staking, restaking, swap diff --git a/.claude/skills/translate/locales/uk.md b/.claude/skills/translate/locales/uk.md index cc8a21456c2..21be30850d9 100644 --- a/.claude/skills/translate/locales/uk.md +++ b/.claude/skills/translate/locales/uk.md @@ -1,6 +1,9 @@ # Ukrainian (uk) — Formal (ви) - Use formal "ви" address consistently +- Register violations beyond pronouns: imperative forms (ти-imperative "роби" vs ви-imperative "робіть"); possessives ("твій" is ти-form, use "ваш") +- WRONG: "Підключи свій гаманець" (ти-imperative) +- RIGHT: "Підключіть ваш гаманець" (ви-imperative + ви-possessive) - "staking" = "стейкiнг" — same transliteration pattern as Russian but with Ukrainian orthography - "restaking" = "рестейкiнг" — NEVER native calques - "unstaking" = "зняти зi стейкiнгу" — NEVER meaningless calques like "ненаставлений" diff --git a/.claude/skills/translate/locales/zh.md b/.claude/skills/translate/locales/zh.md index a00a1b60235..38b9e76896c 100644 --- a/.claude/skills/translate/locales/zh.md +++ b/.claude/skills/translate/locales/zh.md @@ -5,6 +5,7 @@ - "trade" = 交易 (broader); "swap" = 兑换 (token exchange) — do not use 兑换 for "trade" - Always use full-width Chinese punctuation (。?!) not half-width (.?!) - Use 您 (formal "you") in error/status messages, not 你 (informal) +- Beyond pronouns: prefer formal lexical choices — 请 (please, neutral) over casual omission; avoid colloquial particles (啦, 啊, 哦) in UI text - "dust" stays English (glossary never-translate) — not 灰尘 (physical dust) or 小额资金 (small funds) - "streaming swap" = 流式兑换 — not 流动兑换 (流动 means "flowing/liquid", 流式 means "streaming-style") - "allowance" (token approval) = 授权额度 — not unrelated terms diff --git a/.claude/skills/translate/scripts/prepare-locale.js b/.claude/skills/translate/scripts/prepare-locale.js index 6e431aa29cf..af007d207aa 100644 --- a/.claude/skills/translate/scripts/prepare-locale.js +++ b/.claude/skills/translate/scripts/prepare-locale.js @@ -59,7 +59,7 @@ const approvedTerms = {}; for (const [term, value] of Object.entries(glossary)) { if (term === '_meta') continue; if (typeof value === 'object' && value !== null && value[locale]) { - approvedTerms[term] = value[locale]; + approvedTerms[term] = Array.isArray(value[locale]) ? value[locale][0] : value[locale]; } } @@ -86,9 +86,9 @@ function filterGlossaryForBatch(batch) { ); const relevantApprovedTerms = {}; - for (const [term, translation] of Object.entries(approvedTerms)) { + for (const [term, canonical] of Object.entries(approvedTerms)) { if (batchText.includes(term.toLowerCase())) { - relevantApprovedTerms[term] = translation; + relevantApprovedTerms[term] = canonical; } } diff --git a/.claude/skills/translate/scripts/script-utils.js b/.claude/skills/translate/scripts/script-utils.js index 75d38628fd2..126142e4592 100644 --- a/.claude/skills/translate/scripts/script-utils.js +++ b/.claude/skills/translate/scripts/script-utils.js @@ -6,20 +6,94 @@ function extractPlaceholders(str) { return [...str.matchAll(/%\{(\w+)\}/g)].map(m => m[1]); } -const INFLECTED_LOCALES = new Set(['de', 'es', 'fr', 'pt', 'ru', 'tr', 'uk']); +const LOCALE_CONFIGS = { + de: { + stemRatio: 0.75, + suffixes: [ + 'ungen', 'keit', 'lich', 'isch', 'ung', 'ten', 'tet', 'en', 'er', 'es', 'em', 'te', 'st', 'e', + ], + }, + es: { + stemRatio: 0.65, + suffixes: [ + 'iendo', 'ando', 'ción', 'ado', 'ido', 'mos', 'ar', 'er', 'ir', 'an', 'en', 'as', 'es', 'os', + ], + }, + fr: { + stemRatio: 0.65, + suffixes: [ + 'tion', 'ment', 'ons', 'ant', 'ent', 'ais', 'ait', 'eur', 'ée', 'és', 'ez', 'er', 'ir', 're', + ], + }, + pt: { + stemRatio: 0.65, + suffixes: [ + 'mente', 'ando', 'ção', 'ado', 'ido', 'mos', 'ar', 'er', 'ir', 'am', 'em', 'as', 'es', + ], + }, + ru: { + stemRatio: 0.55, + suffixes: [ + 'ения', 'ение', 'ами', 'ать', 'ять', 'ишь', 'ала', 'али', 'ите', 'ют', 'ут', 'ят', 'ть', 'ти', + 'ит', 'ов', 'ам', 'ах', 'ом', 'ой', 'ен', 'на', 'ы', 'а', 'у', 'е', 'и', + ], + }, + tr: { + stemRatio: 0.60, + suffixes: [ + 'sınız', 'siniz', 'abilir', 'ebilir', 'ıyor', 'iyor', 'mak', 'mek', 'lar', 'ler', 'lık', 'lik', + 'ın', 'in', 'da', 'de', 'ta', 'te', 'dı', 'di', 'ı', 'i', 'u', 'ü', + ], + }, + uk: { + stemRatio: 0.55, + suffixes: [ + 'ення', 'ання', 'ами', 'ати', 'яти', 'іть', 'ає', 'ює', 'ала', 'али', 'іте', 'айте', + 'ть', 'ти', 'ав', 'ів', 'ам', 'ах', 'ом', 'ою', 'а', 'у', 'і', 'и', + ], + }, +}; + +function stripSuffix(word, suffixes) { + for (const suffix of suffixes) { + if (word.endsWith(suffix)) { + const root = word.slice(0, -suffix.length); + if (root.length >= 3) return root; + } + } + return null; +} function stemMatch(target, approved, locale) { - if (!INFLECTED_LOCALES.has(locale)) { - return target.toLowerCase().includes(approved.toLowerCase()); + const config = LOCALE_CONFIGS[locale]; + + if (!config) { + const forms = Array.isArray(approved) ? approved : [approved]; + return forms.some(form => target.toLowerCase().includes(form.toLowerCase())); } const targetLower = target.toLowerCase(); - const words = approved.split(/\s+/); + const forms = Array.isArray(approved) ? approved : [approved]; + + return forms.some(form => { + const words = form.split(/\s+/); + return words.every(word => { + const wordLower = word.toLowerCase(); + + // Tier 1: Exact substring + if (targetLower.includes(wordLower)) return true; + + // Tier 2: Stem prefix + const minLen = Math.max(3, Math.ceil(wordLower.length * config.stemRatio)); + const stem = wordLower.slice(0, minLen); + if (targetLower.includes(stem)) return true; + + // Tier 3: Suffix-stripped root + const root = stripSuffix(wordLower, config.suffixes); + if (root && targetLower.includes(root)) return true; - return words.every(word => { - const minLen = Math.max(3, Math.ceil(word.length * 0.7)); - const stem = word.slice(0, minLen).toLowerCase(); - return targetLower.includes(stem); + return false; + }); }); } @@ -47,7 +121,7 @@ function flattenJson(obj, prefix) { module.exports = { CJK_LOCALES, - INFLECTED_LOCALES, + LOCALE_CONFIGS, extractPlaceholders, stemMatch, loadGlossary, diff --git a/.claude/skills/translate/scripts/validate.js b/.claude/skills/translate/scripts/validate.js index 7cebd312248..dfddc90ac68 100644 --- a/.claude/skills/translate/scripts/validate.js +++ b/.claude/skills/translate/scripts/validate.js @@ -1,6 +1,7 @@ const fs = require('fs'); const { CJK_LOCALES, + LOCALE_CONFIGS, extractPlaceholders, stemMatch, loadGlossary, @@ -119,8 +120,10 @@ for (const path of Object.keys(source)) { } } else if (typeof value === 'object' && value[locale]) { if (termRegex.test(src) && !stemMatch(tgt, value[locale], locale)) { - flags.push({ path, reason: 'glossary approved translation', details: `"${term}" should be "${value[locale]}" in ${locale}` }); - isFlagged = true; + const display = Array.isArray(value[locale]) ? value[locale][0] : value[locale]; + const isInflected = !!LOCALE_CONFIGS[locale]; + flags.push({ path, reason: 'glossary approved translation', severity: isInflected ? 'info' : 'error', details: `"${term}" should be "${display}" in ${locale}` }); + if (!isInflected) isFlagged = true; } } } diff --git a/src/assets/translations/de/main.json b/src/assets/translations/de/main.json index 2a34ae50f33..ac3ceb09301 100644 --- a/src/assets/translations/de/main.json +++ b/src/assets/translations/de/main.json @@ -345,7 +345,7 @@ "marketCapRank": "Rangliste der Marktkapitalisierung", "volume24h": "24h Volumen", "volMcap": "Vol/Marktkap.", - "about": "Über", + "about": "Über %{symbol}", "noDescription": "Für diese Wertanlage ist keine Beschreibung verfügbar." }, "receive": { @@ -1951,7 +1951,7 @@ "multiChain": { "title": "Mehrere Ketten werden gleichzeitig unterstützt ", "subTitle": "Sie müssen nie das Netzwerk in der Wallet wechseln, es funktioniert mit jeder nahtlos.", - "body": "Wenn eine neues Netzwerk in ShapeShift hinzugefügt wird, wird Ihre Native Wallet es automatisch unterstützen." + "body": "Greifen Sie auf Bitcoin, Ethereum, Solana und viele weitere Chains über ein einziges Wallet zu. Handeln und verwalten Sie Ihre Assets nahtlos über verschiedene Netzwerke hinweg." }, "selfCustody": { "title": "Ihre ShapeShift Wallet verwalten Sie", diff --git a/src/assets/translations/es/main.json b/src/assets/translations/es/main.json index 4c2701465a7..d920de81f61 100644 --- a/src/assets/translations/es/main.json +++ b/src/assets/translations/es/main.json @@ -345,7 +345,7 @@ "marketCapRank": "Clasificación en capitalización de mercado", "volume24h": "Volumen 24 horas", "volMcap": "Vol/Cap. M", - "about": "Acerca de", + "about": "Acerca de %{symbol}", "noDescription": "No hay descripción disponible para este activo." }, "receive": { @@ -1951,7 +1951,7 @@ "multiChain": { "title": "Se admiten varias cadenas simultáneamente", "subTitle": "Nunca necesita cambiar cadenas en la billetera, funciona con cada cadena sin problemas.", - "body": "Al agregar una nueva cadena a ShapeShift, su billetera ShapeShift la admitirá automáticamente." + "body": "Accede a Bitcoin, Ethereum, Solana y muchas más cadenas desde una sola billetera. Intercambia y gestiona tus activos en diferentes redes sin problemas." }, "selfCustody": { "title": "Su Billetera ShapeShift es auto-custodia", diff --git a/src/assets/translations/fr/main.json b/src/assets/translations/fr/main.json index f954470f1a1..aabfa920769 100644 --- a/src/assets/translations/fr/main.json +++ b/src/assets/translations/fr/main.json @@ -345,7 +345,7 @@ "marketCapRank": "Rang de capitalisation boursière", "volume24h": "Volume sur 24h", "volMcap": "Vol/Cap", - "about": "Informations sur", + "about": "À propos de %{symbol}", "noDescription": "Aucune description disponible pour cet actif." }, "receive": { @@ -1951,7 +1951,7 @@ "multiChain": { "title": "Plusieurs chaînes sont prises en charge simultanément", "subTitle": "Vous n'avez jamais besoin de changer de chaîne dans le porte-monnaie, il fonctionne avec chaque chaîne de manière transparente.", - "body": "Lorsqu'une nouvelle chaîne est ajoutée sur ShapeShift, votre porte-monnaie ShapeShift la prend en charge automatiquement." + "body": "Accédez à Bitcoin, Ethereum, Solana et bien d'autres chaînes depuis un seul portefeuille. Échangez et gérez vos actifs sur différents réseaux en toute simplicité." }, "selfCustody": { "title": "Votre porte-monnaie ShapeShift est auto-dépositaire", diff --git a/src/assets/translations/glossary.json b/src/assets/translations/glossary.json index 6bcb7899d71..98649a81c97 100644 --- a/src/assets/translations/glossary.json +++ b/src/assets/translations/glossary.json @@ -46,6 +46,8 @@ "XDEFI": null, "Keplr": null, "Phantom": null, + "Multichain Snap": null, + "Snap": null, "GridPlus": null, "Uniswap": null, "SushiSwap": null, @@ -174,9 +176,9 @@ "fr": "réclamer", "ja": "請求", "pt": "resgatar", - "ru": "получить", - "tr": "talep etmek", - "uk": "отримати", + "ru": ["получить", "получение"], + "tr": ["talep etmek", "talep"], + "uk": ["отримати", "отримання"], "zh": "领取" }, "dust": null, @@ -186,9 +188,9 @@ "fr": "échanger", "ja": "取引", "pt": "negociar", - "ru": "торговать", - "tr": "işlem yapmak", - "uk": "торгувати", + "ru": ["торговать", "торговля"], + "tr": ["işlem yapmak", "işlem"], + "uk": ["торгувати", "торгівля"], "zh": "交易" }, "Loan to Value": { @@ -208,9 +210,9 @@ "fr": "approuver", "ja": "承認", "pt": "aprovar", - "ru": "одобрить", + "ru": ["одобрить", "одобрение"], "tr": "onaylamak", - "uk": "схвалити", + "uk": ["схвалити", "схвалення"], "zh": "授权" }, "revert": { @@ -230,9 +232,9 @@ "fr": "déposer", "ja": "入金", "pt": "depositar", - "ru": "внести", - "tr": "yatırmak", - "uk": "внести", + "ru": ["внести", "внесение", "депозит"], + "tr": ["yatırmak", "yatırma"], + "uk": ["внести", "внесення"], "zh": "存入" }, "insufficient funds": { diff --git a/src/assets/translations/ja/main.json b/src/assets/translations/ja/main.json index 008f82da672..91887dad378 100644 --- a/src/assets/translations/ja/main.json +++ b/src/assets/translations/ja/main.json @@ -345,7 +345,7 @@ "marketCapRank": "時価総額ランキング", "volume24h": "24時間ボリューム", "volMcap": "出来高/時価総額", - "about": "について", + "about": "%{symbol}について", "noDescription": "このアセットの説明はありません。" }, "receive": { @@ -1951,7 +1951,7 @@ "multiChain": { "title": "複数のチェーンが同時にサポートされます", "subTitle": "ウォレット内のチェーンを切り替える必要はなく、各チェーンでシームレスに動作します。", - "body": "新しいチェーンがShapeShiftに追加されると、ShapeShiftウォレットは自動的にそれをサポートします。" + "body": "1つのウォレットからBitcoin、Ethereum、Solanaなど、多数のチェーンにアクセスできます。異なるネットワーク間でシームレスに取引や資産管理を行えます。" }, "selfCustody": { "title": "ShapeShiftウォレットはセルフカストディです", diff --git a/src/assets/translations/pt/main.json b/src/assets/translations/pt/main.json index 5a04cc0b8b2..89bbf6d69c1 100644 --- a/src/assets/translations/pt/main.json +++ b/src/assets/translations/pt/main.json @@ -345,7 +345,7 @@ "marketCapRank": "Classificação de capitalização de mercado", "volume24h": "Volume nas últimas 24 horas", "volMcap": "Vol/Cap Mercado", - "about": "Sobre", + "about": "Sobre %{symbol}", "noDescription": "Não há descrição disponível para este recurso." }, "receive": { @@ -1951,7 +1951,7 @@ "multiChain": { "title": "Multi-chain", "subTitle": "Você nunca precisa trocar de corrente na carteira, funciona perfeitamente com cada rede.", - "body": "Quando uma nova rede é adicionada ao ShapeShift, sua carteira será compatível com ela automaticamente." + "body": "Acesse Bitcoin, Ethereum, Solana e muitas outras redes a partir de uma única carteira. Negocie e gerencie seus ativos em diferentes redes sem complicações." }, "selfCustody": { "title": "Auto-custódia", diff --git a/src/assets/translations/ru/main.json b/src/assets/translations/ru/main.json index 3b018a8c13e..905d36e6899 100644 --- a/src/assets/translations/ru/main.json +++ b/src/assets/translations/ru/main.json @@ -345,7 +345,7 @@ "marketCapRank": "Рейтинг по рыночной капитализации", "volume24h": "24-часовой объем", "volMcap": "Объем/Рыночная капитализация", - "about": "О нас", + "about": "О %{symbol}", "noDescription": "Описание для этого актива отсутствует." }, "receive": { @@ -1951,7 +1951,7 @@ "multiChain": { "title": "Одновременная поддержка нескольких сетей", "subTitle": "Вам никогда не придется менять сеть в кошельке, он работает с каждой сетью без проблем.", - "body": "Когда в ShapeShift появится новая сеть, ваш кошелек будет автоматически поддерживать ее." + "body": "Получите доступ к Bitcoin, Ethereum, Solana и многим другим сетям из одного кошелька. Беспрепятственно торгуйте и управляйте своими активами в различных сетях." }, "selfCustody": { "title": "Ваш кошелек ShapeShift - это самоохрана", diff --git a/src/assets/translations/tr/main.json b/src/assets/translations/tr/main.json index 797e840ea75..e7f1de7d473 100644 --- a/src/assets/translations/tr/main.json +++ b/src/assets/translations/tr/main.json @@ -345,7 +345,7 @@ "marketCapRank": "Piyasa Değeri Sıralaması", "volume24h": "24sa Hacim", "volMcap": "Hacim/MCap", - "about": "Hakkında", + "about": "%{symbol} Hakkında", "noDescription": "Bu varlık için herhangi bir açıklama mevcut değil." }, "receive": { @@ -1951,7 +1951,7 @@ "multiChain": { "title": "Aynı anda birden çok zincir desteklenir", "subTitle": "Cüzdandaki zincirleri asla değiştirmenize gerek yoktur, her zincirle sorunsuz bir şekilde çalışır.", - "body": "ShapeShift'e yeni bir zincir eklendiğinde, Cüzdanınız bunu otomatik olarak destekleyecektir." + "body": "Tek bir cüzdandan Bitcoin, Ethereum, Solana ve daha birçok zincire erişin. Farklı ağlardaki varlıklarınızı sorunsuz bir şekilde takas edin ve yönetin." }, "selfCustody": { "title": "ShapeShift Cüzdanınız kişisel velayettir", diff --git a/src/assets/translations/uk/main.json b/src/assets/translations/uk/main.json index 518726e2398..db04a93863e 100644 --- a/src/assets/translations/uk/main.json +++ b/src/assets/translations/uk/main.json @@ -345,7 +345,7 @@ "marketCapRank": "Рейтинг за ринковою капіталізацією", "volume24h": "24-годинний обсяг", "volMcap": "Обсяг/ринкова капіталізація", - "about": "Про", + "about": "Про %{symbol}", "noDescription": "Опис для цього ресурсу відсутній." }, "receive": { @@ -1951,7 +1951,7 @@ "multiChain": { "title": "Одночасно підтримується кілька мереж", "subTitle": "Вам ніколи не доведеться перемикати мережу в гаманці, він працює з кожною мережею безперебійно.", - "body": "Коли в ShapeShift додається нова мережа, ваш гаманець автоматично підтримує його." + "body": "Отримайте доступ до Bitcoin, Ethereum, Solana та багатьох інших мереж з одного гаманця. Безперешкодно торгуйте та керуйте своїми активами у різних мережах." }, "selfCustody": { "title": "Ваш гаманець ShapeShift - це самозахист", diff --git a/src/assets/translations/zh/main.json b/src/assets/translations/zh/main.json index ca02b067f50..6d12281e2f6 100644 --- a/src/assets/translations/zh/main.json +++ b/src/assets/translations/zh/main.json @@ -345,7 +345,7 @@ "marketCapRank": "市值排名", "volume24h": "24 小时交易量", "volMcap": "交易量/市值", - "about": "关于", + "about": "关于 %{symbol}", "noDescription": "此资产暂无描述。" }, "receive": { @@ -1951,7 +1951,7 @@ "multiChain": { "title": "同时支持多条链", "subTitle": "你永远不需要在钱包中切换链,它可以与每个链条无缝配合。", - "body": "当 ShapeShift 上添加新链时,你的 ShapeShift 钱包将自动支持它。" + "body": "通过单个钱包即可访问 Bitcoin、Ethereum、Solana 等众多链。在不同网络间无缝交易和管理您的资产。" }, "selfCustody": { "title": "你的 ShapeShift 钱包是自我保管的", From 7e659ea1d84e28ae1d108e58e305312662c87d6f Mon Sep 17 00:00:00 2001 From: Jibles Date: Wed, 25 Feb 2026 10:23:03 +0700 Subject: [PATCH 09/10] =?UTF-8?q?fix:=20benchmark=20measurement=20accuracy?= =?UTF-8?q?=20=E2=80=94=20strip=20placeholders,=20fix=20Turkish=20=C4=B0/i?= =?UTF-8?q?,=20add=20"stake"=20glossary?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Strip %{placeholder} tokens before glossary term matching to eliminate false positives (e.g. %{tradeFeeSource} triggering "trade" match). Add locale-aware toLower() for Turkish İ→i case mapping in stemMatch. Add "stake" glossary entry for root-form matches across locales. Co-Authored-By: Claude Opus 4.6 --- .../benchmark-translate/scripts/compile-report.js | 11 ++++++----- .claude/skills/translate/scripts/prepare-locale.js | 6 +++++- .claude/skills/translate/scripts/script-utils.js | 14 ++++++++++++-- .claude/skills/translate/scripts/validate.js | 6 ++++-- src/assets/translations/glossary.json | 11 +++++++++++ 5 files changed, 38 insertions(+), 10 deletions(-) diff --git a/.claude/skills/benchmark-translate/scripts/compile-report.js b/.claude/skills/benchmark-translate/scripts/compile-report.js index c20cf5c344f..424f14490e8 100644 --- a/.claude/skills/benchmark-translate/scripts/compile-report.js +++ b/.claude/skills/benchmark-translate/scripts/compile-report.js @@ -1,7 +1,7 @@ const fs = require('fs') const path = require('path') -const { stemMatch } = require('../../translate/scripts/script-utils') +const { stemMatch, stripPlaceholders } = require('../../translate/scripts/script-utils') const TRANSLATIONS_DIR = path.resolve(__dirname, '../../../../src/assets/translations') const BENCHMARK_DIR = path.resolve(__dirname, '../../../../scripts/translations/benchmark') @@ -91,7 +91,7 @@ for (const locale of TEST_LOCALES) { } function checkGlossaryCorrectness(english, skill, locale) { - const englishLower = english.toLowerCase() + const englishLower = stripPlaceholders(english).toLowerCase() const matched = [] for (const term of GLOSSARY_TARGET_TERMS) { if (!englishLower.includes(term.englishPattern)) continue @@ -123,15 +123,16 @@ function validate(english, skill, locale) { const ratio = english.length > 0 ? skill.length / english.length : 1 const lengthRatioOk = ratio <= (isCjk ? 4.0 : 3.0) && ratio >= (isCjk ? 0.15 : 0.25) + const englishStripped = stripPlaceholders(english).toLowerCase() const glossaryViolations = [] for (const term of neverTranslateTerms) { - if (english.toLowerCase().includes(term.toLowerCase()) && !skill.toLowerCase().includes(term.toLowerCase())) { + if (englishStripped.includes(term.toLowerCase()) && !skill.toLowerCase().includes(term.toLowerCase())) { glossaryViolations.push(`"${term}" should stay in English`) } } for (const { term, approved } of (approvedByLocale[locale] || [])) { const tl = term.toLowerCase() - if (english.toLowerCase().includes(tl) && !stemMatch(skill, approved, locale)) { + if (englishStripped.includes(tl) && !stemMatch(skill, approved, locale)) { const display = Array.isArray(approved) ? approved[0] : approved glossaryViolations.push(`"${term}" should be "${display}"`) } @@ -314,7 +315,7 @@ if (expanded.length > 0) { console.log('\n--- Glossary Term Correctness ---') for (const tt of GLOSSARY_TARGET_TERMS) { - const tr = results.filter(r => r.english.toLowerCase().includes(tt.englishPattern)) + const tr = results.filter(r => stripPlaceholders(r.english).toLowerCase().includes(tt.englishPattern)) const checked = tr.filter(r => r.validation.glossaryCorrectness !== null) const correct = checked.filter(r => r.validation.glossaryCorrectness === true).length console.log(` "${tt.englishPattern}": ${correct}/${checked.length} correct (${checked.length > 0 ? ((correct / checked.length) * 100).toFixed(0) : 'N/A'}%)`) diff --git a/.claude/skills/translate/scripts/prepare-locale.js b/.claude/skills/translate/scripts/prepare-locale.js index af007d207aa..9e087438ec1 100644 --- a/.claude/skills/translate/scripts/prepare-locale.js +++ b/.claude/skills/translate/scripts/prepare-locale.js @@ -78,8 +78,12 @@ if (fewShotPath && fs.existsSync(fewShotPath)) { const batchArray = Array.isArray(batches) ? batches : [batches]; const tagKeys = batchArray.flatMap(batch => Object.keys(batch).filter(k => k.includes('.tags.'))); +function stripPlaceholders(str) { + return str.replace(/%\{\w+\}/g, ''); +} + function filterGlossaryForBatch(batch) { - const batchText = Object.values(batch).join(' ').toLowerCase(); + const batchText = stripPlaceholders(Object.values(batch).join(' ')).toLowerCase(); const relevantNeverTranslate = neverTranslate.filter(term => batchText.includes(term.toLowerCase()) diff --git a/.claude/skills/translate/scripts/script-utils.js b/.claude/skills/translate/scripts/script-utils.js index 126142e4592..58201471d1e 100644 --- a/.claude/skills/translate/scripts/script-utils.js +++ b/.claude/skills/translate/scripts/script-utils.js @@ -6,6 +6,14 @@ function extractPlaceholders(str) { return [...str.matchAll(/%\{(\w+)\}/g)].map(m => m[1]); } +function stripPlaceholders(str) { + return str.replace(/%\{\w+\}/g, ''); +} + +function toLower(str, locale) { + return locale === 'tr' ? str.toLocaleLowerCase('tr') : str.toLowerCase(); +} + const LOCALE_CONFIGS = { de: { stemRatio: 0.75, @@ -72,13 +80,13 @@ function stemMatch(target, approved, locale) { return forms.some(form => target.toLowerCase().includes(form.toLowerCase())); } - const targetLower = target.toLowerCase(); + const targetLower = toLower(target, locale); const forms = Array.isArray(approved) ? approved : [approved]; return forms.some(form => { const words = form.split(/\s+/); return words.every(word => { - const wordLower = word.toLowerCase(); + const wordLower = toLower(word, locale); // Tier 1: Exact substring if (targetLower.includes(wordLower)) return true; @@ -123,6 +131,8 @@ module.exports = { CJK_LOCALES, LOCALE_CONFIGS, extractPlaceholders, + stripPlaceholders, + toLower, stemMatch, loadGlossary, glossaryTerms, diff --git a/.claude/skills/translate/scripts/validate.js b/.claude/skills/translate/scripts/validate.js index dfddc90ac68..c658aea7e0c 100644 --- a/.claude/skills/translate/scripts/validate.js +++ b/.claude/skills/translate/scripts/validate.js @@ -3,6 +3,7 @@ const { CJK_LOCALES, LOCALE_CONFIGS, extractPlaceholders, + stripPlaceholders, stemMatch, loadGlossary, } = require('./script-utils'); @@ -109,17 +110,18 @@ for (const path of Object.keys(source)) { } // Check 5: Glossary compliance + const srcStripped = stripPlaceholders(src); for (const [term, value] of Object.entries(glossary)) { if (term === '_meta') continue; const termRegex = new RegExp('\\b' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\b', 'gi'); if (value === null) { - if (termRegex.test(src) && !new RegExp('\\b' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\b', 'i').test(tgt)) { + if (termRegex.test(srcStripped) && !new RegExp('\\b' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\b', 'i').test(tgt)) { flags.push({ path, reason: 'glossary never-translate', details: `"${term}" should stay in English` }); isFlagged = true; } } else if (typeof value === 'object' && value[locale]) { - if (termRegex.test(src) && !stemMatch(tgt, value[locale], locale)) { + if (termRegex.test(srcStripped) && !stemMatch(tgt, value[locale], locale)) { const display = Array.isArray(value[locale]) ? value[locale][0] : value[locale]; const isInflected = !!LOCALE_CONFIGS[locale]; flags.push({ path, reason: 'glossary approved translation', severity: isInflected ? 'info' : 'error', details: `"${term}" should be "${display}" in ${locale}` }); diff --git a/src/assets/translations/glossary.json b/src/assets/translations/glossary.json index 98649a81c97..d79b5e2908c 100644 --- a/src/assets/translations/glossary.json +++ b/src/assets/translations/glossary.json @@ -60,6 +60,17 @@ "Jupiter": null, "Chainflip": null, "FOXy": null, + "stake": { + "de": "Stake", + "es": "stake", + "fr": "stake", + "ja": "ステーク", + "pt": "stake", + "ru": "стейк", + "tr": "stake", + "uk": "стейк", + "zh": "质押" + }, "staking": { "de": "Staking", "es": "staking", From 21f9d9896e2f4cfee6c14718981d16023d6de559 Mon Sep 17 00:00:00 2001 From: Jibles Date: Thu, 26 Feb 2026 05:07:06 +0700 Subject: [PATCH 10/10] =?UTF-8?q?fix:=20address=20CodeRabbit=20review=20fi?= =?UTF-8?q?ndings=20=E2=80=94=20translations=20and=20tooling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix Spanish gender agreement: "cantidad quemado" → "cantidad quemada" - Fix Spanish register inconsistency: align failed messages to formal "Su" - Fix Ukrainian FAQ title capitalization: "як" → "Як" - Fix Chinese withdrawal term consistency: "提款" → "提现" - Fix ja.md wording: "Japanese" → "kanji" for script contrast - Fix validate.js: emit info-severity glossary flags instead of dropping them - Fix select-keys.js: persist stale key pruning to disk Co-Authored-By: Claude Opus 4.6 --- .claude/skills/benchmark-translate/scripts/select-keys.js | 4 +++- .claude/skills/translate/locales/ja.md | 2 +- .claude/skills/translate/scripts/validate.js | 7 ++----- src/assets/translations/es/main.json | 8 ++++---- src/assets/translations/uk/main.json | 2 +- src/assets/translations/zh/main.json | 2 +- 6 files changed, 12 insertions(+), 13 deletions(-) diff --git a/.claude/skills/benchmark-translate/scripts/select-keys.js b/.claude/skills/benchmark-translate/scripts/select-keys.js index 8fa3a18d356..72581034962 100644 --- a/.claude/skills/benchmark-translate/scripts/select-keys.js +++ b/.claude/skills/benchmark-translate/scripts/select-keys.js @@ -185,6 +185,7 @@ function main() { let coreKeys = [] let coreCreated = false + let stalePruned = false if (fs.existsSync(CORE_KEYS_PATH)) { const loaded = JSON.parse(fs.readFileSync(CORE_KEYS_PATH, 'utf8')) @@ -192,6 +193,7 @@ function main() { const staleCount = loaded.length - validated.length if (staleCount > 0) { console.log(`Core keys: removed ${staleCount} stale keys (no longer in all locales)`) + stalePruned = true } coreKeys = validated console.log(`Core keys loaded: ${coreKeys.length}/${loaded.length} valid`) @@ -212,7 +214,7 @@ function main() { coreCreated = true } - if (!fs.existsSync(CORE_KEYS_PATH) || coreCreated) { + if (!fs.existsSync(CORE_KEYS_PATH) || coreCreated || stalePruned) { fs.mkdirSync(path.dirname(CORE_KEYS_PATH), { recursive: true }) fs.writeFileSync(CORE_KEYS_PATH, JSON.stringify(coreKeys, null, 2) + '\n') console.log(`Core keys saved to ${CORE_KEYS_PATH} (${coreKeys.length} keys)`) diff --git a/.claude/skills/translate/locales/ja.md b/.claude/skills/translate/locales/ja.md index 0450410655b..4ad6de29500 100644 --- a/.claude/skills/translate/locales/ja.md +++ b/.claude/skills/translate/locales/ja.md @@ -9,7 +9,7 @@ - For "X out of Y" → Y件中X件 - Prefer リスト over リスティング - For bridge/DeFi "claim" (withdrawal action), use 請求 not 獲得 (which means earn/win) -- Avoid mixing katakana and Japanese in a single compound where it reads unnaturally (e.g., ペンディング中の → 保留中の) +- Avoid mixing katakana and kanji in a single compound where it reads unnaturally (e.g., ペンディング中の → 保留中の) - Reorder %{…} placeholders to match Japanese SOV/postpositional grammar (e.g., "%{count} of %{total}" → "%{total}件中%{count}件") - "allowance" (token approval) = 承認枠 or 承認額 — NEVER プール (= pool, completely wrong meaning) - "vault" = ヴォールト — not バルト (typo that changes the meaning entirely) diff --git a/.claude/skills/translate/scripts/validate.js b/.claude/skills/translate/scripts/validate.js index c658aea7e0c..8cee8c4de41 100644 --- a/.claude/skills/translate/scripts/validate.js +++ b/.claude/skills/translate/scripts/validate.js @@ -147,11 +147,8 @@ for (const path of Object.keys(source)) { } } - if (isFlagged) { - flagged.push(...flags); - } else { - passed.push(path); - } + if (flags.length) flagged.push(...flags); + if (!isFlagged) passed.push(path); } console.log(JSON.stringify({ rejected, flagged, passed }, null, 2)); diff --git a/src/assets/translations/es/main.json b/src/assets/translations/es/main.json index d920de81f61..8c8fc58c306 100644 --- a/src/assets/translations/es/main.json +++ b/src/assets/translations/es/main.json @@ -2609,7 +2609,7 @@ "body": "Sí, puedes tener múltiples cantidades en recuperación. Cada acción tendrá su propio período de 28 días." }, "connect": { - "title": "¿Cómo se relacionan las tarifas totales recaudadas, total Staking FOX, emisiones y cantidad quemado?", + "title": "¿Cómo se relacionan las tarifas totales recaudadas, total Staking FOX, emisiones y cantidad quemada?", "body": "Las tarifas recaudadas por la DAO están en activos nativos por swapper. Una parte de estos ingresos se convierte y distribuye a los que tienen FOX en staking como recompensas, mientras que otra parte se destina a reducir el suministro total de FOX mediante quema de tokens. El total de FOX en staking influye en la distribución de recompensas, ya que se distribuyen de forma proporcional según la cantidad de FOX en staking. El pool de emisiones representa la cantidad de FOX disponible para recompensas de staking, y la cantidad de quema de FOX es la porción de FOX comprada y quemada." } }, @@ -2841,17 +2841,17 @@ "deposit": { "pending": "Su depósito de %{amount} %{symbol} procesando.", "complete": "Su depósito de %{amount} %{symbol} completado.", - "failed": "Tu depósito de %{amount} %{symbol} ha fallado." + "failed": "Su depósito de %{amount} %{symbol} ha fallado." }, "withdrawal": { "pending": "Su retiro de %{amount} %{symbol} procesando.", "complete": "Su retiro de %{amount} %{symbol} completado.", - "failed": "Tu retiro de %{amount} %{symbol} ha fallado." + "failed": "Su retiro de %{amount} %{symbol} ha fallado." }, "claim": { "pending": "Su reclamo de %{amount} %{symbol} procesando.", "complete": "Su reclamo de %{amount} %{symbol} está completo.", - "failed": "Tu reclamación de %{amount} %{symbol} ha fallado." + "failed": "Su reclamo de %{amount} %{symbol} ha fallado." }, "yield": { "unstakeAvailableIn": "Tu retiro del staking de %{symbol} estará disponible en %{duration}.", diff --git a/src/assets/translations/uk/main.json b/src/assets/translations/uk/main.json index db04a93863e..a457a519cdb 100644 --- a/src/assets/translations/uk/main.json +++ b/src/assets/translations/uk/main.json @@ -2609,7 +2609,7 @@ "body": "Так, ви можете виконати декілька дій зі зняття стейкінгу. Кожна дія зі зняття стейкінгу матиме власний 28-денний період затишшя." }, "connect": { - "title": "як пов'язані між собою загальна сума зібраних комісій, загальна сума в стейкінгу FOX, пул монет та кількість спалених FOX?", + "title": "Як пов'язані між собою загальна сума зібраних комісій, загальна сума в стейкінгу FOX, пул монет та кількість спалених FOX?", "body": "Комісії, зібрані DAO, надходять у нативних активах від кожного свопера. Частина цього доходу конвертується та розподіляється між стейкерами FOX як винагороди, тоді як інша частина спрямовується на скорочення загальної пропозиції FOX через спалювання токенів. Загальна кількість застейканого FOX впливає на розподіл винагород, оскільки винагороди розподіляються пропорційно відповідно до обсягу застейканого FOX. Пул емісій представляє кількість FOX, доступного для винагород за стейкінг, а сума спалювання FOX — це частина FOX, придбаного та спаленого." } }, diff --git a/src/assets/translations/zh/main.json b/src/assets/translations/zh/main.json index 6d12281e2f6..7d384dc904b 100644 --- a/src/assets/translations/zh/main.json +++ b/src/assets/translations/zh/main.json @@ -2999,7 +2999,7 @@ "providers": "提供者", "successStaked": "您已成功质押 %{amount} %{symbol}", "successUnstaked": "您已成功取消质押 %{amount} %{symbol}", - "cooldownNotice": "您的提款将在 %{cooldownDuration} 后可用。", + "cooldownNotice": "您的提现将在 %{cooldownDuration} 后可用。", "successDeposited": "您已成功充值 %{amount} %{symbol}", "successWithdrawn": "您已成功提现 %{amount} %{symbol}", "successClaim": "您已成功领取 %{amount} %{symbol}",