Documents the 12-iteration binary search, root cause analysis,
fix pattern, and lessons learned for the Svelte 5 store-detection
failure on Proxy objects with subscribe getter.
Includes recommended follow-ups: ESLint rule, document the _t
pattern in semantics-svelte skill, audit other complex-template
pages for the same latent bug.
Svelte 5 compiler fails to detect Proxy-based i18n store `t` as a store
in deeply nested/complex templates and in `.svelte.ts` model files,
generating `ReferenceError: $t is not defined` at runtime.
Fix: replace `$t.` references with:
- Template: `_t.` where `const _t = $derived(getT())`
- Script: `getT()?.` direct function call
Applied to:
- translate/[id]/+page.svelte (the failing page)
- TranslationJobModel.svelte.ts ($derived.by() in `tabs`)
- 12 translate components (ConfigTabForm, ScheduleConfig,
TranslationPreview, TargetTabForm, RunTabContent, BulkReplaceModal,
TranslationRunProgress, TranslationRunResult, TermCorrectionPopup,
BulkCorrectionSidebar, TranslationMetricsDashboard, CorrectionCell,
TranslationRunGlobalIndicator)
Verified all 5 tabs render correctly in browser:
Config, Preview, Target, Run, Schedule.
Build clean, 698/698 tests pass, 0 color violations.
Problem: closing and reopening the translate job page (or tab close+reopen)
lost the progress bar because the in-memory store starts fresh. The backend
keeps translating, but the UI shows no progress.
Fix:
- Store: save {runId, jobId, isFullRun} to sessionStorage on startTranslationRun()
via _saveToSessionStorage()
- Store: add reconnectToRun() — re-establishes WS without resetting store state,
preserving any progress data already received
- Store: add getStoredActiveRun() — public reader for sessionStorage
- Store: clear sessionStorage via _clearSessionStorage() on terminal states
(added to _cleanup() called by WS terminal, max-reconnects, stop, reset)
- Page: add that checks getStoredActiveRun() after job loads
and calls reconnectToRun() to pick up the live progress
Edge cases handled:
- Store already connected to same run → skip (no duplicate WS)
- sessionStorage unavailable → silent catch
- SPA navigation → store already has the run, WS still connected → skip
- Page reload → sessionStorage has the run, reconnects
- Tab close + reopen → same as reload
- Run completes while away → sessionStorage cleared by _cleanup()
- Run already finished → getStoredActiveRun() returns null → skip
Build ✅ 698 tests ✅
QA found critical regression: after removing HTTP polling, a WS
disconnect left the UI permanently stuck (uxState='running' forever).
Fixes:
- WS reconnect: up to 3 attempts with 10s backoff on close (non-1000/1005)
- App timeout: 600s (10 min) max — transitions to failed if stuck
- Stale log message: removed 'falling back to polling' (no longer exists)
- Contract header: updated @BRIEF, restored @UX_STATE/@UX_FEEDBACK/@UX_RECOVERY
- cleanup(): unified WS close + timer clear (used by stop, timeout, terminal)
- onclose handler: triggers reconnect via _handleWsFailure()
- onerror handler: defers to onclose (fires after error)
- onopen handler: resets _reconnectCount on successful connect
- Terminal states: call _cleanup() to stop reconnect timer + timeout
Build ✅ 698 tests ✅
Previously startTranslationRun() used both WebSocket + 2s HTTP polling.
After WS debug logging was added, the HTTP fallback is no longer needed:
- WS streams status every 1s from backend
- WS handler already detects terminal states + fires onComplete
- WS errors are now logged (onerror → console.warn)
Removed: pollStatus(), _pollingInterval, _pollCount, MAX_POLLS,
fetchRunStatus import, setInterval in startTranslationRun,
clearInterval in stopTranslationRun.
Build ✅ 698 tests ✅
1. dict_snapshot_hash now includes entry count + max(updated_at)
per dictionary. Previously only hashed dictionary IDs, meaning
edits to dictionary entries did NOT invalidate the translation
cache. Stale cached translations could be served after editing
dictionary entries. (HIGH severity)
2. _batch_insert.py now uses SQLGenerator.generate_batch() with
500-row chunking instead of a single massive INSERT statement.
Prevents potential SQL size limit issues with large batches or
many target languages. (LOW severity)
3. Fixed same bug in preview_response_parser.py —
compute_dict_snapshot_hash had identical ID-only hash flaw.
Tests: 69/69 translate tests pass.
Backend:
- TranslationRun model: add cache_hits Integer column (default 0)
- TranslationRunResponse schema: add cache_hits field
- _helpers.py/_run_list_routes.py/orchestrator_aggregator.py: include
cache_hits in all run API responses
- _batch_proc.py: count pre_rows served from translation cache per
batch, return cache_hits in batch result
- executor.py: accumulate cache_hits across batches, persist to run
- Alembic migration: dabc9709 — add cache_hits column to translation_runs
Frontend:
- translationRun.svelte.ts: add cacheHits to store state, WS handler,
polling handler
- TranslationRunProgress.svelte: 5-col stats grid with purple Cache card
- TranslationRunGlobalIndicator.svelte: 5-col stats with Cache
- TranslationRunResult.svelte: 5-col detail stats with Cache card
- History page: cache_hits shown in run list row + detail panel
Visual: cache hits shown in purple alongside green/yellow/red metrics
(total/success/failed/skipped). Visible during run + in history.
Tests: backend 69/69 translate tests ✅, frontend 698/698 tests ✅,
frontend build ✅
Bug 1 (secondary): _persist_pre() created TranslationLanguage with
empty final_value for non-source target languages when same_language
flag was set but no cache/approved value existed. This produced
is_original=0 rows with empty text in target table.
Fix: skip TranslationLanguage creation when fv is empty (continue
in the loop instead of falling through to else with empty string).
Bug 2 (diagnostic): No visibility into _classify() routing decisions.
Add logger.reason with pre/llm/total counts per batch.
Scenario trace: ru source + targets [ru,en] + no cache → llm_rows ✓
If same-language partial match hits cache for all targets → pre_rows ✓
If same-language partial match misses cache → llm_rows ✓
If same-language all-match (targets=[ru]) → short-circuit pre_rows ✓
Tests: 69/69 translate tests pass.
- DashboardHealthItem schema: add run_id, policy_id, execution_path,
issues_count, timings, token_usage, screenshot_paths, chunk_count,
dashboard_name fields for v2 LLM validation reporting.
- HealthService: populate v2 fields from validation run records.
- ValidationService: update to support v2 validation run fields.
- validation_tasks route: minor fix for v2 field handling.
- tailwind.config.js: refactor design tokens — add success/warning/info
palettes, surface/border/text hierarchy, semantic action colors.
- Agent configs: update svelte-coder and semantics-svelte for
.svelte.ts runes and Screen Model patterns.
Root cause: _classify() immediately skipped (continue) when detected
language matched ANY target language, preventing cache lookup and LLM
translation for remaining targets. E.g., ru source + targets [ru, en]
→ only ru row created, en never processed (8978 ru vs 18 en in DB).
Fix:
1. _classify(): Only short-circuit when ALL targets match detected
language. Partial match → mark _same_language but continue to
cache check and LLM for non-matching targets.
2. _persist_pre(): Unify two code paths into single loop over all
target languages. Same-language targets use source_text as-is;
cached targets use cache value; fallback to approved_translation.
3. SIM102: Merge nested 'if cl: if all(...)' → 'if cl and all(...)'
to keep cyclomatic complexity ≤ 10 (INV_7).
QA: All 69 translate tests pass. 7 scenario traces (A-G) verified.
No regressions in approved_translation, preview edits, or single-
target same-language flows.
downloadSkippedCsv and downloadFailedCsv call api.fetchApiBlob()
but it was not exposed on the api object — causing
"o.fetchApiBlob is not a function" at runtime.
Deleted stale .js files (taskService, toolsService, report_page.contract.test)
7 .svelte.js test files intentionally preserved (Svelte 5 convention)
Final metrics:
- Production JS files: 0
- Production TS/TSX/Svelte files: all
- npm run build ✅
- npm run test ✅ (645 passed, 6 pre-existing)
When Kilo gateway + Nvidia NeMo :free model returns content: null
for image inputs, get_json_completion crashed with
'the JSON object must be str, bytes or bytearray, not NoneType'
and retried 5 times (unnecessary).
Fixes:
- _should_retry no longer retries RuntimeError with 'null content'
(retrying won't help — the model genuinely returned null)
- get_json_completion now raises RuntimeError explicitly when
content is None, with a clear message (instead of json.loads(None))
Two bugs preventing screenshot display in report UI:
1. Plugin's ValidationRecord constructor did NOT include
screenshot_paths (plural, JSON array). The column stayed NULL
even though paths were stored in raw_response. Records created
by the plugin now include screenshot_paths=webp_paths|jpeg_paths.
2. Backward compat for existing records: _record_to_dict now
falls back to extracting screenshot_paths from raw_response JSON
when the DB column is empty.
Also fixes: broken import statement in validation_service.py
(missing closing paren + missing imports from earlier edit).
API returns 'screenshot_paths' but frontend template reads 'screenshots'.
Without field mapping, screenshots are never displayed.
Fix: map screenshot_paths -> screenshots and logs_sent_to_llm -> logs_sent
in the +page.js load function.
Also verifies that /api/storage/file endpoint exists (mounted at /api/storage)
and the frontend API_BASE_URL='/api' correctly prefixes /storage/file.
Svelte each block key used , but when
two issues share the same message text (e.g. 'Main content area
is completely blank') and have no id, the key collides.
Fix: use composite key
as fallback when issue.id is missing.
The _supports_json_response_format check only looked for
'openrouter.ai' in base_url, missing the Kilo gateway
('api.kilo.ai'). Free-tier models routed through ANY gateway
(Nvidia NeMo :free via OpenRouter OR Kilo) reject json_object
response format, causing the LLM to return content: null.
Error: 'the JSON object must be str, bytes or bytearray, not NoneType'
Fix: check model name for ':free' and 'stepfun/' directly,
regardless of gateway. This covers OpenRouter, Kilo, and any
future gateway.
When max_images could not be detected (e.g. Kilo gateway doesn't
support OpenAI image format, probe returned 0), chunking was
disabled entirely and all screenshots sent in a single request.
Nvidia NeMo still enforces an 8-image limit regardless of the
gateway, causing 400 errors.
Fix: default to 8 images per chunk when max_images is 0 or None,
so chunking always applies for multi-tab dashboards.
Root cause of stuck 'running' runs: the plugin's execute() method
only processed dashboard_ids[0] and returned. The remaining N-1
dashboards were never processed, and _update_run_status was called
after each single dashboard without checking dashboard_count.
Fixes:
- execute() now loops over ALL dashboard_ids and processes each
via _execute_path_a or _execute_path_b
- _update_run_status is called once AFTER the loop finishes
- _update_run_status now checks that len(records) >= dashboard_count
before marking the run as finished (prevents premature completion)
- Per-dashboard _update_run_status calls removed from _execute_path_a
and _execute_path_b (the parent loop owns it now)
- Test updated for new batch return format {dashboards: [...], total: N}
When the backend restarts (deploy, crash, reload), the in-memory
TaskManager loses its queue. ValidationRun records left 'running'
in the DB would hang forever, blocking re-runs.
Fix: during lifespan startup, query all ValidationRun with
status='running' and force-stop them as FAIL with a clear summary.
This prevents the 'already has a running run' error after restarts.
- Extract _optimize_images() helper to eliminate duplicate
optimization code (was duplicated between initial pass and
quality-reduction fallback)
- Move quality-reduction estimate to only apply when NOT chunking
(each chunk fits the image limit by definition)
- Fix _merge_chunk_results to return 'chunk_count' instead of 'chunks'
for consistency with plugin.py
- Simplify plugin.py: analysis.get('chunk_count', 1) instead of
fallback chain
- Document that Kilo API gateway is incompatible with AsyncOpenAI
image format (probe returns 0)