Daryl Lim Claude Opus 4.6 commited on
Commit
78c8423
·
1 Parent(s): 9c55188

docs: add UI improvements implementation plan

Browse files

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

docs/plans/2026-03-04-ui-improvements-plan.md ADDED
@@ -0,0 +1,563 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # UI Improvements Implementation Plan
2
+
3
+ > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
4
+
5
+ **Goal:** Add a sidebar with generation parameters and a tabbed Single/Batch translation interface to `app.py`.
6
+
7
+ **Architecture:** Refactor `_build_demo()` into a two-column `gr.Blocks` layout. Left sidebar holds the target language dropdown and three generation parameter sliders. Right main area uses `gr.Tab` for Single (existing textbox flow) and Batch (file upload/download flow). The `translate()` function gains three new parameters. A new `translate_batch()` function handles file parsing and per-row translation with progress.
8
+
9
+ **Tech Stack:** Gradio (`gr.Blocks`, `gr.Tab`, `gr.Column`, `gr.Slider`, `gr.File`), Python `csv` module, `tempfile` for batch output files.
10
+
11
+ ---
12
+
13
+ ### Task 1: Refactor `translate()` to accept generation parameters
14
+
15
+ **Files:**
16
+ - Modify: `app.py:49-65` (the `translate` function)
17
+ - Test: `tests/test_app.py`
18
+
19
+ **Step 1: Write the failing test**
20
+
21
+ Add to `tests/test_app.py` after the existing fast tests:
22
+
23
+ ```python
24
+ def test_translate_accepts_generation_params():
25
+ """translate() signature must accept generation parameters."""
26
+ import inspect
27
+ import app
28
+
29
+ sig = inspect.signature(app.translate)
30
+ params = list(sig.parameters.keys())
31
+ assert "max_new_tokens" in params
32
+ assert "num_beams" in params
33
+ assert "temperature" in params
34
+ ```
35
+
36
+ **Step 2: Run test to verify it fails**
37
+
38
+ Run: `pytest tests/test_app.py::test_translate_accepts_generation_params -v`
39
+ Expected: FAIL — current `translate()` only has `text` and `target_language_name`
40
+
41
+ **Step 3: Write minimal implementation**
42
+
43
+ Replace the `translate` function in `app.py` (lines 49–65):
44
+
45
+ ```python
46
+ @spaces.GPU
47
+ def translate(
48
+ text: str,
49
+ target_language_name: str,
50
+ max_new_tokens: int = 512,
51
+ num_beams: int = 1,
52
+ temperature: float = 1.0,
53
+ ) -> str:
54
+ tokenizer = _load_tokenizer()
55
+ model = _load_model()
56
+ device = model.device
57
+
58
+ name_to_code, _ = _build_language_mappings()
59
+ target_code = name_to_code.get(target_language_name)
60
+ if target_code is None:
61
+ raise ValueError(f"Unsupported language: {target_language_name}")
62
+
63
+ if num_beams > 1 and temperature != 1.0:
64
+ gr.Info("Temperature has no effect when beam search is enabled (num_beams > 1).")
65
+
66
+ input_ids = tokenizer(target_code + " " + text, return_tensors="pt").input_ids.to(device)
67
+
68
+ generate_kwargs: dict = {"input_ids": input_ids, "max_new_tokens": max_new_tokens, "num_beams": num_beams}
69
+ if num_beams == 1:
70
+ generate_kwargs["do_sample"] = True
71
+ generate_kwargs["temperature"] = temperature
72
+
73
+ outputs = model.generate(**generate_kwargs)
74
+ result = tokenizer.decode(outputs[0], skip_special_tokens=True)
75
+ if not isinstance(result, str):
76
+ raise TypeError(f"Expected str from decode, got {type(result)}")
77
+ return result
78
+ ```
79
+
80
+ Key details:
81
+ - `temperature` only applies when `num_beams == 1` (sampling mode). With beam search, HF ignores temperature — we skip it and show `gr.Info()`.
82
+ - `do_sample=True` is required for temperature to take effect. We only set it in sampling mode.
83
+
84
+ **Step 4: Run test to verify it passes**
85
+
86
+ Run: `pytest tests/test_app.py::test_translate_accepts_generation_params -v`
87
+ Expected: PASS
88
+
89
+ **Step 5: Run linter**
90
+
91
+ Run: `ruff check app.py tests/test_app.py`
92
+ Expected: No errors
93
+
94
+ **Step 6: Commit**
95
+
96
+ ```bash
97
+ git add app.py tests/test_app.py
98
+ git commit -m "feat: add generation parameters to translate function"
99
+ ```
100
+
101
+ ---
102
+
103
+ ### Task 2: Add batch file parsing helpers
104
+
105
+ **Files:**
106
+ - Modify: `app.py` (add two helper functions after `translate`)
107
+ - Test: `tests/test_app.py`
108
+
109
+ **Step 1: Write the failing tests**
110
+
111
+ Add to `tests/test_app.py`:
112
+
113
+ ```python
114
+ import csv
115
+ import tempfile
116
+ from pathlib import Path
117
+
118
+
119
+ def _make_txt_file(lines: list[str]) -> str:
120
+ """Create a temp .txt file and return its path."""
121
+ f = tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False)
122
+ f.write("\n".join(lines))
123
+ f.close()
124
+ return f.name
125
+
126
+
127
+ def _make_csv_file(rows: list[dict]) -> str:
128
+ """Create a temp .csv file and return its path."""
129
+ f = tempfile.NamedTemporaryFile(mode="w", suffix=".csv", delete=False, newline="")
130
+ writer = csv.DictWriter(f, fieldnames=rows[0].keys())
131
+ writer.writeheader()
132
+ writer.writerows(rows)
133
+ f.close()
134
+ return f.name
135
+
136
+
137
+ def test_parse_txt_file():
138
+ import app
139
+
140
+ path = _make_txt_file(["Hello", "World", "Test"])
141
+ texts, original_data = app._parse_input_file(path)
142
+ assert texts == ["Hello", "World", "Test"]
143
+ assert original_data is None
144
+ Path(path).unlink()
145
+
146
+
147
+ def test_parse_csv_file():
148
+ import app
149
+
150
+ path = _make_csv_file([{"id": "1", "text": "Hello"}, {"id": "2", "text": "World"}])
151
+ texts, original_data = app._parse_input_file(path)
152
+ assert texts == ["Hello", "World"]
153
+ assert original_data is not None
154
+ assert len(original_data) == 2
155
+ assert original_data[0]["id"] == "1"
156
+ Path(path).unlink()
157
+
158
+
159
+ def test_parse_csv_missing_text_column():
160
+ import app
161
+
162
+ path = _make_csv_file([{"id": "1", "content": "Hello"}])
163
+ try:
164
+ app._parse_input_file(path)
165
+ assert False, "Should have raised ValueError"
166
+ except ValueError as e:
167
+ assert "text" in str(e).lower()
168
+ finally:
169
+ Path(path).unlink()
170
+
171
+
172
+ def test_write_txt_output():
173
+ import app
174
+
175
+ output_path = app._write_output_file(["Bonjour", "Monde"], None, ".txt")
176
+ content = Path(output_path).read_text()
177
+ assert content == "Bonjour\nMonde"
178
+ Path(output_path).unlink()
179
+
180
+
181
+ def test_write_csv_output():
182
+ import app
183
+
184
+ original_data = [{"id": "1", "text": "Hello"}, {"id": "2", "text": "World"}]
185
+ output_path = app._write_output_file(["Bonjour", "Monde"], original_data, ".csv")
186
+ with open(output_path, newline="") as f:
187
+ reader = list(csv.DictReader(f))
188
+ assert len(reader) == 2
189
+ assert reader[0]["translation"] == "Bonjour"
190
+ assert reader[0]["id"] == "1"
191
+ assert reader[1]["translation"] == "Monde"
192
+ Path(output_path).unlink()
193
+ ```
194
+
195
+ **Step 2: Run tests to verify they fail**
196
+
197
+ Run: `pytest tests/test_app.py::test_parse_txt_file tests/test_app.py::test_parse_csv_file tests/test_app.py::test_parse_csv_missing_text_column tests/test_app.py::test_write_txt_output tests/test_app.py::test_write_csv_output -v`
198
+ Expected: FAIL — `_parse_input_file` and `_write_output_file` don't exist
199
+
200
+ **Step 3: Write minimal implementation**
201
+
202
+ Add these imports to the top of `app.py`:
203
+
204
+ ```python
205
+ import csv
206
+ import tempfile
207
+ from pathlib import Path
208
+ ```
209
+
210
+ Add these functions after the `translate` function in `app.py`:
211
+
212
+ ```python
213
+ def _parse_input_file(file_path: str) -> tuple[list[str], list[dict] | None]:
214
+ """Parse a .txt or .csv file into a list of texts to translate.
215
+
216
+ Returns (texts, original_data). original_data is None for .txt files,
217
+ or a list of row dicts for .csv files (used to preserve extra columns in output).
218
+ """
219
+ path = Path(file_path)
220
+ ext = path.suffix.lower()
221
+
222
+ if ext == ".txt":
223
+ texts = [line for line in path.read_text().splitlines() if line.strip()]
224
+ return texts, None
225
+
226
+ if ext == ".csv":
227
+ with open(path, newline="") as f:
228
+ reader = csv.DictReader(f)
229
+ if reader.fieldnames is None or "text" not in reader.fieldnames:
230
+ raise ValueError("CSV file must have a 'text' column.")
231
+ rows = list(reader)
232
+ texts = [row["text"] for row in rows]
233
+ return texts, rows
234
+
235
+ raise ValueError(f"Unsupported file type: {ext}. Use .txt or .csv.")
236
+
237
+
238
+ def _write_output_file(translations: list[str], original_data: list[dict] | None, ext: str) -> str:
239
+ """Write translation results to a temp file and return its path."""
240
+ if ext == ".txt":
241
+ f = tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False)
242
+ f.write("\n".join(translations))
243
+ f.close()
244
+ return f.name
245
+
246
+ # CSV: append translation column to original data
247
+ f = tempfile.NamedTemporaryFile(mode="w", suffix=".csv", delete=False, newline="")
248
+ assert original_data is not None
249
+ fieldnames = list(original_data[0].keys()) + ["translation"]
250
+ writer = csv.DictWriter(f, fieldnames=fieldnames)
251
+ writer.writeheader()
252
+ for row, translation in zip(original_data, translations):
253
+ row["translation"] = translation
254
+ writer.writerow(row)
255
+ f.close()
256
+ return f.name
257
+ ```
258
+
259
+ **Step 4: Run tests to verify they pass**
260
+
261
+ Run: `pytest tests/test_app.py::test_parse_txt_file tests/test_app.py::test_parse_csv_file tests/test_app.py::test_parse_csv_missing_text_column tests/test_app.py::test_write_txt_output tests/test_app.py::test_write_csv_output -v`
262
+ Expected: PASS
263
+
264
+ **Step 5: Run linter**
265
+
266
+ Run: `ruff check app.py tests/test_app.py`
267
+ Expected: No errors
268
+
269
+ **Step 6: Commit**
270
+
271
+ ```bash
272
+ git add app.py tests/test_app.py
273
+ git commit -m "feat: add batch file parsing and output helpers"
274
+ ```
275
+
276
+ ---
277
+
278
+ ### Task 3: Add `translate_batch()` function
279
+
280
+ **Files:**
281
+ - Modify: `app.py` (add `translate_batch` after `_write_output_file`)
282
+ - Test: `tests/test_app.py` (slow test only — calls the model)
283
+
284
+ **Step 1: Write the failing test**
285
+
286
+ Add to `tests/test_app.py` fast tests:
287
+
288
+ ```python
289
+ def test_translate_batch_exists():
290
+ import app
291
+
292
+ assert callable(app.translate_batch)
293
+ ```
294
+
295
+ **Step 2: Run test to verify it fails**
296
+
297
+ Run: `pytest tests/test_app.py::test_translate_batch_exists -v`
298
+ Expected: FAIL
299
+
300
+ **Step 3: Write minimal implementation**
301
+
302
+ Add after `_write_output_file` in `app.py`:
303
+
304
+ ```python
305
+ @spaces.GPU
306
+ def translate_batch(
307
+ file,
308
+ target_language_name: str,
309
+ max_new_tokens: int = 512,
310
+ num_beams: int = 1,
311
+ temperature: float = 1.0,
312
+ progress=gr.Progress(),
313
+ ) -> str:
314
+ if file is None:
315
+ raise gr.Error("Please upload a file first.")
316
+
317
+ file_path = file.name if hasattr(file, "name") else str(file)
318
+ ext = Path(file_path).suffix.lower()
319
+ texts, original_data = _parse_input_file(file_path)
320
+
321
+ if not texts:
322
+ raise gr.Error("File is empty or contains no text to translate.")
323
+
324
+ if num_beams > 1 and temperature != 1.0:
325
+ gr.Info("Temperature has no effect when beam search is enabled (num_beams > 1).")
326
+
327
+ translations = []
328
+ for i, text in enumerate(progress.tqdm(texts, desc="Translating")):
329
+ result = translate(text, target_language_name, max_new_tokens, num_beams, temperature)
330
+ translations.append(result)
331
+
332
+ output_path = _write_output_file(translations, original_data, ext)
333
+ return output_path
334
+ ```
335
+
336
+ **Step 4: Run test to verify it passes**
337
+
338
+ Run: `pytest tests/test_app.py::test_translate_batch_exists -v`
339
+ Expected: PASS
340
+
341
+ **Step 5: Run linter**
342
+
343
+ Run: `ruff check app.py tests/test_app.py`
344
+ Expected: No errors
345
+
346
+ **Step 6: Commit**
347
+
348
+ ```bash
349
+ git add app.py tests/test_app.py
350
+ git commit -m "feat: add translate_batch function for file-based translation"
351
+ ```
352
+
353
+ ---
354
+
355
+ ### Task 4: Rebuild the UI layout with sidebar and tabs
356
+
357
+ **Files:**
358
+ - Modify: `app.py:68-115` (the `_build_demo` function)
359
+ - Test: `tests/test_app.py`
360
+
361
+ **Step 1: Write the failing tests**
362
+
363
+ Add to `tests/test_app.py` fast tests:
364
+
365
+ ```python
366
+ def test_demo_has_sidebar_and_tabs():
367
+ """Verify the demo contains the expected layout components."""
368
+ import app
369
+
370
+ demo = app._build_demo()
371
+ # Flatten all blocks in the demo
372
+ blocks = demo.blocks
373
+
374
+ # Check for key component types by looking at block values
375
+ component_types = {type(b).__name__ for b in blocks.values()}
376
+ assert "Tab" in component_types, f"Expected Tab component, found: {component_types}"
377
+ assert "Slider" in component_types, f"Expected Slider component, found: {component_types}"
378
+ assert "Dropdown" in component_types, f"Expected Dropdown component, found: {component_types}"
379
+
380
+
381
+ def test_demo_has_three_sliders():
382
+ """Three sliders: max_new_tokens, num_beams, temperature."""
383
+ import app
384
+
385
+ demo = app._build_demo()
386
+ sliders = [b for b in demo.blocks.values() if type(b).__name__ == "Slider"]
387
+ assert len(sliders) == 3, f"Expected 3 sliders, found {len(sliders)}"
388
+
389
+
390
+ def test_demo_has_two_tabs():
391
+ """Two tabs: Single and Batch."""
392
+ import app
393
+
394
+ demo = app._build_demo()
395
+ tabs = [b for b in demo.blocks.values() if type(b).__name__ == "Tab"]
396
+ assert len(tabs) == 2, f"Expected 2 tabs, found {len(tabs)}"
397
+ ```
398
+
399
+ **Step 2: Run tests to verify they fail**
400
+
401
+ Run: `pytest tests/test_app.py::test_demo_has_sidebar_and_tabs tests/test_app.py::test_demo_has_three_sliders tests/test_app.py::test_demo_has_two_tabs -v`
402
+ Expected: FAIL — current demo has no tabs or sliders
403
+
404
+ **Step 3: Write the new `_build_demo` function**
405
+
406
+ Replace `_build_demo` in `app.py`:
407
+
408
+ ```python
409
+ def _build_demo() -> gr.Blocks:
410
+ _, language_names = _build_language_mappings()
411
+
412
+ with gr.Blocks(title="MADLAD-400 Translation") as demo:
413
+ gr.Markdown(
414
+ "# MADLAD-400 Translation\n"
415
+ "Translate English into 22 production-ready languages using Google's MADLAD-400 3B model. "
416
+ "[Paper](https://arxiv.org/pdf/2309.04662)"
417
+ )
418
+
419
+ with gr.Row():
420
+ # --- Sidebar ---
421
+ with gr.Column(scale=1, min_width=250):
422
+ gr.Markdown("### Settings")
423
+
424
+ target_language = gr.Dropdown(
425
+ choices=language_names,
426
+ value="French",
427
+ label="Target language",
428
+ )
429
+ max_new_tokens = gr.Slider(
430
+ minimum=1, maximum=1024, value=512, step=1, label="Max new tokens"
431
+ )
432
+ num_beams = gr.Slider(minimum=1, maximum=10, value=1, step=1, label="Num beams")
433
+ temperature = gr.Slider(minimum=0.1, maximum=2.0, value=1.0, step=0.1, label="Temperature")
434
+
435
+ # --- Main area ---
436
+ with gr.Column(scale=3):
437
+ with gr.Tab("Single"):
438
+ with gr.Row():
439
+ input_text = gr.Textbox(
440
+ label="English",
441
+ placeholder="Enter English text here",
442
+ lines=4,
443
+ )
444
+ output_text = gr.Textbox(
445
+ label="Translation",
446
+ lines=4,
447
+ buttons=["copy"],
448
+ interactive=False,
449
+ )
450
+
451
+ single_btn = gr.Button("Translate", variant="primary")
452
+
453
+ single_btn.click(
454
+ fn=translate,
455
+ inputs=[input_text, target_language, max_new_tokens, num_beams, temperature],
456
+ outputs=output_text,
457
+ )
458
+
459
+ gr.Examples(
460
+ examples=[
461
+ ["Hello, how are you today?", "French"],
462
+ ["The weather is beautiful.", "Spanish"],
463
+ ["Thank you very much.", "German"],
464
+ ["Where is the train station?", "Portuguese"],
465
+ ],
466
+ inputs=[input_text, target_language],
467
+ )
468
+
469
+ with gr.Tab("Batch"):
470
+ gr.Markdown(
471
+ "Upload a `.txt` file (one sentence per line) or a `.csv` file with a `text` column."
472
+ )
473
+ batch_input = gr.File(label="Upload file", file_types=[".txt", ".csv"])
474
+ batch_output = gr.File(label="Download translations", interactive=False)
475
+
476
+ batch_btn = gr.Button("Translate", variant="primary")
477
+
478
+ batch_btn.click(
479
+ fn=translate_batch,
480
+ inputs=[batch_input, target_language, max_new_tokens, num_beams, temperature],
481
+ outputs=batch_output,
482
+ )
483
+
484
+ return demo
485
+ ```
486
+
487
+ **Step 4: Run tests to verify they pass**
488
+
489
+ Run: `pytest tests/test_app.py::test_demo_has_sidebar_and_tabs tests/test_app.py::test_demo_has_three_sliders tests/test_app.py::test_demo_has_two_tabs -v`
490
+ Expected: PASS
491
+
492
+ **Step 5: Run all fast tests to check for regressions**
493
+
494
+ Run: `pytest -m "not slow" -v`
495
+ Expected: All PASS
496
+
497
+ **Step 6: Run linter**
498
+
499
+ Run: `ruff check app.py tests/test_app.py`
500
+ Expected: No errors
501
+
502
+ **Step 7: Commit**
503
+
504
+ ```bash
505
+ git add app.py tests/test_app.py
506
+ git commit -m "feat: rebuild UI with sidebar and tabbed Single/Batch layout"
507
+ ```
508
+
509
+ ---
510
+
511
+ ### Task 5: Update CLAUDE.md and README.md
512
+
513
+ **Files:**
514
+ - Modify: `CLAUDE.md`
515
+ - Modify: `README.md`
516
+
517
+ **Step 1: Update CLAUDE.md**
518
+
519
+ In the **Architecture** section for `app.py`, update the description to reflect the new layout:
520
+
521
+ Replace:
522
+ > **`app.py`** — Single-file application. Uses `@lru_cache` for lazy loading...
523
+
524
+ With:
525
+ > **`app.py`** — Single-file application with two-column layout: a settings sidebar (target language + generation parameters) and a tabbed main area (Single text / Batch file translation). Uses `@lru_cache` for lazy loading of the `google/madlad400-3b-mt` tokenizer and model (no download on import). Uses `float16` on CUDA, `float32` on CPU. MPS is not supported (produces garbage output with T5 models). Translation prepends a language token with a space to the input text (e.g., `<2fr> Hello`) before tokenization and generation. The `@spaces.GPU` decorator allocates GPU on HF Spaces infrastructure. Batch mode accepts `.txt` (one sentence per line) and `.csv` (requires `text` column) files.
526
+
527
+ **Step 2: Update README.md if it references the UI**
528
+
529
+ Check README.md for any UI-specific text and update accordingly.
530
+
531
+ **Step 3: Commit**
532
+
533
+ ```bash
534
+ git add CLAUDE.md README.md
535
+ git commit -m "docs: update CLAUDE.md and README.md for new UI layout"
536
+ ```
537
+
538
+ ---
539
+
540
+ ### Task 6: Final verification
541
+
542
+ **Step 1: Run all fast tests**
543
+
544
+ Run: `pytest -m "not slow" -v`
545
+ Expected: All PASS
546
+
547
+ **Step 2: Run linter and formatter**
548
+
549
+ Run: `ruff check . && ruff format --check .`
550
+ Expected: No errors
551
+
552
+ **Step 3: Run type checker**
553
+
554
+ Run: `ty check`
555
+ Expected: No new errors
556
+
557
+ **Step 4: Manual smoke test**
558
+
559
+ Run: `python app.py`
560
+ Expected: App launches at http://localhost:7860 with sidebar and tabs visible. Verify:
561
+ - Sidebar shows: target language dropdown, 3 sliders with correct defaults/ranges
562
+ - Single tab: input/output textboxes, translate button, examples
563
+ - Batch tab: file upload, download area, translate button