# i18n with react-i18next Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Add full react-i18next localization to dune-admin covering 10 languages with TypeScript compile-time key safety and a flag-emoji language selector in the header.

**Architecture:** Option C — i18next-parser (codegen) + `CustomTypeOptions` declaration that points at `en-US/translation.json` as the type source. Language stored in `localStorage('dune_admin_locale')` defaulting to `en-US`. No browser language detection. 10 locale files under `web/src/locales/`.

**Tech Stack:** `i18next`, `react-i18next`, `i18next-parser` (devDep). pnpm. All files under `web/`.

---

## File Map

| Create / Modify | Purpose |
|---|---|
| Create `web/src/i18n/index.ts` | i18next init + `LANGUAGES` registry + `setLocale()` |
| Create `web/src/i18n/i18n.d.ts` | `CustomTypeOptions` — types `t()` from `en-US/translation.json` |
| Create `web/i18next-parser.config.ts` | parser config for `pnpm i18n:extract` |
| Create `web/src/locales/en-US/translation.json` | English source of truth (all keys) |
| Create `web/src/locales/{de,fr,es,pt-BR,ru,pl,tr,zh-CN,ja}/translation.json` | 9 AI-generated translations |
| Create `web/src/components/LanguageSelector.tsx` | Globe-icon dropdown with flag emojis |
| Modify `web/package.json` | add `i18n:extract` script |
| Modify `web/src/main.tsx` | import `../i18n` before App |
| Modify `web/src/App.tsx` | import LanguageSelector, wire strings |
| Modify `web/src/tabs/BattlegroupTab/index.tsx` | wire t() |
| Modify `web/src/tabs/BattlegroupTab/types.ts` | action labels/msgs via t() at call site |
| Modify `web/src/tabs/BattlegroupTab/ServersTable.tsx` | wire t() |
| Modify `web/src/tabs/BattlegroupTab/modals/ConfirmDialog.tsx` | wire t() |
| Modify `web/src/tabs/BattlegroupTab/modals/CommandOutputModal.tsx` | wire t() |
| Modify `web/src/tabs/BattlegroupTab/modals/RestoreModal.tsx` | wire t() |
| Modify `web/src/tabs/PlayersTab/index.tsx` | wire t() |
| Modify `web/src/tabs/PlayersTab/types.ts` | ACTION_SECTIONS labels → translation keys |
| Modify `web/src/tabs/PlayersTab/views/ActionsView.tsx` | wire t() — large file |
| Modify `web/src/tabs/PlayersTab/views/InventoryView.tsx` | wire t() |
| Modify `web/src/tabs/PlayersTab/views/VehiclesView.tsx` | wire t() |
| Modify `web/src/tabs/PlayersTab/views/GiveItemsView.tsx` | wire t() |
| Modify `web/src/tabs/PlayersTab/components/PlayerDetailPanel.tsx` | wire t() |
| Modify `web/src/tabs/PlayersTab/modals/GiveItemsModal.tsx` | wire t() |
| Modify `web/src/tabs/PlayersTab/modals/InventoryModal.tsx` | wire t() |
| Modify `web/src/tabs/PlayersTab/modals/PlayerActionsModal.tsx` | wire t() |
| Modify `web/src/tabs/MarketTab/index.tsx` | wire t() |
| Modify `web/src/tabs/MarketTab/bot/BotControlPanel.tsx` | wire t() |
| Modify `web/src/tabs/MarketTab/bot/BotStatusCard.tsx` | wire t() |
| Modify `web/src/tabs/MarketTab/bot/BotActions.tsx` | wire t() |
| Modify `web/src/tabs/MarketTab/bot/BotLogViewer.tsx` | wire t() |
| Modify `web/src/tabs/MarketTab/bot/BotConfigEditor.tsx` | wire t() |
| Modify `web/src/tabs/MarketTab/bot/BotServerConfig.tsx` | wire t() |
| Modify `web/src/tabs/MarketTab/bot/DisabledItemsManager.tsx` | wire t() |
| Modify `web/src/tabs/MarketTab/MarketSearch.tsx` | wire t() |
| Modify `web/src/tabs/MarketTab/MarketSidebar.tsx` | wire t() |
| Modify `web/src/tabs/MarketTab/MarketTable.tsx` | wire t() |
| Modify `web/src/tabs/MarketTab/MarketGrid.tsx` | wire t() |
| Modify `web/src/tabs/MarketTab/ItemDetail.tsx` | wire t() |
| Modify `web/src/tabs/DatabaseTab.tsx` | wire t() |
| Modify `web/src/tabs/LogsTab.tsx` | wire t() |
| Modify `web/src/tabs/BlueprintsTab.tsx` | wire t() |
| Modify `web/src/tabs/StorageTab.tsx` | wire t() |
| Modify `web/src/tabs/ServerSettingsTab.tsx` | wire t() |
| Modify `web/src/components/SettingsConfigForm.tsx` | wire t() |
| Modify `web/src/dune-ui/ConfirmDialog.tsx` | wire t() |

---

## Task 1: Install packages

**Files:** `web/package.json` (modified by pnpm)

- [ ] **Step 1: Install runtime packages**

```bash
cd /Volumes/Engineering/Icehunter/dune-admin/web
pnpm add i18next react-i18next
```

- [ ] **Step 2: Install devDep**

```bash
pnpm add -D i18next-parser
```

- [ ] **Step 3: Verify install**

```bash
pnpm list i18next react-i18next i18next-parser
```

Expected: all three listed.

---

## Task 2: Create i18n infrastructure

**Files:**

- Create: `web/src/i18n/index.ts`
- Create: `web/src/i18n/i18n.d.ts`
- Create: `web/i18next-parser.config.ts`
- Modify: `web/package.json` — add `i18n:extract` script

- [ ] **Step 1: Create `web/src/i18n/index.ts`**

```typescript
import i18n from 'i18next'
import { initReactI18next } from 'react-i18next'

import enUS from '../locales/en-US/translation.json'
import de from '../locales/de/translation.json'
import fr from '../locales/fr/translation.json'
import es from '../locales/es/translation.json'
import ptBR from '../locales/pt-BR/translation.json'
import ru from '../locales/ru/translation.json'
import pl from '../locales/pl/translation.json'
import tr from '../locales/tr/translation.json'
import zhCN from '../locales/zh-CN/translation.json'
import ja from '../locales/ja/translation.json'

export const LOCALE_KEY = 'dune_admin_locale'
export const DEFAULT_LOCALE = 'en-US'

export const LANGUAGES = [
  { code: 'en-US', label: 'English', flag: '🇺🇸' },
  { code: 'de', label: 'Deutsch', flag: '🇩🇪' },
  { code: 'fr', label: 'Français', flag: '🇫🇷' },
  { code: 'es', label: 'Español', flag: '🇪🇸' },
  { code: 'pt-BR', label: 'Português (BR)', flag: '🇧🇷' },
  { code: 'ru', label: 'Русский', flag: '🇷🇺' },
  { code: 'pl', label: 'Polski', flag: '🇵🇱' },
  { code: 'tr', label: 'Türkçe', flag: '🇹🇷' },
  { code: 'zh-CN', label: '中文 (简体)', flag: '🇨🇳' },
  { code: 'ja', label: '日本語', flag: '🇯🇵' },
] as const

const saved = localStorage.getItem(LOCALE_KEY) ?? DEFAULT_LOCALE

i18n
  .use(initReactI18next)
  .init({
    resources: {
      'en-US': { translation: enUS },
      de: { translation: de },
      fr: { translation: fr },
      es: { translation: es },
      'pt-BR': { translation: ptBR },
      ru: { translation: ru },
      pl: { translation: pl },
      tr: { translation: tr },
      'zh-CN': { translation: zhCN },
      ja: { translation: ja },
    },
    lng: saved,
    fallbackLng: DEFAULT_LOCALE,
    interpolation: { escapeValue: false },
  })

export function setLocale(code: string): void {
  localStorage.setItem(LOCALE_KEY, code)
  void i18n.changeLanguage(code)
}

export default i18n
```

- [ ] **Step 2: Create `web/src/i18n/i18n.d.ts`**

```typescript
import 'i18next'
import type enUS from '../locales/en-US/translation.json'

declare module 'i18next' {
  interface CustomTypeOptions {
    defaultNS: 'translation'
    resources: {
      translation: typeof enUS
    }
  }
}
```

- [ ] **Step 3: Create `web/i18next-parser.config.ts`**

```typescript
const config = {
  input: ['src/**/*.{ts,tsx}'],
  output: 'src/locales/$LOCALE/translation.json',
  locales: ['en-US', 'de', 'fr', 'es', 'pt-BR', 'ru', 'pl', 'tr', 'zh-CN', 'ja'],
  defaultNamespace: 'translation',
  defaultValue: (locale: string) => (locale === 'en-US' ? '' : 'MISSING'),
  keySeparator: '.',
  namespaceSeparator: false,
  sort: true,
}

export default config
```

- [ ] **Step 4: Add `i18n:extract` script to `web/package.json`**

In the `"scripts"` section, add:

```json
"i18n:extract": "i18next-parser --config i18next-parser.config.ts"
```

---

## Task 3: Create `en-US/translation.json`

**Files:**

- Create: `web/src/locales/en-US/translation.json`

This is the source of truth. All keys used in `t()` calls across the app must be here.

- [ ] **Step 1: Create `web/src/locales/en-US/translation.json`**

