Changes
diff --git a/tavern-translate/app.js b/tavern-translate/app.js
new file mode 100644
index 0000000..68b12e2
--- /dev/null
+++ b/tavern-translate/app.js
@@ -0,0 +1,430 @@
+import { h, render } from 'https://esm.sh/preact';
+import { useState, useEffect } from 'https://esm.sh/preact/hooks';
+import { signal, computed } from 'https://esm.sh/@preact/signals';
+import htm from 'https://esm.sh/htm';
+
+const html = htm.bind(h);
+
+// --- State and Classes ---
+
+class TranslationNode
+{
+ constructor(original_value, is_ignored = false)
+ {
+ this.original_value = original_value;
+ this.translated_value = signal(null);
+ this.status = signal(is_ignored ? 'ignored' : 'pending');
+ this.error = signal(null);
+ }
+}
+
+class TaskQueue
+{
+ constructor(concurrency = 3)
+ {
+ this.concurrency = concurrency;
+ this.queue = [];
+ this.active_count = 0;
+ }
+
+ async add(task)
+ {
+ return new Promise((resolve, reject) =>
+ {
+ this.queue.push({ task, resolve, reject });
+ this.process();
+ });
+ }
+
+ async process()
+ {
+ if(this.active_count >= this.concurrency || this.queue.length === 0)
+ {
+ return;
+ }
+
+ const { task, resolve, reject } = this.queue.shift();
+ this.active_count++;
+
+ try
+ {
+ const result = await task();
+ resolve(result);
+ }
+ catch(err)
+ {
+ reject(err);
+ }
+ finally
+ {
+ this.active_count--;
+ this.process();
+ }
+ }
+}
+
+// --- Global Signals ---
+
+const STORAGE_PREFIX = 'tavern-translator-';
+const getStorage = (key) => localStorage.getItem(STORAGE_PREFIX + key);
+const setStorage = (key, val) => localStorage.setItem(STORAGE_PREFIX + key, val);
+
+const config = signal({
+ api_url: getStorage('api_url') || 'https://api.openai.com/v1/chat/completions',
+ api_key: getStorage('api_key') || '',
+ system_prompt: getStorage('system_prompt') || '作为一名资深翻译家,把用户提供的文本从英文翻译成中文,保持原文的格式和语气。注意 `{{user}}` 和 `{{char}}` 为特殊标记,不要翻译。',
+ ignored_keys: getStorage('ignored_keys') || 'spec,spec_version,avatar,url,id,keys,secondary_keys,creator,version,chub,fav,talkativeness',
+ concurrency: parseInt(getStorage('concurrency') || '3', 10)
+});
+
+const task_queue = new TaskQueue(config.value.concurrency);
+
+// --- Helper Functions ---
+
+function createProcessTree(input_node, ignored_patterns, is_parent_ignored = false)
+{
+ if(Array.isArray(input_node))
+ {
+ return input_node.map(item => createProcessTree(item, ignored_patterns, is_parent_ignored));
+ }
+ else if(input_node !== null && typeof input_node === 'object')
+ {
+ const new_obj = {};
+ for(const [key, value] of Object.entries(input_node))
+ {
+ const is_ignored = is_parent_ignored || ignored_patterns.some(pattern =>
+ {
+ try
+ {
+ return new RegExp(pattern.trim()).test(key);
+ }
+ catch(e)
+ {
+ return false;
+ }
+ });
+ new_obj[key] = createProcessTree(value, ignored_patterns, is_ignored);
+ }
+ return new_obj;
+ }
+ else if(typeof input_node === 'string')
+ {
+ const node = new TranslationNode(input_node, is_parent_ignored);
+ if(!input_node.trim())
+ {
+ node.status.value = 'done';
+ }
+ return node;
+ }
+ return input_node;
+}
+
+function flattenTree(node)
+{
+ if(Array.isArray(node))
+ {
+ return node.map(flattenTree);
+ }
+ else if(node instanceof TranslationNode)
+ {
+ return node.status.value === 'done' ? node.translated_value.value : node.original_value;
+ }
+ else if(node !== null && typeof node === 'object')
+ {
+ const new_obj = {};
+ for(const [key, value] of Object.entries(node))
+ {
+ new_obj[key] = flattenTree(value);
+ }
+ return new_obj;
+ }
+ return node;
+}
+
+async function translateNode(node, force = false)
+{
+ if(node.status.value === 'translating') return;
+ if(!force && node.status.value === 'done') return;
+
+ node.status.value = 'translating';
+ node.error.value = null;
+
+ try
+ {
+ const result = await task_queue.add(async () =>
+ {
+ const response = await fetch(config.value.api_url, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Bearer ${config.value.api_key}`
+ },
+ body: JSON.stringify({
+ model: 'gpt-3.5-turbo',
+ messages: [
+ { role: 'system', content: config.value.system_prompt },
+ { role: 'user', content: node.original_value }
+ ],
+ temperature: 0.3
+ })
+ });
+
+ if(!response.ok)
+ {
+ const error_data = await response.json();
+ throw new Error(error_data.error?.message || 'API Error');
+ }
+
+ const data = await response.json();
+ return data.choices[0].message.content;
+ });
+
+ node.translated_value.value = result;
+ node.status.value = 'done';
+ }
+ catch(err)
+ {
+ node.status.value = 'error';
+ node.error.value = err.message;
+ }
+}
+
+// --- Components ---
+
+function TreeNode({ label, value, depth = 0 })
+{
+ const [is_open, set_is_open] = useState(depth < 2);
+
+ if(value instanceof TranslationNode)
+ {
+ const can_revert = value.status.value === 'done' || value.status.value === 'error';
+ const can_retry = value.status.value === 'done' || value.status.value === 'error';
+
+ return html`
+ <div class="tree-item">
+ <span class="key-name">${label}: </span>
+ <span class="status-badge status-${value.status.value}">${value.status.value}</span>
+ <div class="translation-box">
+ <div class="original-text">${value.original_value}</div>
+ <div class="translated-text">${value.status.value === 'done' ? value.translated_value.value : '(Pending/Original)'}</div>
+ ${value.error.value && html`<div class="error-text" style="color: var(--error-color); font-size: 11px;">${value.error.value}</div>`}
+ <div class="node-actions">
+ <button onclick=${() => translateNode(value)} disabled=${value.status.value === 'done'}>Translate</button>
+ <button class="secondary-btn" onclick=${() => { value.translated_value.value = null; value.status.value = 'pending'; }} disabled=${!can_revert}>Revert</button>
+ <button class="secondary-btn" onclick=${() => translateNode(value, true)} disabled=${!can_retry}>Retry</button>
+ </div>
+ </div>
+ </div>
+ `;
+ }
+
+ if(Array.isArray(value) || (value !== null && typeof value === 'object'))
+ {
+ return html`
+ <div class="tree-node-container">
+ <div class="tree-item" onclick=${() => set_is_open(!is_open)} style="cursor: pointer;">
+ <span class="key-name">${is_open ? '▼' : '▶'} ${label}</span>
+ </div>
+ ${is_open && html`
+ <div class="tree-node">
+ ${Object.entries(value).map(([k, v]) => html`<${TreeNode} label=${k} value=${v} depth=${depth + 1} />`)}
+ </div>
+ `}
+ </div>
+ `;
+ }
+
+ return html`
+ <div class="tree-item">
+ <span class="key-name">${label}: </span>
+ <span>${String(value)}</span>
+ </div>
+ `;
+}
+
+function OptionsDialog({ close })
+{
+ const [local_config, set_local_config] = useState({ ...config.value });
+
+ const save = () =>
+ {
+ config.value = local_config;
+ Object.entries(local_config).forEach(([k, v]) => setStorage(k, v));
+ task_queue.concurrency = local_config.concurrency;
+ close();
+ };
+
+ return html`
+ <div class="modal-overlay">
+ <div class="modal-content">
+ <h3>Options</h3>
+ <div class="form-group">
+ <label>API Endpoint URL</label>
+ <input type="text" value=${local_config.api_url} oninput=${(e) => set_local_config({ ...local_config, api_url: e.target.value })} />
+ </div>
+ <div class="form-group">
+ <label>API Key</label>
+ <input type="password" value=${local_config.api_key} oninput=${(e) => set_local_config({ ...local_config, api_key: e.target.value })} />
+ </div>
+ <div class="form-group">
+ <label>Concurrency (Max active requests)</label>
+ <input type="number" min="1" max="20" value=${local_config.concurrency} oninput=${(e) => set_local_config({ ...local_config, concurrency: parseInt(e.target.value, 10) })} />
+ </div>
+ <div class="form-group">
+ <label>System Prompt</label>
+ <textarea rows="4" value=${local_config.system_prompt} oninput=${(e) => set_local_config({ ...local_config, system_prompt: e.target.value })}></textarea>
+ </div>
+ <div class="form-group">
+ <label>Ignored Keys (Regex, one per line)</label>
+ <textarea rows="4" value=${local_config.ignored_keys.split(',').join('\n')} oninput=${(e) => set_local_config({ ...local_config, ignored_keys: e.target.value.split('\n').join(',') })}></textarea>
+ </div>
+ <div class="modal-actions">
+ <button class="secondary-btn" onclick=${close}>Cancel</button>
+ <button onclick=${save}>Save</button>
+ </div>
+ </div>
+ </div>
+ `;
+}
+
+function App()
+{
+ const [show_options_modal, set_show_options_modal] = useState(false);
+ const [tree, set_tree] = useState(null);
+ const [translating, set_translating] = useState(false);
+ const [json_input, set_json_input] = useState('');
+ const [progress, set_progress] = useState({ current: 0, total: 0 });
+
+ const handleFileUpload = (e) =>
+ {
+ const file = e.target.files[0];
+ if(!file) return;
+
+ const reader = new FileReader();
+ reader.onload = (event) =>
+ {
+ set_json_input(event.target.result);
+ loadJson(event.target.result);
+ };
+ reader.readAsText(file);
+ };
+
+ const loadJson = async (input = json_input) =>
+ {
+ try
+ {
+ const parsed = JSON.parse(input);
+ const patterns = config.value.ignored_keys.split(',').filter(p => p.trim());
+ const new_tree = createProcessTree(parsed, patterns);
+ set_tree(new_tree);
+
+ // Start translation immediately
+ await startAutoTranslate(new_tree);
+ }
+ catch(err)
+ {
+ alert('Invalid JSON: ' + err.message);
+ }
+ };
+
+ const startAutoTranslate = async (current_tree) =>
+ {
+ const target_tree = current_tree || tree;
+ if(!target_tree) return;
+
+ set_translating(true);
+
+ const nodes = [];
+ const findNodes = (obj) =>
+ {
+ if(obj instanceof TranslationNode)
+ {
+ if(obj.status.value === 'pending') nodes.push(obj);
+ }
+ else if(Array.isArray(obj))
+ {
+ obj.forEach(findNodes);
+ }
+ else if(obj !== null && typeof obj === 'object')
+ {
+ Object.values(obj).forEach(findNodes);
+ }
+ };
+
+ findNodes(target_tree);
+
+ set_progress({ current: 0, total: nodes.length });
+
+ let completed = 0;
+ const tasks = nodes.map(async (node) =>
+ {
+ await translateNode(node);
+ completed++;
+ set_progress({ current: completed, total: nodes.length });
+ });
+
+ await Promise.all(tasks);
+ set_translating(false);
+ };
+
+ const downloadResult = () =>
+ {
+ const flattened = flattenTree(tree);
+ const blob = new Blob([JSON.stringify(flattened, null, 4)], { type: 'application/json' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = 'translated_card.json';
+ a.click();
+ };
+
+ const progress_percent = progress.total > 0 ? (progress.current / progress.total) * 100 : 0;
+
+ return html`
+ <div>
+ <header>
+ <h1>SillyTavern Card Translator</h1>
+ <button class="secondary-btn" onclick=${() => set_show_options_modal(true)}>Options</button>
+ </header>
+
+ <main>
+ <div class="input-section">
+ <div class="form-group">
+ <label>Upload SillyTavern JSON</label>
+ <input type="file" accept=".json" onchange=${handleFileUpload} />
+ </div>
+ <div class="form-group">
+ <label>Or Paste JSON</label>
+ <textarea rows="5" value=${json_input} oninput=${(e) => set_json_input(e.target.value)}></textarea>
+ </div>
+ <button onclick=${() => loadJson()}>Load and Translate</button>
+ </div>
+
+ ${tree && html`
+ <div class="controls">
+ ${translating && html`
+ <div style="flex-grow: 1;">
+ <div class="progress-container">
+ <div class="progress-bar" style="width: ${progress_percent}%"></div>
+ <div class="progress-text">${progress.current} / ${progress.total}</div>
+ </div>
+ </div>
+ `}
+ <button onclick=${() => startAutoTranslate()} disabled=${translating}>
+ ${translating ? 'Translating...' : 'Retry All Pending'}
+ </button>
+ <button class="secondary-btn" onclick=${downloadResult} disabled=${translating}>Download JSON</button>
+ </div>
+
+ <div class="tree-container">
+ <${TreeNode} label="Root" value=${tree} />
+ </div>
+ `}
+ </main>
+
+ ${show_options_modal && html`<${OptionsDialog} close=${() => set_show_options_modal(false)} />`}
+ </div>
+ `;
+}
+
+render(html`<${App} />`, document.getElementById('app'));
diff --git a/tavern-translate/designs/design-0-translator.md b/tavern-translate/designs/design-0-translator.md
new file mode 100644
index 0000000..4a22b2c
--- /dev/null
+++ b/tavern-translate/designs/design-0-translator.md
@@ -0,0 +1,182 @@
+# Design Document: SillyTavern Character Card Translator
+
+## 1. Overview
+This document describes the design and implementation of a pure front-end web application intended to translate SillyTavern character cards (JSON format) from one language to another using OpenAI-compatible LLM APIs.
+
+The application is designed to be highly portable, requiring no build process and capable of being served from any static file server.
+
+## 2. Technical Stack
+
+### 2.1 Preact.js (without Build Process)
+We use [Preact](https://preactjs.com/) as the UI library for its small footprint and compatibility with React. To avoid a build process (like Webpack or Vite), we utilize [htm](https://github.com/developit/htm) (Hyperscript Tagged Markup).
+
+- **Script Loading**: Preact and htm will be loaded via ESM (ECMAScript Modules) from a CDN (e.g., [esm.sh](https://esm.sh/)).
+- **Syntax**: We will use `html` tagged templates instead of JSX.
+ ```javascript
+ import { h, render } from 'https://esm.sh/preact';
+ import htm from 'https://esm.sh/htm';
+ const html = htm.bind(h);
+ ```
+
+### 2.2 Styling
+No external CSS frameworks like Bootstrap or Tailwind will be used, as per the workspace mandates.
+- **CSS**: Custom Vanilla CSS will be used, written in a separate `style.css` file or embedded within the HTML.
+- **Design Principles**: Focus on accessibility, clean typography, and responsive layouts.
+
+### 2.3 Storage
+- **localStorage**: Used to persist configuration options (API keys, endpoints, prompts, ignored keys).
+
+## 3. Architecture and Data Structures
+
+### 3.1 Intermediate Data Representation
+To support "revert" and "retry" functionality, we cannot simply mutate the JSON strings in place. We must maintain a mapping between the original and translated content.
+
+We will define a class (or a structure) `TranslationNode`:
+```javascript
+class TranslationNode
+{
+ constructor(original_value)
+ {
+ this.original_value = original_value;
+ this.translated_value = null;
+ this.status = 'pending'; // 'pending', 'translating', 'done', 'error'
+ }
+}
+```
+
+### 3.2 Application State
+The global state will manage:
+1. **Configuration**: API credentials and preferences.
+2. **Source Data**: The original JSON object.
+3. **Process Tree**: A mirrored object tree where leaf strings are replaced by `TranslationNode` instances.
+
+## 4. Translation Logic
+
+### 4.1 Recursive Traversal and State Mirroring
+The application will transform the input JSON into a reactive state tree. This process is crucial because it converts static strings into interactive objects.
+
+**Algorithm: `createProcessTree(input_node, ignored_patterns)`**
+1. **Input**: A JavaScript value (Object, Array, String, Number, Boolean, or Null).
+2. **Logic**:
+ - If `input_node` is an **Array**:
+ - Create a new reactive array (e.g., a signal-wrapped array).
+ - Iterate through each element and recursively call `createProcessTree` for each.
+ - If `input_node` is an **Object**:
+ - Create a new reactive object.
+ - Iterate through each `key-value` pair.
+ - Check if `key` matches any regex in `ignored_patterns`.
+ - If it matches, recursively call `createProcessTree` for the value, but pass a flag to mark all downstream nodes as `ignored`.
+ - Otherwise, recursively call `createProcessTree` for the value.
+ - If `input_node` is a **String**:
+ - If the `ignored` flag is set, return a `TranslationNode` with `status: 'ignored'`.
+ - Otherwise, return a new `TranslationNode(input_node)`.
+ - For all other types: Return the value as-is.
+
+### 4.2 OpenAI API Integration Details
+The `translateText` function must handle the specific schema of OpenAI-compatible APIs (like GPT-4 or local models like Ollama/vLLM).
+
+**Request Structure**:
+```json
+{
+ "model": "gpt-3.5-turbo",
+ "messages": [
+ {"role": "system", "content": "System prompt from settings..."},
+ {"role": "user", "content": "Text to translate..."}
+ ],
+ "temperature": 0.3
+}
+```
+
+**Response Handling**:
+- Success: Extract `choices[0].message.content`.
+- Error: Handle `401` (Unauthorized), `429` (Rate Limit), and `500` (Server Error). Update the `TranslationNode.status` to `'error'` and store the error message for display.
+
+**Concurrency**: To avoid browser-level hang-ups or API rate limits, we use a task queue pattern. A `TaskQueue` class will manage pending translations, ensuring only `N` (e.g., 3) requests are active at once.
+
+**TaskQueue Implementation Details**:
+1. **Queue Storage**: A private array `this._queue` stores objects containing the `text` and a `resolve/reject` pair.
+2. **Execution Loop**: A `process()` method that:
+ - Checks if the number of active requests is less than the limit.
+ - If so, shifts a task from the queue.
+ - Increments the `active_count`.
+ - Executes the `fetch` request.
+ - Upon completion (success or failure), decrements `active_count` and calls `process()` again to trigger the next task.
+3. **Usage**: `const translated = await task_queue.add(() => translateText(node.original_value))`.
+
+## 5. UI Components
+
+### 5.1 Main Layout
+A simple container with:
+- **Header**: Title and "Options" button.
+- **Input Area**: A file upload or text area for the raw JSON.
+- **Control Bar**: "Start Translation", "Download Result" buttons.
+- **Main View**: Toggleable between the raw input and the Fine-tuning Tree View.
+
+### 5.2 Options Dialog
+A modal containing:
+- **API URL**: e.g., `https://api.openai.com/v1/chat/completions`.
+- **API Key**: Password field for the key.
+- **System Prompt**: Multi-line text area.
+- **Ignored Keys**: A list of strings, each treated as a regex.
+- **Save/Close**: Persists to `localStorage`.
+
+### 5.3 Fine-tuning Tree View Implementation
+The Tree View is a recursive component: `TreeNode({ key, value, depth })`.
+
+- **Collapsible Folders**: If `value` is an Object or Array, render a toggle icon to collapse/expand children. Use a `depth` variable to apply CSS `padding-left`.
+- **String Node Rendering**:
+ - If `value` is a `TranslationNode`:
+ - Render the `original_value` in a dimmed style.
+ - Render a text area or div containing the `translated_value` (if present) or `original_value` (if pending).
+ - **Action Buttons**:
+ - `Translate`: Sets `status` to `'translating'`, calls `translateText`, then updates `translated_value` and sets `status` to `'done'`.
+ - `Revert`: Sets `translated_value` to `null` and `status` to `'pending'`.
+ - `Retry`: Same as `Translate`, but ignores the "already translated" check.
+
+### 5.4 Global State Management (Signals)
+Since we are using Preact, we will use [@preact/signals](https://preactjs.com/guide/v10/signals/) for state management. This allows fine-grained updates; clicking "Retry" on a single leaf will only re-render that specific `TreeNode` component, not the entire tree.
+
+- `config`: A signal holding the settings object.
+- `processTree`: A signal holding the root of the mirrored JSON tree.
+- `isTranslating`: A boolean signal to disable global controls during batch processing.
+
+## 6. Detailed Implementation Steps
+
+### Step 1: Basic Scaffolding
+1. Create `index.html`.
+2. Import Preact and htm.
+3. Create a basic "Hello World" component to verify the environment.
+
+### Step 2: Configuration Management
+1. Implement `Settings` service to read/write to `localStorage`.
+2. Build the `OptionsDialog` component.
+
+### Step 3: JSON Processing Engine
+1. Write a function `preprocessJson(obj, path, ignored_patterns)` that returns a tree of `TranslationNode` objects.
+2. Implement the recursive logic for `ignored_keys`.
+
+### Step 4: Translation Service
+1. Implement `translateText(text, settings)` which calls the LLM API.
+2. Handle error states and network timeouts.
+
+### Step 5: Tree View Component
+1. Build a recursive `TreeNode` component.
+2. Connect the `Translate`, `Revert`, and `Retry` buttons to the state.
+
+### Step 6: Export Logic
+1. Implement a function to "flatten" the `TranslationNode` tree back into a standard JSON string for download.
+
+## 7. Coding Standards (Per GEMINI.md)
+
+- **Indentation**: 4 spaces.
+- **Braces**: Left brace on a new line.
+- **Naming**:
+ - Variables: `snake_case` (e.g., `api_key`).
+ - Functions: `camelCase` (e.g., `handleFileUpload`).
+ - Classes: `CapCase` (e.g., `TranslationManager`).
+- **Logic**: Use `unique_ptr` logic for state (ensure single source of truth).
+
+## 8. Testing Strategy
+- **Unit Tests**: Test the regex filtering logic with various SillyTavern card samples.
+- **Integration Tests**: Mock the API response to test the UI's handling of translation results and errors.
+- **Manual Verification**: Upload a real character card and verify the tree view renders correctly.
diff --git a/tavern-translate/index.html b/tavern-translate/index.html
new file mode 100644
index 0000000..8396339
--- /dev/null
+++ b/tavern-translate/index.html
@@ -0,0 +1,13 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>SillyTavern Card Translator</title>
+ <link rel="stylesheet" href="style.css">
+</head>
+<body>
+ <div id="app"></div>
+ <script type="module" src="app.js"></script>
+</body>
+</html>
diff --git a/tavern-translate/prd.md b/tavern-translate/prd.md
new file mode 100644
index 0000000..39be35a
--- /dev/null
+++ b/tavern-translate/prd.md
@@ -0,0 +1,73 @@
+# Translator for SillyTavern Cards
+
+This is a pure front-end web app to translate (e.g. from English to
+Chinese) SillyTavern character cards.
+
+## Tech stack
+
+- Use preact.js. The app should be written in a way that doesn’t
+ require a build process. The user should be able to simply host the
+ files as static files in an HTTP server and use it.
+- No style-related external libraries (e.g. bootstrap).
+
+## The translation process
+
+A SillyTavern character card is basically just a piece of JSON code.
+The process of tranlate such a character card is the following:
+
+1. Parse the JSON into JavaScript object
+2. Iterate over all the “leaf” values in this object. If the value is
+ a string, it should be translated.
+3. For each “leaf” string value, the app should use an
+ OpenAI-compatible LLM API to translate it, and replace the value
+ with the translated value. These requests should be managed by a
+ task queue to handle concurrency and prevent overloading the API
+ or the browser.
+4. After all “leaf” string values are translated, provide the JSON as
+ a string to the user.
+
+## Details
+
+- The app should have an option dialog.
+
+- The options should include the LLM prompt.
+
+- The endpoint URL of the LLM API, the model name, and the API key are
+ also configurable in the option dialog.
+
+- There should be a list of “ignored keys”, which is a list of regular
+ expressions. In the process of iterating over the “leaf” values, if
+ the any of patterns in the ignored keys is found in the key, all the
+ values under the key should not be translated. Note that the key
+ does not have to be the key of the “leaf” value; it could be a key
+ in an intermediate layer. The ignored keys should be configurable in
+ the option dialog.
+
+- The number of concurrent translations in the task queue should also
+ be configurable in the option dialog.
+
+- The options are saved in the browser’s local storage.
+
+- After the translation is done. There should be an interface that
+ layout the JSON as a tree on the UI. Each string value will be
+ accompanied by three buttons: translate, revert, or retry. This
+ interface will allow the user to finetune the translation result.
+
+ * If the user clicks the “translate” button, and the value was not
+ translated at that point (maybe because it was ignored by an
+ ignored key), the app should translate the value. If the value is
+ already translated, the “translate” button does nothing.
+
+ * If the user clicks the “revert” button, and the value was
+ translated, it should revert the value to the original value. If
+ that value was not translated, this button does nothing.
+
+ * If the user clicks the “retry” button, and the value was
+ translated, the app should redo the translation on that value. If
+ the value was not translated, this button does nothing.
+
+- Because of this interface, it is recommended to create an
+ intermediate type (maybe a JavaScript class), which stores both the
+ original value, and the translated value. In the beginning of the
+ translation process, the string values in the JSON should be
+ replaced by an object of this type.
diff --git a/tavern-translate/style.css b/tavern-translate/style.css
new file mode 100644
index 0000000..6828526
--- /dev/null
+++ b/tavern-translate/style.css
@@ -0,0 +1,249 @@
+:root
+{
+ --bg-color: #1a1a1a;
+ --text-color: #e0e0e0;
+ --accent-color: #4a90e2;
+ --secondary-bg: #2d2d2d;
+ --border-color: #444;
+ --success-color: #4caf50;
+ --error-color: #f44336;
+ --pending-color: #ff9800;
+}
+
+body
+{
+ background-color: var(--bg-color);
+ color: var(--text-color);
+ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
+ margin: 0;
+ padding: 0;
+ line-height: 1.6;
+}
+
+#app
+{
+ max-width: 1000px;
+ margin: 0 auto;
+ padding: 20px;
+}
+
+header
+{
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ border-bottom: 1px solid var(--border-color);
+ margin-bottom: 20px;
+ padding-bottom: 10px;
+}
+
+button
+{
+ background-color: var(--accent-color);
+ color: white;
+ border: none;
+ padding: 8px 16px;
+ border-radius: 4px;
+ cursor: pointer;
+ font-size: 14px;
+ transition: opacity 0.2s;
+}
+
+button:hover
+{
+ opacity: 0.8;
+}
+
+button:disabled
+{
+ background-color: #666;
+ cursor: not-allowed;
+}
+
+.secondary-btn
+{
+ background-color: transparent;
+ border: 1px solid var(--accent-color);
+ color: var(--accent-color);
+}
+
+.controls
+{
+ display: flex;
+ gap: 10px;
+ margin-bottom: 20px;
+}
+
+.input-section
+{
+ background-color: var(--secondary-bg);
+ padding: 20px;
+ border-radius: 8px;
+ margin-bottom: 20px;
+}
+
+textarea
+{
+ width: 100%;
+ background-color: #121212;
+ color: #ccc;
+ border: 1px solid var(--border-color);
+ border-radius: 4px;
+ padding: 10px;
+ font-family: monospace;
+ resize: vertical;
+}
+
+/* Tree View Styling */
+.tree-container
+{
+ background-color: var(--secondary-bg);
+ border-radius: 8px;
+ padding: 20px;
+ overflow-x: auto;
+}
+
+.tree-node
+{
+ margin-left: 20px;
+ border-left: 1px solid #444;
+ padding-left: 10px;
+}
+
+.tree-item
+{
+ margin: 5px 0;
+}
+
+.key-name
+{
+ font-weight: bold;
+ color: var(--accent-color);
+}
+
+.translation-box
+{
+ background-color: #222;
+ padding: 10px;
+ border-radius: 4px;
+ margin-top: 5px;
+}
+
+.original-text
+{
+ font-size: 12px;
+ color: #888;
+ margin-bottom: 5px;
+ white-space: pre-wrap;
+}
+
+.translated-text
+{
+ white-space: pre-wrap;
+}
+
+.node-actions
+{
+ margin-top: 5px;
+ display: flex;
+ gap: 5px;
+}
+
+.node-actions button
+{
+ padding: 4px 8px;
+ font-size: 11px;
+}
+
+.status-badge
+{
+ display: inline-block;
+ padding: 2px 6px;
+ border-radius: 3px;
+ font-size: 10px;
+ margin-left: 10px;
+ text-transform: uppercase;
+}
+
+.status-pending { background-color: var(--pending-color); }
+.status-translating { background-color: var(--accent-color); }
+.status-done { background-color: var(--success-color); }
+.status-error { background-color: var(--error-color); }
+.status-ignored { background-color: #555; }
+
+/* Modal Styling */
+.modal-overlay
+{
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background-color: rgba(0,0,0,0.8);
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ z-index: 1000;
+}
+
+.modal-content
+{
+ background-color: var(--secondary-bg);
+ padding: 30px;
+ border-radius: 8px;
+ width: 90%;
+ max-width: 600px;
+}
+
+.form-group
+{
+ margin-bottom: 15px;
+}
+
+.form-group label
+{
+ display: block;
+ margin-bottom: 5px;
+}
+
+.form-group input, .form-group textarea
+{
+ width: 100%;
+ box-sizing: border-box;
+}
+
+.progress-container
+{
+ background-color: #333;
+ border-radius: 10px;
+ height: 20px;
+ width: 100%;
+ margin-bottom: 20px;
+ overflow: hidden;
+ border: 1px solid var(--border-color);
+ position: relative;
+}
+
+.progress-bar
+{
+ background-color: var(--success-color);
+ height: 100%;
+ transition: width 0.3s ease;
+}
+
+.progress-text
+{
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 12px;
+ font-weight: bold;
+ color: white;
+ text-shadow: 1px 1px 2px rgba(0,0,0,0.8);
+ pointer-events: none;
+}