@nac3/forge-cli 1.0.55 → 1.0.56

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -121,6 +121,176 @@ export function renderPanelHtml(cfg) {
121
121
  wizardDoneKanji: tForLang(lang, 'wizard.done.kanji'),
122
122
  wizardDoneTitle: tForLang(lang, 'wizard.done.title'),
123
123
  wizardDoneMsg: tForLang(lang, 'wizard.done.msg'),
124
+ /* Batch 2 -- runtime status / error toasts + confirm/prompt
125
+ * dialogs + voice (STT/TTS) messages from the client JS. */
126
+ panelStatusProjectSwitchFail: tForLang(lang, 'panel.status.projectSwitchFail'),
127
+ panelStatusProjectRemoveFail: tForLang(lang, 'panel.status.projectRemoveFail'),
128
+ panelStatusProjectAddFail: tForLang(lang, 'panel.status.projectAddFail'),
129
+ panelStatusCancelFail: tForLang(lang, 'panel.status.cancelFail'),
130
+ panelStatusCancelNetError: tForLang(lang, 'panel.status.cancelNetError'),
131
+ panelStatusNotEditable: tForLang(lang, 'panel.status.notEditable'),
132
+ panelStatusSavedAsPicker: tForLang(lang, 'panel.status.savedAsPicker'),
133
+ panelStatusSaveAsFail: tForLang(lang, 'panel.status.saveAsFail'),
134
+ panelStatusSaveFail: tForLang(lang, 'panel.status.saveFail'),
135
+ panelStatusSavedAs: tForLang(lang, 'panel.status.savedAs'),
136
+ panelStatusNetError: tForLang(lang, 'panel.status.netError'),
137
+ panelStatusSavedTabOnly: tForLang(lang, 'panel.status.savedTabOnly'),
138
+ panelStatusSaved: tForLang(lang, 'panel.status.saved'),
139
+ panelStatusCopied: tForLang(lang, 'panel.status.copied'),
140
+ panelStatusCopyFail: tForLang(lang, 'panel.status.copyFail'),
141
+ panelStatusModeAvailable: tForLang(lang, 'panel.status.modeAvailable'),
142
+ panelStatusModeChangeFail: tForLang(lang, 'panel.status.modeChangeFail'),
143
+ panelStatusModeChanged: tForLang(lang, 'panel.status.modeChanged'),
144
+ panelStatusGenericError: tForLang(lang, 'panel.status.genericError'),
145
+ panelStatusBrainTierAuto: tForLang(lang, 'panel.status.brainTierAuto'),
146
+ panelStatusBrainTierForced: tForLang(lang, 'panel.status.brainTierForced'),
147
+ panelStatusReading: tForLang(lang, 'panel.status.reading'),
148
+ panelStatusIngestFailed: tForLang(lang, 'panel.status.ingest_failed'),
149
+ panelStatusReadFailed: tForLang(lang, 'panel.status.read_failed'),
150
+ panelStatusAudioPlayError: tForLang(lang, 'panel.status.audio_play_error'),
151
+ panelStatusImageTooLarge: tForLang(lang, 'panel.status.imageTooLarge'),
152
+ panelStatusImageReady: tForLang(lang, 'panel.status.imageReady'),
153
+ panelStatusImageReadFail: tForLang(lang, 'panel.status.imageReadFail'),
154
+ panelConfirmTabUnsaved: tForLang(lang, 'panel.confirm.tabUnsaved'),
155
+ panelConfirmTabsUnsaved: tForLang(lang, 'panel.confirm.tabsUnsaved'),
156
+ panelConfirmDiscardChanges: tForLang(lang, 'panel.confirm.discardChanges'),
157
+ panelConfirmRemoveSlot: tForLang(lang, 'panel.confirm.removeSlot'),
158
+ panelConfirmRemoveKey: tForLang(lang, 'panel.confirm.removeKey'),
159
+ panelPromptSaveAs: tForLang(lang, 'panel.prompt.saveAs'),
160
+ panelVoiceSttFallback: tForLang(lang, 'panel.voice.sttFallback'),
161
+ panelVoiceNoMicSupport: tForLang(lang, 'panel.voice.noMicSupport'),
162
+ panelVoiceRecordingCountdown: tForLang(lang, 'panel.voice.recordingCountdown'),
163
+ panelVoiceAutoStopped: tForLang(lang, 'panel.voice.autoStopped'),
164
+ panelVoiceNoAudio: tForLang(lang, 'panel.voice.noAudio'),
165
+ panelVoiceAudioTooLong: tForLang(lang, 'panel.voice.audioTooLong'),
166
+ panelVoiceDidNotCatch: tForLang(lang, 'panel.status.did_not_catch'),
167
+ panelVoiceMicDenied: tForLang(lang, 'panel.status.mic_denied'),
168
+ panelVoiceTtsUnavailable: tForLang(lang, 'panel.voice.ttsUnavailable'),
169
+ panelVoiceMicClosedHalfDuplex: tForLang(lang, 'panel.voice.micClosedHalfDuplex'),
170
+ panelVoiceMicBlocked: tForLang(lang, 'panel.voice.micBlocked'),
171
+ panelVoiceNoMicDevice: tForLang(lang, 'panel.voice.noMicDevice'),
172
+ panelVoiceMicOpenFail: tForLang(lang, 'panel.voice.micOpenFail'),
173
+ panelVoiceMicOpenListening: tForLang(lang, 'panel.voice.micOpenListening'),
174
+ panelVoiceListening: tForLang(lang, 'panel.voice.listening'),
175
+ panelVoiceListeningInterim: tForLang(lang, 'panel.voice.listeningInterim'),
176
+ panelVoiceMicBlockedBrowser: tForLang(lang, 'panel.voice.micBlockedBrowser'),
177
+ panelVoiceNoMicPhysical: tForLang(lang, 'panel.voice.noMicPhysical'),
178
+ panelVoiceNetworkRequired: tForLang(lang, 'panel.voice.networkRequired'),
179
+ panelVoiceLangNotSupported: tForLang(lang, 'panel.voice.langNotSupported'),
180
+ panelVoiceMicError: tForLang(lang, 'panel.voice.micError'),
181
+ panelVoiceNoAudioToRecognizer: tForLang(lang, 'panel.voice.noAudioToRecognizer'),
182
+ panelVoiceMicOpenNoVoice: tForLang(lang, 'panel.voice.micOpenNoVoice'),
183
+ panelVoiceVoiceNoText: tForLang(lang, 'panel.voice.voiceNoText'),
184
+ panelVoiceStartingDictation: tForLang(lang, 'panel.voice.startingDictation'),
185
+ panelVoiceCouldNotStartMic: tForLang(lang, 'panel.voice.couldNotStartMic'),
186
+ /* Batch 3 -- tooltips/hints/runtime literals from the client JS. */
187
+ tipRefreshDiscovery: tForLang(lang, 'panel.tip.refreshDiscovery'),
188
+ tipOpenInEditor: tForLang(lang, 'panel.tip.openInEditor'),
189
+ tipOpen: tForLang(lang, 'panel.tip.open'),
190
+ tipSaveAs: tForLang(lang, 'panel.tip.saveAs'),
191
+ tipCodeModeDetail: tForLang(lang, 'panel.tip.codeModeDetail'),
192
+ tipCodeModeZen: tForLang(lang, 'panel.tip.codeModeZen'),
193
+ tipCancelShell: tForLang(lang, 'panel.tip.cancelShell'),
194
+ tipCloseTab: tForLang(lang, 'panel.tip.closeTab'),
195
+ tipReopenNativeTabs: tForLang(lang, 'panel.tip.reopenNativeTabs'),
196
+ tipNotTestedYet: tForLang(lang, 'panel.tip.notTestedYet'),
197
+ ariaThinking: tForLang(lang, 'panel.aria.thinking'),
198
+ hintBoardEmpty: tForLang(lang, 'panel.hint.boardEmpty'),
199
+ hintBookmarksEmpty: tForLang(lang, 'panel.hint.bookmarksEmpty'),
200
+ panelVoiceReplyFailed: tForLang(lang, 'panel.voice.replyFailed'),
201
+ panelVoiceReplyError: tForLang(lang, 'panel.voice.replyError'),
202
+ panelStatusLanguageSwitchFailed: tForLang(lang, 'panel.status.languageSwitchFailed'),
203
+ panelStatusLanguageListFailed: tForLang(lang, 'panel.status.languageListFailed'),
204
+ legalLoading: tForLang(lang, 'panel.legal.loading'),
205
+ legalLoadError: tForLang(lang, 'panel.legal.loadError'),
206
+ legalNetworkError: tForLang(lang, 'panel.legal.networkError'),
207
+ legalDefaultTitle: tForLang(lang, 'panel.legal.defaultTitle'),
208
+ fbCopied: tForLang(lang, 'panel.feedback.copied'),
209
+ fbCopyFailed: tForLang(lang, 'panel.feedback.copyFailed'),
210
+ fbClipboardUnavailable: tForLang(lang, 'panel.feedback.clipboardUnavailable'),
211
+ fbSaving: tForLang(lang, 'panel.feedback.saving'),
212
+ fbSaved: tForLang(lang, 'panel.feedback.saved'),
213
+ fbSaveError: tForLang(lang, 'panel.feedback.saveError'),
214
+ fbNetError: tForLang(lang, 'panel.feedback.netError'),
215
+ fbNetErrorSaving: tForLang(lang, 'panel.feedback.netErrorSaving'),
216
+ fbErrorPrefix: tForLang(lang, 'panel.feedback.errorPrefix'),
217
+ pairGenerating: tForLang(lang, 'panel.pair.generating'),
218
+ pairGenerateQr: tForLang(lang, 'panel.pair.generateQr'),
219
+ pairCopy: tForLang(lang, 'panel.pair.copy'),
220
+ pairExpiry: tForLang(lang, 'panel.pair.expiry'),
221
+ pairQrNetError: tForLang(lang, 'panel.pair.qrNetError'),
222
+ auditCopyJson: tForLang(lang, 'panel.audit.copyJson'),
223
+ limitsResetApplied: tForLang(lang, 'panel.limits.resetApplied'),
224
+ nac3Effects: tForLang(lang, 'panel.nac3.effects'),
225
+ nac3EffectsUnknown: tForLang(lang, 'panel.nac3.effectsUnknown'),
226
+ nac3Cancel: tForLang(lang, 'panel.nac3.cancel'),
227
+ nac3Reject: tForLang(lang, 'panel.nac3.reject'),
228
+ nac3Approve: tForLang(lang, 'panel.nac3.approve'),
229
+ nac3ApproveDestructive: tForLang(lang, 'panel.nac3.approveDestructive'),
230
+ nac3Executing: tForLang(lang, 'panel.nac3.executing'),
231
+ ttsProviderBrowser: tForLang(lang, 'panel.tts.providerBrowser'),
232
+ ttsProviderGoogle: tForLang(lang, 'panel.tts.providerGoogle'),
233
+ ttsProviderTitle: tForLang(lang, 'panel.tts.providerTitle'),
234
+ sttProviderBrowser: tForLang(lang, 'panel.stt.providerBrowser'),
235
+ sttProviderGoogle: tForLang(lang, 'panel.stt.providerGoogle'),
236
+ sttProviderWhisper: tForLang(lang, 'panel.stt.providerWhisper'),
237
+ sttProviderTitle: tForLang(lang, 'panel.stt.providerTitle'),
238
+ ttsVoiceTitle: tForLang(lang, 'panel.tts.voiceTitle'),
239
+ licLendNeedsLicense: tForLang(lang, 'panel.license.lendNeedsLicense'),
240
+ licEmailInvalid: tForLang(lang, 'panel.license.emailInvalid'),
241
+ licConnecting: tForLang(lang, 'panel.license.connecting'),
242
+ licConnect: tForLang(lang, 'settings.license.connect'),
243
+ licSavingEmail: tForLang(lang, 'panel.license.savingEmail'),
244
+ licSyncFailedRetry: tForLang(lang, 'panel.license.syncFailedRetry'),
245
+ licPlanOk: tForLang(lang, 'panel.license.planOk'),
246
+ licNetError: tForLang(lang, 'panel.license.netError'),
247
+ licSyncing: tForLang(lang, 'panel.license.syncing'),
248
+ licQuerying: tForLang(lang, 'panel.license.querying'),
249
+ licSyncError: tForLang(lang, 'panel.license.syncError'),
250
+ licPlanUnchanged: tForLang(lang, 'panel.license.planUnchanged'),
251
+ licPlanUpdated: tForLang(lang, 'panel.license.planUpdated'),
252
+ licSyncWithPolar: tForLang(lang, 'panel.license.syncWithPolar'),
253
+ licErrorWithCode: tForLang(lang, 'panel.license.errorWithCode'),
254
+ keyEditorTooShort: tForLang(lang, 'panel.keyEditor.keyTooShort'),
255
+ bridgeCoordUnavailable: tForLang(lang, 'panel.bridge.coordUnavailable'),
256
+ bridgeStatusUnavailable: tForLang(lang, 'panel.bridge.statusUnavailable'),
257
+ mcpLoadingBridges: tForLang(lang, 'panel.mcp.loadingBridges'),
258
+ toolsLoading: tForLang(lang, 'panel.tools.loading'),
259
+ agentsChatBackPairs: tForLang(lang, 'panel.agentsChat.backPairs'),
260
+ agentsChatNoMessages: tForLang(lang, 'panel.agentsChat.noMessages'),
261
+ agentsChatNoAgents: tForLang(lang, 'panel.agentsChat.noAgents'),
262
+ agentsChatPickAgent: tForLang(lang, 'panel.agentsChat.pickAgent'),
263
+ agentsChatSending: tForLang(lang, 'panel.agentsChat.sending'),
264
+ agentsChatErrorGeneric: tForLang(lang, 'panel.agentsChat.errorGeneric'),
265
+ alertNoEditor: tForLang(lang, 'panel.alert.noEditor'),
266
+ alertPathRelative: tForLang(lang, 'panel.alert.pathRelative'),
267
+ saveCopy: tForLang(lang, 'pizarron.button.copy'),
268
+ filesLoadingGeneric: tForLang(lang, 'panel.files.loading'),
269
+ tipSaveTo: tForLang(lang, 'panel.tip.saveTo'),
270
+ tipSaveToMemory: tForLang(lang, 'panel.tip.saveToMemory'),
271
+ btnSave: tForLang(lang, 'vault.add.button_save'),
272
+ btnSaveAs: tForLang(lang, 'panel.button.saveAs'),
273
+ fbSavedShort: tForLang(lang, 'panel.feedback.savedShort'),
274
+ pairQrFailed: tForLang(lang, 'panel.pair.qrFailed'),
275
+ brainModePlan: tForLang(lang, 'panel.brain.modePlan'),
276
+ brainModeConsole: tForLang(lang, 'panel.brain.modeConsole'),
277
+ brainApplying: tForLang(lang, 'panel.brain.applying'),
278
+ brainApplied: tForLang(lang, 'panel.brain.applied'),
279
+ brainReopenHint: tForLang(lang, 'panel.brain.reopenHint'),
280
+ brainActive: tForLang(lang, 'panel.brain.active'),
281
+ bridgeRoleHost: tForLang(lang, 'panel.bridge.roleHost'),
282
+ bridgeRoleClient: tForLang(lang, 'panel.bridge.roleClient'),
283
+ bridgeInstanceOne: tForLang(lang, 'panel.bridge.instanceOne'),
284
+ bridgeInstanceMany: tForLang(lang, 'panel.bridge.instanceMany'),
285
+ bridgeYou: tForLang(lang, 'panel.bridge.you'),
286
+ tipDeleteTask: tForLang(lang, 'panel.tip.deleteTask'),
287
+ tasksCmdPlaceholder: tForLang(lang, 'panel.tasks.cmdPlaceholder'),
288
+ readAloudCode: tForLang(lang, 'panel.readAloud.code'),
289
+ readAloudCodeLang: tForLang(lang, 'panel.readAloud.codeLang'),
290
+ readAloudFile: tForLang(lang, 'panel.readAloud.file'),
291
+ readAloudContent: tForLang(lang, 'panel.readAloud.content'),
292
+ tabOne: tForLang(lang, 'panel.pizarron.tabOne'),
293
+ tabMany: tForLang(lang, 'panel.pizarron.tabMany'),
124
294
  };