```json
{
  "common": {
    "refresh": "Refresh",
    "loading": "Loading…",
    "save": "Save",
    "cancel": "Cancel",
    "close": "Close",
    "export": "Export",
    "import": "Import",
    "run": "Run",
    "send": "Send",
    "add": "Add",
    "remove": "Remove",
    "download": "Download",
    "confirm": "Confirm",
    "reset": "Reset",
    "search": "Search",
    "noResults": "No results.",
    "error": "Error:",
    "failed": "Failed: {{message}}",
    "saving": "Saving…",
    "checking": "Checking…"
  },
  "app": {
    "title": "DUNE ADMIN",
    "openSettings": "Open Settings",
    "configureBackend": "Configure backend",
    "signIn": "Sign In",
    "settings": "Settings",
    "changesNote": "Changes on all tabs are saved together.",
    "checkUpdates": "Check for Updates",
    "reinstall": "Reinstall",
    "saveApply": "Save & Apply",
    "updated": "Updated to {{version}}. Server is restarting…",
    "reinstalled": "Reinstalled {{version}}. Server is restarting…",
    "updateFailed": "Update failed: {{message}}",
    "selectLanguage": "Select language"
  },
  "nav": {
    "battlegroup": "Battlegroup",
    "players": "Players",
    "database": "Database",
    "logs": "Logs",
    "blueprints": "Blueprints",
    "bases": "Bases",
    "storage": "Storage",
    "server": "Server",
    "market": "Market",
    "adminSections": "Admin sections"
  },
  "battlegroup": {
    "statusTitle": "Battlegroup Status",
    "titleFull": "{{bgTitle}} ({{bgName}})",
    "phase": "Phase",
    "database": "Database",
    "initWarning": "Server just started — game servers may still be initializing (~2 min).",
    "loadingStatus": "Loading status...",
    "statusFailed": "Status failed: {{message}}",
    "noGameServers": "No game servers found.",
    "clickRefresh": "Click Refresh to load status.",
    "initializing": "(initializing)",
    "serverControl": "Server Control",
    "broadcasts": "Broadcasts",
    "restore": "Restore",
    "genericMessage": "Generic Message",
    "titlePlaceholder": "Title",
    "bodyPlaceholder": "Body",
    "durationLabel": "Duration (s)",
    "broadcastSent": "Broadcast sent",
    "shutdownBroadcast": "Shutdown Broadcast",
    "shutdownType": "Type",
    "shutdownTypeLabel": "Shutdown type",
    "shutdownDelay": "Delay (min)",
    "shutdownDelayLabel": "Delay minutes",
    "shutdownSent": "Shutdown broadcast sent ({{delay}} min)",
    "shutdownCancelled": "Shutdown broadcast cancelled",
    "broadcastBtn": "Broadcast",
    "cmdCompleted": "{{label}} completed",
    "cmdFailed": "{{label}} failed: {{message}}",
    "noOutput": "(no output)",
    "backupLoadFailed": "Could not load backup files",
    "runningCmd": "Running {{cmd}}...",
    "columns": {
      "map": "Map",
      "phase": "Phase",
      "players": "Players",
      "ready": "Ready",
      "dim": "Dim",
      "part": "Part"
    },
    "actions": {
      "start": "Start",
      "stop": "Stop",
      "restart": "Restart",
      "update": "Update",
      "backup": "Backup",
      "startMsg": "Start the battlegroup server?",
      "stopMsg": "Stop the server? All players will be disconnected.",
      "restartMsg": "Restart the server? Players will be briefly disconnected.",
      "updateMsg": "Run a server update? This takes the server offline briefly.",
      "backupMsg": "Create a database backup? This may take a few minutes."
    },
    "confirm": {
      "serverSuffix": "Server",
      "confirmPrefix": "Confirm"
    },
    "restore": {
      "title": "Restore from Backup",
      "warning": "This overwrites current data. Server should be stopped first.",
      "dropzone": "Drop or click to upload a .backup or .zip from your computer",
      "noBackups": "No backup files on server yet.",
      "uploaded": "Uploaded {{name}}",
      "restoreCompleted": "Restore completed",
      "restoreBtn": "Restore {{file}}"
    },
    "modal": {
      "download": "Download"
    }
  },
  "players": {
    "title": "Players",
    "onlineCount": "{{count}} online",
    "searchLabel": "Search players",
    "searchPlaceholder": "Filter players...",
    "noPlayersFound": "No players found",
    "selectPlayer": "Select a player",
    "tabs": {
      "overview": "Overview",
      "inventory": "Inventory",
      "vehicles": "Vehicles",
      "give": "Give",
      "actions": "Actions"
    },
    "actions": {
      "sections": {
        "resources": "Stats",
        "specs": "Specs",
        "progression": "Progression",
        "contracts": "Contracts",
        "journey": "Journey",
        "admin": "Admin",
        "tags": "Tags",
        "history": "History",
        "experimental": "Experimental"
      },
      "resources": {
        "currencyResources": "Currency & Resources",
        "giveCurrency": "Give Currency",
        "giveScrip": "Give Scrip",
        "awardIntel": "Award Intel",
        "give": "Give",
        "award": "Award",
        "characterXP": "Character XP",
        "currentXP": "Current: {{xp}} XP — Level {{level}} / 200",
        "awardCharXP": "Award Char XP",
        "charXPNote": "Max 344,440 (level 200) · live if online, DB if offline",
        "liveActions": "Live Actions",
        "liveActionsNote": "Player must be online.",
        "skillPoints": "Skill Points",
        "skillPointsNote": "Sets unspent points",
        "set": "Set",
        "fillWater": "Fill Water",
        "fillWaterNote": "Fills all water containers to max",
        "fill": "Fill",
        "setSkillModule": "Set Skill Module",
        "selectModule": "Select module…",
        "skillModules": "Skill modules",
        "factionReputation": "Faction Reputation",
        "faction": "Faction",
        "reputation": "Reputation",
        "reputationNote": "Adds to current, max 12,474"
      },
      "specs": {
        "specializations": "Specializations",
        "grantMaxKeystones": "Grant Max Keystones",
        "resetAllKeystones": "Reset All Keystones",
        "resetKeystonesTitle": "Reset all keystones?",
        "resetKeystonesDesc": "This will remove all granted keystones for {{player}}. This action cannot be undone.",
        "onlineWarning": "⚠ Player is online — XP changes may not take effect until they reconnect",
        "columns": {
          "track": "Track",
          "xp": "XP",
          "level": "Level"
        },
        "grantMax": "Grant Max",
        "resetSpec": "Reset Spec",
        "resetSpecTitle": "Reset {{track}} spec?",
        "resetSpecDesc": "This will wipe all XP and levels for the {{track}} specialization track.",
        "specsLabel": "Specializations"
      },
      "progression": {
        "quickPresets": "Quick Presets",
        "quickPresetsDesc": "Curated bundles that complete one or more root journey nodes and cascade to all children + tags.",
        "loadingPresets": "Loading…",
        "noPresets": "No presets available",
        "nodes": "{{count}} nodes",
        "apply": "Apply",
        "progressionUnlock": "Progression Unlock",
        "progressionUnlockDesc": "Completes DA_FQ_ClimbTheRanks journey nodes and writes faction tier tags.",
        "applyUnlock": "Apply Unlock",
        "reverseUnlock": "Reverse Unlock",
        "reverseUnlockTitle": "Reverse progression unlock?",
        "reverseUnlockDesc": "This will undo the {{preset}} preset for {{faction}} and remove associated tags from {{player}}.",
        "unlockTrainer": "Unlock Trainer",
        "unlockTrainerDesc": "Completes every Trainer_X_* contract and grants the full job skill tree.",
        "trainerLabel": "Trainer",
        "resetSkillTreeTitle": "Reset {{trainer}} skill tree?",
        "resetSkillTreeDesc": "This will wipe the full {{trainer}} job skill tree for {{player}}.",
        "resetSkillTree": "Reset Skill Tree",
        "unlockMainQuest": "Unlock Main Quest",
        "unlockMainQuestDesc": "Flips every DA_MQ_<name>.* journey row complete and applies the m_TagsToAdd union.",
        "mainQuestLabel": "Main quest"
      },
      "contracts": {
        "title": "Complete Contract(s)",
        "loadFailed": "load failed: {{error}} — restart the server",
        "count": "{{count}} contracts",
        "loadingContracts": "loading…",
        "desc": "Applies the contract's AddedFlagsOnCompletion tags + tier-promotion side effects. Multi-select supported.",
        "filterPlaceholder": "Filter contracts (e.g. Trainer_Mentat, Atre_Rank01)...",
        "filterLabel": "Filter contracts",
        "applyContracts": "Apply Contract(s) ({{count}})",
        "reverseContracts": "Reverse Contract(s) ({{count}})",
        "clearAll": "clear all",
        "noMatching": "No matching contracts",
        "tagCount": "{{count}} tag",
        "tagCountPlural": "{{count}} tags"
      },
      "journey": {
        "title": "Journey Nodes",
        "wipeAll": "Wipe All",
        "wipeAllTitle": "Wipe all journey nodes?",
        "wipeAllDesc": "This will delete all journey progress for {{player}}. This action cannot be undone.",
        "filterPlaceholder": "Filter nodes...",
        "noNodes": "No journey nodes found",
        "noMatching": "No matching nodes",
        "columns": {
          "nodeId": "Node ID",
          "done": "Done",
          "revealed": "Revealed",
          "reward": "Reward"
        },
        "redo": "↻ Re-do",
        "complete": "Complete",
        "reset": "Reset",
        "journeyLabel": "Journey nodes"
      },
      "experimental": {
        "warning": "⚠ Experimental — these commands are sent via RabbitMQ but their effects are unverified. Use on test characters only.",
        "knownScripts": "CheatScript — Known Scripts",
        "knownScriptsDesc": "Named scripts defined in DefaultGame.ini. Fire-and-forget.",
        "customScript": "CheatScript — Custom",
        "customScriptDesc": "Try any script name or console command.",
        "customScriptPlaceholder": "Script / command name…",
        "customScriptLabel": "Custom script name",
        "run": "Run",
        "try": "Try",
        "runTitle": "Run {{label}}?",
        "confirmRun": "Confirm Run"
      },
      "admin": {
        "liveActions": "Live Actions",
        "liveActionsDesc": "RabbitMQ commands — player must be online.",
        "kickPlayer": "Kick Player",
        "kickDesc": "Disconnects the player from the server",
        "kick": "Kick",
        "destructive": "Destructive Live Actions",
        "destructiveDesc": "Irreversible — player must be online.",
        "wipeInventory": "Wipe Inventory",
        "wipeInventoryDesc": "Destroys all items in the player's inventory",
        "wipeInventoryTitle": "Wipe inventory permanently?",
        "wipeInventoryConfirmDesc": "This will destroy all items in {{player}}'s inventory. This action cannot be undone.",
        "confirmWipe": "Confirm Wipe",
        "wipe": "Wipe",
        "resetProgression": "Reset Progression",
        "resetProgressionDesc": "Wipes XP, skills and journey for the player",
        "resetProgressionTitle": "Reset progression permanently?",
        "resetProgressionConfirmDesc": "This will wipe XP, skills, and journey progress for {{player}}. This action cannot be undone.",
        "confirmReset": "Confirm Reset",
        "resetActions": "Reset Actions",
        "deleteTutorials": "Delete Tutorials",
        "deleteTutorialsDesc": "Removes all tutorial completion records",
        "deleteTutorialsTitle": "Delete tutorials?",
        "deleteTutorialsConfirmDesc": "This will remove all tutorial completion records for {{player}}.",
        "delete": "Delete",
        "wipeCodex": "Wipe Codex",
        "wipeCodexDesc": "Clears all codex discoveries",
        "wipeCodexTitle": "Wipe codex?",
        "wipeCodexConfirmDesc": "This will clear all codex discoveries for {{player}}.",
        "dismissReturning": "Dismiss Returning Player Popup",
        "dismissReturningDesc": "Marks the award as claimed so the login popup stops appearing",
        "dismiss": "Dismiss",
        "characterExport": "Character Export",
        "characterExportDesc": "Download character data as JSON",
        "downloadExport": "Download",
        "exportDownloaded": "Export downloaded",
        "teleport": "Teleport",
        "teleportPlaceholder": "Select location...",
        "move": "Move",
        "teleportNote": "Live if online · written to DB if offline",
        "teleportToPlayer": "Teleport to Player",
        "selectTargetLabel": "Target player",
        "loadingPlayers": "Loading players…",
        "pickTarget": "Pick a target…",
        "whisper": "Whisper",
        "whisperFrom": "From:",
        "whisperSent": "Whisper sent to {{player}}",
        "spawnVehicle": "Spawn Vehicle",
        "spawnVehicleDesc": "Player must be online. Spawns at the chosen partition location.",
        "selectVehicle": "Select vehicle…",
        "vehicleLabel": "Vehicle type",
        "templateLabel": "Template",
        "spawnLocationLabel": "Spawn location",
        "selectSpawnLocation": "Select spawn location…",
        "persistent": "Persistent",
        "spawn": "Spawn"
      },
      "tags": {
        "addTags": "Add Tags",
        "searchPlaceholder": "Search tags…",
        "filterPlaceholder": "Filter to remove…",
        "activeTags": "Active Tags ({{count}})",
        "noTags": "No tags",
        "removedTag": "Removed tag",
        "addCount": "Add ({{count}})",
        "activeTagsLabel": "Active tags",
        "tagColumn": "Tag"
      },
      "history": {
        "gameEvents": "Game Events",
        "dungeonRecords": "Dungeon Records",
        "noEvents": "No events",
        "noDungeons": "No dungeon records",
        "gameEventsLabel": "Game events",
        "dungeonLabel": "Dungeon records",
        "columns": {
          "time": "Time",
          "map": "Map",
          "eventType": "Event Type",
          "location": "Location",
          "dungeon": "Dungeon",
          "difficulty": "Difficulty",
          "duration": "Duration",
          "partySize": "Party Size"
        }
      }
    },
    "inventory": {
      "title": "Inventory",
      "noItems": "No items",
      "deleteItem": "Delete",
      "columns": {
        "id": "ID",
        "template": "Template",
        "stack": "Stack",
        "quality": "Quality",
        "durability": "Durability"
      }
    },
    "vehicles": {
      "title": "Vehicles",
      "noVehicles": "No vehicles"
    },
    "give": {
      "title": "Give Items"
    }
  },
  "market": {
    "title": "Market Board",
    "subtitle": "Browse active exchange listings from bot and player sellers.",
    "botControl": "Bot Control",
    "noBotConnected": "No market bot connected — enable the built-in bot to manage it here",
    "refresh": "Refresh",
    "noItems": "No items",
    "search": {
      "searchPlaceholder": "Search items...",
      "ownerPlaceholder": "Filter by owner...",
      "allCategories": "All categories",
      "reset": "Reset"
    },
    "sidebar": {
      "categories": "Categories",
      "allItems": "All Items"
    },
    "columns": {
      "name": "Name",
      "category": "Category",
      "price": "Price",
      "stock": "Stock",
      "seller": "Seller"
    },
    "bot": {
      "panelTitle": "Bot Control — Revy",
      "botSectionsLabel": "Bot sections",
      "config": "Config",
      "disabledItems": "Disabled Items",
      "server": "Server",
      "logs": "Logs",
      "configUnavailable": "Config unavailable.",
      "tickingEnabled": "Ticking enabled",
      "reset": "Reset",
      "reloadConfig": "Reload Config",
      "saveConfig": "Save Config",
      "dormantHint": "Bot is dormant — configure it in the Server tab to connect it to a game server.",
      "enableBot": "Enable Bot"
    }
  },
  "database": {
    "sections": {
      "tables": "Tables",
      "describe": "Describe",
      "sample": "Sample",
      "search": "Search Columns",
      "sql": "Run SQL"
    },
    "sideNavTitle": "Database",
    "tableNameLabel": "Table name",
    "tablePlaceholder": "actors",
    "limitLabel": "Limit",
    "runBtn": "Run",
    "searchLabel": "Column or table name",
    "searchPlaceholder": "player_id, faction...",
    "sqlPlaceholder": "SELECT * FROM dune.actors LIMIT 10;",
    "runQuery": "Run Query",
    "runHint": "Cmd/Ctrl+Enter to run · Tab accepts suggestion",
    "noResults": "No results.",
    "resultLabel": "Result",
    "failed": "Failed: {{message}}",
    "enterTableName": "Enter a table name",
    "enterSearchTerm": "Enter a search term",
    "enterSQL": "Enter a SQL query",
    "rowsLimited": "Results limited to 200 rows.",
    "refreshLabel": "Refresh",
    "tableColumn": "Table",
    "rowsColumn": "Rows",
    "columnColumn": "Column",
    "typeColumn": "Type",
    "nullableColumn": "Nullable"
  },
  "logs": {
    "logFiles": "Log Files",
    "pods": "Pods",
    "logFileSingular": "log file",
    "podSingular": "pod",
    "cheats7d": "Cheats (7d)",
    "antiCheatLog": "Anti-cheat log",
    "antiCheatTitle": "Anti-Cheat Events (7d)",
    "eventsCount": "{{count}} events",
    "noCheatEvents": "No cheat events found in the last 7 days.",
    "antiCheatLabel": "Anti-cheat events",
    "columns": {
      "time": "Time",
      "character": "Character",
      "cheatType": "Cheat Type"
    },
    "connectedPod": "● Connected · {{pod}}",
    "disconnected": "○ Disconnected",
    "selectSource": "○ Select a {{label}}",
    "autoScroll": "Auto-scroll",
    "stop": "Stop",
    "reconnect": "Reconnect",
    "clear": "Clear",
    "linesCount": "{{count}} lines",
    "waitingForLines": "Waiting for log lines...",
    "disconnectedState": "Disconnected.",
    "selectFromPanel": "Select a {{label}} from the left panel to start streaming logs.",
    "failedToLoad": "Failed to load pods: {{message}}",
    "wsError": "WebSocket error",
    "sourceTitle": "{{label}} ({{count}})"
  },
  "bases": {
    "title": "Bases ({{count}})",
    "subtitle": "Live in-world player bases. Export any base as a solido-compatible blueprint.",
    "failedToLoad": "Failed to load bases: {{message}}",
    "ariaLabel": "Player bases",
    "noBasesFound": "No bases found.",
    "featureNotAvailable": "Feature not available",
    "featureNotAvailableDesc": "This version of the dune-admin binary does not support base listing. Upgrade to the latest release to use this feature.",
    "layoutAccountRequired": "A Layout Tools account is required to export bases. Sign in using the button in the top right.",
    "layoutAccountStrong": "Layout Tools",
    "export": "Export",
    "columns": {
      "id": "ID",
      "name": "Name",
      "pieces": "Pieces",
      "placeables": "Placeables"
    }
  },
  "blueprints": {
    "title": "Blueprints ({{count}})",
    "subtitle": "Manage saved base blueprints. Export or import player constructions.",
    "failedToLoad": "Failed to load blueprints: {{message}}",
    "ariaLabel": "Blueprints",
    "noBlueprintsFound": "No blueprints found.",
    "layoutAccountRequired": "A Layout Tools account is required to export or import blueprints. Sign in using the button in the top right.",
    "layoutAccountStrong": "Layout Tools",
    "importBlueprint": "Import Blueprint",
    "export": "Export",
    "importSuccess": "Blueprint imported successfully",
    "importFailed": "Import failed: {{message}}",
    "selectFile": "Select a blueprint file",
    "selectPlayer": "Select a player",
    "columns": {
      "id": "ID",
      "owner": "Owner",
      "name": "Name",
      "itemId": "Item ID",
      "pieces": "Pieces",
      "placeables": "Placeables"
    },
    "importModal": {
      "title": "Import Blueprint",
      "blueprintFile": "Blueprint File",
      "dropzone": "Drop or click to upload a .json blueprint file",
      "playerLabel": "Player",
      "playerPlaceholder": "Select a player…",
      "playersLabel": "Players",
      "import": "Import"
    }
  },
  "storage": {
    "warningText": "Items added to or removed from storage containers require a server zone restart to become visible to other players.",
    "warningStrong": "server zone restart",
    "containersTitle": "Containers ({{count}})",
    "searchPlaceholder": "Search...",
    "searchLabel": "Search containers",
    "selectContainer": "Select a container to view its contents",
    "containerTitle": "Container #{{id}}",
    "ownerLabel": "Owner: {{name}}",
    "addItems": "Add Items",
    "itemRemoved": "Item removed",
    "ariaLabel": "Container items",
    "containerEmpty": "Container is empty",
    "remove": "Remove",
    "columns": {
      "id": "ID",
      "template": "Template",
      "stack": "Stack",
      "quality": "Quality",
      "durability": "Durability"
    },
    "addModal": {
      "searchPlaceholder": "Search templates...",
      "templateLabel": "Template",
      "qtyLabel": "Qty",
      "qualityLabel": "Quality",
      "add": "Add",
      "selectTemplate": "Select a template",
      "qtyColLabel": "Qty",
      "qualityColLabel": "Quality"
    }
  },
  "server": {
    "title": "Server Settings",
    "loading": "Loading settings…",
    "searchPlaceholder": "Search settings…",
    "searchLabel": "Search settings",
    "showAll": "All",
    "showUser": "User",
    "showAllAriaLabel": "Showing all settings — click to show user settings only",
    "showUserAriaLabel": "Showing user settings only — click to show all",
    "save": "Save",
    "saveWithCount": "Save ({{count}})",
    "writeNote": "Changes are written to UserGame.ini or UserEngine.ini. A server restart is required for them to take effect.",
    "noMatchSettings": "No settings match \"{{query}}\".",
    "commonSettings": "Common Settings",
    "commonSettingsDesc": "The dozen or so knobs admins reach for most often. Everything else lives in the categories below.",
    "advancedCategories": "Advanced — All Categories",
    "advancedCategoriesDesc": "Click a category to expand it in place. Click again or pick another to switch.",
    "settingCount_one": "{{count}} setting",
    "settingCount_other": "{{count}} settings",
    "overriddenCount": "{{count}} overridden",
    "rawIniSections": "Raw INI Sections",
    "rawIniDesc": "Array entries (+key=val) and unrecognised keys, grouped by INI section.",
    "unsaved": "unsaved",
    "userOverride": "· user override",
    "collapse": "Cancel",
    "collapseSection": "Collapse section",
    "collapseCategory": "Collapse category",
    "rawPlaceholder": "Key=Value or +Key=Value for array entries",
    "savedTo": "Saved to {{file}}",
    "saveFailed": "Save failed: {{message}}",
    "removedFrom": "Removed from {{file}}",
    "deleteFailed": "Delete failed: {{message}}",
    "iniNotFound": "Could not locate server INI files: {{error}}. For kubectl, ensure the game server PVC is mounted. For docker/local, add server_ini_dir to ~/.dune-admin/config.yaml.",
    "noResultsFor": "No settings match \"{{query}}\"."
  }
}
```

