diff --git a/.beads/pr-context.jsonl b/.beads/pr-context.jsonl index b46eb353c9f..ec4cf00c824 100644 --- a/.beads/pr-context.jsonl +++ b/.beads/pr-context.jsonl @@ -1,7 +1,4 @@ -{"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":"{}"}]} +{"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/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..424f14490e8 --- /dev/null +++ b/.claude/skills/benchmark-translate/scripts/compile-report.js @@ -0,0 +1,500 @@ +const fs = require('fs') +const path = require('path') + +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') +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', 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', isNeverTranslate: false }, + { englishPattern: 'seed phrase', glossaryKey: 'seed phrase', 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 }, + { 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 = stripPlaceholders(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 = 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}"` }) + } + } + } + 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 englishStripped = stripPlaceholders(english).toLowerCase() + const glossaryViolations = [] + for (const term of neverTranslateTerms) { + 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 (englishStripped.includes(tl) && !stemMatch(skill, approved, locale)) { + const display = Array.isArray(approved) ? approved[0] : approved + glossaryViolations.push(`"${term}" should be "${display}"`) + } + } + + 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 => 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'}%)`) + 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..72581034962 --- /dev/null +++ b/.claude/skills/benchmark-translate/scripts/select-keys.js @@ -0,0 +1,253 @@ +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 + let stalePruned = 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)`) + stalePruned = true + } + 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 || 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)`) + } + + 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..8bfe3949254 --- /dev/null +++ b/.claude/skills/translate/SKILL.md @@ -0,0 +1,384 @@ +--- +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: Spawn Self-Contained Language Agents + +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. + +The orchestrator's only job after spawning is to read status files and compile the report (Step 6). + +### Language Agent Prompt + +For each locale, spawn a Task with the following prompt (substituting `{LOCALE_CODE}` and `{LANGUAGE_NAME}`): + +``` +You are a self-contained translation agent for {LANGUAGE_NAME} ({LOCALE_CODE}) in a cryptocurrency/DeFi application. + +You own the full translation lifecycle for your locale. Do NOT read any codebase source files — all context is in the locale bundle. + +## Your Locale Bundle + +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, 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) + +For each batch in the `batches` array: + +### 1. Translate + +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 `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 (`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 +``` + +This outputs `{ rejected, flagged, passed }`. + +### 3. Retry Rejected Strings + +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. + +### 4. Review (spawn fresh sub-agent) + +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_FROM_BUNDLE} + +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 +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} + +VALIDATION FLAGS: +{FLAGS_FOR_FLAGGED_STRINGS_OR_NONE} + +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]". +``` + +### 5. Refine (spawn fresh sub-agent, conditional) + +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. + +LOCALE RULES: +{LOCALE_RULES_FROM_BUNDLE} + +RULES: Preserve %{placeholders}, use approved terminology, be concise, be faithful to source. + +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. +``` + +Re-validate refined output. If it still fails after 1 retry, mark as "manual review". + +### 6. Accumulate + +After processing all batches, combine all passing translations into a single object. + +## Post-Batch: Merge & Verify + +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). 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 + ``` + +3. Run post-merge validation: + ```bash + node .claude/skills/translate/scripts/validate-file.js {LOCALE_CODE} --pre-merge=/tmp/pre-merge-{LOCALE_CODE}.json + ``` + +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 + +5. Write status to `/tmp/translate-status-{LOCALE_CODE}.json`: + ```json + { + "locale": "{LOCALE_CODE}", + "status": "success" | "failed", + "translated": , + "manualReview": [{ "path": "...", "reason": "..." }], + "errors": ["..."] + } + ``` + +## Error Handling + +- **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 +``` + +### 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 + +After all 9 language agents complete, read their status files and compile results. + +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). + +2. **Write marker file** (only if at least one locale succeeded): + ```bash + git rev-parse HEAD > src/assets/translations/.last-translation-sha + ``` + +3. **Update glossary timestamp** (if glossary was modified during this run): + Update `_meta.lastUpdated` in `src/assets/translations/glossary.json` to today's date. + +4. **Print summary report**: + ``` + === Translation Summary === + SHA marker: + + Strings translated: across languages + Strings skipped (manual review needed): + Locales failed (rolled back): + + Per-language breakdown: + de: translated, skipped [success|failed|no response] + es: translated, skipped [success|failed|no response] + ... + + Skipped strings (need manual review): + - (): + ``` + +## 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. + +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..26fb7cf1266 --- /dev/null +++ b/.claude/skills/translate/locales/de.md @@ -0,0 +1,17 @@ +# 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 +- 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") +- 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..7293c25405e --- /dev/null +++ b/.claude/skills/translate/locales/es.md @@ -0,0 +1,10 @@ +# Spanish (es) — Informal (tu) + +- 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 +- "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..1abe2944b94 --- /dev/null +++ b/.claude/skills/translate/locales/fr.md @@ -0,0 +1,17 @@ +# 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 +- 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" +- 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/ja.md b/.claude/skills/translate/locales/ja.md new file mode 100644 index 00000000000..4ad6de29500 --- /dev/null +++ b/.claude/skills/translate/locales/ja.md @@ -0,0 +1,17 @@ +# 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件 +- Prefer リスト over リスティング +- For bridge/DeFi "claim" (withdrawal action), use 請求 not 獲得 (which means earn/win) +- 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) +- "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..449ea97b111 --- /dev/null +++ b/.claude/skills/translate/locales/pt.md @@ -0,0 +1,12 @@ +# Portuguese — Brazil (pt) — Informal (você) + +- 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 +- "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..533185074a7 --- /dev/null +++ b/.claude/skills/translate/locales/ru.md @@ -0,0 +1,14 @@ +# 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 +- "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 +- "dust" (in crypto context, meaning tiny leftover amounts) must stay in English — do NOT translate as "пыль" diff --git a/.claude/skills/translate/locales/tr.md b/.claude/skills/translate/locales/tr.md new file mode 100644 index 00000000000..3287b4309fb --- /dev/null +++ b/.claude/skills/translate/locales/tr.md @@ -0,0 +1,13 @@ +# 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 +- 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 new file mode 100644 index 00000000000..21be30850d9 --- /dev/null +++ b/.claude/skills/translate/locales/uk.md @@ -0,0 +1,17 @@ +# 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 "ненаставлений" +- "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 +- "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}") diff --git a/.claude/skills/translate/locales/zh.md b/.claude/skills/translate/locales/zh.md new file mode 100644 index 00000000000..38b9e76896c --- /dev/null +++ b/.claude/skills/translate/locales/zh.md @@ -0,0 +1,12 @@ +# 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) +- 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 +- 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..48c9072455c --- /dev/null +++ b/.claude/skills/translate/scripts/diff.js @@ -0,0 +1,33 @@ +const { execSync } = require('child_process'); +const fs = require('fs'); + +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')); + +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..3790d2b9528 --- /dev/null +++ b/.claude/skills/translate/scripts/merge.js @@ -0,0 +1,93 @@ +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]; + +if (!translationsArg) { + console.error('Usage: node merge.js [--force]'); + process.exit(1); +} + +let newTranslations; +if (fs.existsSync(translationsArg)) { + newTranslations = JSON.parse(fs.readFileSync(translationsArg, 'utf8')); +} else { + 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'; +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'); + +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; + 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; +} + +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 ' + added + ' new translations into ' + locale + ' (' + skipped + ' existing skipped, backup: ' + backupPath + ')'); diff --git a/.claude/skills/translate/scripts/missing-keys.js b/.claude/skills/translate/scripts/missing-keys.js new file mode 100644 index 00000000000..2425c02f266 --- /dev/null +++ b/.claude/skills/translate/scripts/missing-keys.js @@ -0,0 +1,30 @@ +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) { + 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/.claude/skills/translate/scripts/prepare-locale.js b/.claude/skills/translate/scripts/prepare-locale.js new file mode 100644 index 00000000000..9e087438ec1 --- /dev/null +++ b/.claude/skills/translate/scripts/prepare-locale.js @@ -0,0 +1,125 @@ +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] = Array.isArray(value[locale]) ? value[locale][0] : 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 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 = stripPlaceholders(Object.values(batch).join(' ')).toLowerCase(); + + const relevantNeverTranslate = neverTranslate.filter(term => + batchText.includes(term.toLowerCase()) + ); + + const relevantApprovedTerms = {}; + for (const [term, canonical] of Object.entries(approvedTerms)) { + if (batchText.includes(term.toLowerCase())) { + relevantApprovedTerms[term] = canonical; + } + } + + return { relevantNeverTranslate, relevantApprovedTerms }; +} + +const batchesWithGlossary = batchArray.map(batch => { + const { relevantNeverTranslate, relevantApprovedTerms } = filterGlossaryForBatch(batch); + return { + strings: batch, + relevantNeverTranslate, + relevantApprovedTerms, + }; +}); + +const bundle = { + locale, + language: meta.language, + register: meta.register, + localeRules, + neverTranslate, + approvedTerms, + termContext, + fewShot, + tagKeys, + batches: batchesWithGlossary, +}; + +fs.writeFileSync(outputPath, JSON.stringify(bundle, null, 2) + '\n'); +console.log(`Wrote locale bundle to ${outputPath}`); diff --git a/.claude/skills/translate/scripts/script-utils.js b/.claude/skills/translate/scripts/script-utils.js new file mode 100644 index 00000000000..58201471d1e --- /dev/null +++ b/.claude/skills/translate/scripts/script-utils.js @@ -0,0 +1,140 @@ +const fs = require('fs'); + +const CJK_LOCALES = new Set(['ja', 'zh']); + +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, + 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) { + 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 = 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 = toLower(word, locale); + + // 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 false; + }); + }); +} + +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, + LOCALE_CONFIGS, + extractPlaceholders, + stripPlaceholders, + toLower, + stemMatch, + loadGlossary, + glossaryTerms, + flattenJson, +}; 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-file.js b/.claude/skills/translate/scripts/validate-file.js new file mode 100644 index 00000000000..16c3a37fdb4 --- /dev/null +++ b/.claude/skills/translate/scripts/validate-file.js @@ -0,0 +1,82 @@ +const fs = require('fs'); +const { + flattenJson, +} = require('./script-utils'); + +const locale = process.argv[2]; + +if (!locale) { + console.error('Usage: node validate-file.js [--pre-merge=]'); + process.exit(1); +} + +let preMergePath; + +for (const arg of process.argv.slice(3)) { + if (arg.startsWith('--pre-merge=')) preMergePath = arg.slice('--pre-merge='.length); +} + +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: 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 new file mode 100644 index 00000000000..8cee8c4de41 --- /dev/null +++ b/.claude/skills/translate/scripts/validate.js @@ -0,0 +1,154 @@ +const fs = require('fs'); +const { + CJK_LOCALES, + LOCALE_CONFIGS, + extractPlaceholders, + stripPlaceholders, + stemMatch, + loadGlossary, +} = require('./script-utils'); + +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); +} + +const glossary = loadGlossary(glossaryPath); + +let termContext = {}; +if (termContextPath && fs.existsSync(termContextPath)) { + termContext = JSON.parse(fs.readFileSync(termContextPath, 'utf8')); +} + +const rejected = []; +const flagged = []; +const passed = []; + +// 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: 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(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(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}` }); + if (!isInflected) isFlagged = true; + } + } + } + + // 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); + if (translations.length === 0) continue; + + 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 (flags.length) flagged.push(...flags); + if (!isFlagged) passed.push(path); +} + +console.log(JSON.stringify({ rejected, flagged, passed }, null, 2)); 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/.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/.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..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", @@ -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..8c8fc58c306 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", @@ -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 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." } }, "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": "Su 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": "Su 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": "Su reclamo 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..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", @@ -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é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.", @@ -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/glossary.json b/src/assets/translations/glossary.json new file mode 100644 index 00000000000..d79b5e2908c --- /dev/null +++ b/src/assets/translations/glossary.json @@ -0,0 +1,317 @@ +{ + "_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, + "Multichain Snap": null, + "Snap": 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, + "stake": { + "de": "Stake", + "es": "stake", + "fr": "stake", + "ja": "ステーク", + "pt": "stake", + "ru": "стейк", + "tr": "stake", + "uk": "стейк", + "zh": "质押" + }, + "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": { + "de": "Einfordern", + "es": "reclamar", + "fr": "réclamer", + "ja": "請求", + "pt": "resgatar", + "ru": ["получить", "получение"], + "tr": ["talep etmek", "talep"], + "uk": ["отримати", "отримання"], + "zh": "领取" + }, + "dust": null, + "trade": { + "de": "Handeln", + "es": "intercambiar", + "fr": "échanger", + "ja": "取引", + "pt": "negociar", + "ru": ["торговать", "торговля"], + "tr": ["işlem yapmak", "işlem"], + "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": { + "de": "Genehmigen", + "es": "aprobar", + "fr": "approuver", + "ja": "承認", + "pt": "aprovar", + "ru": ["одобрить", "одобрение"], + "tr": "onaylamak", + "uk": ["схвалити", "схвалення"], + "zh": "授权" + }, + "revert": { + "de": "rückgängig machen", + "es": "revertir", + "fr": "annuler", + "ja": "リバート", + "pt": "reverter", + "ru": "откатить", + "tr": "geri almak", + "uk": "скасувати", + "zh": "回滚" + }, + "deposit": { + "de": "Einzahlen", + "es": "depositar", + "fr": "déposer", + "ja": "入金", + "pt": "depositar", + "ru": ["внести", "внесение", "депозит"], + "tr": ["yatırmak", "yatırma"], + "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": "流动资金池" + } +} diff --git a/src/assets/translations/ja/main.json b/src/assets/translations/ja/main.json index 3aa597303d8..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ウォレットはセルフカストディです", @@ -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..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", @@ -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..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 - это самоохрана", @@ -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..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", @@ -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..a457a519cdb 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 - це самозахист", @@ -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..7d384dc904b 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 钱包是自我保管的", @@ -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}",