125
295
  const runtimeConfig = JSON.stringify({
126
296
  projectName: cfg.projectName,
@@ -1907,7 +2077,7 @@ ${renderYujinSplash()}
1907
2077
  <div id="yf-whats-new-title" class="whats-new-title">Novedades en esta version</div>
1908
2078
  <div id="yf-whats-new-sub" class="whats-new-sub"></div>
1909
2079
  </div>
1910
- <button type="button" id="yf-whats-new-dismiss" class="whats-new-close" aria-label="Cerrar">x</button>
2080
+ <button type="button" id="yf-whats-new-dismiss" class="whats-new-close" aria-label="${escapeHtml(tForLang(lang, 'panel.whatsNew.dismiss'))}">x</button>
1911
2081
  </div>
1912
2082
  <div id="yf-whats-new-body" class="whats-new-body"></div>
1913
2083
  </div>
@@ -1935,7 +2105,7 @@ ${renderYujinSplash()}
1935
2105
  <form id="bar-mini" data-nac-id="yujin.chat.bar-mini" class="bar" onsubmit="return false">
1936
2106
  <button type="button" class="mic" data-mic data-mic-btn aria-label="${escapeHtml(tr.micButton)}" title="${escapeHtml(tr.micButton)}">🎙</button>
1937
2107
  <button type="button" class="tts-toggle" data-tts-toggle aria-label="${escapeHtml(tr.ttsButton)}" title="${escapeHtml(tr.ttsButton)}">🔊</button>
1938
- <button type="button" class="clip" data-attach-clip aria-label="Adjuntar archivo" title="Adjuntar archivo">📎</button>
2108
+ <button type="button" class="clip" data-attach-clip aria-label="${escapeHtml(tForLang(lang, 'panel.tip.attachFile'))}" title="${escapeHtml(tForLang(lang, 'panel.tip.attachFile'))}">📎</button>
1939
2109
  <textarea class="composer-input" rows="1" placeholder="${escapeHtml(tr.inputPlaceholder)}" aria-label="${escapeHtml(tr.inputPlaceholder)}"></textarea>
1940
2110
  <button type="submit" class="send">${escapeHtml(tr.sendButton)}</button>
1941
2111
  <button type="button" class="stop" data-stop aria-label="${escapeHtml(tr.stopButton)}" title="${escapeHtml(tr.stopButton)}" style="display:none">&#9632; ${escapeHtml(tr.stopButton)}</button>
@@ -2048,9 +2218,9 @@ ${renderYujinSplash()}
2048
2218
  <button data-mcp-close aria-label="${escapeHtml(tr.closeButton)}" title="${escapeHtml(tr.closeButton)}">${escapeHtml(tr.closeButton)}</button>
2049
2219
  </div>
2050
2220
  <div class="mcp-body">
2051
- <button class="mcp-refresh" data-mcp-refresh title="Refrescar discovery">Refrescar</button>
2221
+ <button class="mcp-refresh" data-mcp-refresh title="${escapeHtml(tForLang(lang, 'panel.tip.refreshDiscovery'))}">${escapeHtml(tForLang(lang, 'pizarron.graph.refresh'))}</button>
2052
2222
  <div data-mcp-content>
2053
- <div class="mcp-empty">Cargando...</div>
2223
+ <div class="mcp-empty">${escapeHtml(tForLang(lang, 'panel.legal.loading'))}</div>
2054
2224
  </div>
2055
2225
  </div>
2056
2226
  </div>
@@ -2066,24 +2236,24 @@ ${renderYujinSplash()}
2066
2236
  <p class="act-subtitle">Feed timestamped + bridge inspector + chat-per-agent</p>
2067
2237
  </div>
2068
2238
  <div style="display:flex;align-items:center;gap:10px;">
2069
- <select data-act-project-filter aria-label="Filter by project"
2239
+ <select data-act-project-filter aria-label="${escapeHtml(tForLang(lang, 'panel.tip.filterByProject'))}"
2070
2240
  style="background:var(--bg-2,#232631);color:var(--ink,#d4d4d4);border:1px solid var(--ink-subtle,#5a5e6a);border-radius:4px;padding:4px 8px;font-family:inherit;font-size:12px;">
2071
- <option value="">All projects</option>
2241
+ <option value="">${escapeHtml(tForLang(lang, 'panel.activity.allProjects'))}</option>
2072
2242
  </select>
2073
- <span data-act-concurrency aria-label="Concurrent runs"
2074
- title="Concurrent runs: global / per-project caps"
2243
+ <span data-act-concurrency aria-label="${escapeHtml(tForLang(lang, 'panel.aria.concurrentRuns'))}"
2244
+ title="${escapeHtml(tForLang(lang, 'panel.tip.concurrentRuns'))}"
2075
2245
  style="font-family:ui-monospace,Menlo,Consolas,monospace;font-size:11px;color:var(--ink-muted,#8a8a8a);white-space:nowrap;">-/-</span>
2076
2246
  <button data-activity-close aria-label="${escapeHtml(tr.closeButton)}" title="${escapeHtml(tr.closeButton)}">${escapeHtml(tr.closeButton)}</button>
2077
2247
  </div>
2078
2248
  </div>
2079
2249
  <div class="act-body">
2080
2250
  <div data-act-panel="feed">
2081
- <div class="act-empty">Cargando feed...</div>
2251
+ <div class="act-empty">${escapeHtml(tForLang(lang, 'panel.activity.loadingFeed'))}</div>
2082
2252
  </div>
2083
2253
  </div>
2084
2254
  <div class="act-controls">
2085
2255
  <label><input type="checkbox" data-act-autorefresh checked /> auto-refresh 3s</label>
2086
- <button data-act-refresh>Refrescar ahora</button>
2256
+ <button data-act-refresh>${escapeHtml(tForLang(lang, 'panel.tip.refreshNow'))}</button>
2087
2257
  <span data-act-last-update style="margin-left:auto;font-family:ui-monospace,Menlo,Consolas,monospace;"></span>
2088
2258
  </div>
2089
2259
  </div>
@@ -2110,49 +2280,49 @@ ${renderYujinSplash()}
2110
2280
  data-nac-role="action"
2111
2281
  data-nac-action="open_mcp"
2112
2282
  aria-label="MCP"
2113
- title="MCP Bridges (F101)">MCP</button>
2283
+ title="${escapeHtml(tForLang(lang, 'panel.tip.mcpBridges'))}">MCP</button>
2114
2284
  <button class="agents-btn"
2115
2285
  data-activity-open
2116
2286
  data-nac-id="yujin.panel.activity"
2117
2287
  data-nac-role="action"
2118
2288
  data-nac-action="open_activity"
2119
- aria-label="Activity"
2120
- title="Agent activity + bridge inspector + chat-per-agent">Activity</button>
2289
+ aria-label="${escapeHtml(tForLang(lang, 'panel.aria.activity'))}"
2290
+ title="${escapeHtml(tForLang(lang, 'panel.tip.activity'))}">Activity</button>
2121
2291
  <button class="settings-btn"
2122
2292
  data-settings-toggle
2123
2293
  data-nac-id="yujin.panel.settings"
2124
2294
  data-nac-role="action"
2125
2295
  data-nac-action="open_settings"
2126
- aria-label="ajustes"
2127
- title="Ajustes (modo voz, STT, TTS, claves, idioma)">⚙</button>
2296
+ aria-label="${escapeHtml(tForLang(lang, 'panel.aria.settings'))}"
2297
+ title="${escapeHtml(tForLang(lang, 'panel.tip.settings'))}">⚙</button>
2128
2298
  <button id="full-zoom-out"
2129
2299
  data-zoom-step="-1"
2130
2300
  data-nac-id="yujin.panel.zoom-out"
2131
2301
  data-nac-role="action"
2132
2302
  data-nac-action="zoom_out"
2133
- aria-label="Reducir zoom"
2134
- title="Reducir zoom (Ctrl -)">&#8722;</button>
2303
+ aria-label="${escapeHtml(tForLang(lang, 'panel.aria.zoomOut'))}"
2304
+ title="${escapeHtml(tForLang(lang, 'panel.tip.zoomOut'))}">&#8722;</button>
2135
2305
  <button id="full-zoom-reset"
2136
2306
  data-zoom-reset
2137
2307
  data-nac-id="yujin.panel.zoom-reset"
2138
2308
  data-nac-role="action"
2139
2309
  data-nac-action="zoom_reset"
2140
2310
  aria-label="Zoom 100%"
2141
- title="Restablecer zoom (Ctrl 0)">100%</button>
2311
+ title="${escapeHtml(tForLang(lang, 'panel.tip.zoomReset'))}">100%</button>
2142
2312
  <button id="full-zoom-in"
2143
2313
  data-zoom-step="1"
2144
2314
  data-nac-id="yujin.panel.zoom-in"
2145
2315
  data-nac-role="action"
2146
2316
  data-nac-action="zoom_in"
2147
- aria-label="Aumentar zoom"
2148
- title="Aumentar zoom (Ctrl +)">+</button>
2317
+ aria-label="${escapeHtml(tForLang(lang, 'panel.aria.zoomIn'))}"
2318
+ title="${escapeHtml(tForLang(lang, 'panel.tip.zoomIn'))}">+</button>
2149
2319
  <button id="full-maximize"
2150
2320
  data-maximize-toggle
2151
2321
  data-nac-id="yujin.panel.maximize"
2152
2322
  data-nac-role="action"
2153
2323
  data-nac-action="maximize_panel"
2154
- aria-label="Maximizar"
2155
- title="Maximizar / restaurar (pantalla completa)">&#9744;</button>
2324
+ aria-label="${escapeHtml(tForLang(lang, 'panel.aria.maximize'))}"
2325
+ title="${escapeHtml(tForLang(lang, 'panel.tip.maximize'))}">&#9744;</button>
2156
2326
  <button id="full-mini"
2157
2327
  data-nac-id="yujin.panel.full-to-mini"
2158
2328
  data-nac-role="action"
@@ -2262,29 +2432,29 @@ ${renderYujinSplash()}
2262
2432
  API keys (BYOK) + voice settings + license. -->
2263
2433
  <div id="yf-settings" class="hidden settings-panel">
2264
2434
  <div class="set-section" data-bridge-section>
2265
- <div class="set-section-title">Coordinacion entre instancias
2435
+ <div class="set-section-title">${escapeHtml(tForLang(lang, 'settings.bridge.title'))}
2266
2436
  <span class="bridge-status" data-bridge-status></span>
2267
2437
  </div>
2268
- <label class="bridge-toggle"><input type="checkbox" data-bridge-accept> Aceptar mensajes dirigidos de otras instancias (los inyecta como chat)</label>
2438
+ <label class="bridge-toggle"><input type="checkbox" data-bridge-accept> ${escapeHtml(tForLang(lang, 'settings.bridge.accept'))}</label>
2269
2439
  <div class="bridge-roster" data-bridge-roster></div>
2270
2440
  <div class="bridge-feed" data-bridge-feed></div>
2271
2441
  <div class="bridge-compose">
2272
- <input type="text" class="bridge-input" data-bridge-input placeholder="Mensaje a las otras instancias..." maxlength="2000" autocomplete="off">
2273
- <button type="button" class="set-btn" data-bridge-send>Enviar</button>
2442
+ <input type="text" class="bridge-input" data-bridge-input placeholder="${escapeHtml(tForLang(lang, 'settings.bridge.composePlaceholder'))}" maxlength="2000" autocomplete="off">
2443
+ <button type="button" class="set-btn" data-bridge-send>${escapeHtml(tForLang(lang, 'panel.button.send'))}</button>
2274
2444
  </div>
2275
2445
  </div>
2276
2446
  <div class="set-section">
2277
- <div class="set-section-title">Voz</div>
2278
- <div class="set-row"><span class="set-lbl">Modo</span>
2279
- <button type="button" data-voice-mode-btn class="set-btn">modo mic</button>
2447
+ <div class="set-section-title">${escapeHtml(tForLang(lang, 'settings.voice.title'))}</div>
2448
+ <div class="set-row"><span class="set-lbl">${escapeHtml(tForLang(lang, 'settings.voice.mode'))}</span>
2449
+ <button type="button" data-voice-mode-btn class="set-btn">${escapeHtml(tForLang(lang, 'settings.voice.modeMic'))}</button>
2280
2450
  </div>
2281
- <div class="set-row"><span class="set-lbl">STT</span>
2282
- <button type="button" data-stt-provider-btn class="set-btn">STT navegador</button>
2451
+ <div class="set-row"><span class="set-lbl">${escapeHtml(tForLang(lang, 'settings.voice.stt'))}</span>
2452
+ <button type="button" data-stt-provider-btn class="set-btn">${escapeHtml(tForLang(lang, 'settings.voice.sttBrowser'))}</button>
2283
2453
  </div>
2284
- <div class="set-row"><span class="set-lbl">TTS engine</span>
2285
- <button type="button" data-tts-provider-btn class="set-btn">TTS navegador</button>
2454
+ <div class="set-row"><span class="set-lbl">${escapeHtml(tForLang(lang, 'settings.voice.ttsEngine'))}</span>
2455
+ <button type="button" data-tts-provider-btn class="set-btn">${escapeHtml(tForLang(lang, 'settings.voice.ttsBrowser'))}</button>
2286
2456
  </div>
2287
- <div class="set-row"><span class="set-lbl">Voz TTS (solo Google)</span>
2457
+ <div class="set-row"><span class="set-lbl">${escapeHtml(tForLang(lang, 'settings.voice.ttsVoice'))}</span>
2288
2458
  <button type="button" data-tts-voice-btn class="set-btn">es-ES-Studio-C</button>
2289
2459
  </div>
2290
2460
  </div>
@@ -2293,63 +2463,63 @@ ${renderYujinSplash()}
2293
2463
  environment (quiet room / fan / open office / music). All values
2294
2464
  hot-apply on the next VAD tick; no reload needed. -->
2295
2465
  <div class="set-section vad-section">
2296
- <div class="set-section-title">VAD (deteccion de silencio)
2297
- <button type="button" data-vad-reset class="set-btn">Reset defaults</button>
2466
+ <div class="set-section-title">${escapeHtml(tForLang(lang, 'settings.vad.title'))}
2467
+ <button type="button" data-vad-reset class="set-btn">${escapeHtml(tForLang(lang, 'settings.vad.resetDefaults'))}</button>
2298
2468
  </div>
2299
- <div class="set-row"><span class="set-lbl">Motor</span>
2300
- <label class="vad-radio"><input type="radio" name="vad-engine" value="amplitude"> Amplitud (RMS)</label>
2301
- <label class="vad-radio"><input type="radio" name="vad-engine" value="freq-band"> Banda 80-400Hz</label>
2469
+ <div class="set-row"><span class="set-lbl">${escapeHtml(tForLang(lang, 'settings.vad.engine'))}</span>
2470
+ <label class="vad-radio"><input type="radio" name="vad-engine" value="amplitude"> ${escapeHtml(tForLang(lang, 'settings.vad.engineAmplitude'))}</label>
2471
+ <label class="vad-radio"><input type="radio" name="vad-engine" value="freq-band"> ${escapeHtml(tForLang(lang, 'settings.vad.engineBand'))}</label>
2302
2472
  </div>
2303
2473
  <div class="vad-slider-row" data-vad-field="baseline_ms" data-vad-min="100" data-vad-max="2000" data-vad-step="50">
2304
- <span class="vad-lbl">Baseline window</span>
2474
+ <span class="vad-lbl">${escapeHtml(tForLang(lang, 'settings.vad.baselineWindow'))}</span>
2305
2475
  <input type="range" class="vad-slider">
2306
2476
  <span class="vad-val">600 ms</span>
2307
2477
  </div>
2308
2478
  <div class="vad-slider-row" data-vad-field="threshold_mult" data-vad-min="1.0" data-vad-max="8.0" data-vad-step="0.1">
2309
- <span class="vad-lbl">Threshold mult (baseline x N)</span>
2479
+ <span class="vad-lbl">${escapeHtml(tForLang(lang, 'settings.vad.thresholdMult'))}</span>
2310
2480
  <input type="range" class="vad-slider">
2311
2481
  <span class="vad-val">3.0</span>
2312
2482
  </div>
2313
2483
  <div class="vad-slider-row" data-vad-field="min_threshold" data-vad-min="0.001" data-vad-max="0.1" data-vad-step="0.001">
2314
- <span class="vad-lbl">Threshold minimo</span>
2484
+ <span class="vad-lbl">${escapeHtml(tForLang(lang, 'settings.vad.thresholdMin'))}</span>
2315
2485
  <input type="range" class="vad-slider">
2316
2486
  <span class="vad-val">0.012</span>
2317
2487
  </div>
2318
2488
  <div class="vad-slider-row" data-vad-field="max_threshold" data-vad-min="0.005" data-vad-max="0.3" data-vad-step="0.005">
2319
- <span class="vad-lbl">Threshold maximo</span>
2489
+ <span class="vad-lbl">${escapeHtml(tForLang(lang, 'settings.vad.thresholdMax'))}</span>
2320
2490
  <input type="range" class="vad-slider">
2321
2491
  <span class="vad-val">0.06</span>
2322
2492
  </div>
2323
2493
  <div class="vad-slider-row" data-vad-field="silence_ms" data-vad-min="200" data-vad-max="4000" data-vad-step="50">
2324
- <span class="vad-lbl">Silencio para cerrar</span>
2494
+ <span class="vad-lbl">${escapeHtml(tForLang(lang, 'settings.vad.silence'))}</span>
2325
2495
  <input type="range" class="vad-slider">
2326
2496
  <span class="vad-val">1000 ms</span>
2327
2497
  </div>
2328
2498
  <div class="vad-slider-row" data-vad-field="min_voiced_ms" data-vad-min="50" data-vad-max="1000" data-vad-step="25">
2329
- <span class="vad-lbl">Voz minima (anti-blip)</span>
2499
+ <span class="vad-lbl">${escapeHtml(tForLang(lang, 'settings.vad.minVoiced'))}</span>
2330
2500
  <input type="range" class="vad-slider">
2331
2501
  <span class="vad-val">200 ms</span>
2332
2502
  </div>
2333
2503
  <div class="vad-slider-row" data-vad-field="tick_ms" data-vad-min="20" data-vad-max="200" data-vad-step="5">
2334
- <span class="vad-lbl">Tick del VAD</span>
2504
+ <span class="vad-lbl">${escapeHtml(tForLang(lang, 'settings.vad.tick'))}</span>
2335
2505
  <input type="range" class="vad-slider">
2336
2506
  <span class="vad-val">50 ms</span>
2337
2507
  </div>
2338
2508
  <div class="vad-slider-row vad-freq-only" data-vad-field="freq_low_hz" data-vad-min="20" data-vad-max="500" data-vad-step="10">
2339
- <span class="vad-lbl">Banda voz: low (Hz)</span>
2509
+ <span class="vad-lbl">${escapeHtml(tForLang(lang, 'settings.vad.bandLow'))}</span>
2340
2510
  <input type="range" class="vad-slider">
2341
2511
  <span class="vad-val">80</span>
2342
2512
  </div>
2343
2513
  <div class="vad-slider-row vad-freq-only" data-vad-field="freq_high_hz" data-vad-min="200" data-vad-max="2000" data-vad-step="10">
2344
- <span class="vad-lbl">Banda voz: high (Hz)</span>
2514
+ <span class="vad-lbl">${escapeHtml(tForLang(lang, 'settings.vad.bandHigh'))}</span>
2345
2515
  <input type="range" class="vad-slider">
2346
2516
  <span class="vad-val">400</span>
2347
2517
  </div>
2348
2518
  <div class="set-row">
2349
- <label class="vad-check"><input type="checkbox" data-vad-toggle="debug_overlay"> Mostrar overlay live</label>
2519
+ <label class="vad-check"><input type="checkbox" data-vad-toggle="debug_overlay"> ${escapeHtml(tForLang(lang, 'settings.vad.showOverlay'))}</label>
2350
2520
  </div>
2351
2521
  <div class="set-row">
2352
- <label class="vad-check"><input type="checkbox" data-vad-toggle="session_stats"> Loggear stats por sesion</label>
2522
+ <label class="vad-check"><input type="checkbox" data-vad-toggle="session_stats"> ${escapeHtml(tForLang(lang, 'settings.vad.logStats'))}</label>
2353
2523
  <span class="set-status">~/.yujin-forge/vad_sessions.jsonl</span>
2354
2524
  </div>
2355
2525
  </div>
@@ -2359,42 +2529,42 @@ ${renderYujinSplash()}
2359
2529
  <div class="set-section brain-section">
2360
2530
  <div class="set-section-title">Brain</div>
2361
2531
  <div class="set-row">
2362
- <span class="set-lbl">Anthropic: modalidad</span>
2363
- <label class="vad-radio"><input type="radio" name="brain-anthropic-mode" value="plan"> Plan (suscripcion Claude)</label>
2364
- <label class="vad-radio"><input type="radio" name="brain-anthropic-mode" value="console"> Console (API key)</label>
2532
+ <span class="set-lbl">${escapeHtml(tForLang(lang, 'settings.brain.anthropicMode'))}</span>
2533
+ <label class="vad-radio"><input type="radio" name="brain-anthropic-mode" value="plan"> ${escapeHtml(tForLang(lang, 'settings.brain.modePlan'))}</label>
2534
+ <label class="vad-radio"><input type="radio" name="brain-anthropic-mode" value="console"> ${escapeHtml(tForLang(lang, 'settings.brain.modeConsole'))}</label>
2365
2535
  </div>
2366
2536
  <div class="set-row">
2367
- <span class="set-status" data-brain-mode-note hidden>Se aplica al reabrir el panel.</span>
2537
+ <span class="set-status" data-brain-mode-note hidden>${escapeHtml(tForLang(lang, 'settings.brain.modeNote'))}</span>
2368
2538
  </div>
2369
2539
  <div class="set-row">
2370
- <label class="vad-check"><input type="checkbox" data-brain-eco-enabled> Modo eco habilitado</label>
2371
- <span class="set-status">apagado = frontier siempre</span>
2540
+ <label class="vad-check"><input type="checkbox" data-brain-eco-enabled> ${escapeHtml(tForLang(lang, 'settings.brain.ecoEnabled'))}</label>
2541
+ <span class="set-status">${escapeHtml(tForLang(lang, 'settings.brain.ecoHint'))}</span>
2372
2542
  </div>
2373
2543
  <div class="set-row">
2374
2544
  <label class="vad-check"><input type="checkbox" data-startup-tabs-enabled> ${escapeHtml(tr.startupTabsLabel)}</label>
2375
2545
  </div>
2376
2546
  <div class="set-row" data-brain-tier-row="eco">
2377
- <span class="set-lbl">Modelo eco</span>
2547
+ <span class="set-lbl">${escapeHtml(tForLang(lang, 'settings.brain.modelEco'))}</span>
2378
2548
  <select class="set-select" data-brain-provider="eco"></select>
2379
2549
  <select class="set-select" data-brain-model="eco"></select>
2380
2550
  </div>
2381
2551
  <div class="set-row" data-brain-tier-row="frontier">
2382
- <span class="set-lbl">Modelo frontier</span>
2552
+ <span class="set-lbl">${escapeHtml(tForLang(lang, 'settings.brain.modelFrontier'))}</span>
2383
2553
  <select class="set-select" data-brain-provider="frontier"></select>
2384
2554
  <select class="set-select" data-brain-model="frontier"></select>
2385
2555
  </div>
2386
2556
  <div class="set-row" data-brain-tier-row="multimodal">
2387
- <span class="set-lbl">Modelo multimodal</span>
2557
+ <span class="set-lbl">${escapeHtml(tForLang(lang, 'settings.brain.modelMultimodal'))}</span>
2388
2558
  <select class="set-select" data-brain-provider="multimodal"></select>
2389
2559
  <select class="set-select" data-brain-model="multimodal"></select>
2390
- <button type="button" class="set-btn" data-brain-multimodal-clear title="Quitar el brain multimodal dedicado (vuelve al ruteo de vision automatico)">Auto</button>
2560
+ <button type="button" class="set-btn" data-brain-multimodal-clear title="${escapeHtml(tForLang(lang, 'settings.brain.multimodalClearTitle'))}">${escapeHtml(tForLang(lang, 'settings.brain.auto'))}</button>
2391
2561
  </div>
2392
2562
  <div class="set-row">
2393
- <span class="set-status">Brain dedicado para turnos con imagen. Auto = ruteo de vision automatico (el frontier si ve, o aviso si no).</span>
2563
+ <span class="set-status">${escapeHtml(tForLang(lang, 'settings.brain.multimodalHint'))}</span>
2394
2564
  </div>
2395
2565
  <div class="set-row">
2396
- <label class="vad-check"><input type="checkbox" data-gemini-explicit-cache> Cache explicito de Gemini (CachedContent)</label>
2397
- <span class="set-status">Opt-in. Conviene en sesiones largas con Gemini Pro; en sesiones cortas el cache implicito gratis de 2.5 ya alcanza.</span>
2566
+ <label class="vad-check"><input type="checkbox" data-gemini-explicit-cache> ${escapeHtml(tForLang(lang, 'settings.brain.geminiCache'))}</label>
2567
+ <span class="set-status">${escapeHtml(tForLang(lang, 'settings.brain.geminiCacheHint'))}</span>
2398
2568
  </div>
2399
2569
  <div class="set-row">
2400
2570
  <span class="set-status" data-brain-status></span>
@@ -2403,129 +2573,129 @@ ${renderYujinSplash()}
2403
2573
  <!-- F4 brain federation -- conectar al brain de otro Forge
2404
2574
  (child) y generar codigos para autorizar/rutear (parent). -->
2405
2575
  <div class="set-section federation-section">
2406
- <div class="set-section-title">Federacion de brain</div>
2576
+ <div class="set-section-title">${escapeHtml(tForLang(lang, 'settings.federation.title'))}</div>
2407
2577
  <div class="set-row">
2408
- <span class="set-status">Usa el brain de otro Forge (su key nunca sale de su maquina) o presta el tuyo. Tu codigo se ejecuta local; solo la inferencia viaja.</span>
2578
+ <span class="set-status">${escapeHtml(tForLang(lang, 'settings.federation.intro'))}</span>
2409
2579
  </div>
2410
2580
  <!-- Estado actual (cuando estas federado como hijo) -->
2411
2581
  <div class="set-row fed-linked" data-fed-linked hidden>
2412
- <span class="set-lbl">Conectado a</span>
2582
+ <span class="set-lbl">${escapeHtml(tForLang(lang, 'settings.federation.connectedTo'))}</span>
2413
2583
  <span class="set-status" data-fed-parent>--</span>
2414
- <button type="button" class="set-btn set-btn-danger" data-fed-leave>Desconectar</button>
2584
+ <button type="button" class="set-btn set-btn-danger" data-fed-leave>${escapeHtml(tForLang(lang, 'settings.federation.disconnect'))}</button>
2415
2585
  </div>
2416
2586
  <!-- Conectarme a otro Forge (child) -->
2417
2587
  <div class="set-row" data-fed-join-row>
2418
- <span class="set-lbl">Conectarme</span>
2419
- <input type="text" class="set-input" data-fed-token placeholder="Pega el codigo de federacion" autocomplete="off" spellcheck="false">
2420
- <button type="button" class="set-btn" data-fed-join>Conectar</button>
2588
+ <span class="set-lbl">${escapeHtml(tForLang(lang, 'settings.federation.connectMe'))}</span>
2589
+ <input type="text" class="set-input" data-fed-token placeholder="${escapeHtml(tForLang(lang, 'settings.federation.tokenPlaceholder'))}" autocomplete="off" spellcheck="false">
2590
+ <button type="button" class="set-btn" data-fed-join>${escapeHtml(tForLang(lang, 'settings.federation.connect'))}</button>
2421
2591
  </div>
2422
2592
  <!-- Prestar mi brain (parent) -->
2423
2593
  <div class="set-row" data-fed-invite-row>
2424
- <span class="set-lbl">Prestar mi brain</span>
2425
- <button type="button" class="set-btn" data-fed-invite>Generar codigo</button>
2426
- <label class="vad-check" data-fed-host-wrap hidden><input type="checkbox" data-fed-host> Hospedar (responder a los hijos)</label>
2594
+ <span class="set-lbl">${escapeHtml(tForLang(lang, 'settings.federation.lendBrain'))}</span>
2595
+ <button type="button" class="set-btn" data-fed-invite>${escapeHtml(tForLang(lang, 'settings.federation.generateCode'))}</button>
2596
+ <label class="vad-check" data-fed-host-wrap hidden><input type="checkbox" data-fed-host> ${escapeHtml(tForLang(lang, 'settings.federation.host'))}</label>
2427
2597
  </div>
2428
2598
  <div class="set-row" data-fed-brain-row hidden>
2429
- <span class="set-lbl">Brain de federacion</span>
2599
+ <span class="set-lbl">${escapeHtml(tForLang(lang, 'settings.federation.fedBrain'))}</span>
2430
2600
  <select class="set-select" data-fed-brain-provider></select>
2431
2601
  <select class="set-select" data-fed-brain-model></select>
2432
2602
  </div>
2433
2603
  <div class="set-row" data-fed-brain-note hidden>
2434
- <span class="set-status">El que responde a los hijos. API directa (DeepSeek por defecto). Claude Plan no sirve para hospedar; necesita una key con saldo.</span>
2604
+ <span class="set-status">${escapeHtml(tForLang(lang, 'settings.federation.fedBrainNote'))}</span>
2435
2605
  </div>
2436
2606
  <div class="set-row" data-fed-token-out-row hidden>
2437
- <span class="set-lbl">Codigo (1 solo uso)</span>
2607
+ <span class="set-lbl">${escapeHtml(tForLang(lang, 'settings.federation.codeOnce'))}</span>
2438
2608
  <input type="text" class="set-input" data-fed-token-out readonly>
2439
- <button type="button" class="set-btn" data-fed-copy>Copiar</button>
2609
+ <button type="button" class="set-btn" data-fed-copy>${escapeHtml(tForLang(lang, 'settings.federation.copy'))}</button>
2440
2610
  </div>
2441
2611
  <div class="set-row">
2442
2612
  <span class="set-status" data-fed-status></span>
2443
2613
  </div>
2444
2614
  </div>
2445
2615
  <div class="set-section">
2446
- <div class="set-section-title">API keys (BYOK)</div>
2447
- <div class="set-key-row" data-key-slot="anthropic_api_key"><span class="set-lbl">Brain (Anthropic)</span>
2616
+ <div class="set-section-title">${escapeHtml(tForLang(lang, 'settings.keys.title'))}</div>
2617
+ <div class="set-key-row" data-key-slot="anthropic_api_key"><span class="set-lbl">${escapeHtml(tForLang(lang, 'settings.keys.brainAnthropic'))}</span>
2448
2618
  <span class="set-status" data-key-status>--</span>
2449
- <button type="button" class="set-btn" data-key-edit>Configurar</button>
2450
- <button type="button" class="set-btn set-btn-danger" data-key-clear hidden>Quitar</button>
2619
+ <button type="button" class="set-btn" data-key-edit>${escapeHtml(tForLang(lang, 'settings.keys.configure'))}</button>
2620
+ <button type="button" class="set-btn set-btn-danger" data-key-clear hidden>${escapeHtml(tForLang(lang, 'settings.keys.remove'))}</button>
2451
2621
  </div>
2452
- <div class="set-key-row" data-key-slot="openai_api_key"><span class="set-lbl">Brain (OpenAI)</span>
2622
+ <div class="set-key-row" data-key-slot="openai_api_key"><span class="set-lbl">${escapeHtml(tForLang(lang, 'settings.keys.brainOpenai'))}</span>
2453
2623
  <span class="set-status" data-key-status>--</span>
2454
- <button type="button" class="set-btn" data-key-edit>Configurar</button>
2455
- <button type="button" class="set-btn set-btn-danger" data-key-clear hidden>Quitar</button>
2624
+ <button type="button" class="set-btn" data-key-edit>${escapeHtml(tForLang(lang, 'settings.keys.configure'))}</button>
2625
+ <button type="button" class="set-btn set-btn-danger" data-key-clear hidden>${escapeHtml(tForLang(lang, 'settings.keys.remove'))}</button>
2456
2626
  </div>
2457
2627
  <!-- Bug 1 (Pablo 2026-06-10) -- faltaban los slots de los
2458
2628
  demas brain providers BYOK que el selector de modelo
2459
2629
  (PND-069) ya ofrece. Slots reales de provider_keys.ts. -->
2460
- <div class="set-key-row" data-key-slot="google_ai_key"><span class="set-lbl">Brain (Google IA)</span>
2630
+ <div class="set-key-row" data-key-slot="google_ai_key"><span class="set-lbl">${escapeHtml(tForLang(lang, 'settings.keys.brainGoogle'))}</span>
2461
2631
  <span class="set-status" data-key-status>--</span>
2462
- <button type="button" class="set-btn" data-key-edit>Configurar</button>
2463
- <button type="button" class="set-btn set-btn-danger" data-key-clear hidden>Quitar</button>
2632
+ <button type="button" class="set-btn" data-key-edit>${escapeHtml(tForLang(lang, 'settings.keys.configure'))}</button>
2633
+ <button type="button" class="set-btn set-btn-danger" data-key-clear hidden>${escapeHtml(tForLang(lang, 'settings.keys.remove'))}</button>
2464
2634
  </div>
2465
- <div class="set-key-row" data-key-slot="deepseek_api_key"><span class="set-lbl">Brain (DeepSeek)</span>
2635
+ <div class="set-key-row" data-key-slot="deepseek_api_key"><span class="set-lbl">${escapeHtml(tForLang(lang, 'settings.keys.brainDeepseek'))}</span>
2466
2636
  <span class="set-status" data-key-status>--</span>
2467
- <button type="button" class="set-btn" data-key-edit>Configurar</button>
2468
- <button type="button" class="set-btn set-btn-danger" data-key-clear hidden>Quitar</button>
2637
+ <button type="button" class="set-btn" data-key-edit>${escapeHtml(tForLang(lang, 'settings.keys.configure'))}</button>
2638
+ <button type="button" class="set-btn set-btn-danger" data-key-clear hidden>${escapeHtml(tForLang(lang, 'settings.keys.remove'))}</button>
2469
2639
  </div>
2470
- <div class="set-key-row" data-key-slot="xai_api_key"><span class="set-lbl">Brain (xAI / Grok)</span>
2640
+ <div class="set-key-row" data-key-slot="xai_api_key"><span class="set-lbl">${escapeHtml(tForLang(lang, 'settings.keys.brainXai'))}</span>
2471
2641
  <span class="set-status" data-key-status>--</span>
2472
- <button type="button" class="set-btn" data-key-edit>Configurar</button>
2473
- <button type="button" class="set-btn set-btn-danger" data-key-clear hidden>Quitar</button>
2642
+ <button type="button" class="set-btn" data-key-edit>${escapeHtml(tForLang(lang, 'settings.keys.configure'))}</button>
2643
+ <button type="button" class="set-btn set-btn-danger" data-key-clear hidden>${escapeHtml(tForLang(lang, 'settings.keys.remove'))}</button>
2474
2644
  </div>
2475
- <div class="set-key-row" data-key-slot="mistral_api_key"><span class="set-lbl">Brain (Mistral)</span>
2645
+ <div class="set-key-row" data-key-slot="mistral_api_key"><span class="set-lbl">${escapeHtml(tForLang(lang, 'settings.keys.brainMistral'))}</span>
2476
2646
  <span class="set-status" data-key-status>--</span>
2477
- <button type="button" class="set-btn" data-key-edit>Configurar</button>
2478
- <button type="button" class="set-btn set-btn-danger" data-key-clear hidden>Quitar</button>
2647
+ <button type="button" class="set-btn" data-key-edit>${escapeHtml(tForLang(lang, 'settings.keys.configure'))}</button>
2648
+ <button type="button" class="set-btn set-btn-danger" data-key-clear hidden>${escapeHtml(tForLang(lang, 'settings.keys.remove'))}</button>
2479
2649
  </div>
2480
- <div class="set-key-row" data-key-slot="cohere_api_key"><span class="set-lbl">Brain (Cohere)</span>
2650
+ <div class="set-key-row" data-key-slot="cohere_api_key"><span class="set-lbl">${escapeHtml(tForLang(lang, 'settings.keys.brainCohere'))}</span>
2481
2651
  <span class="set-status" data-key-status>--</span>
2482
- <button type="button" class="set-btn" data-key-edit>Configurar</button>
2483
- <button type="button" class="set-btn set-btn-danger" data-key-clear hidden>Quitar</button>
2652
+ <button type="button" class="set-btn" data-key-edit>${escapeHtml(tForLang(lang, 'settings.keys.configure'))}</button>
2653
+ <button type="button" class="set-btn set-btn-danger" data-key-clear hidden>${escapeHtml(tForLang(lang, 'settings.keys.remove'))}</button>
2484
2654
  </div>
2485
- <div class="set-key-row" data-key-slot="google_stt_key"><span class="set-lbl">STT Google</span>
2655
+ <div class="set-key-row" data-key-slot="google_stt_key"><span class="set-lbl">${escapeHtml(tForLang(lang, 'settings.keys.sttGoogle'))}</span>
2486
2656
  <span class="set-status" data-key-status>--</span>
2487
- <button type="button" class="set-btn" data-key-edit>Configurar</button>
2488
- <button type="button" class="set-btn set-btn-danger" data-key-clear hidden>Quitar</button>
2657
+ <button type="button" class="set-btn" data-key-edit>${escapeHtml(tForLang(lang, 'settings.keys.configure'))}</button>
2658
+ <button type="button" class="set-btn set-btn-danger" data-key-clear hidden>${escapeHtml(tForLang(lang, 'settings.keys.remove'))}</button>
2489
2659
  </div>
2490
- <div class="set-key-row" data-key-slot="google_tts_key"><span class="set-lbl">TTS Google</span>
2660
+ <div class="set-key-row" data-key-slot="google_tts_key"><span class="set-lbl">${escapeHtml(tForLang(lang, 'settings.keys.ttsGoogle'))}</span>
2491
2661
  <span class="set-status" data-key-status>--</span>
2492
- <button type="button" class="set-btn" data-key-edit>Configurar</button>
2493
- <button type="button" class="set-btn set-btn-danger" data-key-clear hidden>Quitar</button>
2662
+ <button type="button" class="set-btn" data-key-edit>${escapeHtml(tForLang(lang, 'settings.keys.configure'))}</button>
2663
+ <button type="button" class="set-btn set-btn-danger" data-key-clear hidden>${escapeHtml(tForLang(lang, 'settings.keys.remove'))}</button>
2494
2664
  </div>
2495
- <div class="set-key-row" data-key-slot="elevenlabs_api_key"><span class="set-lbl">TTS ElevenLabs</span>
2665
+ <div class="set-key-row" data-key-slot="elevenlabs_api_key"><span class="set-lbl">${escapeHtml(tForLang(lang, 'settings.keys.ttsElevenlabs'))}</span>
2496
2666
  <span class="set-status" data-key-status>--</span>
2497
- <button type="button" class="set-btn" data-key-edit>Configurar</button>
2498
- <button type="button" class="set-btn set-btn-danger" data-key-clear hidden>Quitar</button>
2667
+ <button type="button" class="set-btn" data-key-edit>${escapeHtml(tForLang(lang, 'settings.keys.configure'))}</button>
2668
+ <button type="button" class="set-btn set-btn-danger" data-key-clear hidden>${escapeHtml(tForLang(lang, 'settings.keys.remove'))}</button>
2499
2669
  </div>
2500
- <div class="set-key-row" data-key-slot="whisper_api_key"><span class="set-lbl">STT Whisper</span>
2670
+ <div class="set-key-row" data-key-slot="whisper_api_key"><span class="set-lbl">${escapeHtml(tForLang(lang, 'settings.keys.sttWhisper'))}</span>
2501
2671
  <span class="set-status" data-key-status>--</span>
2502
- <button type="button" class="set-btn" data-key-edit>Configurar</button>
2503
- <button type="button" class="set-btn set-btn-danger" data-key-clear hidden>Quitar</button>
2672
+ <button type="button" class="set-btn" data-key-edit>${escapeHtml(tForLang(lang, 'settings.keys.configure'))}</button>
2673
+ <button type="button" class="set-btn set-btn-danger" data-key-clear hidden>${escapeHtml(tForLang(lang, 'settings.keys.remove'))}</button>
2504
2674
  </div>
2505
2675
  </div>
2506
2676
  <div class="set-section">
2507
- <div class="set-section-title">Idioma</div>
2508
- <div class="set-row"><span class="set-lbl">Panel + voz</span>
2509
- <button type="button" data-lang-open class="set-btn">${escapeHtml(tr.languageButton)} cambiar</button>
2677
+ <div class="set-section-title">${escapeHtml(tForLang(lang, 'settings.lang.title'))}</div>
2678
+ <div class="set-row"><span class="set-lbl">${escapeHtml(tForLang(lang, 'settings.lang.panelVoice'))}</span>
2679
+ <button type="button" data-lang-open class="set-btn">${escapeHtml(tForLang(lang, 'settings.lang.changeButton'))}</button>
2510
2680
  </div>
2511
2681
  </div>
2512
2682
  <div class="set-section">
2513
- <div class="set-section-title">Vault (encriptado)</div>
2514
- <div class="set-row"><span class="set-lbl">Slots</span>
2515
- <button type="button" data-vault-open class="set-btn">Abrir vault</button>
2683
+ <div class="set-section-title">${escapeHtml(tForLang(lang, 'settings.vault.title'))}</div>
2684
+ <div class="set-row"><span class="set-lbl">${escapeHtml(tForLang(lang, 'settings.vault.slots'))}</span>
2685
+ <button type="button" data-vault-open class="set-btn">${escapeHtml(tForLang(lang, 'settings.vault.open'))}</button>
2516
2686
  </div>
2517
2687
  </div>
2518
2688
  <div class="set-section">
2519
- <div class="set-section-title">License</div>
2520
- <div class="set-row"><span class="set-lbl">Plan</span>
2689
+ <div class="set-section-title">${escapeHtml(tForLang(lang, 'settings.license.title'))}</div>
2690
+ <div class="set-row"><span class="set-lbl">${escapeHtml(tForLang(lang, 'settings.plan'))}</span>
2521
2691
  <span class="set-status" id="set-license-status">--</span>
2522
2692
  </div>
2523
2693
  <div class="set-row" id="set-license-user-handle-row" style="display:none;">
2524
- <span class="set-lbl">Email</span>
2694
+ <span class="set-lbl">${escapeHtml(tForLang(lang, 'settings.license.email'))}</span>
2525
2695
  <span class="set-status" id="set-license-user-handle">--</span>
2526
2696
  </div>
2527
2697
  <div class="set-row" id="set-license-validated-row" style="display:none;">
2528
- <span class="set-lbl">Validado</span>
2698
+ <span class="set-lbl">${escapeHtml(tForLang(lang, 'settings.license.validated'))}</span>
2529
2699
  <span class="set-status" id="set-license-validated">--</span>
2530
2700
  </div>
2531
2701
 
@@ -2534,23 +2704,22 @@ ${renderYujinSplash()}
2534
2704
  /api/forge/license-handle -> writes handle + syncs. -->
2535
2705
  <div id="set-license-connect-block" style="display:none;border:1px dashed var(--border, rgba(255,255,255,0.18));border-radius:6px;padding:10px;margin-top:6px;background:var(--bg-2, #131620);">
2536
2706
  <div style="font-size:11px;opacity:0.85;margin-bottom:8px;">
2537
- Conecta tu cuenta de Polar. Pone el email con el que pagaste y
2538
- sincronizamos tu plan automaticamente.
2707
+ ${escapeHtml(tForLang(lang, 'settings.license.connectHint'))}
2539
2708
  </div>
2540
2709
  <div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap;">
2541
2710
  <input type="email" id="set-license-connect-email"
2542
- placeholder="email@ejemplo.com"
2711
+ placeholder="${escapeHtml(tForLang(lang, 'settings.license.emailPlaceholder'))}"
2543
2712
  style="flex:1;min-width:200px;background:var(--bg-1,#1a1d27);color:var(--ink,#e5e7eb);border:1px solid var(--border, rgba(255,255,255,0.18));padding:5px 8px;border-radius:4px;font-size:12px;"
2544
2713
  autocomplete="email" />
2545
- <button type="button" class="set-btn" id="set-license-connect-btn">Conectar</button>
2714
+ <button type="button" class="set-btn" id="set-license-connect-btn">${escapeHtml(tForLang(lang, 'settings.license.connect'))}</button>
2546
2715
  </div>
2547
2716
  <div id="set-license-connect-msg" style="font-size:11px;opacity:0.85;margin-top:6px;display:none;"></div>
2548
2717
  </div>
2549
2718
 
2550
2719
  <div class="set-row" id="set-license-sync-row" style="gap:6px;display:none;">
2551
- <button type="button" class="set-btn" id="set-license-sync-btn">Sincronizar con Polar</button>
2720
+ <button type="button" class="set-btn" id="set-license-sync-btn">${escapeHtml(tForLang(lang, 'settings.license.sync'))}</button>
2552
2721
  <a href="https://yujin.app/yujin-forge/"
2553
- target="_blank" rel="noopener" class="set-link">Comprar / activar plan</a>
2722
+ target="_blank" rel="noopener" class="set-link">${escapeHtml(tForLang(lang, 'settings.license.buy'))}</a>
2554
2723
  </div>
2555
2724
  <div class="set-row" id="set-license-sync-msg-row" style="display:none;">
2556
2725
  <span class="set-status" id="set-license-sync-msg" style="font-size:11px;opacity:0.85;">--</span>
@@ -2560,15 +2729,15 @@ ${renderYujinSplash()}
2560
2729
  tier + renewal date + buy-extension link. Auto-refreshes
2561
2730
  on settings open + after each chat response. -->
2562
2731
  <div class="set-section" data-nac-id="yujin.panel.budget" data-nac-role="region">
2563
- <div class="set-section-title">Presupuesto este mes</div>
2564
- <div class="set-row"><span class="set-lbl">Plan</span>
2732
+ <div class="set-section-title">${escapeHtml(tForLang(lang, 'settings.budget.title'))}</div>
2733
+ <div class="set-row"><span class="set-lbl">${escapeHtml(tForLang(lang, 'settings.plan'))}</span>
2565
2734
  <span class="set-status" id="set-budget-plan">--</span>
2566
2735
  </div>
2567
- <div class="set-row"><span class="set-lbl">Renueva</span>
2736
+ <div class="set-row"><span class="set-lbl">${escapeHtml(tForLang(lang, 'settings.budget.renews'))}</span>
2568
2737
  <span class="set-status" id="set-budget-renewal">--</span>
2569
2738
  </div>
2570
2739
  <div class="set-row" id="set-budget-eco-row">
2571
- <span class="set-lbl">Tokens eco</span>
2740
+ <span class="set-lbl">${escapeHtml(tForLang(lang, 'settings.budget.tokensEco'))}</span>
2572
2741
  <span class="set-status" id="set-budget-eco-numbers">--</span>
2573
2742
  </div>
2574
2743
  <div class="set-row" id="set-budget-eco-bar-row" style="margin-top:-6px;">
@@ -2577,7 +2746,7 @@ ${renderYujinSplash()}
2577
2746
  </div>
2578
2747
  </div>
2579
2748
  <div class="set-row" id="set-budget-frontier-row">
2580
- <span class="set-lbl">Tokens frontier</span>
2749
+ <span class="set-lbl">${escapeHtml(tForLang(lang, 'settings.budget.tokensFrontier'))}</span>
2581
2750
  <span class="set-status" id="set-budget-frontier-numbers">--</span>
2582
2751
  </div>
2583
2752
  <div class="set-row" id="set-budget-frontier-bar-row" style="margin-top:-6px;">
@@ -2586,18 +2755,18 @@ ${renderYujinSplash()}
2586
2755
  </div>
2587
2756
  </div>
2588
2757
  <div class="set-row" id="set-budget-extension-row" style="display:none;">
2589
- <a href="#" target="_blank" rel="noopener" class="set-link" id="set-budget-extension-link">Comprar extension de tokens</a>
2758
+ <a href="#" target="_blank" rel="noopener" class="set-link" id="set-budget-extension-link">${escapeHtml(tForLang(lang, 'settings.budget.buyExtension'))}</a>
2590
2759
  </div>
2591
2760
  </div>
2592
2761
  <!-- alpha.59c.2 -- Limits card. Cada limite es un input
2593
2762
  numerico; el default va al lado en una pill chica.
2594
2763
  Save por blur. Reset restaura todos. -->
2595
2764
  <div class="set-section" data-nac-id="yujin.panel.limits" data-nac-role="region">
2596
- <div class="set-section-title">Limites (avanzado)</div>
2597
- <div class="set-row"><span class="set-lbl" style="opacity:0.7;">Editar cualquier valor + tab/blur guarda.</span></div>
2765
+ <div class="set-section-title">${escapeHtml(tForLang(lang, 'settings.limits.title'))}</div>
2766
+ <div class="set-row"><span class="set-lbl" style="opacity:0.7;">${escapeHtml(tForLang(lang, 'settings.limits.editHint'))}</span></div>
2598
2767
  <div id="set-limits-fields"></div>
2599
2768
  <div class="set-row" style="justify-content:flex-end;gap:6px;">
2600
- <button type="button" class="set-btn" id="set-limits-reset">Reset a defaults</button>
2769
+ <button type="button" class="set-btn" id="set-limits-reset">${escapeHtml(tForLang(lang, 'settings.limits.reset'))}</button>
2601
2770
  </div>
2602
2771
  <div id="set-limits-msg" class="ke-msg"></div>
2603
2772
  </div>
@@ -2607,10 +2776,10 @@ ${renderYujinSplash()}
2607
2776
  Click row -> envia mensaje hidden al LLM con el
2608
2777
  comando para invocar yujin.reader.bookmark-jump. -->
2609
2778
  <div class="set-section" data-nac-id="yujin.panel.reader-bookmarks" data-nac-role="region">
2610
- <div class="set-section-title">Reader bookmarks</div>
2779
+ <div class="set-section-title">${escapeHtml(tForLang(lang, 'settings.bookmarks.title'))}</div>
2611
2780
  <div id="set-bookmarks-list" class="bookmark-list"></div>
2612
2781
  <div class="set-row" style="justify-content:flex-end;">
2613
- <button type="button" class="set-btn" id="set-bookmarks-refresh">Refrescar</button>
2782
+ <button type="button" class="set-btn" id="set-bookmarks-refresh">${escapeHtml(tForLang(lang, 'pizarron.graph.refresh'))}</button>
2614
2783
  </div>
2615
2784
  </div>
2616
2785
 
@@ -2619,9 +2788,9 @@ ${renderYujinSplash()}
2619
2788
  user know whether the inverse-hallucination guard is
2620
2789
  populated. CLI for full CRUD: yf graph add-node. -->
2621
2790
  <div class="set-section" data-nac-id="yujin.panel.graph-status" data-nac-role="region">
2622
- <div class="set-section-title">Project knowledge graph</div>
2791
+ <div class="set-section-title">${escapeHtml(tForLang(lang, 'settings.graph.title'))}</div>
2623
2792
  <div class="set-row" id="set-graph-summary">
2624
- <span class="set-lbl">Cargando...</span>
2793
+ <span class="set-lbl">${escapeHtml(tForLang(lang, 'vault.list.loading'))}</span>
2625
2794
  </div>
2626
2795
  <div id="set-graph-samples" class="graph-samples"></div>
2627
2796
  <div class="set-row" style="justify-content:space-between;align-items:center;">
@@ -2633,18 +2802,18 @@ ${renderYujinSplash()}
2633
2802
  <!-- Vincular telefono (dogfood 2026-06-10; Option 3 2026-06-16 --
2634
2803
  code-less: el QR es autocontenido, se escanea y listo). -->
2635
2804
  <div class="set-section">
2636
- <div class="set-section-title">Vincular telefono</div>
2805
+ <div class="set-section-title">${escapeHtml(tForLang(lang, 'settings.pair.title'))}</div>
2637
2806
  <div class="set-row">
2638
- <span class="set-lbl" style="opacity:0.7;font-size:11px;">Abri Forge Mobile, toca "Vincular" y escanea este QR. Listo.</span>
2639
- <button type="button" class="set-btn" id="set-pair-generate">Generar QR</button>
2807
+ <span class="set-lbl" style="opacity:0.7;font-size:11px;">${escapeHtml(tForLang(lang, 'settings.pair.hint'))}</span>
2808
+ <button type="button" class="set-btn" id="set-pair-generate">${escapeHtml(tForLang(lang, 'settings.pair.generateQr'))}</button>
2640
2809
  </div>
2641
2810
  <div id="set-pair-result" class="hidden" style="text-align:center;padding:8px 0;">
2642
2811
  <div id="set-pair-qr" style="display:inline-block;background:#ffffff;padding:10px;border-radius:8px;width:220px;height:220px;"></div>
2643
2812
  <div id="set-pair-expiry" style="font-size:11px;opacity:0.6;margin-top:6px;"></div>
2644
- <div style="font-size:11px;opacity:0.7;margin-top:10px;">La camara no lee el QR? Copia este codigo y pegalo en Forge Mobile:</div>
2813
+ <div style="font-size:11px;opacity:0.7;margin-top:10px;">${escapeHtml(tForLang(lang, 'settings.pair.cantRead'))}</div>
2645
2814
  <div style="display:flex;gap:6px;align-items:center;justify-content:center;margin-top:4px;">
2646
2815
  <input id="set-pair-code" readonly style="flex:1;max-width:210px;font-size:10px;font-family:monospace;padding:4px 6px;border:1px solid var(--border);border-radius:4px;background:var(--bg-1);color:var(--ink);" />
2647
- <button type="button" class="set-btn" id="set-pair-copy">Copiar</button>
2816
+ <button type="button" class="set-btn" id="set-pair-copy">${escapeHtml(tForLang(lang, 'settings.federation.copy'))}</button>
2648
2817
  </div>
2649
2818
  </div>
2650
2819
  <div id="set-pair-error" class="hidden" style="font-size:12px;color:var(--err,#c0392b);padding:6px 0;"></div>
@@ -2655,18 +2824,18 @@ ${renderYujinSplash()}
2655
2824
  del config. Click en "Ver completo" abre el audit modal
2656
2825
  con filtros + lista paginada + detail inline. -->
2657
2826
  <div class="set-section audit-log" data-nac-id="yujin.panel.audit-log" data-nac-role="region">
2658
- <div class="set-section-title">Audit log</div>
2827
+ <div class="set-section-title">${escapeHtml(tForLang(lang, 'settings.audit.title'))}</div>
2659
2828
  <div id="set-audit-preview" class="audit-preview">
2660
- <div class="audit-preview-empty">Cargando...</div>
2829
+ <div class="audit-preview-empty">${escapeHtml(tForLang(lang, 'vault.list.loading'))}</div>
2661
2830
  </div>
2662
2831
  <div class="set-row" style="justify-content:flex-end;gap:6px;margin-top:6px;">
2663
- <button type="button" class="set-btn" id="set-audit-refresh">Refrescar</button>
2664
- <button type="button" class="set-btn" id="set-audit-open-modal">Ver completo</button>
2832
+ <button type="button" class="set-btn" id="set-audit-refresh">${escapeHtml(tForLang(lang, 'pizarron.graph.refresh'))}</button>
2833
+ <button type="button" class="set-btn" id="set-audit-open-modal">${escapeHtml(tForLang(lang, 'settings.audit.viewFull'))}</button>
2665
2834
  </div>
2666
2835
  </div>
2667
2836
  <!-- z.111 -- About / legal docs. Populated by fetchAndRenderAbout() on first settings open. -->
2668
2837
  <div class="set-section about-section" data-nac-id="yujin.panel.about" data-nac-role="region">
2669
- <div class="set-section-title">About</div>
2838
+ <div class="set-section-title">${escapeHtml(tForLang(lang, 'settings.about.title'))}</div>
2670
2839
  <div class="set-row" style="font-size:11px; color:var(--ink-2);">
2671
2840
  Forge <span id="set-about-version">--</span>
2672
2841
  </div>
@@ -2681,12 +2850,10 @@ ${renderYujinSplash()}
2681
2850
  aparece + cada accion se loguea con
2682
2851
  approval_mode='danger_zone_bypass'. -->
2683
2852
  <div class="set-section danger-zone" data-nac-id="yujin.panel.danger-zone" data-nac-role="region">
2684
- <div class="set-section-title" style="color:#d97706;">⚠ Danger Zone</div>
2853
+ <div class="set-section-title" style="color:#d97706;">⚠ ${escapeHtml(tForLang(lang, 'settings.danger.title'))}</div>
2685
2854
  <div class="set-row" style="opacity:0.8;">
2686
2855
  <span class="set-lbl" style="font-size:11px;">
2687
- Cuando esta ON, Yujin ejecuta TODA accion sin pedir confirmacion
2688
- (incluye shell.exec, rm, git push --force, etc). Velocidad sobre
2689
- seguridad. Todo queda registrado en audit log.
2856
+ ${escapeHtml(tForLang(lang, 'settings.danger.warning'))}
2690
2857
  </span>
2691
2858
  </div>
2692
2859
  <div id="set-danger-toggles"></div>
@@ -2701,22 +2868,22 @@ ${renderYujinSplash()}
2701
2868
  <div class="audit-modal-backdrop" data-audit-close></div>
2702
2869
  <div class="audit-modal-frame" role="dialog" aria-modal="true">
2703
2870
  <div class="audit-modal-header">
2704
- <div class="audit-modal-title">Audit log</div>
2705
- <button type="button" class="set-btn" data-audit-close>Cerrar</button>
2871
+ <div class="audit-modal-title">${escapeHtml(tForLang(lang, 'settings.audit.title'))}</div>
2872
+ <button type="button" class="set-btn" data-audit-close>${escapeHtml(tForLang(lang, 'settings.audit.close'))}</button>
2706
2873
  </div>
2707
2874
  <div class="audit-modal-toolbar">
2708
2875
  <select id="audit-modal-filter-kind" class="audit-filter">
2709
- <option value="">Todos los kinds</option>
2876
+ <option value="">${escapeHtml(tForLang(lang, 'settings.audit.allKinds'))}</option>
2710
2877
  </select>
2711
2878
  <select id="audit-modal-filter-approval" class="audit-filter">
2712
- <option value="">Todos los modes</option>
2879
+ <option value="">${escapeHtml(tForLang(lang, 'settings.audit.allModes'))}</option>
2713
2880
  <option value="auto_approved">auto_approved</option>
2714
2881
  <option value="user_approved">user_approved</option>
2715
2882
  <option value="user_rejected">user_rejected</option>
2716
2883
  <option value="danger_zone_bypass">danger_zone_bypass</option>
2717
2884
  <option value="not_applicable">not_applicable</option>
2718
2885
  </select>
2719
- <button type="button" class="set-btn" id="audit-modal-refresh">Refrescar</button>
2886
+ <button type="button" class="set-btn" id="audit-modal-refresh">${escapeHtml(tForLang(lang, 'pizarron.graph.refresh'))}</button>
2720
2887
  <span id="audit-modal-count" style="font-size:11px;opacity:0.6;margin-left:auto;"></span>
2721
2888
  </div>
2722
2889
  <div id="audit-modal-list" class="audit-modal-list"></div>
@@ -2727,8 +2894,8 @@ ${renderYujinSplash()}
2727
2894
  <div class="audit-modal-detail-sub" id="audit-modal-detail-sub">--</div>
2728
2895
  </div>
2729
2896
  <div style="display:flex;gap:6px;">
2730
- <button type="button" class="set-btn" id="audit-modal-detail-copy">Copiar JSON</button>
2731
- <button type="button" class="set-btn" id="audit-modal-detail-close">Cerrar detalle</button>
2897
+ <button type="button" class="set-btn" id="audit-modal-detail-copy">${escapeHtml(tForLang(lang, 'settings.audit.copyJson'))}</button>
2898
+ <button type="button" class="set-btn" id="audit-modal-detail-close">${escapeHtml(tForLang(lang, 'settings.audit.closeDetail'))}</button>
2732
2899
  </div>
2733
2900
  </div>
2734
2901
  <pre class="audit-modal-detail-body" id="audit-modal-detail-body"></pre>
@@ -2741,20 +2908,20 @@ ${renderYujinSplash()}
2741
2908
  <div class="legal-modal-backdrop"></div>
2742
2909
  <div class="legal-modal-frame">
2743
2910
  <div class="legal-modal-header">
2744
- <div class="legal-modal-title" id="legal-modal-title">Legal</div>
2745
- <button type="button" class="set-btn" id="legal-modal-close">Cerrar</button>
2911
+ <div class="legal-modal-title" id="legal-modal-title">${escapeHtml(tForLang(lang, 'settings.legal.title'))}</div>
2912
+ <button type="button" class="set-btn" id="legal-modal-close">${escapeHtml(tForLang(lang, 'settings.audit.close'))}</button>
2746
2913
  </div>
2747
- <div class="legal-modal-body" id="legal-modal-body">Cargando...</div>
2914
+ <div class="legal-modal-body" id="legal-modal-body">${escapeHtml(tForLang(lang, 'vault.list.loading'))}</div>
2748
2915
  </div>
2749
2916
  </div>
2750
2917
  <!-- inline edit form (single, repositioned over the active key row) -->
2751
2918
  <div id="yf-key-editor" class="hidden key-editor">
2752
- <div class="ke-title">Configurar <span id="ke-slot-name"></span></div>
2753
- <input type="password" id="ke-input" placeholder="Pega aqui tu API key" autocomplete="off"/>
2919
+ <div class="ke-title">${escapeHtml(tForLang(lang, 'settings.keys.configure'))} <span id="ke-slot-name"></span></div>
2920
+ <input type="password" id="ke-input" placeholder="${escapeHtml(tForLang(lang, 'settings.keyEditor.placeholder'))}" autocomplete="off"/>
2754
2921
  <div class="ke-actions">
2755
- <button type="button" id="ke-save" class="set-btn">Guardar</button>
2756
- <button type="button" id="ke-clear" class="set-btn set-btn-danger">Quitar</button>
2757
- <button type="button" id="ke-cancel" class="set-btn">Cancelar</button>
2922
+ <button type="button" id="ke-save" class="set-btn">${escapeHtml(tForLang(lang, 'vault.add.button_save'))}</button>
2923
+ <button type="button" id="ke-clear" class="set-btn set-btn-danger">${escapeHtml(tForLang(lang, 'settings.keys.remove'))}</button>
2924
+ <button type="button" id="ke-cancel" class="set-btn">${escapeHtml(tForLang(lang, 'vault.add.button_cancel'))}</button>
2758
2925
  </div>
2759
2926
  <div id="ke-msg" class="ke-msg"></div>
2760
2927
  </div>
@@ -2767,7 +2934,7 @@ ${renderYujinSplash()}
2767
2934
  <form id="bar-full" data-nac-id="yujin.chat.bar-full" class="bar" onsubmit="return false">
2768
2935
  <button type="button" class="mic" data-mic data-mic-btn aria-label="${escapeHtml(tr.micButton)}" title="${escapeHtml(tr.micButton)}">🎙</button>
2769
2936
  <button type="button" class="tts-toggle" data-tts-toggle aria-label="${escapeHtml(tr.ttsButton)}" title="${escapeHtml(tr.ttsButton)}">🔊</button>
2770
- <button type="button" class="clip" data-attach-clip aria-label="Adjuntar archivo" title="Adjuntar archivo">📎</button>
2937
+ <button type="button" class="clip" data-attach-clip aria-label="${escapeHtml(tForLang(lang, 'panel.tip.attachFile'))}" title="${escapeHtml(tForLang(lang, 'panel.tip.attachFile'))}">📎</button>
2771
2938
  <textarea class="composer-input" rows="1" placeholder="${escapeHtml(tr.inputPlaceholder)}" aria-label="${escapeHtml(tr.inputPlaceholder)}"></textarea>
2772
2939
  <button type="submit" class="send">${escapeHtml(tr.sendButton)}</button>
2773
2940
  <button type="button" class="stop" data-stop aria-label="${escapeHtml(tr.stopButton)}" title="${escapeHtml(tr.stopButton)}" style="display:none">&#9632; ${escapeHtml(tr.stopButton)}</button>
@@ -2789,7 +2956,7 @@ ${renderYujinSplash()}
2789
2956
  data-nac-id="yujin.pizarron.clear-all"
2790
2957
  data-nac-role="action"
2791
2958
  data-nac-action="clear_pizarron"
2792
- aria-label="cerrar todo" title="Cerrar todo el pizarrón">×</button>
2959
+ aria-label="${escapeHtml(tForLang(lang, 'panel.aria.closeBoard'))}" title="${escapeHtml(tForLang(lang, 'panel.tip.closeBoard'))}">×</button>
2793
2960
  </div>
2794
2961
  <div class="pz-tabs" id="pizarron-tabs"
2795
2962
  data-nac-id="yujin.pizarron.tabs"
@@ -2799,17 +2966,12 @@ ${renderYujinSplash()}
2799
2966
  data-nac-role="region">
2800
2967
  <div class="pizarra-empty" id="pizarron-empty">
2801
2968
  <div class="kanji-big">板</div>
2802
- <p>Aquí se muestra contenido estructurado: código, tablas, documentos. Pedile a Yujin que escriba o edite algo.</p>
2969
+ <p>${escapeHtml(tForLang(lang, 'panel.hint.boardEmpty'))}</p>
2803
2970
  <!-- PND-064 -- aviso cuando el brain corre en modo Plan
2804
2971
  (claude-code-cli): en ese modo el pizarron no es
2805
2972
  alcanzable. Se muestra solo si el endpoint reporta
2806
2973
  ese provider activo. -->
2807
- <p id="pizarron-plan-hint" class="pizarra-plan-hint hidden">
2808
- Estas en modo Plan (suscripcion). El pizarron se dibuja
2809
- cuando el brain corre en modo API: cambialo en
2810
- <strong>Ajustes &gt; Brain &gt; Anthropic: modalidad &gt; Console</strong>
2811
- y reabri el panel.
2812
- </p>
2974
+ <p id="pizarron-plan-hint" class="pizarra-plan-hint hidden">${tForLang(lang, 'panel.hint.planMode')}</p>
2813
2975
  </div>
2814
2976
  </div>
2815
2977
  <!-- Action trace (tool calls log) -- siempre visible
@@ -2998,10 +3160,10 @@ async function switchProject(slug) {
2998
3160
  setProj();
2999
3161
  setStatus('');
3000
3162
  } else {
3001
- setStatus((j && j.error) || 'No se pudo cambiar de proyecto.', true);
3163
+ setStatus((j && j.error) || CONFIG.i18n.panelStatusProjectSwitchFail, true);
3002
3164
  }
3003
3165
  } catch (e) {
3004
- setStatus('No se pudo cambiar de proyecto.', true);
3166
+ setStatus(CONFIG.i18n.panelStatusProjectSwitchFail, true);
3005
3167
  }
3006
3168
  }