---

## Task 4: Wire i18n into `main.tsx`

**Files:** Modify `web/src/main.tsx`

- [ ] **Step 1: Add i18n import at the top of `web/src/main.tsx`**

Insert after `import './index.css'`:

```typescript
import './i18n'
```

Full imports block becomes:

```typescript
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { HashRouter } from 'react-router-dom'
import './index.css'
import './i18n'
import App from './App.tsx'
import { ClerkProvider } from '@clerk/react'
import { dark } from '@clerk/themes'
```

---

## Task 5: Create `LanguageSelector` component + migrate `App.tsx`

**Files:**

- Create: `web/src/components/LanguageSelector.tsx`
- Modify: `web/src/App.tsx`

- [ ] **Step 1: Create `web/src/components/LanguageSelector.tsx`**

```tsx
import { useState, useEffect } from 'react'
import { Button, ListBox, Popover } from '@heroui/react'
import { useTranslation } from 'react-i18next'
import { LANGUAGES, setLocale, LOCALE_KEY, DEFAULT_LOCALE } from '../i18n'

export function LanguageSelector() {
  const { t } = useTranslation()
  const [current, setCurrent] = useState(
    localStorage.getItem(LOCALE_KEY) ?? DEFAULT_LOCALE,
  )
  const [open, setOpen] = useState(false)

  useEffect(() => {
    const onStorage = (e: StorageEvent) => {
      if (e.key === LOCALE_KEY && e.newValue) setCurrent(e.newValue)
    }
    window.addEventListener('storage', onStorage)
    return () => window.removeEventListener('storage', onStorage)
  }, [])

  const selected = LANGUAGES.find((l) => l.code === current) ?? LANGUAGES[0]

  return (
    <div className="relative">
      <Button
        size="sm"
        variant="ghost"
        aria-label={t('app.selectLanguage')}
        onPress={() => setOpen((v) => !v)}
      >
        <span>{selected.flag}</span>
      </Button>
      {open && (
        <Popover isOpen={open} onOpenChange={setOpen}>
          <div className="absolute right-0 top-full mt-1 z-50 min-w-[180px] rounded-[var(--radius)] border border-border bg-surface shadow-lg overflow-hidden">
            <ListBox
              aria-label={t('app.selectLanguage')}
              onAction={(key) => {
                const code = String(key)
                setLocale(code)
                setCurrent(code)
                setOpen(false)
              }}
            >
              {LANGUAGES.map((lang) => (
                <ListBox.Item
                  key={lang.code}
                  id={lang.code}
                  textValue={lang.label}
                  className={`flex items-center gap-2 px-3 py-2 text-sm cursor-pointer hover:bg-surface-hover ${current === lang.code ? 'text-accent font-medium' : 'text-foreground'}`}
                >
                  <span>{lang.flag}</span>
                  <span>{lang.label}</span>
                </ListBox.Item>
              ))}
            </ListBox>
          </div>
        </Popover>
      )}
    </div>
  )
}
```