3007
3169
  /* Remove a project from the list (the "X"). Confirms first; on success drops
@@ -3022,10 +3184,10 @@ async function removeProjectViaClick(slug, rowEl) {
3022
3184
  setStatus(((CONFIG.i18n && CONFIG.i18n.projectRemoved) || 'Proyecto quitado de la lista') + ': ' + slug);
3023
3185
  setTimeout(function () { setStatus(''); }, 2500);
3024
3186
  } else {
3025
- setStatus((j && j.error) || 'No se pudo quitar el proyecto.', true);
3187
+ setStatus((j && j.error) || CONFIG.i18n.panelStatusProjectRemoveFail, true);
3026
3188
  }
3027
3189
  } catch (e) {
3028
- setStatus('No se pudo quitar el proyecto.', true);
3190
+ setStatus(CONFIG.i18n.panelStatusProjectRemoveFail, true);
3029
3191
  }
3030
3192
  }
3031
3193
  async function addProjectViaPrompt() {
@@ -3038,10 +3200,10 @@ async function addProjectViaPrompt() {
3038
3200
  body: JSON.stringify({ path: path.trim() }),
3039
3201
  });
3040
3202
  const j = await r.json();
3041
- if (!j || !j.ok) { setStatus((j && j.error) || 'No se pudo anadir el proyecto.', true); return; }
3203
+ if (!j || !j.ok) { setStatus((j && j.error) || CONFIG.i18n.panelStatusProjectAddFail, true); return; }
3042
3204
  /* reopen the menu so the user can pick the freshly added project */
3043
3205
  openProjectMenu($('#proj-full') || $('#proj-mini'));
3044
- } catch (e) { setStatus('No se pudo anadir el proyecto.', true); }
3206
+ } catch (e) { setStatus(CONFIG.i18n.panelStatusProjectAddFail, true); }
3045
3207
  }
3046
3208
 
3047
3209
  function escapeHtml(s) {
@@ -3097,7 +3259,7 @@ function renderMd(src) {
3097
3259
  * /api/forge/open-in-editor. */
3098
3260
  out = out.replace(
3099
3261
  /(?<![\\w/])((?:src|tests|docs|packages|apps|scripts)\\/[\\w./-]+(?:\\.[\\w]+)(?::\\d+(?::\\d+)?)?)/g,
3100
- '<a class="fpath" data-fpath="$1" href="#" title="Abrir en mi editor">$1</a>',
3262
+ '<a class="fpath" data-fpath="$1" href="#" title="' + escapeHtml(CONFIG.i18n.tipOpenInEditor) + '">$1</a>',
3101
3263
  );
3102
3264
  return out;
3103
3265
  }
@@ -3143,7 +3305,7 @@ function renderMdRich(src) {
3143
3305
  /* file paths */
3144
3306
  r = r.replace(
3145
3307
  /(?<![\\w/])((?:src|tests|docs|packages|apps|scripts)\\/[\\w./-]+(?:\\.[\\w]+)(?::\\d+(?::\\d+)?)?)/g,
3146
- '<a class="fpath" data-fpath="$1" href="#" title="Abrir">$1</a>',
3308
+ '<a class="fpath" data-fpath="$1" href="#" title="' + escapeHtml(CONFIG.i18n.tipOpen) + '">$1</a>',
3147
3309
  );
3148
3310
  return r;
3149
3311
  }
@@ -3397,7 +3559,7 @@ function renderStream() {
3397
3559
  '<div class="tp-hint">' + hint + ' <span class="tp-count">(' + sp.round + '/' + sp.max_rounds + ')</span></div>' +
3398
3560
  '</div>';
3399
3561
  } else {
3400
- tdiv.innerHTML = '<div class="thinking" aria-label="thinking"><span></span><span></span><span></span></div>';
3562
+ tdiv.innerHTML = '<div class="thinking" aria-label="' + escapeHtml(CONFIG.i18n.ariaThinking) + '"><span></span><span></span><span></span></div>';
3401
3563
  }
3402
3564
  target.appendChild(tdiv);
3403
3565
  }