- [ ] **Step 2: Migrate `web/src/App.tsx`**

Add imports:

```typescript
import { useTranslation } from 'react-i18next'
import { LanguageSelector } from './components/LanguageSelector'
```

Inside `AppCore` (after `const navigate = useNavigate()`):

```typescript
const { t } = useTranslation()
```

Replace all hardcoded strings with `t()` calls:

| Old | New |
|---|---|
| `title="Open Settings"` | `title={t('app.openSettings')}` |
| `aria-label="Configure backend"` | `aria-label={t('app.configureBackend')}` |
| `<span className="text-xl font-bold...">DUNE ADMIN</span>` | `<span className="text-xl font-bold...">{t('app.title')}</span>` |
| `toast.success(``${force ? 'Reinstalled' : 'Updated to'} ${result.version ?? 'latest'}. Server is restarting…``)` | `toast.success(force ? t('app.reinstalled', { version: result.version ?? 'latest' }) : t('app.updated', { version: result.version ?? 'latest' }))` |
| `toast.info(result.message)` | unchanged (dynamic) |
| `toast.danger(``Update failed: ${...}``)` | `toast.danger(t('app.updateFailed', { message: e instanceof Error ? e.message : String(e) }))` |
| `{updateChecking ? <><Spinner.../>Checking…</>  : 'Check for Updates'}` | `{updateChecking ? <><Spinner.../>{t('common.checking')}</>  : t('app.checkUpdates')}` |
| `'Reinstall'` (button) | `{t('app.reinstall')}` |
| `{updateApplying ? <Spinner/> : <span>v{updateInfo.current}{' → '}v...</span>}` | unchanged (dynamic version display) |
| `<Modal.Heading className="text-accent">Settings</Modal.Heading>` | `<Modal.Heading className="text-accent">{t('app.settings')}</Modal.Heading>` |
| `<span className="text-xs text-muted">Changes on all tabs are saved together.</span>` | `<span className="text-xs text-muted">{t('app.changesNote')}</span>` |
| `{formSaving ? <><Spinner/>Saving…</> : <><Icon name="save"/> Save & Apply</>}` | `{formSaving ? <><Spinner/>{t('common.saving')}</> : <><Icon name="save"/>{' '}{t('app.saveApply')}</>}` |
| `Close` (button) | `{t('common.close')}` |
| `Sign In` | `{t('app.signIn')}` |
| `aria-label="Admin sections"` | `aria-label={t('nav.adminSections')}` |
| Tab labels: `Battlegroup`, `Players`, `Database`, `Logs`, `Blueprints`, `Bases`, `Storage`, `Server`, `Market` | Use `t('nav.battlegroup')` etc. |

Add `<LanguageSelector />` in the right-side header `div`, just before the settings Button:

```tsx
<div className="flex items-center gap-3">
  {/* ... ConnectionBadge items ... */}
  <LanguageSelector />
  <Button size="sm" variant="ghost" isIconOnly
    aria-label={t('app.configureBackend')}
    ...
  >
```

---

## Task 6: Migrate `BattlegroupTab`

**Files:** Modify all files in `web/src/tabs/BattlegroupTab/`

- [ ] **Step 1: Migrate `types.ts`**

The `ACTIONS` and `SERVER_COLUMNS` arrays have hardcoded English strings that are rendered in JSX. Instead of modifying types.ts, translate at the render sites (Tasks 6 steps 2–6).

Modify `SERVER_COLUMNS` labels to use translation keys — since this is a TypeScript const (not JSX), leave the `label` values as-is; they're used as aria-labels and are short enough that they render acceptably in English for v1. **OR** convert to a function that accepts `t`:

Replace in `types.ts`:

```typescript
import type { TFunction } from 'i18next'
import type { Column } from '../../dune-ui'

// ...

export function getServerColumns(t: TFunction): Column<ServerSortKey>[] {
  return [
    { key: 'map', label: t('battlegroup.columns.map'), isRowHeader: true },
    { key: 'phase', label: t('battlegroup.columns.phase') },
    { key: 'players', label: t('battlegroup.columns.players') },
    { key: 'ready', label: t('battlegroup.columns.ready') },
    { key: 'dimension', label: t('battlegroup.columns.dim') },
    { key: 'partition', label: t('battlegroup.columns.part') },
  ]
}

export const ACTIONS: ActionDef[] = [
  { label: 'start', cmd: 'start', danger: false, msg: 'startMsg' },
  { label: 'stop', cmd: 'stop', danger: true, msg: 'stopMsg' },
  { label: 'restart', cmd: 'restart', danger: false, msg: 'restartMsg' },
  { label: 'update', cmd: 'update', danger: false, msg: 'updateMsg' },
  { label: 'backup', cmd: 'backup', danger: false, msg: 'backupMsg' },
]
```

Note: `ACTIONS` labels and msgs are now i18n keys (e.g. `'start'` maps to `t('battlegroup.actions.start')`).

- [ ] **Step 2: Migrate `ServersTable.tsx`**

```tsx
import { useTranslation } from 'react-i18next'
import { getServerColumns } from './types'
// ...
export function ServersTable({ servers, isInitializing, emptyMessage }: Props) {
  const { t } = useTranslation()
  return (
    <DataTable<ServerRow, ServerSortKey>
      aria-label={t('nav.battlegroup')}
      columns={getServerColumns(t)}
      // ...
      renderCell={(s, key) => {
        // ...
        case 'phase':
          return (
            <span ...>
              {s.phase || '—'}
              {isInitializing && s.phase === 'Running' && (
                <span className="ml-1 font-normal text-warning">({t('battlegroup.initializing')})</span>
              )}
            </span>
          )
      }}
    />
  )
}
```

- [ ] **Step 3: Migrate `BattlegroupTab/index.tsx`**

Add `import { useTranslation } from 'react-i18next'` and `const { t } = useTranslation()` inside the component.

Key replacements:

| Old string | New t() call |
|---|---|
| `'Battlegroup Status'` (fallback title) | `t('battlegroup.statusTitle')` |
| `` `${bg.title} (${bg.name})` `` | `t('battlegroup.titleFull', { bgTitle: bg.title, bgName: bg.name })` |
| `"Phase"` (InfoCard label) | `t('battlegroup.phase')` |
| `"Database"` (InfoCard label) | `t('battlegroup.database')` |
| `"Server just started — game servers may still be initializing (~2 min)."` | `t('battlegroup.initWarning')` |
| `"Loading status..."` | `t('battlegroup.loadingStatus')` |
| `` `Status failed: ${msg}` `` | `t('battlegroup.statusFailed', { message: msg })` |
| `emptyMessage={status ? 'No game servers found.' : 'Click Refresh to load status.'}` | `emptyMessage={status ? t('battlegroup.noGameServers') : t('battlegroup.clickRefresh')}` |
| `"Server Control"` (SectionDivider) | `t('battlegroup.serverControl')` |
| `"Broadcasts"` (SectionDivider) | `t('battlegroup.broadcasts')` |
| `"Restore"` (Button) | `t('battlegroup.restore')` |
| `"Generic Message"` | `t('battlegroup.genericMessage')` |
| `placeholder="Title"` | `placeholder={t('battlegroup.titlePlaceholder')}` |
| `placeholder="Body"` | `placeholder={t('battlegroup.bodyPlaceholder')}` |
| `"Duration (s)"` | `t('battlegroup.durationLabel')` |
| `"Send"` | `t('common.send')` |
| `toast.success('Broadcast sent')` | `toast.success(t('battlegroup.broadcastSent'))` |
| `"Shutdown Broadcast"` | `t('battlegroup.shutdownBroadcast')` |
| `"Type"` | `t('battlegroup.shutdownType')` |
| `aria-label="Shutdown type"` | `aria-label={t('battlegroup.shutdownTypeLabel')}` |
| `"Delay (min)"` | `t('battlegroup.shutdownDelay')` |
| `aria-label="Delay minutes"` | `aria-label={t('battlegroup.shutdownDelayLabel')}` |
| `"Broadcast"` (button) | `t('battlegroup.broadcastBtn')` |
| `"Cancel"` | `t('common.cancel')` |
| `` `Shutdown broadcast sent (${shutdownDelay} min)` `` | `t('battlegroup.shutdownSent', { delay: shutdownDelay })` |
| `'Shutdown broadcast cancelled'` | `t('battlegroup.shutdownCancelled')` |
| `toast.success(``${action.label} completed``)` | `toast.success(t('battlegroup.cmdCompleted', { label: t(``battlegroup.actions.${action.cmd}`` as never) }))` |
| `toast.danger(``${action.label} failed: ${msg}``)` | `toast.danger(t('battlegroup.cmdFailed', { label: t(``battlegroup.actions.${action.cmd}`` as never), message: msg }))` |
| `'(no output)'` | `t('battlegroup.noOutput')` |
| `toast.danger('Could not load backup files')` | `toast.danger(t('battlegroup.backupLoadFailed'))` |
| Action buttons: `{action.label}` | `{t(``battlegroup.actions.${action.cmd}`` as never)}` |

- [ ] **Step 4: Migrate `ConfirmDialog.tsx`**

```tsx
import { useTranslation } from 'react-i18next'

export function ConfirmDialog({ action, onConfirm, onClose }: Props) {
  const { t } = useTranslation()
  return (
    // ...
    <Modal.Heading>
      {action ? t(`battlegroup.actions.${action.cmd}` as never) : ''}{' '}{t('battlegroup.confirm.serverSuffix')}
    </Modal.Heading>
    <p>{action ? t(`battlegroup.actions.${action.cmd}Msg` as never) : ''}</p>
    <Button variant="tertiary" slot="close">{t('common.cancel')}</Button>
    <Button variant={action?.danger ? 'danger' : 'primary'} ...>
      {t('battlegroup.confirm.confirmPrefix')} {action ? t(`battlegroup.actions.${action.cmd}` as never) : ''}
    </Button>
  )
}
```

- [ ] **Step 5: Migrate `CommandOutputModal.tsx`**

```tsx
import { useTranslation } from 'react-i18next'

export function CommandOutputModal({ runningCmd, cmdOutput, cmdDone, lastBackupFile, onClose }: Props) {
  const { t } = useTranslation()
  // ...
  // Replace: "Running {runningCmd?.toLowerCase() ?? ''}..."
  // With:
  <p className="text-sm text-muted">
    {t('battlegroup.runningCmd', { cmd: runningCmd?.toLowerCase() ?? '' })}
  </p>
  // Replace: "Download" link text
  // With: {t('battlegroup.modal.download')}
  // Replace: <Button onPress={onClose}>Close</Button>
  // With: <Button onPress={onClose}>{t('common.close')}</Button>
}
```

- [ ] **Step 6: Migrate `RestoreModal.tsx`**

```tsx
import { useTranslation } from 'react-i18next'

// Inside RestoreModal:
const { t } = useTranslation()

// Replace:
// "Restore from Backup" → t('battlegroup.restore.title')
// "This overwrites current data. Server should be stopped first." → t('battlegroup.restore.warning')
// "Drop or click to upload..." → t('battlegroup.restore.dropzone')
// toast.success(`Uploaded ${res.name}`) → toast.success(t('battlegroup.restore.uploaded', { name: res.name }))
// "No backup files on server yet." → t('battlegroup.restore.noBackups')
// aria-label="Download" → unchanged (icon only)
// "Cancel" → t('common.cancel')
// toast.success('Restore completed') → toast.success(t('battlegroup.restore.restoreCompleted'))
// '(done)' → '(done)' (technical, leave as-is)
// `Restore ${selectedFile.slice(-20)}` → t('battlegroup.restore.restoreBtn', { file: selectedFile.slice(-20) })
```

---

## Task 7: Migrate `PlayersTab`

**Files:** Modify `web/src/tabs/PlayersTab/index.tsx`, `types.ts`, all views and components

- [ ] **Step 1: Migrate `PlayersTab/types.ts`**

Convert `ACTION_SECTIONS` to use i18n keys. The simplest approach is to keep the `key` values but change `label` to the translation key string, then translate at the render site in `ActionsView.tsx`.

Change:

```typescript
export const ACTION_SECTIONS: { key: ActionSection, label: string }[] = [
  { key: 'resources', label: 'players.actions.sections.resources' },
  { key: 'specs', label: 'players.actions.sections.specs' },
  { key: 'progression', label: 'players.actions.sections.progression' },
  { key: 'contracts', label: 'players.actions.sections.contracts' },
  { key: 'journey', label: 'players.actions.sections.journey' },
  { key: 'admin', label: 'players.actions.sections.admin' },
  { key: 'tags', label: 'players.actions.sections.tags' },
  { key: 'history', label: 'players.actions.sections.history' },
  { key: 'experimental', label: 'players.actions.sections.experimental' },
]
```

- [ ] **Step 2: Migrate `PlayersTab/index.tsx`**

Add `import { useTranslation } from 'react-i18next'` and `const { t } = useTranslation()`.

Replace:

| Old | New |
|---|---|
| `title="Players"` | `title={t('players.title')}` |
| `{onlineCount} online` | `{t('players.onlineCount', { count: onlineCount })}` |
| `aria-label="Search players"` | `aria-label={t('players.searchLabel')}` |
| `placeholder="Filter players..."` | `placeholder={t('players.searchPlaceholder')}` |
| `No players found` | `{t('players.noPlayersFound')}` |
| `Select a player` | `{t('players.selectPlayer')}` |
| `DETAIL_TABS` labels: `'Overview'`, `'Inventory'`, `'Vehicles'`, `'Give'`, `'Actions'` | `t('players.tabs.overview')` etc. |

- [ ] **Step 3: Migrate `PlayersTab/views/ActionsView.tsx`**

Add `import { useTranslation } from 'react-i18next'` and `const { t } = useTranslation()` inside `ActionsView`.

In the section nav buttons, render `t(s.label as never)` instead of `{s.label}`:

```tsx
<button key={s.key} onClick={() => setSection(s.key)} ...>
  {t(s.label as never)}
</button>
```