@@ -3421,7 +3583,7 @@ function renderStream() {
3421
3583
  const body = await r.json();
3422
3584
  if (!body.ok && body.path) {
3423
3585
  navigator.clipboard.writeText(body.path).catch(function () {});
3424
- alert('No editor en PATH. Copie el path: ' + body.path);
3586
+ alert(CONFIG.i18n.alertNoEditor.replace('{path}', body.path));
3425
3587
  }
3426
3588
  fetch('/api/forge/trust-event', {
3427
3589
  method: 'POST',
@@ -3507,13 +3669,13 @@ async function openLegalModal(title, url) {
3507
3669
  const titleEl = document.getElementById('legal-modal-title');
3508
3670
  const bodyEl = document.getElementById('legal-modal-body');
3509
3671
  if (!modal || !titleEl || !bodyEl) return;
3510
- titleEl.textContent = title || 'Legal';
3511
- bodyEl.textContent = 'Cargando...';
3672
+ titleEl.textContent = title || CONFIG.i18n.legalDefaultTitle;
3673
+ bodyEl.textContent = CONFIG.i18n.legalLoading;
3512
3674
  modal.classList.remove('hidden');
3513
3675
  try {
3514
3676
  const r = await fetch(url);
3515
3677
  if (!r.ok) {
3516
- bodyEl.textContent = 'Error ' + r.status + ' cargando el documento.';
3678
+ bodyEl.textContent = CONFIG.i18n.legalLoadError.replace('{status}', r.status);
3517
3679
  return;
3518
3680
  }
3519
3681
  const md = await r.text();
@@ -3523,7 +3685,7 @@ async function openLegalModal(title, url) {
3523
3685
  bodyEl.textContent = md;
3524
3686
  bodyEl.scrollTop = 0;
3525
3687
  } catch (err) {
3526
- bodyEl.textContent = 'Error de red: ' + (err && err.message ? err.message : 'unknown');
3688
+ bodyEl.textContent = CONFIG.i18n.legalNetworkError.replace('{err}', (err && err.message ? err.message : 'unknown'));
3527
3689
  }
3528
3690
  }
3529
3691
 
@@ -3946,20 +4108,20 @@ async function refreshLimitsCard() {
3946
4108
  if (jj && jj.ok) {
3947
4109
  input.dataset.original = String(v);
3948
4110
  if (msgEl) {
3949
- msgEl.textContent = fieldPath + ' = ' + v + ' (guardado)';
4111
+ msgEl.textContent = fieldPath + ' = ' + v + ' (' + CONFIG.i18n.fbSavedShort + ')';
3950
4112
  msgEl.style.color = '#5fb35f';
3951
4113
  setTimeout(function () { if (msgEl) msgEl.textContent = ''; }, 2000);
3952
4114
  }
3953
4115
  } else {
3954
4116
  if (msgEl) {
3955
- msgEl.textContent = jj && jj.error ? String(jj.error) : 'error guardando';
4117
+ msgEl.textContent = jj && jj.error ? String(jj.error) : CONFIG.i18n.fbSaveError;
3956
4118
  msgEl.style.color = '#d97706';
3957
4119
  }
3958
4120
  input.value = original || String(f.value);
3959
4121
  }
3960
4122
  } catch (e) {
3961
4123
  if (msgEl) {
3962
- msgEl.textContent = 'error de red guardando';
4124
+ msgEl.textContent = CONFIG.i18n.fbNetErrorSaving;
3963
4125
  msgEl.style.color = '#d97706';
3964
4126
  }
3965
4127
  }
@@ -4000,7 +4162,7 @@ async function pairGenerate() {
4000
4162
  const btn = document.getElementById('set-pair-generate');
4001
4163
  const result = document.getElementById('set-pair-result');
4002
4164
  const errEl = document.getElementById('set-pair-error');
4003
- if (btn) { btn.disabled = true; btn.textContent = 'Generando...'; }
4165
+ if (btn) { btn.disabled = true; btn.textContent = CONFIG.i18n.pairGenerating; }
4004
4166
  if (errEl) errEl.classList.add('hidden');
4005
4167
  try {
4006
4168
  const r = await fetch('/api/forge/mobile-pair', { method: 'POST' });
@@ -4016,25 +4178,25 @@ async function pairGenerate() {
4016
4178
  if (copyBtn && !copyBtn.__wired) {
4017
4179
  copyBtn.__wired = true;
4018
4180
  copyBtn.addEventListener('click', function () {
4019
- try { navigator.clipboard.writeText(codeEl.value); copyBtn.textContent = 'Copiado'; setTimeout(function () { copyBtn.textContent = 'Copiar'; }, 1500); }
4181
+ try { navigator.clipboard.writeText(codeEl.value); copyBtn.textContent = CONFIG.i18n.fbCopied; setTimeout(function () { copyBtn.textContent = CONFIG.i18n.pairCopy; }, 1500); }
4020
4182
  catch (e) { codeEl.select(); }
4021
4183
  });
4022
4184
  }
4023
4185
  }
4024
4186
  if (exp && j.expires_at) {
4025
4187
  const mins = Math.max(0, Math.round((new Date(j.expires_at).getTime() - Date.now()) / 60000));
4026
- exp.textContent = 'Expira en ~' + mins + ' min. Generalo de nuevo si caduca.';
4188
+ exp.textContent = CONFIG.i18n.pairExpiry.replace('{mins}', mins);
4027
4189
  }
4028
4190
  if (result) result.classList.remove('hidden');
4029
4191
  } else if (errEl) {
4030
- errEl.textContent = (j && j.error ? j.error : 'No pude generar el QR.')
4192
+ errEl.textContent = (j && j.error ? j.error : CONFIG.i18n.pairQrFailed)
4031
4193
  + (j && j.hint ? ' (corre: ' + j.hint + ')' : '');
4032
4194
  errEl.classList.remove('hidden');
4033
4195
  }
4034
4196
  } catch (e) {
4035
- if (errEl) { errEl.textContent = 'Error de red al generar el QR.'; errEl.classList.remove('hidden'); }
4197
+ if (errEl) { errEl.textContent = CONFIG.i18n.pairQrNetError; errEl.classList.remove('hidden'); }
4036
4198
  }
4037
- if (btn) { btn.disabled = false; btn.textContent = 'Generar QR'; }
4199
+ if (btn) { btn.disabled = false; btn.textContent = CONFIG.i18n.pairGenerateQr; }
4038
4200
  }
4039
4201
 
4040
4202
  /* alpha.59r -- project knowledge graph status card. Reads
@@ -4145,7 +4307,7 @@ async function refreshBookmarksList() {
4145
4307
  const j = await r.json();
4146
4308
  if (!j || !j.ok || !Array.isArray(j.bookmarks)) return;
4147
4309
  if (j.bookmarks.length === 0) {
4148
- host.innerHTML = '<div class="set-row" style="opacity:0.55;font-size:11px;">Sin bookmarks. Pedile a Yujin "marca este punto como ..." al leer un doc.</div>';
4310
+ host.innerHTML = '<div class="set-row" style="opacity:0.55;font-size:11px;">' + escapeHtml(CONFIG.i18n.hintBookmarksEmpty) + '</div>';
4149
4311
  return;
4150
4312
  }
4151
4313
  host.innerHTML = j.bookmarks.slice(0, 40).map((bm) => {
@@ -4323,8 +4485,8 @@ function showAuditDetailInline(entry) {
4323
4485
  copyBtn.onclick = async function () {
4324
4486
  try {
4325
4487
  await navigator.clipboard.writeText(pretty);
4326
- copyBtn.textContent = 'Copiado';
4327
- setTimeout(function () { copyBtn.textContent = 'Copiar JSON'; }, 1200);
4488
+ copyBtn.textContent = CONFIG.i18n.fbCopied;
4489
+ setTimeout(function () { copyBtn.textContent = CONFIG.i18n.auditCopyJson; }, 1200);
4328
4490
  } catch (_) { /* swallow */ }
4329
4491
  };
4330
4492
  }
@@ -4358,11 +4520,11 @@ function openAuditDetail(entry) {
4358
4520
  '<div class="audit-detail-title">' + escapeHtml(kind) + ' <span class="a-mode ' + escapeHtml(mode) + '" style="margin-left:6px;">' + escapeHtml(mode) + '</span></div>' +
4359
4521
  '<div class="audit-detail-sub">' + escapeHtml(ts) + ' -- ' + escapeHtml(summary) + '</div>' +
4360
4522
  '</div>' +
4361
- '<button type="button" class="audit-detail-close" aria-label="Cerrar">x</button>' +
4523
+ '<button type="button" class="audit-detail-close" aria-label="' + escapeHtml(CONFIG.i18n.closeButton) + '">x</button>' +
4362
4524
  '</div>' +
4363
4525
  '<pre class="audit-detail-body"></pre>' +
4364
4526
  '<div class="audit-detail-actions">' +
4365
- '<button type="button" class="set-btn" data-audit-copy>Copiar JSON</button>' +
4527
+ '<button type="button" class="set-btn" data-audit-copy>' + escapeHtml(CONFIG.i18n.auditCopyJson) + '</button>' +
4366
4528
  '<span class="audit-detail-msg" id="audit-detail-msg"></span>' +
4367
4529
  '</div>' +
4368
4530
  '</div>';
@@ -4387,14 +4549,14 @@ function openAuditDetail(entry) {
4387
4549
  try {
4388
4550
  if (navigator && navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
4389
4551
  navigator.clipboard.writeText(pretty).then(function () {
4390
- if (msg) { msg.textContent = 'Copiado'; setTimeout(function () { if (msg) msg.textContent = ''; }, 1800); }
4552
+ if (msg) { msg.textContent = CONFIG.i18n.fbCopied; setTimeout(function () { if (msg) msg.textContent = ''; }, 1800); }
4391
4553
  }, function () {
4392
- if (msg) { msg.textContent = 'Fallo el copiado'; }
4554
+ if (msg) { msg.textContent = CONFIG.i18n.fbCopyFailed; }
4393
4555
  });
4394
4556
  } else if (msg) {
4395
- msg.textContent = 'Clipboard no disponible';
4557
+ msg.textContent = CONFIG.i18n.fbClipboardUnavailable;
4396
4558
  }
4397
- } catch (_) { if (msg) msg.textContent = 'Fallo el copiado'; }
4559
+ } catch (_) { if (msg) msg.textContent = CONFIG.i18n.fbCopyFailed; }
4398
4560
  });
4399
4561
  }
4400
4562
  }
@@ -4410,7 +4572,7 @@ async function resetLimitsCard() {
4410
4572
  const j = await r.json();
4411
4573
  if (j && j.ok) {
4412
4574
  if (msgEl) {
4413
- msgEl.textContent = 'Reset a defaults aplicado';
4575
+ msgEl.textContent = CONFIG.i18n.limitsResetApplied;
4414
4576
  msgEl.style.color = '#5fb35f';
4415
4577
  setTimeout(function () { if (msgEl) msgEl.textContent = ''; }, 2500);
4416
4578
  }
@@ -4470,14 +4632,14 @@ async function refreshDangerZoneToggles() {
4470
4632
  } else {
4471
4633
  cb.checked = !newVal;
4472
4634
  if (msgEl) {
4473
- msgEl.textContent = jj && jj.error ? String(jj.error) : 'error guardando';
4635
+ msgEl.textContent = jj && jj.error ? String(jj.error) : CONFIG.i18n.fbSaveError;
4474
4636
  msgEl.style.color = '#d97706';
4475
4637
  }
4476
4638
  }
4477
4639
  } catch (_e) {
4478
4640
  cb.checked = !newVal;
4479
4641
  if (msgEl) {
4480
- msgEl.textContent = 'error de red';
4642
+ msgEl.textContent = CONFIG.i18n.fbNetError;
4481
4643
  msgEl.style.color = '#d97706';
4482
4644
  }
4483
4645
  }
@@ -4630,10 +4792,10 @@ async function killShellTab(tabId) {
4630
4792
  if (tab) tab.content = tab.content + '\\n[CANCELLED by user]\\n';
4631
4793
  renderPizarron();
4632
4794
  } else {
4633
- setStatus('No se pudo cancelar: ' + (j && j.reason ? j.reason : 'unknown'), true);
4795
+ setStatus(CONFIG.i18n.panelStatusCancelFail.replace('{reason}', (j && j.reason ? j.reason : 'unknown')), true);
4634
4796
  }
4635
4797
  } catch (e) {
4636
- setStatus('Error de red al cancelar shell.', true);
4798
+ setStatus(CONFIG.i18n.panelStatusCancelNetError, true);
4637
4799
  }
4638
4800
  }
4639
4801
 
@@ -4800,27 +4962,27 @@ function nac3OpenApprovalModal(envelope) {
4800
4962
 
4801
4963
  const sideEffects = document.createElement('div');
4802
4964
  sideEffects.style.cssText = 'font-size:11px;opacity:0.7;margin-bottom:16px;';
4803
- sideEffects.textContent = 'Efectos: ' + (Array.isArray(verb.side_effects) ? verb.side_effects.join(', ') : 'desconocidos');
4965
+ sideEffects.textContent = CONFIG.i18n.nac3Effects.replace('{list}', (Array.isArray(verb.side_effects) ? verb.side_effects.join(', ') : CONFIG.i18n.nac3EffectsUnknown));
4804
4966
 
4805
4967
  const btnRow = document.createElement('div');
4806
4968
  btnRow.style.cssText = 'display:flex;gap:8px;justify-content:flex-end;';
4807
4969
 
4808
4970
  const btnCancel = document.createElement('button');
4809
- btnCancel.textContent = 'Cancelar';
4971
+ btnCancel.textContent = CONFIG.i18n.nac3Cancel;
4810
4972
  btnCancel.setAttribute('data-nac-id', 'yujin.modal.nac3-approval.cancel');
4811
4973
  btnCancel.setAttribute('data-nac-role', 'button');
4812
4974
  btnCancel.setAttribute('data-nac-action', 'cancel');
4813
4975
  btnCancel.style.cssText = 'padding:8px 16px;border-radius:6px;border:1px solid #3a4258;background:transparent;color:inherit;cursor:pointer;font-size:13px;';
4814
4976
 
4815
4977
  const btnReject = document.createElement('button');
4816
- btnReject.textContent = 'Rechazar';
4978
+ btnReject.textContent = CONFIG.i18n.nac3Reject;
4817
4979
  btnReject.setAttribute('data-nac-id', 'yujin.modal.nac3-approval.reject');
4818
4980
  btnReject.setAttribute('data-nac-role', 'button');
4819
4981
  btnReject.setAttribute('data-nac-action', 'reject');
4820
4982
  btnReject.style.cssText = 'padding:8px 16px;border-radius:6px;border:1px solid #803030;background:#3a1818;color:#f4b8b8;cursor:pointer;font-size:13px;';
4821
4983
 
4822
4984
  const btnApprove = document.createElement('button');
4823
- btnApprove.textContent = verb.policy === 'destructive' ? 'Aprobar (destructivo)' : 'Aprobar';
4985
+ btnApprove.textContent = verb.policy === 'destructive' ? CONFIG.i18n.nac3ApproveDestructive : CONFIG.i18n.nac3Approve;
4824
4986
  btnApprove.setAttribute('data-nac-id', 'yujin.modal.nac3-approval.approve');
4825
4987
  btnApprove.setAttribute('data-nac-role', 'button');
4826
4988
  btnApprove.setAttribute('data-nac-action', 'approve');
@@ -4861,7 +5023,7 @@ function nac3OpenApprovalModal(envelope) {
4861
5023
 
4862
5024
  btnApprove.addEventListener('click', async () => {
4863
5025
  btnApprove.disabled = true; btnReject.disabled = true;
4864
- btnApprove.textContent = 'Ejecutando...';
5026
+ btnApprove.textContent = CONFIG.i18n.nac3Executing;
4865
5027
  let payloadNote = '';
4866
5028
  try {
4867
5029
  const r = await fetch('/api/forge/nac3-approve', {
@@ -4995,7 +5157,7 @@ function pizarronCloseItem(id) {
4995
5157
  if (idx < 0) return;
4996
5158
  const it = state.pizarron.items[idx];
4997
5159
  if (it.dirty) {
4998
- if (!confirm('La pestaña "' + it.title + '" tiene cambios sin guardar. ¿Cerrar igual?')) return;
5160
+ if (!confirm(CONFIG.i18n.panelConfirmTabUnsaved.replace('{title}', it.title))) return;
4999
5161
  }
5000
5162
  /* Startup tabs (dogfood 2026-06-10) -- si el usuario cierra una
5001
5163
  * pestania de startup (o el lienzo Coding), recordarlo: no se
@@ -5018,7 +5180,7 @@ function pizarronCloseItem(id) {
5018
5180
 
5019
5181
  function pizarronClearAll() {
5020
5182
  const dirty = state.pizarron.items.find((x) => x.dirty);
5021
- if (dirty && !confirm('Hay pestañas con cambios sin guardar. ¿Cerrar todo igual?')) return;
5183
+ if (dirty && !confirm(CONFIG.i18n.panelConfirmTabsUnsaved)) return;
5022
5184
  state.pizarron.items = [];
5023
5185
  state.pizarron.activeId = null;
5024
5186
  renderPizarron();
@@ -5028,7 +5190,7 @@ function pizarronStartEdit(id) {
5028
5190
  const it = state.pizarron.items.find((x) => x.id === id);
5029
5191
  if (!it) return;
5030
5192
  if (it.kind !== 'code' && it.kind !== 'markdown' && it.kind !== 'document') {
5031
- setStatus('Este tipo de contenido no es editable.', true);
5193
+ setStatus(CONFIG.i18n.panelStatusNotEditable, true);
5032
5194
  return;
5033
5195
  }
5034
5196
  it.isEditing = true;
@@ -5059,22 +5221,22 @@ async function pizarronSaveAs(id) {
5059
5221
  it.dirty = false;
5060
5222
  it.file_path = handle.name; /* solo el nombre; el browser
5061
5223
  no expone full path */
5062
- setStatus('Guardado como: ' + handle.name + ' (en la ruta que elegiste en el dialog).');
5224
+ setStatus(CONFIG.i18n.panelStatusSavedAsPicker.replace('{name}', handle.name));
5063
5225
  renderPizarron();
5064
5226
  return;
5065
5227
  } catch (err) {
5066
5228
  /* AbortError = el user cancelo el dialog -- silencioso. */
5067
5229
  if (err && err.name === 'AbortError') return;
5068
- setStatus('Error en Guardar como: ' + (err && err.message ? err.message : err) + ' -- usando fallback.', true);
5230
+ setStatus(CONFIG.i18n.panelStatusSaveAsFail.replace('{error}', (err && err.message ? err.message : err)), true);
5069
5231
  }
5070
5232
  }
5071
5233
  /* Fallback: prompt con path relativo al projectRoot. */
5072
5234
  const initial = it.file_path || 'untitled.txt';
5073
- const nextPath = prompt('Guardar como (path relativo al proyecto):', initial);
5235
+ const nextPath = prompt(CONFIG.i18n.panelPromptSaveAs, initial);
5074
5236
  if (!nextPath || nextPath.trim() === '') return;
5075
5237
  const rel = nextPath.trim();
5076
5238
  if (rel.startsWith('/') || rel.includes('..')) {
5077
- alert('El path debe ser relativo al proyecto (sin "/" inicial ni "..").');
5239
+ alert(CONFIG.i18n.alertPathRelative);
5078
5240
  return;
5079
5241
  }
5080
5242
  try {
@@ -5085,17 +5247,17 @@ async function pizarronSaveAs(id) {
5085
5247
  });
5086
5248
  const data = await r.json();
5087
5249
  if (!r.ok || !data.ok) {
5088
- setStatus('Error al guardar: ' + (data.error || r.status), true);
5250
+ setStatus(CONFIG.i18n.panelStatusSaveFail.replace('{error}', (data.error || r.status)), true);
5089
5251
  return;
5090
5252
  }
5091
5253
  it.content = it.editValue;
5092
5254
  it.file_path = rel;
5093
5255
  it.title = rel.split('/').pop() || rel;
5094
5256
  it.dirty = false;
5095
- setStatus('Guardado como: ' + rel + ' (' + data.bytes_written + ' bytes)');
5257
+ setStatus(CONFIG.i18n.panelStatusSavedAs.replace('{path}', rel).replace('{bytes}', data.bytes_written));
5096
5258
  renderPizarron();
5097
5259
  } catch (err) {
5098
- setStatus('Error de red: ' + (err && err.message ? err.message : err), true);
5260
+ setStatus(CONFIG.i18n.panelStatusNetError.replace('{error}', (err && err.message ? err.message : err)), true);
5099
5261
  }
5100
5262
  }
5101
5263
 
@@ -5106,7 +5268,7 @@ async function pizarronSave(id) {
5106
5268
  /* Snippet-only edit: persist back to the in-memory item. */
5107
5269
  it.content = it.editValue;
5108
5270
  it.dirty = false;
5109
- setStatus('Cambios guardados en la pestaña (no en disco -- sin file_path).');
5271
+ setStatus(CONFIG.i18n.panelStatusSavedTabOnly);
5110
5272
  renderPizarron();
5111
5273
  return;
5112
5274
  }
@@ -5119,22 +5281,22 @@ async function pizarronSave(id) {
5119
5281
  });
5120
5282
  const data = await r.json();
5121
5283
  if (!r.ok || !data.ok) {
5122
- setStatus('Error al guardar: ' + (data.error || r.status), true);
5284
+ setStatus(CONFIG.i18n.panelStatusSaveFail.replace('{error}', (data.error || r.status)), true);
5123
5285
  return;
5124
5286
  }
5125
5287
  it.content = it.editValue;
5126
5288
  it.dirty = false;
5127
- setStatus('Guardado: ' + it.file_path + ' (' + data.bytes_written + ' bytes)');
5289
+ setStatus(CONFIG.i18n.panelStatusSaved.replace('{path}', it.file_path).replace('{bytes}', data.bytes_written));
5128
5290
  renderPizarron();
5129
5291
  } catch (err) {
5130
- setStatus('Error de red: ' + (err && err.message ? err.message : err), true);
5292
+ setStatus(CONFIG.i18n.panelStatusNetError.replace('{error}', (err && err.message ? err.message : err)), true);
5131
5293
  }
5132
5294
  }
5133
5295
 
5134
5296
  function pizarronDiscard(id) {
5135
5297
  const it = state.pizarron.items.find((x) => x.id === id);
5136
5298
  if (!it) return;
5137
- if (it.dirty && !confirm('Descartar los cambios sin guardar?')) return;
5299
+ if (it.dirty && !confirm(CONFIG.i18n.panelConfirmDiscardChanges)) return;
5138
5300
  it.isEditing = false;
5139
5301
  it.editValue = it.content;
5140
5302
  it.dirty = false;
@@ -5147,9 +5309,9 @@ function pizarronReadAloud(id) {
5147
5309
  /* Strip markdown structure to make it readable, then TTS. */
5148
5310
  let txt = it.content;
5149
5311
  if (it.kind === 'code') {
5150
- txt = 'Código' + (it.language ? ' en ' + it.language : '') + '. '
5151
- + (it.file_path ? 'Archivo ' + it.file_path + '. ' : '')
5152
- + 'Contenido: ' + txt;
5312
+ txt = (it.language ? CONFIG.i18n.readAloudCodeLang.replace('{lang}', it.language) : CONFIG.i18n.readAloudCode) + '. '
5313
+ + (it.file_path ? CONFIG.i18n.readAloudFile.replace('{file}', it.file_path) + '. ' : '')
5314
+ + CONFIG.i18n.readAloudContent.replace('{content}', txt);
5153
5315
  }
5154
5316
  playTtsForText(txt);
5155
5317
  }
@@ -5160,8 +5322,8 @@ function pizarronCopy(id) {
5160
5322
  const value = it.isEditing ? it.editValue : it.content;
5161
5323
  if (navigator.clipboard && navigator.clipboard.writeText) {
5162
5324
  navigator.clipboard.writeText(value)
5163
- .then(() => setStatus('Copiado al portapapeles.'))
5164
- .catch(() => setStatus('No se pudo copiar.', true));
5325
+ .then(() => setStatus(CONFIG.i18n.panelStatusCopied))
5326
+ .catch(() => setStatus(CONFIG.i18n.panelStatusCopyFail, true));
5165
5327
  }
5166
5328
  }
5167
5329
 
@@ -5178,11 +5340,11 @@ function renderPizarronCardBody(it) {
5178
5340
  + ' <button type="button" class="pz-save" data-pz-save data-pz-item-id="' + it.id + '"'
5179
5341
  + ' data-nac-id="yujin.pizarron.editor.' + it.id + '.save"'
5180
5342
  + ' data-nac-role="action" data-nac-action="save_file"'
5181
- + ' title="Guardar en ' + escapeHtml(it.file_path || '(memoria)') + '">Guardar</button>'
5343
+ + ' title="' + escapeHtml(CONFIG.i18n.tipSaveTo.replace('{path}', it.file_path || CONFIG.i18n.tipSaveToMemory)) + '">' + escapeHtml(CONFIG.i18n.btnSave) + '</button>'
5182
5344
  + ' <button type="button" data-pz-save-as data-pz-item-id="' + it.id + '"'
5183
5345
  + ' data-nac-id="yujin.pizarron.editor.' + it.id + '.save-as"'
5184
5346
  + ' data-nac-role="action" data-nac-action="save_file_as"'
5185
- + ' title="Guardar como -- elegí nuevo path (relativo al proyecto)">Guardar como…</button>'
5347
+ + ' title="' + escapeHtml(CONFIG.i18n.tipSaveAs) + '">' + escapeHtml(CONFIG.i18n.btnSaveAs) + '</button>'
5186
5348
  + ' <button type="button" data-pz-discard data-pz-item-id="' + it.id + '"'
5187
5349
  + ' data-nac-id="yujin.pizarron.editor.' + it.id + '.discard"'
5188
5350
  + ' data-nac-role="action" data-nac-action="discard_changes">Descartar</button>'
@@ -5242,8 +5404,8 @@ function renderPizarronCardBody(it) {
5242
5404
  const cmode = codingMode();
5243
5405
  return '<div class="pz-body pz-coding-wrap m-' + cmode + '" data-nac-id="yujin.pizarron.item.' + it.id + '.body" data-nac-role="region" tabindex="0">'
5244
5406
  + '<div class="pz-coding-switch" role="tablist">'
5245
- + '<button type="button" class="pz-cmode' + (cmode === 'detail' ? ' on' : '') + '" data-pz-coding-mode="detail" title="Detallado: archivos, diffs y comandos en vivo">codewrapper</button>'
5246
- + '<button type="button" class="pz-cmode' + (cmode === 'zen' ? ' on' : '') + '" data-pz-coding-mode="zen" title="Sumi-e: arte con texto ocasional">vibecoder</button>'
5407
+ + '<button type="button" class="pz-cmode' + (cmode === 'detail' ? ' on' : '') + '" data-pz-coding-mode="detail" title="' + escapeHtml(CONFIG.i18n.tipCodeModeDetail) + '">codewrapper</button>'
5408
+ + '<button type="button" class="pz-cmode' + (cmode === 'zen' ? ' on' : '') + '" data-pz-coding-mode="zen" title="' + escapeHtml(CONFIG.i18n.tipCodeModeZen) + '">vibecoder</button>'
5247
5409
  + '</div>'
5248
5410
  + (cmode === 'zen'
5249
5411
  ? '<div class="pz-coding-zen"><canvas data-pz-coding-canvas class="pz-coding-canvas"></canvas><div class="pz-coding-hint" data-pz-coding-hint>esperando a que el brain trabaje...</div></div>'
@@ -5255,7 +5417,7 @@ function renderPizarronCardBody(it) {
5255
5417
  if (it.kind === 'tasks') {
5256
5418
  return '<div class="pz-body pz-tasks-wrap pz-sumi" data-nac-id="yujin.pizarron.item.' + it.id + '.body" data-nac-role="region" tabindex="0">'
5257
5419
  + '<div class="pz-tasks-new">'
5258
- + '<input type="text" class="pz-tasks-input" data-pz-tasks-cmd placeholder="comando (ej: pnpm install)..." />'
5420
+ + '<input type="text" class="pz-tasks-input" data-pz-tasks-cmd placeholder="' + escapeHtml(CONFIG.i18n.tasksCmdPlaceholder) + '" />'
5259
5421
  + '<button type="button" class="pz-tasks-run" data-pz-tasks-run>Lanzar</button>'
5260
5422
  + '</div>'
5261
5423
  + '<div class="pz-tasks-list" data-pz-tasks-list><div class="pz-tasks-empty">cargando...</div></div>'
@@ -5333,7 +5495,7 @@ function renderPizarron() {
5333
5495
  const empty = $('#pizarron-empty');
5334
5496
  const count = $('#pizarron-count');
5335
5497
  if (!tabs || !body || !count) return;
5336
- count.textContent = state.pizarron.items.length + (state.pizarron.items.length === 1 ? ' pestaña' : ' pestañas');
5498
+ count.textContent = state.pizarron.items.length + ' ' + (state.pizarron.items.length === 1 ? CONFIG.i18n.tabOne : CONFIG.i18n.tabMany);
5337
5499
  /* Render tabs */
5338
5500
  applySavedTabOrder(); /* PND-137 -- respeta el orden elegido por drag */
5339
5501
  const tabsHtml = state.pizarron.items.map((it) => {
@@ -5351,12 +5513,12 @@ function renderPizarron() {
5351
5513
  + '<span class="pz-tab-name">' + escapeHtml(it.title) + '</span>'
5352
5514
  + (showKill
5353
5515
  ? '<button type="button" class="pz-tab-kill" data-pz-kill data-pz-item-id="' + it.id + '"'
5354
- + ' title="Cancelar comando shell" aria-label="cancel">⏹</button>'
5516
+ + ' title="' + escapeHtml(CONFIG.i18n.tipCancelShell) + '" aria-label="cancel">⏹</button>'
5355
5517
  : '')
5356
5518
  + '<button type="button" class="pz-tab-close" data-pz-close data-pz-item-id="' + it.id + '"'
5357
5519
  + ' data-nac-id="yujin.pizarron.tab.' + it.id + '.close"'
5358
5520
  + ' data-nac-role="action" data-nac-action="close_tab"'
5359
- + ' title="Cerrar pestaña" aria-label="cerrar">×</button>'
5521
+ + ' title="' + escapeHtml(CONFIG.i18n.tipCloseTab) + '" aria-label="cerrar">×</button>'
5360
5522
  + '</div>';
5361
5523
  }).join('');
5362
5524
  /* "+" para reabrir pestanas nativas cerradas (Grafo/Workflow/
@@ -5365,7 +5527,7 @@ function renderPizarron() {
5365
5527
  let restoreHtml = '';
5366
5528
  if (closedNative.length) {
5367
5529
  restoreHtml = '<div class="pz-restore-wrap">'
5368
- + '<button type="button" class="pz-restore-btn" data-pz-restore title="Reabrir pestanas nativas cerradas">+</button>'
5530
+ + '<button type="button" class="pz-restore-btn" data-pz-restore title="' + escapeHtml(CONFIG.i18n.tipReopenNativeTabs) + '">+</button>'
5369
5531
  + '<div class="pz-restore-menu hidden" data-pz-restore-menu>'
5370
5532
  + '<div class="pz-restore-head">Reabrir</div>'
5371
5533
  + closedNative.map(function (k) {
@@ -5379,7 +5541,7 @@ function renderPizarron() {
5379
5541
  if (state.pizarron.items.length === 0) {
5380
5542
  if (empty) empty.style.display = '';
5381
5543
  body.innerHTML = '<div class="pizarra-empty" id="pizarron-empty"><div class="kanji-big">板</div>'
5382
- + '<p>Aquí se muestra contenido estructurado: código, tablas, documentos. Pedile a Yujin que escriba o edite algo.</p></div>';
5544
+ + '<p>' + escapeHtml(CONFIG.i18n.hintBoardEmpty) + '</p></div>';
5383
5545
  return;
5384
5546
  }
5385
5547
  const it = pizarronGetActive() || state.pizarron.items[state.pizarron.items.length - 1];
@@ -5505,7 +5667,7 @@ function renderTasksList(tasks) {
5505
5667
  + (t.status === 'running'
5506
5668
  ? '<button type="button" class="pz-task-kill" data-pz-task-kill="' + escapeHtml(t.id) + '">stop</button>'
5507
5669
  : '<button type="button" class="pz-task-log" data-pz-task-log="' + escapeHtml(t.id) + '">log</button>')
5508
- + '<button type="button" class="pz-task-del" data-pz-task-del="' + escapeHtml(t.id) + '" title="Eliminar tarea" aria-label="Eliminar tarea">&times;</button>'
5670
+ + '<button type="button" class="pz-task-del" data-pz-task-del="' + escapeHtml(t.id) + '" title="' + escapeHtml(CONFIG.i18n.tipDeleteTask) + '" aria-label="' + escapeHtml(CONFIG.i18n.tipDeleteTask) + '">&times;</button>'
5509
5671
  + '</div>'
5510
5672
  + '</div>'
5511
5673
  + '<div class="pz-task-logbox" data-pz-task-logbox="' + escapeHtml(t.id) + '"></div>';
@@ -7673,7 +7835,7 @@ async function handleSlashCommand(raw) {
7673
7835
  };
7674
7836
  const target = map[arg];
7675
7837
  if (!target) {
7676
- setStatus('Modos disponibles: didactico, tecnico. Uso: /modo tecnico', true);
7838
+ setStatus(CONFIG.i18n.panelStatusModeAvailable, true);
7677
7839
  return true;
7678
7840
  }
7679
7841
  try {
@@ -7684,13 +7846,13 @@ async function handleSlashCommand(raw) {
7684
7846
  });
7685
7847
  const data = await r.json();
7686
7848
  if (!data.ok) {
7687
- setStatus('No pude cambiar el modo: ' + (data.error || 'unknown'), true);
7849
+ setStatus(CONFIG.i18n.panelStatusModeChangeFail.replace('{error}', (data.error || 'unknown')), true);
7688
7850
  return true;
7689
7851
  }
7690
7852
  try { document.cookie = 'yf-mode=' + target + '; path=/; max-age=' + (60*60*24*365) + '; SameSite=Lax'; } catch (_) {}
7691
- setStatus('Modo cambiado a ' + target + '. El proximo turno ya usa este modo.', false);
7853
+ setStatus(CONFIG.i18n.panelStatusModeChanged.replace('{target}', target), false);
7692
7854
  } catch (err) {
7693
- setStatus('Error: ' + String(err && err.message ? err.message : err), true);
7855
+ setStatus(CONFIG.i18n.panelStatusGenericError.replace('{error}', String(err && err.message ? err.message : err)), true);
7694
7856
  }
7695
7857
  return true;
7696
7858
  }
@@ -7703,8 +7865,8 @@ async function handleSlashCommand(raw) {
7703
7865
  state.brainTierOverride = target;
7704
7866
  setStatus(
7705
7867
  target === null
7706
- ? 'Brain tier vuelve a auto (router decide segun verbo / phase).'
7707
- : 'Brain tier forzado a "' + target + '" para esta sesion. /auto vuelve al router.',
7868
+ ? CONFIG.i18n.panelStatusBrainTierAuto
7869
+ : CONFIG.i18n.panelStatusBrainTierForced.replace('{target}', target),
7708
7870
  false,
7709
7871
  );
7710
7872
  return true;
@@ -7754,7 +7916,7 @@ async function vaultRefreshList() {
7754
7916
  return;
7755
7917
  }
7756
7918
  const rows = data.slots.map((s) => {
7757
- let testCell = '<span class="t-pending" title="not tested yet">--</span>';
7919
+ let testCell = '<span class="t-pending" title="' + escapeHtml(CONFIG.i18n.tipNotTestedYet) + '">--</span>';
7758
7920
  if (s.last_test_ok === true) testCell = '<span class="t-ok" title="tested ok at ' + escapeHtml(s.last_test_at || '') + '">ok</span>';
7759
7921
  else if (s.last_test_ok === false) testCell = '<span class="t-fail" title="tested fail at ' + escapeHtml(s.last_test_at || '') + '">fail</span>';
7760
7922
  const expiryCell = s.expiry ? '<span class="t-exp" title="expires ' + escapeHtml(s.expiry) + '">exp</span>' : '';
@@ -7825,7 +7987,7 @@ async function vaultAdd() {
7825
7987
  }
7826
7988
  }
7827
7989
  async function vaultRemove(slot) {
7828
- if (!confirm('Remove slot "' + slot + '"? This cannot be undone.')) return;
7990
+ if (!confirm(CONFIG.i18n.panelConfirmRemoveSlot.replace('{slot}', slot))) return;
7829
7991
  try {
7830
7992
  const r = await fetch('/api/vault/remove', {
7831
7993
  method: 'POST',
@@ -7914,9 +8076,8 @@ function cycleTtsProvider() {
7914
8076
  function updateTtsProviderButtons() {
7915
8077
  const p = ttsProvider();
7916
8078
  document.querySelectorAll('[data-tts-provider-btn]').forEach((b) => {
7917
- b.textContent = p === 'browser' ? 'TTS navegador' : 'TTS Google';
7918
- b.title = 'Click para alternar entre navegador (SpeechSynthesis '
7919
- + 'nativo, voces del SO) y Google Cloud TTS (server-side).';
8079
+ b.textContent = p === 'browser' ? CONFIG.i18n.ttsProviderBrowser : CONFIG.i18n.ttsProviderGoogle;
8080
+ b.title = CONFIG.i18n.ttsProviderTitle;
7920
8081
  });
7921
8082
  }
7922
8083
 
@@ -7940,13 +8101,12 @@ function setSttProvider(p) {
7940
8101
  }
7941
8102
  function updateSttProviderButtons() {
7942
8103
  const p = sttProvider();
7943
- const label = p === 'browser' ? 'STT navegador'
7944
- : p === 'google' ? 'STT Google'
7945
- : 'STT Whisper';
8104
+ const label = p === 'browser' ? CONFIG.i18n.sttProviderBrowser
8105
+ : p === 'google' ? CONFIG.i18n.sttProviderGoogle
8106
+ : CONFIG.i18n.sttProviderWhisper;
7946
8107
  document.querySelectorAll('[data-stt-provider-btn]').forEach((b) => {
7947
8108
  b.textContent = label;
7948
- b.title = 'Click para alternar entre navegador (rapido + es-AR nativo + gratis), '
7949
- + 'Google Cloud STT (server-side, BYOK) o Whisper (server-side, BYOK).';
8109
+ b.title = CONFIG.i18n.sttProviderTitle;
7950
8110
  });
7951
8111
  }
7952
8112
  function cycleSttProvider() {
@@ -8004,10 +8164,9 @@ function updateTtsVoiceButtons() {
8004
8164
  const meta = TTS_VOICES.find((v) => v.id === cur) || TTS_VOICES[0];
8005
8165
  document.querySelectorAll('[data-tts-voice-btn]').forEach((b) => {
8006
8166
  b.textContent = meta.persona + ' [' + meta.style + ']';
8007
- b.title = 'Voz actual: ' + meta.id + ' / ' + meta.persona
8008
- + ' (' + meta.gender + ') -- ' + meta.style + '. '
8009
- + 'Click para ciclar. Studio = articula s claro; Neural2 = '
8010
- + 'natural espaniol (aspira s finales).';
8167
+ b.title = CONFIG.i18n.ttsVoiceTitle
8168
+ .replace('{id}', meta.id).replace('{persona}', meta.persona)
8169
+ .replace('{gender}', meta.gender).replace('{style}', meta.style);
8011
8170
  });
8012
8171
  }
8013
8172
 
@@ -8364,18 +8523,18 @@ function brainConfigBind() {
8364
8523
  radio.addEventListener('change', async () => {
8365
8524
  if (!radio.checked) return;
8366
8525
  const note = document.querySelector('[data-brain-mode-note]');
8367
- const modeLabel = radio.value === 'plan' ? 'Plan (suscripcion)' : 'Console (API key)';
8368
- const data = await brainConfigSave({ anthropic_mode: radio.value }, 'aplicando ' + modeLabel + '...');
8526
+ const modeLabel = radio.value === 'plan' ? CONFIG.i18n.brainModePlan : CONFIG.i18n.brainModeConsole;
8527
+ const data = await brainConfigSave({ anthropic_mode: radio.value }, CONFIG.i18n.brainApplying.replace('{mode}', modeLabel));
8369
8528
  /* Hot-swap (server applies live now). Only show the restart hint if
8370
8529
  * the swap did NOT apply live (e.g. test client). */
8371
8530
  const live = data && data.applied_live;
8372
8531
  if (note) {
8373
8532
  note.hidden = false;
8374
8533
  note.textContent = live
8375
- ? modeLabel + ' aplicado. El proximo turno ya usa este modo.'
8376
- : 'Se aplica al reabrir el panel.';
8534
+ ? CONFIG.i18n.brainApplied.replace('{mode}', modeLabel)
8535
+ : CONFIG.i18n.brainReopenHint;
8377
8536
  }
8378
- if (live) brainStatus(modeLabel + ' activo');
8537
+ if (live) brainStatus(CONFIG.i18n.brainActive.replace('{mode}', modeLabel));
8379
8538
  });
8380
8539
  });
8381
8540
  const ecoToggle = document.querySelector('[data-brain-eco-enabled]');
@@ -8493,7 +8652,7 @@ async function federationLoad() {
8493
8652
  if (!note) {
8494
8653
  const span = document.createElement('span');
8495
8654
  span.className = 'set-status'; span.setAttribute('data-fed-invite-note', '');
8496
- span.textContent = 'activa tu licencia para prestar tu brain';
8655
+ span.textContent = CONFIG.i18n.licLendNeedsLicense;
8497
8656
  inviteRow.appendChild(span);
8498
8657
  }
8499
8658
  }
@@ -8516,7 +8675,7 @@ async function federationLoad() {
8516
8675
  await fedBrainModelsPopulate(d.host_brain.provider, d.host_brain.model);
8517
8676
  }
8518
8677
  } catch (err) {
8519
- fedStatus('error: ' + (err && err.message ? err.message : String(err)));
8678
+ fedStatus(CONFIG.i18n.licErrorWithCode.replace('{code}', (err && err.message ? err.message : String(err))));
8520
8679
  }
8521
8680
  }
8522
8681
 
@@ -8572,7 +8731,7 @@ async function fedBrainSave() {
8572
8731
  if (!r.ok || !d.ok) { fedStatus('no pude guardar el brain: ' + (d.error || ('HTTP ' + r.status))); return; }
8573
8732
  fedStatus('brain de federacion: ' + provider + (model ? ' / ' + model : ''));
8574
8733
  } catch (err) {
8575
- fedStatus('error: ' + (err && err.message ? err.message : String(err)));
8734
+ fedStatus(CONFIG.i18n.licErrorWithCode.replace('{code}', (err && err.message ? err.message : String(err))));
8576
8735
  }
8577
8736
  }
8578
8737
 
@@ -8595,7 +8754,7 @@ function federationBind() {
8595
8754
  fedStatus('conectado a ' + d.parent_handle + '. El proximo turno usa su brain.');
8596
8755
  await federationLoad();
8597
8756
  } catch (err) {
8598
- fedStatus('error: ' + (err && err.message ? err.message : String(err)));
8757
+ fedStatus(CONFIG.i18n.licErrorWithCode.replace('{code}', (err && err.message ? err.message : String(err))));
8599
8758
  }
8600
8759
  });