Then replace all other hardcoded strings with `t()` calls matching the keys in `en-US/translation.json` under `players.actions.*`. There are many (50+). The agent should do a full pass through the file replacing:

- Section titles (SectionLabel children)
- Button labels
- Placeholder texts
- Toast messages
- Warning text
- Column labels in inline DataTable definitions

Key patterns:

- `"Currency & Resources"` → `t('players.actions.resources.currencyResources')`
- `"Give Currency"` → `t('players.actions.resources.giveCurrency')`
- `"Give"` (button) → `t('players.actions.resources.give')`
- `"Character XP"` → `t('players.actions.resources.characterXP')`
- `"Specializations"` → `t('players.actions.specs.specializations')`
- `"Grant Max Keystones"` → `t('players.actions.specs.grantMaxKeystones')`
- `"Journey Nodes"` → `t('players.actions.journey.title')`
- `"Wipe All"` → `t('players.actions.journey.wipeAll')`
- All toast success/danger messages use `t()` with interpolation
- All `gate()` calls pass `t()` results for title/description/confirmLabel

- [ ] **Step 4: Migrate remaining PlayersTab files**

For each file below, add `const { t } = useTranslation()` and replace hardcoded UI strings with `t()` calls. All translation keys map to the `players.*` namespace:

- `views/InventoryView.tsx`: column labels, empty state, button labels
- `views/VehiclesView.tsx`: column labels, empty state
- `views/GiveItemsView.tsx`: labels, placeholders, button text
- `components/PlayerDetailPanel.tsx`: field labels, section headings
- `modals/GiveItemsModal.tsx`: modal title, form labels, button text
- `modals/InventoryModal.tsx`: modal title, column labels
- `modals/PlayerActionsModal.tsx`: modal title, tab labels

---

## Task 8: Migrate `MarketTab`

**Files:** Modify all files in `web/src/tabs/MarketTab/`

- [ ] **Step 1: Migrate `MarketTab/index.tsx`**

Add `const { t } = useTranslation()`. Replace:

- `"Market Board"` → `t('market.title')`
- `"Browse active exchange listings..."` → `t('market.subtitle')`
- `"Bot Control"` → `t('market.botControl')`
- `"No market bot connected — enable..."` → `t('market.noBotConnected')`
- `"Refresh"` → `t('market.refresh')`

- [ ] **Step 2: Migrate `MarketTab/bot/BotControlPanel.tsx`**

Add `const { t } = useTranslation()`. Replace:

- `"Bot Control — Revy"` → `t('market.bot.panelTitle')`
- `aria-label="Bot sections"` → `aria-label={t('market.bot.botSectionsLabel')}`
- Tab labels: `"Config"`, `"Disabled Items"`, `"Server"`, `"Logs"` → `t('market.bot.config')` etc.
- `"Config unavailable."` → `t('market.bot.configUnavailable')`
- `"Ticking enabled"` → `t('market.bot.tickingEnabled')`
- `"Reset"` → `t('market.bot.reset')`
- `"Reload Config"` → `t('market.bot.reloadConfig')`
- `"Save Config"` → `t('market.bot.saveConfig')`

- [ ] **Step 3: Migrate remaining MarketTab files**

For each of `BotStatusCard.tsx`, `BotActions.tsx`, `BotLogViewer.tsx`, `BotConfigEditor.tsx`, `BotServerConfig.tsx`, `DisabledItemsManager.tsx`, `MarketSearch.tsx`, `MarketSidebar.tsx`, `MarketTable.tsx`, `MarketGrid.tsx`, `ItemDetail.tsx`:

Add `const { t } = useTranslation()` and replace user-visible strings with keys from `market.*` namespace. Add any missing keys to `en-US/translation.json` as needed.

---

## Task 9: Migrate `DatabaseTab.tsx`

**Files:** Modify `web/src/tabs/DatabaseTab.tsx`

- [ ] **Step 1: Add translation hook and replace SECTIONS array**

Change the static `SECTIONS` array to use translation keys:

```typescript
const { t } = useTranslation()

const SECTIONS: { key: Section, label: string }[] = [
  { key: 'tables', label: t('database.sections.tables') },
  { key: 'describe', label: t('database.sections.describe') },
  { key: 'sample', label: t('database.sections.sample') },
  { key: 'search', label: t('database.sections.search') },
  { key: 'sql', label: t('database.sections.sql') },
]
```

Note: Move SECTIONS inside `DatabaseTab()` so `t` is in scope.

- [ ] **Step 2: Replace all hardcoded strings**

Key replacements:

| Old | New |
|---|---|
| `title="Database"` (SideNav) | `title={t('database.sideNavTitle')}` |
| `aria-label="Table name"` | `aria-label={t('database.tableNameLabel')}` |
| `placeholder="actors"` | `placeholder={t('database.tablePlaceholder')}` |
| `"Limit"` (InputGroup.Prefix) | `{t('database.limitLabel')}` |
| `"Run"` | `{t('database.runBtn')}` |
| `aria-label="Column or table name"` | `aria-label={t('database.searchLabel')}` |
| `placeholder="player_id, faction..."` | `placeholder={t('database.searchPlaceholder')}` |
| `placeholder="SELECT * FROM dune.actors LIMIT 10;"` | `placeholder={t('database.sqlPlaceholder')}` |
| `"Run Query"` | `{t('database.runQuery')}` |
| `"Cmd/Ctrl+Enter to run · Tab accepts suggestion"` | `{t('database.runHint')}` |
| `"No results."` | `{t('database.noResults')}` |
| `aria-label="Result"` | `aria-label={t('database.resultLabel')}` |
| `toast.danger(``Failed: ${msg}``)` | `toast.danger(t('database.failed', { message: msg }))` |
| `toast.warning('Enter a table name')` | `toast.warning(t('database.enterTableName'))` |
| `toast.warning('Enter a search term')` | `toast.warning(t('database.enterSearchTerm'))` |
| `toast.warning('Enter a SQL query')` | `toast.warning(t('database.enterSQL'))` |
| `"Results limited to 200 rows."` | `{t('database.rowsLimited')}` |
| `aria-label="Refresh"` | `aria-label={t('database.refreshLabel')}` |
| ResultTable headers: `['Table', 'Rows']` | `[t('database.tableColumn'), t('database.rowsColumn')]` |
| `['Column', 'Type', 'Nullable']` | `[t('database.columnColumn'), t('database.typeColumn'), t('database.nullableColumn')]` |
| `<strong>Error:</strong>` | `<strong>{t('database.error')}</strong>` (note: 'database.error' isn't in JSON — use `'common.error'`) |

---

## Task 10: Migrate `LogsTab.tsx`

**Files:** Modify `web/src/tabs/LogsTab.tsx`

- [ ] **Step 1: Add `useTranslation` and replace dynamic labels**

```typescript
const { t } = useTranslation()
const sourceLabel = isFileBased ? t('logs.logFiles') : t('logs.pods')
const itemLabel = isFileBased ? t('logs.logFileSingular') : t('logs.podSingular')
```

- [ ] **Step 2: Replace all hardcoded strings**

Key replacements:

| Old | New |
|---|---|
| `{ key: 'cheats' as NavKey, label: 'Cheats (7d)', sublabel: 'Anti-cheat log' }` | `label: t('logs.cheats7d'), sublabel: t('logs.antiCheatLog')` |
| `` `${sourceLabel} (${pods.length})` `` | `t('logs.sourceTitle', { label: sourceLabel, count: pods.length })` |
| `"Anti-Cheat Events (7d)"` | `t('logs.antiCheatTitle')` |
| `` `${cheats.length} events` `` | `t('logs.eventsCount', { count: cheats.length })` |
| `"Refresh"` | `t('common.refresh')` |
| `aria-label="Anti-cheat events"` | `aria-label={t('logs.antiCheatLabel')}` |
| `"No cheat events found in the last 7 days."` | `t('logs.noCheatEvents')` |
| Chip text: `` `● Connected · ${selectedPod?.name}` `` | `t('logs.connectedPod', { pod: selectedPod?.name })` |
| `'○ Disconnected'` | `t('logs.disconnected')` |
| `` `○ Select a ${itemLabel}` `` | `t('logs.selectSource', { label: itemLabel })` |
| `"Auto-scroll"` | `t('logs.autoScroll')` |
| `"Stop"` | `t('logs.stop')` |
| `"Reconnect"` | `t('logs.reconnect')` |
| `"Export"` | `t('common.export')` |
| `"Clear"` | `t('logs.clear')` |
| `` `${displayLines.length} lines` `` | `t('logs.linesCount', { count: displayLines.length })` |
| `'Waiting for log lines...'` | `t('logs.waitingForLines')` |
| `'Disconnected.'` | `t('logs.disconnectedState')` |
| `` `Select a ${itemLabel} from the left panel...` `` | `t('logs.selectFromPanel', { label: itemLabel })` |
| `` `Failed to load pods: ${msg}` `` | `t('logs.failedToLoad', { message: msg })` |
| `'WebSocket error'` | `t('logs.wsError')` |
| CHEAT_COLUMNS labels | `t('logs.columns.time')` etc. |

---

## Task 11: Migrate `BlueprintsTab.tsx`, `StorageTab.tsx`, `ServerSettingsTab.tsx`

**Files:** Modify the three tab files.

- [ ] **Step 1: Migrate `BlueprintsTab.tsx`**

Add `const { t } = useTranslation()`. Key replacements:

- `title={\`Blueprints (${blueprints.length})\`}` → `title={t('blueprints.title', { count: blueprints.length })}`
- `subtitle="Manage saved base blueprints..."` → `subtitle={t('blueprints.subtitle')}`
- `toast.danger(``Failed to load blueprints: ${msg}``)` → `toast.danger(t('blueprints.failedToLoad', { message: msg }))`
- `"Refresh"` → `t('common.refresh')`
- `"Import Blueprint"` → `t('blueprints.importBlueprint')`
- `aria-label="Blueprints"` → `aria-label={t('blueprints.ariaLabel')}`
- `"No blueprints found."` → `t('blueprints.noBlueprintsFound')`
- `"Export"` → `t('common.export')`
- Layout Tools warning: render as JSX with `<strong>` around the translated `layoutAccountStrong` key
- COLUMNS labels: `t('blueprints.columns.id')` etc.
- ImportModal: `"Import Blueprint"` heading, `"Blueprint File"` label, `"Drop or click..."` prompt, `"Select a blueprint file"` warning, `"Select a player"` warning, `"Import"` button, `toast.success('Blueprint imported successfully')`, `toast.danger(``Import failed: ${msg}``)`

- [ ] **Step 2: Migrate `BasesTab.tsx`**

Add `const { t } = useTranslation()`. Key replacements mirror `blueprints.*` — use `bases.*` namespace.

- [ ] **Step 3: Migrate `StorageTab.tsx`**

Add `const { t } = useTranslation()`. Key replacements:

- Warning banner text → `t('storage.warningText')` with `<strong>` around `t('storage.warningStrong')`
- `title={\`Containers (${containers.length})\`}` → `t('storage.containersTitle', { count: containers.length })`
- `aria-label="Search containers"` → `aria-label={t('storage.searchLabel')}`
- `placeholder="Search..."` → `placeholder={t('storage.searchPlaceholder')}`
- `"Select a container to view its contents"` → `t('storage.selectContainer')`
- Header title for selected container → `t('storage.containerTitle', { id: selected.id })`
- `Owner: ${selected.owner_name}` → `t('storage.ownerLabel', { name: selected.owner_name })`
- `"Add Items"` → `t('storage.addItems')`
- `"Refresh"` → `t('common.refresh')`
- `toast.success('Item removed')` → `toast.success(t('storage.itemRemoved'))`
- `aria-label="Container items"` → `aria-label={t('storage.ariaLabel')}`
- `"Container is empty"` → `t('storage.containerEmpty')`
- `"Remove"` → `t('storage.remove')`
- ITEM_COLUMNS labels → `t('storage.columns.id')` etc.
- AddItemsModal: all form labels, placeholders, button text, result messages

- [ ] **Step 4: Migrate `ServerSettingsTab.tsx`**

Add `const { t } = useTranslation()`. Key replacements:

- `"Loading settings…"` → `t('server.loading')`
- PageHeader title: `"Server Settings"` → `t('server.title')`
- `placeholder="Search settings…"` → `placeholder={t('server.searchPlaceholder')}`
- `aria-label="Search settings"` → `aria-label={t('server.searchLabel')}`
- `'All'` / `'User'` buttons → `t('server.showAll')` / `t('server.showUser')`
- aria-labels for toggle → `t('server.showAllAriaLabel')` / `t('server.showUserAriaLabel')`
- Save button: `Save${dirtyCount > 0 ?` (${dirtyCount})`: ''}` → `dirtyCount > 0 ? t('server.saveWithCount', { count: dirtyCount }) : t('server.save')`
- Write note text → `t('server.writeNote')` with font-mono spans for file names
- `"Common Settings"` → `t('server.commonSettings')`
- `"The dozen or so knobs..."` → `t('server.commonSettingsDesc')`
- `"Advanced — All Categories"` → `t('server.advancedCategories')`
- `"Click a category to expand..."` → `t('server.advancedCategoriesDesc')`
- Setting count: `${catItems.length} setting(s)` → `t('server.settingCount', { count: catItems.length })`
- Override count → `t('server.overriddenCount', { count: overrideCount })`
- `"Raw INI Sections"` → `t('server.rawIniSections')`
- Raw INI desc → `t('server.rawIniDesc')`
- `"unsaved"` → `t('server.unsaved')`
- `"· user override"` → `t('server.userOverride')`
- No-match text → `t('server.noMatchSettings', { query: search.trim() })`
- RawSectionPanel: `"Cancel"` → `t('server.collapse')`, `"Save"` → `t('common.save')`, toasts, placeholder
- `toast.success(``Saved to ${file}``)` → `toast.success(t('server.savedTo', { file }))`
- `toast.danger(``Save failed: ${msg}``)` → `toast.danger(t('server.saveFailed', { message: msg }))`
- `toast.success(``Removed from ${file}``)` → `toast.success(t('server.removedFrom', { file }))`
- `toast.danger(``Delete failed: ${msg}``)` → `toast.danger(t('server.deleteFailed', { message: msg }))`
- Error page INI path message → `t('server.iniNotFound', { error })`
- SettingRow: `"unsaved"` → `t('server.unsaved')`
- aria-labels for collapse buttons
- `placeholder="Key=Value or +Key=Value..."` → `placeholder={t('server.rawPlaceholder')}`

---

## Task 12: Migrate `SettingsConfigForm.tsx` and `dune-ui/ConfirmDialog.tsx`

**Files:**

- Modify `web/src/components/SettingsConfigForm.tsx`
- Modify `web/src/dune-ui/ConfirmDialog.tsx`

- [ ] **Step 1: Read `SettingsConfigForm.tsx`**

Read the file, identify all user-visible strings, add `const { t } = useTranslation()`, and replace strings. Add any new keys to `en-US/translation.json`.

- [ ] **Step 2: Migrate `dune-ui/ConfirmDialog.tsx`**

Read the file. This is the shared ConfirmDialog used by the PlayersTab ActionsView. It receives `title`, `description`, `confirmLabel` as props (already translated by the caller). Migrate any hardcoded strings like "Cancel" and "Confirm":

```tsx
import { useTranslation } from 'react-i18next'

export function ConfirmDialog({ title, description, confirmLabel, onConfirm, onCancel, open }: Props) {
  const { t } = useTranslation()
  // ...
  <Button variant="tertiary" onPress={onCancel}>{t('common.cancel')}</Button>
  <Button onPress={onConfirm}>{confirmLabel ?? t('common.confirm')}</Button>
}
```

---

## Task 13: Generate 9 non-English translation files

**Files:** Create 9 translation JSON files under `web/src/locales/`

For each of the 9 non-English languages, create a complete `translation.json` with all keys from the `en-US` source translated into the target language. The structure must be identical to `en-US/translation.json` — same keys, same nesting.

Languages to generate:

1. `de` (German) → `web/src/locales/de/translation.json`
2. `fr` (French) → `web/src/locales/fr/translation.json`
3. `es` (Spanish) → `web/src/locales/es/translation.json`
4. `pt-BR` (Brazilian Portuguese) → `web/src/locales/pt-BR/translation.json`
5. `ru` (Russian) → `web/src/locales/ru/translation.json`
6. `pl` (Polish) → `web/src/locales/pl/translation.json`
7. `tr` (Turkish) → `web/src/locales/tr/translation.json`
8. `zh-CN` (Simplified Chinese) → `web/src/locales/zh-CN/translation.json`
9. `ja` (Japanese) → `web/src/locales/ja/translation.json`

- [ ] **Step 1: Read `en-US/translation.json`** to get the complete key set.

- [ ] **Step 2: For each language, create the file** with all keys translated. Use your knowledge of each language to produce accurate translations. Keep technical terms (SQL keywords, INI file names, game IDs like `DA_MQ_*`) in English. Preserve `{{variable}}` interpolation syntax exactly.

Note: Each language file is a full standalone JSON — do not reference other files. Translate all values; do not leave any as "MISSING".

---

## Task 14: Add CI script + completeness test

**Files:**

- Modify `web/package.json` (already done in Task 2 — verify `i18n:extract` is there)
- Create `web/src/i18n/i18n.test.ts`

- [ ] **Step 1: Create `web/src/i18n/i18n.test.ts`**

```typescript
import { describe, it, expect } from 'vitest'
import enUS from '../locales/en-US/translation.json'
import de from '../locales/de/translation.json'
import fr from '../locales/fr/translation.json'
import es from '../locales/es/translation.json'
import ptBR from '../locales/pt-BR/translation.json'
import ru from '../locales/ru/translation.json'
import pl from '../locales/pl/translation.json'
import tr from '../locales/tr/translation.json'
import zhCN from '../locales/zh-CN/translation.json'
import ja from '../locales/ja/translation.json'

const LOCALES = { de, fr, es, 'pt-BR': ptBR, ru, pl, tr, 'zh-CN': zhCN, ja }

function flatKeys(obj: Record<string, unknown>, prefix = ''): string[] {
  return Object.entries(obj).flatMap(([k, v]) => {
    const key = prefix ? `${prefix}.${k}` : k
    return typeof v === 'object' && v !== null
      ? flatKeys(v as Record<string, unknown>, key)
      : [key]
  })
}

describe('i18n completeness', () => {
  const enKeys = flatKeys(enUS as Record<string, unknown>)

  it('en-US has no empty values', () => {
    enKeys.forEach((key) => {
      const parts = key.split('.')
      let val: unknown = enUS
      for (const p of parts) val = (val as Record<string, unknown>)[p]
      expect(typeof val === 'string' && val.length > 0, `en-US key "${key}" is empty`).toBe(true)
    })
  })

  Object.entries(LOCALES).forEach(([locale, translations]) => {
    it(`${locale} has no MISSING values`, () => {
      const localeKeys = flatKeys(translations as Record<string, unknown>)
      localeKeys.forEach((key) => {
        const parts = key.split('.')
        let val: unknown = translations
        for (const p of parts) val = (val as Record<string, unknown>)[p]
        expect(val, `${locale} key "${key}" is MISSING`).not.toBe('MISSING')
      })
    })

    it(`${locale} has all en-US keys`, () => {
      const localeKeys = new Set(flatKeys(translations as Record<string, unknown>))
      enKeys.forEach((key) => {
        expect(localeKeys.has(key), `${locale} missing key "${key}"`).toBe(true)
      })
    })
  })
})
```

- [ ] **Step 2: Run the test**

```bash
cd /Volumes/Engineering/Icehunter/dune-admin/web && pnpm test --run src/i18n/i18n.test.ts
```

Expected: all tests pass.

---

## Task 15: Run `make verify`

**Files:** No new changes — verification only.

- [ ] **Step 1: Run type-check on frontend**

```bash
cd /Volumes/Engineering/Icehunter/dune-admin/web && pnpm build
```

Expected: builds successfully with no TypeScript errors.

- [ ] **Step 2: Run full verification**

```bash
cd /Volumes/Engineering/Icehunter/dune-admin && make verify
```

Expected: all checks pass (lint, fmt, tests, build).

- [ ] **Step 3: Fix any TypeScript errors**

If `tsc` reports errors on `t()` calls with dynamic keys (e.g. template literals), cast to `never`:

```typescript
t(`battlegroup.actions.${action.cmd}` as never)
```

If errors come from missing JSON keys, add the missing key to `en-US/translation.json` and the corresponding translations to all 9 other locale files.

---

## Amendment: Git Pull Changes (2026-05-31)

A pull landed after the plan was written. Adjust tasks accordingly.

### What changed

- `App.tsx` was refactored: flat `Tabs` nav replaced by grouped `SideNav` using `NAV_GROUPS`
- Three nav groups added: **Operations**, **Player World**, **Economy**
- Label changes: `"Server"` → `"Server Settings"`, `"Market"` → `"Market Bot"`
- New tab: `WelcomePackageTab` with label `"Welcome Kits"` (key: `welcome`)
- `TabPane` helper component added (no strings)

### Updated `nav.*` keys in `en-US/translation.json`

Replace the `"nav"` section from Task 3 with:

```json
"nav": {
  "groups": {
    "operations": "Operations",
    "playerWorld": "Player World",
    "economy": "Economy"
  },
  "battlegroup": "Battlegroup",
  "players": "Players",
  "database": "Database",
  "logs": "Logs",
  "blueprints": "Blueprints",
  "bases": "Bases",
  "storage": "Storage",
  "server": "Server Settings",
  "market": "Market Bot",
  "welcome": "Welcome Kits",
  "adminSections": "Admin sections"
}
```

### Add `welcome.*` keys to `en-US/translation.json`

Append to the JSON root:

```json
"welcome": {
  "title": "Welcome Kits",
  "subtitle": "Auto-grants a configured item package to every player once, on first login.",
  "refresh": "Refresh",
  "configuration": "Configuration",
  "enabledLabel": "Enabled",
  "enabledHint": "— grants the active package to online players who haven't received that version.",
  "activeVersion": "Active version (granted)",
  "activeVersionHint": "what players receive",
  "scanInterval": "Scan interval (seconds)",
  "scanIntervalHint": "min 5s",
  "packages": "Packages",
  "editingVersion": "Editing version",
  "newVersionLabel": "New version name",
  "newVersionPlaceholder": "e.g. v2 or season2",
  "addVersion": "Add Version",
  "noPackageSelected": "No package selected. Add a version to start.",
  "noItemsYet": "No items in this version yet.",
  "addItem": "Add Item",
  "itemTemplatePlaceholder": "Item template (e.g. AluminiumBar)",
  "qtyTitle": "Quantity",
  "qualityTitle": "Quality (0 = base, live RMQ grant)",
  "removeItem": "Remove item",
  "saveConfig": "Save Config",
  "runNow": "Run Now",
  "grantsTitle": "Grant History (last 100)",
  "grantsLabel": "Welcome package grants",
  "noGrants": "No grants yet.",
  "retry": "Retry",
  "columns": {
    "character": "Character",
    "flsId": "FLS ID",
    "version": "Version",
    "status": "Status",
    "tries": "Tries",
    "updated": "Updated",
    "error": "Error"
  },
  "failedToLoad": "Failed to load welcome package: {{message}}",
  "versionExists": "Version \"{{name}}\" already exists",
  "savedEnabled": "Enabled — granting \"{{version}}\" within one scan tick",
  "savedDisabled": "Welcome package saved (disabled)",
  "saveFailed": "Save failed: {{message}}",
  "scanComplete": "Scan complete — granted {{granted}}, failed {{failed}}, skipped {{skipped}}",
  "runFailed": "Run failed: {{message}}",
  "retryCleared": "Cleared — will re-attempt on the next scan",
  "retryFailed": "Retry failed: {{message}}"
}
```

### Updated App.tsx migration (amends Task 5)

The `NAV_GROUPS` array needs to be declared inside `AppCore` (after `const { t } = useTranslation()`) so group titles and labels are translated:

```tsx
const { t } = useTranslation()

const NAV_GROUPS = [
  {
    title: t('nav.groups.operations'),
    items: [
      { key: 'battlegroup' as TabId, label: t('nav.battlegroup') },
      { key: 'logs' as TabId, label: t('nav.logs') },
      { key: 'database' as TabId, label: t('nav.database') },
      { key: 'server' as TabId, label: t('nav.server') },
    ],
  },
  {
    title: t('nav.groups.playerWorld'),
    items: [
      { key: 'players' as TabId, label: t('nav.players') },
      { key: 'storage' as TabId, label: t('nav.storage') },
      { key: 'bases' as TabId, label: t('nav.bases') },
      { key: 'blueprints' as TabId, label: t('nav.blueprints') },
    ],
  },
  {
    title: t('nav.groups.economy'),
    items: [
      { key: 'market' as TabId, label: t('nav.market') },
      { key: 'welcome' as TabId, label: t('nav.welcome') },
    ],
  },
]
```

Remove the module-level `const NAV_GROUPS` constant from the file.

### Task 16: Migrate `WelcomePackageTab.tsx`

**Files:** Modify `web/src/tabs/WelcomePackageTab.tsx`

- [ ] **Step 1: Add `useTranslation` and replace all strings**

Add `import { useTranslation } from 'react-i18next'` and `const { t } = useTranslation()` inside `WelcomePackageTab`.

Key replacements:

| Old | New |
|---|---|
| `title="Welcome Kits"` | `title={t('welcome.title')}` |
| `subtitle="Auto-grants..."` | `subtitle={t('welcome.subtitle')}` |
| `"Refresh"` | `t('welcome.refresh')` |
| `"Configuration"` (SectionLabel) | `t('welcome.configuration')` |
| `"Enabled"` | `t('welcome.enabledLabel')` |
| `"— grants the active package..."` | `t('welcome.enabledHint')` |
| `"Active version (granted)"` (Field label) | `t('welcome.activeVersion')` |
| `"what players receive"` (hint) | `t('welcome.activeVersionHint')` |
| `"Scan interval (seconds)"` (Field label) | `t('welcome.scanInterval')` |
| `"min 5s"` (hint) | `t('welcome.scanIntervalHint')` |
| `"Packages"` (SectionLabel) | `t('welcome.packages')` |
| `"Editing version"` (Field label) | `t('welcome.editingVersion')` |
| `"New version name"` (Field label) | `t('welcome.newVersionLabel')` |
| `placeholder="e.g. v2 or season2"` | `placeholder={t('welcome.newVersionPlaceholder')}` |
| `"Add Version"` | `t('welcome.addVersion')` |
| `"No package selected. Add a version to start."` | `t('welcome.noPackageSelected')` |
| `"No items in this version yet."` | `t('welcome.noItemsYet')` |
| `"Add Item"` | `t('welcome.addItem')` |
| `placeholder="Item template (e.g. AluminiumBar)"` | `placeholder={t('welcome.itemTemplatePlaceholder')}` |
| `title="Quantity"` | `title={t('welcome.qtyTitle')}` |
| `title="Quality (0 = base, live RMQ grant)"` | `title={t('welcome.qualityTitle')}` |
| `aria-label="Remove item"` | `aria-label={t('welcome.removeItem')}` |
| `"Save Config"` | `t('welcome.saveConfig')` |
| `"Run Now"` | `t('welcome.runNow')` |
| `"Grant History (last 100)"` | `t('welcome.grantsTitle')` |
| `aria-label="Welcome package grants"` | `aria-label={t('welcome.grantsLabel')}` |
| `"No grants yet."` | `t('welcome.noGrants')` |
| `"Retry"` | `t('welcome.retry')` |
| GRANT\_COLUMNS labels | `t('welcome.columns.character')` etc. |
| `toast.danger(``Failed to load welcome package: ${msg}``)` | `toast.danger(t('welcome.failedToLoad', { message: msg }))` |
| `toast.danger(``Version "${name}" already exists``)` | `toast.danger(t('welcome.versionExists', { name }))` |
| `toast.success(enabled ? ``Enabled — granting "${activeVersion}"...`` : 'Welcome package saved (disabled)')` | `toast.success(enabled ? t('welcome.savedEnabled', { version: activeVersion }) : t('welcome.savedDisabled'))` |
| `toast.danger(``Save failed: ${msg}``)` | `toast.danger(t('welcome.saveFailed', { message: msg }))` |
| `toast.success(``Scan complete — granted ${r.granted}, failed ${r.failed}, skipped ${r.skipped}``)` | `toast.success(t('welcome.scanComplete', { granted: r.granted, failed: r.failed, skipped: r.skipped }))` |
| `toast.danger(``Run failed: ${msg}``)` | `toast.danger(t('welcome.runFailed', { message: msg }))` |
| `toast.success('Cleared — will re-attempt on the next scan')` | `toast.success(t('welcome.retryCleared'))` |
| `toast.danger(``Retry failed: ${msg}``)` | `toast.danger(t('welcome.retryFailed', { message: msg }))` |

- [ ] **Step 2: Verify no hardcoded user-visible strings remain**

```bash
grep -n '"[A-Z][^"]*"' /Volumes/Engineering/Icehunter/dune-admin/web/src/tabs/WelcomePackageTab.tsx | grep -v "//\|aria-label\|className\|type=\|placeholder\|INPUT_CLS\|data-"
```

Expected: only dynamic/technical strings remain (version names, template IDs).

Also add the `welcome.*` keys to all 9 non-English translation files (Task 13).