8601
8760
  }
@@ -8610,7 +8769,7 @@ function federationBind() {
8610
8769
  fedStatus('desconectado. Volves a tu brain local.');
8611
8770
  await federationLoad();
8612
8771
  } catch (err) {
8613
- fedStatus('error: ' + (err && err.message ? err.message : String(err)));
8772
+ fedStatus(CONFIG.i18n.licErrorWithCode.replace('{code}', (err && err.message ? err.message : String(err))));
8614
8773
  }
8615
8774
  });
8616
8775
  }
@@ -8632,7 +8791,7 @@ function federationBind() {
8632
8791
  const mins = Math.round((d.expires_in || 1800) / 60);
8633
8792
  fedStatus('codigo de 1 solo uso, vence en ' + mins + ' min. Compartilo y deja "Hospedar" activo.');
8634
8793
  } catch (err) {
8635
- fedStatus('error: ' + (err && err.message ? err.message : String(err)));
8794
+ fedStatus(CONFIG.i18n.licErrorWithCode.replace('{code}', (err && err.message ? err.message : String(err))));
8636
8795
  }
8637
8796
  });
8638
8797
  }
@@ -8670,7 +8829,7 @@ function federationBind() {
8670
8829
  : 'host detenido.');
8671
8830
  } catch (err) {
8672
8831
  hostChk.checked = !enabled;
8673
- fedStatus('error: ' + (err && err.message ? err.message : String(err)));
8832
+ fedStatus(CONFIG.i18n.licErrorWithCode.replace('{code}', (err && err.message ? err.message : String(err))));
8674
8833
  }
8675
8834
  });
8676
8835
  }
@@ -8699,7 +8858,7 @@ async function refreshKeysStatus() {
8699
8858
  const r = await fetch('/api/forge/keys-status');
8700
8859
  const data = await r.json();
8701
8860
  if (!r.ok || !data.ok) {
8702
- if (licEl) licEl.textContent = 'error: ' + (data.error || r.status);
8861
+ if (licEl) licEl.textContent = CONFIG.i18n.licErrorWithCode.replace('{code}', (data.error || r.status));
8703
8862
  return;
8704
8863
  }
8705
8864
  const k = data.keys || {};
@@ -8763,7 +8922,7 @@ async function refreshKeysStatus() {
8763
8922
  syncLicenseFromPanel({ silent: true });
8764
8923
  }
8765
8924
  } catch (err) {
8766
- if (licEl) licEl.textContent = 'error: ' + (err && err.message ? err.message : 'unknown');
8925
+ if (licEl) licEl.textContent = CONFIG.i18n.licErrorWithCode.replace('{code}', (err && err.message ? err.message : 'unknown'));
8767
8926
  }
8768
8927
  }
8769
8928
 
@@ -8784,13 +8943,13 @@ async function connectLicenseHandle() {
8784
8943
  if (!handle || handle.indexOf('@') < 1) {
8785
8944
  msg.style.display = '';
8786
8945
  msg.style.color = '#ff8b7a';
8787
- msg.textContent = 'Pone un email valido (con @).';
8946
+ msg.textContent = CONFIG.i18n.licEmailInvalid;
8788
8947
  return;
8789
8948
  }
8790
- if (btn) { btn.disabled = true; btn.textContent = 'Conectando...'; }
8949
+ if (btn) { btn.disabled = true; btn.textContent = CONFIG.i18n.licConnecting; }
8791
8950
  msg.style.display = '';
8792
8951
  msg.style.color = '';
8793
- msg.textContent = 'Guardando email + sincronizando con Polar...';
8952
+ msg.textContent = CONFIG.i18n.licSavingEmail;
8794
8953
  try {
8795
8954
  const r = await fetch('/api/forge/license-handle', {
8796
8955
  method: 'POST',
@@ -8800,19 +8959,19 @@ async function connectLicenseHandle() {
8800
8959
  const data = await r.json();
8801
8960
  if (!data.ok) {
8802
8961
  msg.style.color = '#ff8b7a';
8803
- msg.textContent = 'Email guardado, pero sync fallo: ' + (data.error || 'unknown')
8804
- + (data.http_status ? ' (HTTP ' + data.http_status + ')' : '')
8805
- + '. Si recien pagaste, espera 1-2 min y dale a "Sincronizar con Polar".';
8962
+ msg.textContent = CONFIG.i18n.licSyncFailedRetry
8963
+ .replace('{err}', (data.error || 'unknown'))
8964
+ .replace('{http}', (data.http_status ? ' (HTTP ' + data.http_status + ')' : ''));
8806
8965
  } else {
8807
8966
  const plan = data.local_plan_after || 'none';
8808
8967
  msg.style.color = '#6dff8b';
8809
- msg.textContent = 'OK -- plan: ' + plan + '.';
8968
+ msg.textContent = CONFIG.i18n.licPlanOk.replace('{plan}', plan);
8810
8969
  }
8811
8970
  } catch (err) {
8812
8971
  msg.style.color = '#ff8b7a';
8813
- msg.textContent = 'Error de red: ' + (err && err.message ? err.message : 'unknown');
8972
+ msg.textContent = CONFIG.i18n.licNetError.replace('{err}', (err && err.message ? err.message : 'unknown'));
8814
8973
  } finally {
8815
- if (btn) { btn.disabled = false; btn.textContent = 'Conectar'; }
8974
+ if (btn) { btn.disabled = false; btn.textContent = CONFIG.i18n.licConnect; }
8816
8975
  /* Always refresh -- if handle saved, the connect-block hides
8817
8976
  * and the sync row appears. */
8818
8977
  refreshKeysStatus();
@@ -8827,10 +8986,10 @@ async function syncLicenseFromPanel(opts) {
8827
8986
  const btn = document.getElementById('set-license-sync-btn');
8828
8987
  const msgRow = document.getElementById('set-license-sync-msg-row');
8829
8988
  const msgEl = document.getElementById('set-license-sync-msg');
8830
- if (btn) { btn.disabled = true; btn.textContent = 'Sincronizando...'; }
8989
+ if (btn) { btn.disabled = true; btn.textContent = CONFIG.i18n.licSyncing; }
8831
8990
  if (msgRow && msgEl && !silent) {
8832
8991
  msgRow.style.display = '';
8833
- msgEl.textContent = 'Consultando servidor...';
8992
+ msgEl.textContent = CONFIG.i18n.licQuerying;
8834
8993
  msgEl.style.color = '';
8835
8994
  }
8836
8995
  try {
@@ -8839,8 +8998,9 @@ async function syncLicenseFromPanel(opts) {
8839
8998
  if (!data.ok) {
8840
8999
  if (msgRow && msgEl) {
8841
9000
  msgRow.style.display = '';
8842
- msgEl.textContent = 'Sync error: ' + (data.error || 'unknown')
8843
- + (data.http_status ? ' (HTTP ' + data.http_status + ')' : '');
9001
+ msgEl.textContent = CONFIG.i18n.licSyncError
9002
+ .replace('{err}', (data.error || 'unknown'))
9003
+ .replace('{http}', (data.http_status ? ' (HTTP ' + data.http_status + ')' : ''));
8844
9004
  msgEl.style.color = '#ff8b7a';
8845
9005
  }
8846
9006
  return;
@@ -8849,8 +9009,8 @@ async function syncLicenseFromPanel(opts) {
8849
9009
  const before = data.local_plan_before || 'none';
8850
9010
  const after = data.local_plan_after || 'none';
8851
9011
  msgEl.textContent = before === after
8852
- ? 'Plan sin cambios: ' + after
8853
- : 'Plan actualizado: ' + before + ' -> ' + after;
9012
+ ? CONFIG.i18n.licPlanUnchanged.replace('{plan}', after)
9013
+ : CONFIG.i18n.licPlanUpdated.replace('{before}', before).replace('{after}', after);
8854
9014
  msgEl.style.color = '#6dff8b';
8855
9015
  }
8856
9016
  /* Refresh the keys status row to show the new plan. */
@@ -8858,11 +9018,13 @@ async function syncLicenseFromPanel(opts) {
8858
9018
  } catch (err) {
8859
9019
  if (msgRow && msgEl) {
8860
9020
  msgRow.style.display = '';
8861
- msgEl.textContent = 'Sync error: ' + (err && err.message ? err.message : 'unknown');
9021
+ msgEl.textContent = CONFIG.i18n.licSyncError
9022
+ .replace('{err}', (err && err.message ? err.message : 'unknown'))
9023
+ .replace('{http}', '');
8862
9024
  msgEl.style.color = '#ff8b7a';
8863
9025
  }
8864
9026
  } finally {
8865
- if (btn) { btn.disabled = false; btn.textContent = 'Sincronizar con Polar'; }
9027
+ if (btn) { btn.disabled = false; btn.textContent = CONFIG.i18n.licSyncWithPolar; }
8866
9028
  }
8867
9029
  }
8868
9030
 
@@ -8893,11 +9055,11 @@ async function saveKeyEditor() {
8893
9055
  if (!input || !msg || !_keyEditorSlot) return;
8894
9056
  const value = input.value.trim();
8895
9057
  if (value.length < 8) {
8896
- msg.textContent = 'Key demasiado corta (min 8 chars).';
9058
+ msg.textContent = CONFIG.i18n.keyEditorTooShort;
8897
9059
  msg.className = 'ke-msg error';
8898
9060
  return;
8899
9061
  }
8900
- msg.textContent = 'Guardando...';
9062
+ msg.textContent = CONFIG.i18n.fbSaving;
8901
9063
  msg.className = 'ke-msg';
8902
9064
  try {
8903
9065
  const r = await fetch('/api/forge/keys-set', {
@@ -8907,15 +9069,15 @@ async function saveKeyEditor() {
8907
9069
  });
8908
9070
  const data = await r.json();
8909
9071
  if (!r.ok || !data.ok) {
8910
- msg.textContent = 'Error: ' + (data.error || r.status);
9072
+ msg.textContent = CONFIG.i18n.fbErrorPrefix.replace('{detail}', (data.error || r.status));
8911
9073
  msg.className = 'ke-msg error';
8912
9074
  return;
8913
9075
  }
8914
- msg.textContent = 'Guardado.';
9076
+ msg.textContent = CONFIG.i18n.fbSaved;
8915
9077
  msg.className = 'ke-msg success';
8916
9078
  setTimeout(() => { closeKeyEditor(); refreshKeysStatus(); }, 600);
8917
9079
  } catch (err) {
8918
- msg.textContent = 'Error: ' + (err && err.message ? err.message : 'unknown');
9080
+ msg.textContent = CONFIG.i18n.fbErrorPrefix.replace('{detail}', (err && err.message ? err.message : 'unknown'));
8919
9081
  msg.className = 'ke-msg error';
8920
9082
  }
8921
9083
  }
@@ -9062,11 +9224,11 @@ async function micToggle(button) {
9062
9224
  return _micToggleWebSpeech(button);
9063
9225
  }
9064
9226
  if (provider === 'browser' && !_webSpeechAvailable()) {
9065
- setStatus('Browser STT unavailable; using server-side fallback.');
9227
+ setStatus(CONFIG.i18n.panelVoiceSttFallback);
9066
9228
  /* Fall through to MediaRecorder path. */
9067
9229
  }
9068
9230
  if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
9069
- setStatus('Your browser does not support microphone access.', true);
9231
+ setStatus(CONFIG.i18n.panelVoiceNoMicSupport, true);
9070
9232
  return;
9071
9233
  }
9072
9234
  try {
@@ -9280,12 +9442,12 @@ async function micToggle(button) {
9280
9442
  }
9281
9443
  const secLeft = Math.max(0, Math.ceil((stopAt - Date.now()) / 1000));
9282
9444
  if (secLeft <= 10) {
9283
- setStatus('Recording... auto-stop in ' + secLeft + 's');
9445
+ setStatus(CONFIG.i18n.panelVoiceRecordingCountdown.replace('{n}', secLeft));
9284
9446
  }
9285
9447
  if (secLeft <= 0) {
9286
9448
  clearInterval(countdown);
9287
9449
  try { mediaRecorder.stop(); } catch { /* ignore */ }
9288
- setStatus('Recording auto-stopped at 55s (Google STT 1-min limit). Try shorter.', true);
9450
+ setStatus(CONFIG.i18n.panelVoiceAutoStopped, true);
9289
9451
  }
9290
9452
  }, 1000);
9291
9453
  mediaRecorder.onstop = async () => {
@@ -9295,7 +9457,7 @@ async function micToggle(button) {
9295
9457
  mediaRecorder = null;
9296
9458
  if (blob.size === 0) {
9297
9459
  setMicButtonsState('idle');
9298
- setStatus('No audio captured. Try again.', true);
9460
+ setStatus(CONFIG.i18n.panelVoiceNoAudio, true);
9299
9461
  return;
9300
9462
  }
9301
9463
  /* Client-side size guard. 4 MB ~ 60s of WebM/Opus at
@@ -9303,7 +9465,7 @@ async function micToggle(button) {
9303
9465
  Refuse before even hitting the network. */
9304
9466
  if (blob.size > 4_000_000) {
9305
9467
  setMicButtonsState('idle');
9306
- setStatus('Audio too long (>1 min). Google STT sync limit. Try shorter.', true);
9468
+ setStatus(CONFIG.i18n.panelVoiceAudioTooLong, true);
9307
9469
  return;
9308
9470
  }
9309
9471
  setMicButtonsState('processing');
@@ -9343,7 +9505,7 @@ async function micToggle(button) {
9343
9505
  }
9344
9506
  const text = (data.transcript && data.transcript.text) || '';
9345
9507
  if (!text) {
9346
- setStatus('Did not catch that -- try again.', true);
9508
+ setStatus(CONFIG.i18n.panelVoiceDidNotCatch, true);
9347
9509
  return;
9348
9510
  }
9349
9511
  /* Mark this turn as voice-originated so playTtsForText
@@ -9376,8 +9538,7 @@ async function micToggle(button) {
9376
9538
  setMicButtonsState('recording');
9377
9539
  setStatus('');
9378
9540
  } catch (err) {
9379
- setStatus('Microphone permission denied or unavailable: '
9380
- + String(err && err.message ? err.message : err), true);
9541
+ setStatus(CONFIG.i18n.panelVoiceMicDenied.replace('{error}', String(err && err.message ? err.message : err)), true);
9381
9542
  }
9382
9543
  }
9383
9544
 
@@ -9571,7 +9732,7 @@ async function _fetchTtsChunk(text) {
9571
9732
  });
9572
9733
  if (!r.ok) {
9573
9734
  const data = await r.json().catch(() => ({}));
9574
- setStatus('Voice reply failed: ' + (data.error || ('HTTP ' + r.status)), true);
9735
+ setStatus(CONFIG.i18n.panelVoiceReplyFailed.replace('{err}', (data.error || ('HTTP ' + r.status))), true);
9575
9736
  return null;
9576
9737
  }
9577
9738
  const ab = await r.arrayBuffer();
@@ -9579,7 +9740,7 @@ async function _fetchTtsChunk(text) {
9579
9740
  const blob = new Blob([ab], { type: contentType });
9580
9741
  return URL.createObjectURL(blob);
9581
9742
  } catch (err) {
9582
- setStatus('Voice reply error: ' + String(err && err.message ? err.message : err), true);
9743
+ setStatus(CONFIG.i18n.panelVoiceReplyError.replace('{err}', String(err && err.message ? err.message : err)), true);
9583
9744
  return null;
9584
9745
  }
9585
9746
  }
@@ -9593,7 +9754,7 @@ function _playChunkUrl(url) {
9593
9754
  audio.addEventListener('ended', done, { once: true });
9594
9755
  audio.addEventListener('error', done, { once: true });
9595
9756
  audio.play().catch((err) => {
9596
- setStatus('Could not play audio: ' + String(err && err.message ? err.message : err), true);
9757
+ setStatus(CONFIG.i18n.panelStatusAudioPlayError.replace('{error}', String(err && err.message ? err.message : err)), true);
9597
9758
  done();
9598
9759
  });
9599
9760
  });
@@ -9617,7 +9778,7 @@ function _playChunkUrl(url) {
9617
9778
  */
9618
9779
  async function ingestSpecFile(file) {
9619
9780
  if (!file) return;
9620
- setStatus('Leyendo ' + file.name + '...');
9781
+ setStatus(CONFIG.i18n.panelStatusReading.replace('{filename}', file.name));
9621
9782
  /* Render an in-progress system message so the user sees
9622
9783
  immediate feedback. */
9623
9784
  state.messages.push({
@@ -9638,10 +9799,10 @@ async function ingestSpecFile(file) {
9638
9799
  const data = await r.json();
9639
9800
  if (!r.ok || !data.ok) {
9640
9801
  const msg = data && data.error ? data.error : ('HTTP ' + r.status);
9641
- setStatus('Ingest fallo: ' + msg, true);
9802
+ setStatus(CONFIG.i18n.panelStatusIngestFailed.replace('{error}', msg), true);
9642
9803
  state.messages.push({
9643
9804
  role: 'assistant',
9644
- content: 'No pude leer la especificacion: ' + msg,
9805
+ content: CONFIG.i18n.panelStatusReadFailed.replace('{error}', msg),
9645
9806
  });
9646
9807
  renderStream();
9647
9808
  return;
@@ -9674,7 +9835,7 @@ async function ingestSpecFile(file) {
9674
9835
  renderTrace();
9675
9836
  setStatus('');
9676
9837
  } catch (err) {
9677
- setStatus('Ingest fallo: ' + String(err && err.message ? err.message : err), true);
9838
+ setStatus(CONFIG.i18n.panelStatusIngestFailed.replace('{error}', String(err && err.message ? err.message : err)), true);
9678
9839
  }
9679
9840
  }
9680
9841
 
@@ -9824,11 +9985,11 @@ function closeMicForTts() {
9824
9985
  if (_webSpeechRecognition) {
9825
9986
  try { _webSpeechRecognition.abort(); } catch (_) {}
9826
9987
  _webSpeechRecognition = null;
9827
- setStatus('Mic closed while Yujin speaks (half-duplex).');
9988
+ setStatus(CONFIG.i18n.panelVoiceMicClosedHalfDuplex);
9828
9989
  }
9829
9990
  if (mediaRecorder && mediaRecorder.state === 'recording') {
9830
9991
  try { mediaRecorder.stop(); } catch { /* ignore */ }
9831
- setStatus('Mic closed while Yujin speaks (half-duplex).');
9992
+ setStatus(CONFIG.i18n.panelVoiceMicClosedHalfDuplex);
9832
9993
  }
9833
9994
  }
9834
9995
 
@@ -9848,11 +10009,11 @@ async function _micToggleWebSpeech(button) {
9848
10009
  } catch (err) {
9849
10010
  const name = (err && err.name) ? String(err.name) : '';
9850
10011
  if (name === 'NotAllowedError' || name === 'SecurityError') {
9851
- setStatus('Microfono bloqueado. Permiti el microfono para esta app en los ajustes del navegador y volve a intentar.', true);
10012
+ setStatus(CONFIG.i18n.panelVoiceMicBlocked, true);
9852
10013
  } else if (name === 'NotFoundError' || name === 'OverconstrainedError') {
9853
- setStatus('No detecto microfono en este dispositivo.', true);
10014
+ setStatus(CONFIG.i18n.panelVoiceNoMicDevice, true);
9854
10015
  } else {
9855
- setStatus('No pude abrir el microfono: ' + (name || (err && err.message) || 'error'), true);
10016
+ setStatus(CONFIG.i18n.panelVoiceMicOpenFail.replace('{error}', (name || (err && err.message) || 'error')), true);
9856
10017
  }
9857
10018
  setMicButtonsState('idle');
9858
10019
  return;
@@ -9895,8 +10056,8 @@ async function _micToggleWebSpeech(button) {
9895
10056
  try { rec.stop(); } catch (_) {}
9896
10057
  }, SILENCE_MS);
9897
10058
  };
9898
- rec.onaudiostart = () => { audioStarted = true; setStatus('Mic abierto, escuchando...'); };
9899
- rec.onspeechstart = () => { speechStarted = true; setStatus('Te escucho...'); };
10059
+ rec.onaudiostart = () => { audioStarted = true; setStatus(CONFIG.i18n.panelVoiceMicOpenListening); };
10060
+ rec.onspeechstart = () => { speechStarted = true; setStatus(CONFIG.i18n.panelVoiceListening); };
9900
10061
  rec.onresult = (e) => {
9901
10062
  interim = '';
9902
10063
  for (let i = e.resultIndex; i < e.results.length; i += 1) {
@@ -9905,7 +10066,7 @@ async function _micToggleWebSpeech(button) {
9905
10066
  else interim += r[0].transcript;
9906
10067
  }
9907
10068
  if (interim || finalText) gotAnyResult = true;
9908
- if (interim) setStatus('Te escucho... ' + interim);
10069
+ if (interim) setStatus(CONFIG.i18n.panelVoiceListeningInterim.replace('{text}', interim));
9909
10070
  /* Only reset the silence countdown when something actually
9910
10071
  * changed since the last frame. A repeated interim is Chrome
9911
10072
  * keeping the channel open on background noise -- NOT user
@@ -9934,39 +10095,19 @@ async function _micToggleWebSpeech(button) {
9934
10095
  break;
9935
10096
  case 'not-allowed':
9936
10097
  case 'service-not-allowed':
9937
- setStatus(
9938
- 'Microfono bloqueado por el navegador. Hace click en el icono '
9939
- + 'del candado en la barra de URL y permiti microfono, despues '
9940
- + 'recarga la pagina. O cambia STT a "google" / "whisper" en '
9941
- + 'la configuracion si tenes la API key.',
9942
- true,
9943
- );
10098
+ setStatus(CONFIG.i18n.panelVoiceMicBlockedBrowser, true);
9944
10099
  break;
9945
10100
  case 'audio-capture':
9946
- setStatus(
9947
- 'No detecto microfono fisico. Conecta uno o cambia STT a '
9948
- + '"google" / "whisper" en la configuracion.',
9949
- true,
9950
- );
10101
+ setStatus(CONFIG.i18n.panelVoiceNoMicPhysical, true);
9951
10102
  break;
9952
10103
  case 'network':
9953
- setStatus(
9954
- 'Web Speech requiere conexion a internet (Chrome usa Google '
9955
- + 'detras). Probate sin conexion intermitente o cambia STT '
9956
- + 'a "whisper" si tenes API key.',
9957
- true,
9958
- );
10104
+ setStatus(CONFIG.i18n.panelVoiceNetworkRequired, true);
9959
10105
  break;
9960
10106
  case 'language-not-supported':
9961
- setStatus(
9962
- 'Tu navegador no soporta este idioma para STT. Cambia el '
9963
- + 'idioma del panel a uno soportado (en, es, pt) o cambia '
9964
- + 'STT a "google" / "whisper".',
9965
- true,
9966
- );
10107
+ setStatus(CONFIG.i18n.panelVoiceLangNotSupported, true);
9967
10108
  break;
9968
10109
  default:
9969
- setStatus('Mic error: ' + errKind, true);
10110
+ setStatus(CONFIG.i18n.panelVoiceMicError.replace('{error}', errKind), true);
9970
10111
  }
9971
10112
  };
9972
10113
  rec.onend = () => {
@@ -9993,20 +10134,20 @@ async function _micToggleWebSpeech(button) {
9993
10134
  /* Precise diagnostic so a mobile user (and we) can tell capture vs STT
9994
10135
  * apart instead of a generic "did not catch that". (Pablo 2026-06-19.) */
9995
10136
  if (!audioStarted) {
9996
- setStatus('No llego audio al reconocedor: el dictado del navegador no esta capturando en esta app.', true);
10137
+ setStatus(CONFIG.i18n.panelVoiceNoAudioToRecognizer, true);
9997
10138
  } else if (!speechStarted && !gotAnyResult) {
9998
- setStatus('Mic abierto pero no detecte voz. Acerca el microfono y habla mas fuerte; si sigue, el dictado gratis no funciona en esta PWA.', true);
10139
+ setStatus(CONFIG.i18n.panelVoiceMicOpenNoVoice, true);
9999
10140
  } else if (!gotAnyResult) {
10000
- setStatus('Detecte voz pero el navegador no devolvio texto (STT gratis no disponible en esta PWA).', true);
10141
+ setStatus(CONFIG.i18n.panelVoiceVoiceNoText, true);
10001
10142
  } else {
10002
- setStatus('No entendi las palabras. Proba de nuevo.', true);
10143
+ setStatus(CONFIG.i18n.panelVoiceDidNotCatch, true);
10003
10144
  }
10004
10145
  };
10005
10146
  try {
10006
10147
  _webSpeechRecognition = rec;
10007
10148
  rec.start();
10008
10149
  setMicButtonsState('recording');
10009
- setStatus('Iniciando dictado...');
10150
+ setStatus(CONFIG.i18n.panelVoiceStartingDictation);
10010
10151
  /* IMPORTANT (alpha.29 bugfix): DO NOT arm the silence timer
10011
10152
  here. If we did, and the user takes >900ms to start
10012
10153
  talking (typical when they click + think), the timer would
@@ -10015,7 +10156,7 @@ async function _micToggleWebSpeech(button) {
10015
10156
  once the FIRST interim/final result lands. */
10016
10157
  } catch (err) {
10017
10158
  _webSpeechRecognition = null;
10018
- setStatus('Could not start mic: ' + (err && err.message ? err.message : String(err)), true);
10159
+ setStatus(CONFIG.i18n.panelVoiceCouldNotStartMic.replace('{error}', (err && err.message ? err.message : String(err))), true);
10019
10160
  }
10020
10161
  }
10021
10162
 
@@ -10028,7 +10169,7 @@ async function _micToggleWebSpeech(button) {
10028
10169
  function _speakBrowser(text, langHint) {
10029
10170
  return new Promise((resolve) => {
10030
10171
  if (typeof window === 'undefined' || !window.speechSynthesis) {
10031
- setStatus('Browser TTS not available; switch back to Google in settings.', true);
10172
+ setStatus(CONFIG.i18n.panelVoiceTtsUnavailable, true);
10032
10173
  resolve();
10033
10174
  return;
10034
10175
  }
@@ -10524,18 +10665,18 @@ document.addEventListener('DOMContentLoaded', () => {
10524
10665
  try { const r = await fetch('/api/forge/bridge/state'); data = await r.json(); }
10525
10666
  catch (_) { return; }
10526
10667
  if (!data || !data.ok || !data.enabled) {
10527
- statusEl.textContent = 'no disponible';
10528
- rosterEl.innerHTML = '<div class="bridge-empty">Coordinacion no disponible.</div>';
10668
+ statusEl.textContent = CONFIG.i18n.bridgeStatusUnavailable;
10669
+ rosterEl.innerHTML = '<div class="bridge-empty">' + escapeHtml(CONFIG.i18n.bridgeCoordUnavailable) + '</div>';
10529
10670
  return;
10530
10671
  }
10531
10672
  const insts = Array.isArray(data.instances) ? data.instances : [];
10532
- statusEl.textContent = (data.role === 'host' ? 'host' : 'cliente')
10533
- + ' - ' + insts.length + (insts.length === 1 ? ' instancia' : ' instancias');
10673
+ statusEl.textContent = (data.role === 'host' ? CONFIG.i18n.bridgeRoleHost : CONFIG.i18n.bridgeRoleClient)
10674
+ + ' - ' + insts.length + ' ' + (insts.length === 1 ? CONFIG.i18n.bridgeInstanceOne : CONFIG.i18n.bridgeInstanceMany);
10534
10675
  rosterEl.innerHTML = insts.length ? insts.map((i) => {
10535
10676
  const self = i.id === data.self_id;
10536
10677
  const name = escapeHtml(i.project_name || i.project_slug || 'forge');
10537
10678
  return '<div class="bridge-inst' + (self ? ' is-self' : '') + '">'
10538
- + '<span class="dot"></span><span>' + name + (self ? ' (vos)' : '')
10679
+ + '<span class="dot"></span><span>' + name + (self ? ' (' + CONFIG.i18n.bridgeYou + ')' : '')
10539
10680
  + '</span><span class="meta">:' + (i.panel_port || '?') + '</span></div>';
10540
10681
  }).join('') : '<div class="bridge-empty">Sin otras instancias.</div>';
10541
10682
  const msgs = Array.isArray(data.messages) ? data.messages : [];
@@ -10657,7 +10798,7 @@ document.addEventListener('DOMContentLoaded', () => {
10657
10798
  $$('[data-key-clear]').forEach((b) => b.addEventListener('click', (e) => {
10658
10799
  const row = e.currentTarget.closest('[data-key-slot]');
10659
10800
  if (!row) return;
10660
- if (confirm('Quitar la key de ' + row.getAttribute('data-key-slot') + '?')) {
10801
+ if (confirm(CONFIG.i18n.panelConfirmRemoveKey.replace('{slot}', row.getAttribute('data-key-slot')))) {
10661
10802
  clearKey(row.getAttribute('data-key-slot'));
10662
10803
  }
10663
10804
  }));
@@ -10667,7 +10808,7 @@ document.addEventListener('DOMContentLoaded', () => {
10667
10808
  if (keSave) keSave.addEventListener('click', saveKeyEditor);
10668
10809
  if (keCancel) keCancel.addEventListener('click', closeKeyEditor);
10669
10810
  if (keClear) keClear.addEventListener('click', () => {
10670
- if (_keyEditorSlot && confirm('Quitar la key de ' + _keyEditorSlot + '?')) {
10811
+ if (_keyEditorSlot && confirm(CONFIG.i18n.panelConfirmRemoveKey.replace('{slot}', _keyEditorSlot))) {
10671
10812
  clearKey(_keyEditorSlot);
10672
10813
  closeKeyEditor();
10673
10814
  }
@@ -10756,7 +10897,7 @@ document.addEventListener('DOMContentLoaded', () => {
10756
10897
  /* Not a supported image -> keep the spec-ingest path. */
10757
10898
  if (SUPPORTED_IMG.indexOf(mt) < 0) { await ingestSpecFile(file); return; }
10758
10899
  if (file.size > 6 * 1024 * 1024) {
10759
- setStatus('Imagen muy grande (>6MB): ' + file.name, true);
10900
+ setStatus(CONFIG.i18n.panelStatusImageTooLarge.replace('{name}', file.name), true);
10760
10901
  return;
10761
10902
  }
10762
10903
  try {
@@ -10764,9 +10905,9 @@ document.addEventListener('DOMContentLoaded', () => {
10764
10905
  state.pendingAttachments = state.pendingAttachments || [];
10765
10906
  state.pendingAttachments.push({ media_type: mt, data: data, name: file.name });
10766
10907
  renderAttachChips();
10767
- setStatus('Imagen lista para enviar: ' + file.name);
10908
+ setStatus(CONFIG.i18n.panelStatusImageReady.replace('{name}', file.name));
10768
10909
  } catch (err) {
10769
- setStatus('No pude leer la imagen: ' + ((err && err.message) || err), true);
10910
+ setStatus(CONFIG.i18n.panelStatusImageReadFail.replace('{error}', ((err && err.message) || err)), true);
10770
10911
  }
10771
10912
  }
10772
10913
  /* Expose so send() + drag&drop can reach them. */
@@ -10852,7 +10993,7 @@ document.addEventListener('DOMContentLoaded', () => {
10852
10993
  /* Reload to apply translated strings everywhere. */
10853
10994
  location.reload();
10854
10995
  } catch (err) {
10855
- setStatus('Language switch failed: ' + String(err && err.message ? err.message : err), true);
10996
+ setStatus(CONFIG.i18n.panelStatusLanguageSwitchFailed.replace('{err}', String(err && err.message ? err.message : err)), true);
10856
10997
  }
10857
10998
  });
10858
10999
  });
@@ -10866,7 +11007,7 @@ document.addEventListener('DOMContentLoaded', () => {
10866
11007
  }
10867
11008
  const data = await loadLangData();
10868
11009
  if (!data) {
10869
- setStatus('Could not load language list', true);
11010
+ setStatus(CONFIG.i18n.panelStatusLanguageListFailed, true);
10870
11011
  return;
10871
11012
  }
10872
11013
  renderLangDropdown(data);
@@ -11265,7 +11406,7 @@ function mcpClose() {
11265
11406
 
11266
11407
  async function mcpLoadBridges(/* force */) {
11267
11408
  if (!yfMcp.contentEl) return;
11268
- yfMcp.contentEl.innerHTML = '<div class="mcp-empty">Cargando bridges...</div>';
11409
+ yfMcp.contentEl.innerHTML = '<div class="mcp-empty">' + escapeHtml(CONFIG.i18n.mcpLoadingBridges) + '</div>';
11269
11410
  try {
11270
11411
  const r = await fetch('/api/forge/mcp/bridges');
11271
11412
  const body = await r.json();
@@ -11352,7 +11493,7 @@ async function mcpLoadTools(bridgeId, cardEl) {
11352
11493
  const panel = document.createElement('div');
11353
11494
  panel.className = 'mcp-tools-list';
11354
11495
  panel.dataset.mcpToolsFor = bridgeId;
11355
- panel.textContent = 'Cargando tools...';
11496
+ panel.textContent = CONFIG.i18n.toolsLoading;
11356
11497
  cardEl.parentElement.insertBefore(panel, cardEl.nextSibling);
11357
11498
  try {
11358
11499
  const r = await fetch('/api/forge/mcp/tools?bridge_id=' + encodeURIComponent(bridgeId));
@@ -11714,7 +11855,7 @@ async function activityLoadBridgeMessages(pairId) {
11714
11855
  const msgs = Array.isArray(body.messages) ? body.messages : [];
11715
11856
  panel.innerHTML = '';
11716
11857
  const back = document.createElement('button');
11717
- back.textContent = '< Pares';
11858
+ back.textContent = CONFIG.i18n.agentsChatBackPairs;
11718
11859
  back.style.cssText = 'background:transparent;color:inherit;border:1px solid var(--ink-subtle,#5a5e6a);border-radius:4px;padding:4px 10px;font-family:inherit;font-size:12px;cursor:pointer;margin-bottom:10px;';
11719
11860
  back.addEventListener('click', () => { yfActivity.selectedPair = null; activityLoadBridgePairs(); });
11720
11861
  panel.appendChild(back);
@@ -11735,7 +11876,7 @@ async function activityLoadBridgeMessages(pairId) {
11735
11876
  if (msgs.length === 0) {
11736
11877
  const empty = document.createElement('div');
11737
11878
  empty.className = 'act-empty';
11738
- empty.textContent = 'Sin mensajes en este par.';
11879
+ empty.textContent = CONFIG.i18n.agentsChatNoMessages;
11739
11880
  panel.appendChild(empty);
11740
11881
  }
11741
11882
  } catch (err) {
@@ -11761,7 +11902,7 @@ async function activityPopulateChatSlugs() {
11761
11902
  if (agents.length === 0) {
11762
11903
  const opt = document.createElement('option');
11763
11904
  opt.value = '';
11764
- opt.textContent = 'no agents';
11905
+ opt.textContent = CONFIG.i18n.agentsChatNoAgents;
11765
11906
  select.appendChild(opt);
11766
11907
  }
11767
11908
  } catch (_err) { /* silent */ }
@@ -11775,8 +11916,8 @@ async function activityChatSend() {
11775
11916
  if (!select || !input || !result) return;
11776
11917
  const slug = String(select.value || '').trim();
11777
11918
  const text = String(input.value || '').trim();
11778
- if (!slug || !text) { result.textContent = 'Elegi un agente + escribe texto antes de enviar.'; return; }
11779
- result.textContent = 'Enviando...';
11919
+ if (!slug || !text) { result.textContent = CONFIG.i18n.agentsChatPickAgent; return; }
11920
+ result.textContent = CONFIG.i18n.agentsChatSending;
11780
11921
  try {
11781
11922
  const r = await fetch('/api/agent/inject', {
11782
11923
  method: 'POST',
@@ -11795,7 +11936,7 @@ async function activityChatSend() {
11795
11936
  /* Trigger a feed refresh so the user sees the new run. */
11796
11937
  if (yfActivity.activeTab === 'feed') setTimeout(() => activityRefresh(true), 800);
11797
11938
  } catch (err) {
11798
- result.textContent = 'Error: ' + (err.message || String(err));
11939
+ result.textContent = CONFIG.i18n.agentsChatErrorGeneric.replace('{err}', (err.message || String(err)));
11799
11940
  }
11800
11941
  }
11801
11942