@nac3/forge-cli 1.0.54 → 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,
@@ -910,6 +1080,9 @@ body {
910
1080
  .header .brand .t { display: flex; flex-direction: column; line-height: 1.1; }
911
1081
  .header .brand .t .name { font-size: 14px; font-weight: 600; }
912
1082
  .header .brand .t .proj { font-size: 11px; opacity: 0.8; }
1083
+ .header .brand .t .proj-base { font-size: 9.5px; opacity: 0.55; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
1084
+ max-width: 280px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; direction: rtl; text-align: left; }
1085
+ .header .brand .t .proj-base:empty { display: none; }
913
1086
  .header .brand .t .proj-switch { cursor: pointer; display: inline-flex; align-items: center;
914
1087
  align-self: flex-start; gap: 4px; padding: 1px 6px; margin-left: -4px; border-radius: 6px;
915
1088
  border: 1px solid transparent; transition: background 0.12s, border-color 0.12s; }
@@ -1904,7 +2077,7 @@ ${renderYujinSplash()}
1904
2077
  <div id="yf-whats-new-title" class="whats-new-title">Novedades en esta version</div>
1905
2078
  <div id="yf-whats-new-sub" class="whats-new-sub"></div>
1906
2079
  </div>
1907
- <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>
1908
2081
  </div>
1909
2082
  <div id="yf-whats-new-body" class="whats-new-body"></div>
1910
2083
  </div>
@@ -1918,7 +2091,7 @@ ${renderYujinSplash()}
1918
2091
  <div class="header">
1919
2092
  <div class="brand">
1920
2093
  <span class="k">${escapeHtml(tr.brandKanji)}</span>
1921
- <div class="t"><span class="name">Yujin Forge</span><span class="proj proj-switch" id="proj-mini" data-proj-switch role="button" tabindex="0" title="${escapeHtml(tr.projectSwitchHint)}" data-nac-id="yujin.panel.project-mini"></span></div>
2094
+ <div class="t"><span class="name">Yujin Forge</span><span class="proj proj-switch" id="proj-mini" data-proj-switch role="button" tabindex="0" title="${escapeHtml(tr.projectSwitchHint)}" data-nac-id="yujin.panel.project-mini"></span><span class="proj-base" id="proj-base-mini" data-proj-base></span></div>
1922
2095
  </div>
1923
2096
  <div class="actions">
1924
2097
  <button class="keys-btn" data-vault-open aria-label="${escapeHtml(tr.keysButton)}" title="${escapeHtml(tr.keysButton)}">${escapeHtml(tr.keysButton)}</button>
@@ -1932,7 +2105,7 @@ ${renderYujinSplash()}
1932
2105
  <form id="bar-mini" data-nac-id="yujin.chat.bar-mini" class="bar" onsubmit="return false">
1933
2106
  <button type="button" class="mic" data-mic data-mic-btn aria-label="${escapeHtml(tr.micButton)}" title="${escapeHtml(tr.micButton)}">🎙</button>
1934
2107
  <button type="button" class="tts-toggle" data-tts-toggle aria-label="${escapeHtml(tr.ttsButton)}" title="${escapeHtml(tr.ttsButton)}">🔊</button>
1935
- <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>
1936
2109
  <textarea class="composer-input" rows="1" placeholder="${escapeHtml(tr.inputPlaceholder)}" aria-label="${escapeHtml(tr.inputPlaceholder)}"></textarea>
1937
2110
  <button type="submit" class="send">${escapeHtml(tr.sendButton)}</button>
1938
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>
@@ -2045,9 +2218,9 @@ ${renderYujinSplash()}
2045
2218
  <button data-mcp-close aria-label="${escapeHtml(tr.closeButton)}" title="${escapeHtml(tr.closeButton)}">${escapeHtml(tr.closeButton)}</button>
2046
2219
  </div>
2047
2220
  <div class="mcp-body">
2048
- <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>
2049
2222
  <div data-mcp-content>
2050
- <div class="mcp-empty">Cargando...</div>
2223
+ <div class="mcp-empty">${escapeHtml(tForLang(lang, 'panel.legal.loading'))}</div>
2051
2224
  </div>
2052
2225
  </div>
2053
2226
  </div>
@@ -2063,24 +2236,24 @@ ${renderYujinSplash()}
2063
2236
  <p class="act-subtitle">Feed timestamped + bridge inspector + chat-per-agent</p>
2064
2237
  </div>
2065
2238
  <div style="display:flex;align-items:center;gap:10px;">
2066
- <select data-act-project-filter aria-label="Filter by project"
2239
+ <select data-act-project-filter aria-label="${escapeHtml(tForLang(lang, 'panel.tip.filterByProject'))}"
2067
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;">
2068
- <option value="">All projects</option>
2241
+ <option value="">${escapeHtml(tForLang(lang, 'panel.activity.allProjects'))}</option>
2069
2242
  </select>
2070
- <span data-act-concurrency aria-label="Concurrent runs"
2071
- 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'))}"
2072
2245
  style="font-family:ui-monospace,Menlo,Consolas,monospace;font-size:11px;color:var(--ink-muted,#8a8a8a);white-space:nowrap;">-/-</span>
2073
2246
  <button data-activity-close aria-label="${escapeHtml(tr.closeButton)}" title="${escapeHtml(tr.closeButton)}">${escapeHtml(tr.closeButton)}</button>
2074
2247
  </div>
2075
2248
  </div>
2076
2249
  <div class="act-body">
2077
2250
  <div data-act-panel="feed">
2078
- <div class="act-empty">Cargando feed...</div>
2251
+ <div class="act-empty">${escapeHtml(tForLang(lang, 'panel.activity.loadingFeed'))}</div>
2079
2252
  </div>
2080
2253
  </div>
2081
2254
  <div class="act-controls">
2082
2255
  <label><input type="checkbox" data-act-autorefresh checked /> auto-refresh 3s</label>
2083
- <button data-act-refresh>Refrescar ahora</button>
2256
+ <button data-act-refresh>${escapeHtml(tForLang(lang, 'panel.tip.refreshNow'))}</button>
2084
2257
  <span data-act-last-update style="margin-left:auto;font-family:ui-monospace,Menlo,Consolas,monospace;"></span>
2085
2258
  </div>
2086
2259
  </div>
@@ -2091,7 +2264,7 @@ ${renderYujinSplash()}
2091
2264
  <div class="header">
2092
2265
  <div class="brand">
2093
2266
  <span class="k">${escapeHtml(tr.brandKanji)}</span>
2094
- <div class="t"><span class="name">Yujin Forge</span><span class="proj proj-switch" id="proj-full" data-proj-switch role="button" tabindex="0" title="${escapeHtml(tr.projectSwitchHint)}" data-nac-id="yujin.panel.project-full"></span></div>
2267
+ <div class="t"><span class="name">Yujin Forge</span><span class="proj proj-switch" id="proj-full" data-proj-switch role="button" tabindex="0" title="${escapeHtml(tr.projectSwitchHint)}" data-nac-id="yujin.panel.project-full"></span><span class="proj-base" id="proj-base-full" data-proj-base></span></div>
2095
2268
  </div>
2096
2269
  <div class="actions">
2097
2270
  <button class="agents-btn"
@@ -2107,49 +2280,49 @@ ${renderYujinSplash()}
2107
2280
  data-nac-role="action"
2108
2281
  data-nac-action="open_mcp"
2109
2282
  aria-label="MCP"
2110
- title="MCP Bridges (F101)">MCP</button>
2283
+ title="${escapeHtml(tForLang(lang, 'panel.tip.mcpBridges'))}">MCP</button>
2111
2284
  <button class="agents-btn"
2112
2285
  data-activity-open
2113
2286
  data-nac-id="yujin.panel.activity"
2114
2287
  data-nac-role="action"
2115
2288
  data-nac-action="open_activity"
2116
- aria-label="Activity"
2117
- 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>
2118
2291
  <button class="settings-btn"
2119
2292
  data-settings-toggle
2120
2293
  data-nac-id="yujin.panel.settings"
2121
2294
  data-nac-role="action"
2122
2295
  data-nac-action="open_settings"
2123
- aria-label="ajustes"
2124
- 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>
2125
2298
  <button id="full-zoom-out"
2126
2299
  data-zoom-step="-1"
2127
2300
  data-nac-id="yujin.panel.zoom-out"
2128
2301
  data-nac-role="action"
2129
2302
  data-nac-action="zoom_out"
2130
- aria-label="Reducir zoom"
2131
- 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>
2132
2305
  <button id="full-zoom-reset"
2133
2306
  data-zoom-reset
2134
2307
  data-nac-id="yujin.panel.zoom-reset"
2135
2308
  data-nac-role="action"
2136
2309
  data-nac-action="zoom_reset"
2137
2310
  aria-label="Zoom 100%"
2138
- title="Restablecer zoom (Ctrl 0)">100%</button>
2311
+ title="${escapeHtml(tForLang(lang, 'panel.tip.zoomReset'))}">100%</button>
2139
2312
  <button id="full-zoom-in"
2140
2313
  data-zoom-step="1"
2141
2314
  data-nac-id="yujin.panel.zoom-in"
2142
2315
  data-nac-role="action"
2143
2316
  data-nac-action="zoom_in"
2144
- aria-label="Aumentar zoom"
2145
- title="Aumentar zoom (Ctrl +)">+</button>
2317
+ aria-label="${escapeHtml(tForLang(lang, 'panel.aria.zoomIn'))}"
2318
+ title="${escapeHtml(tForLang(lang, 'panel.tip.zoomIn'))}">+</button>
2146
2319
  <button id="full-maximize"
2147
2320
  data-maximize-toggle
2148
2321
  data-nac-id="yujin.panel.maximize"
2149
2322
  data-nac-role="action"
2150
2323
  data-nac-action="maximize_panel"
2151
- aria-label="Maximizar"
2152
- 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>
2153
2326
  <button id="full-mini"
2154
2327
  data-nac-id="yujin.panel.full-to-mini"
2155
2328
  data-nac-role="action"
@@ -2259,29 +2432,29 @@ ${renderYujinSplash()}
2259
2432
  API keys (BYOK) + voice settings + license. -->
2260
2433
  <div id="yf-settings" class="hidden settings-panel">
2261
2434
  <div class="set-section" data-bridge-section>
2262
- <div class="set-section-title">Coordinacion entre instancias
2435
+ <div class="set-section-title">${escapeHtml(tForLang(lang, 'settings.bridge.title'))}
2263
2436
  <span class="bridge-status" data-bridge-status></span>
2264
2437
  </div>
2265
- <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>
2266
2439
  <div class="bridge-roster" data-bridge-roster></div>
2267
2440
  <div class="bridge-feed" data-bridge-feed></div>
2268
2441
  <div class="bridge-compose">
2269
- <input type="text" class="bridge-input" data-bridge-input placeholder="Mensaje a las otras instancias..." maxlength="2000" autocomplete="off">
2270
- <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>
2271
2444
  </div>
2272
2445
  </div>
2273
2446
  <div class="set-section">
2274
- <div class="set-section-title">Voz</div>
2275
- <div class="set-row"><span class="set-lbl">Modo</span>
2276
- <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>
2277
2450
  </div>
2278
- <div class="set-row"><span class="set-lbl">STT</span>
2279
- <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>
2280
2453
  </div>
2281
- <div class="set-row"><span class="set-lbl">TTS engine</span>
2282
- <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>
2283
2456
  </div>
2284
- <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>
2285
2458
  <button type="button" data-tts-voice-btn class="set-btn">es-ES-Studio-C</button>
2286
2459
  </div>
2287
2460
  </div>
@@ -2290,63 +2463,63 @@ ${renderYujinSplash()}
2290
2463
  environment (quiet room / fan / open office / music). All values
2291
2464
  hot-apply on the next VAD tick; no reload needed. -->
2292
2465
  <div class="set-section vad-section">
2293
- <div class="set-section-title">VAD (deteccion de silencio)
2294
- <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>
2295
2468
  </div>
2296
- <div class="set-row"><span class="set-lbl">Motor</span>
2297
- <label class="vad-radio"><input type="radio" name="vad-engine" value="amplitude"> Amplitud (RMS)</label>
2298
- <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>
2299
2472
  </div>
2300
2473
  <div class="vad-slider-row" data-vad-field="baseline_ms" data-vad-min="100" data-vad-max="2000" data-vad-step="50">
2301
- <span class="vad-lbl">Baseline window</span>
2474
+ <span class="vad-lbl">${escapeHtml(tForLang(lang, 'settings.vad.baselineWindow'))}</span>
2302
2475
  <input type="range" class="vad-slider">
2303
2476
  <span class="vad-val">600 ms</span>
2304
2477
  </div>
2305
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">
2306
- <span class="vad-lbl">Threshold mult (baseline x N)</span>
2479
+ <span class="vad-lbl">${escapeHtml(tForLang(lang, 'settings.vad.thresholdMult'))}</span>
2307
2480
  <input type="range" class="vad-slider">
2308
2481
  <span class="vad-val">3.0</span>
2309
2482
  </div>
2310
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">
2311
- <span class="vad-lbl">Threshold minimo</span>
2484
+ <span class="vad-lbl">${escapeHtml(tForLang(lang, 'settings.vad.thresholdMin'))}</span>
2312
2485
  <input type="range" class="vad-slider">
2313
2486
  <span class="vad-val">0.012</span>
2314
2487
  </div>
2315
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">
2316
- <span class="vad-lbl">Threshold maximo</span>
2489
+ <span class="vad-lbl">${escapeHtml(tForLang(lang, 'settings.vad.thresholdMax'))}</span>
2317
2490
  <input type="range" class="vad-slider">
2318
2491
  <span class="vad-val">0.06</span>
2319
2492
  </div>
2320
2493
  <div class="vad-slider-row" data-vad-field="silence_ms" data-vad-min="200" data-vad-max="4000" data-vad-step="50">
2321
- <span class="vad-lbl">Silencio para cerrar</span>
2494
+ <span class="vad-lbl">${escapeHtml(tForLang(lang, 'settings.vad.silence'))}</span>
2322
2495
  <input type="range" class="vad-slider">
2323
2496
  <span class="vad-val">1000 ms</span>
2324
2497
  </div>
2325
2498
  <div class="vad-slider-row" data-vad-field="min_voiced_ms" data-vad-min="50" data-vad-max="1000" data-vad-step="25">
2326
- <span class="vad-lbl">Voz minima (anti-blip)</span>
2499
+ <span class="vad-lbl">${escapeHtml(tForLang(lang, 'settings.vad.minVoiced'))}</span>
2327
2500
  <input type="range" class="vad-slider">
2328
2501
  <span class="vad-val">200 ms</span>
2329
2502
  </div>
2330
2503
  <div class="vad-slider-row" data-vad-field="tick_ms" data-vad-min="20" data-vad-max="200" data-vad-step="5">
2331
- <span class="vad-lbl">Tick del VAD</span>
2504
+ <span class="vad-lbl">${escapeHtml(tForLang(lang, 'settings.vad.tick'))}</span>
2332
2505
  <input type="range" class="vad-slider">
2333
2506
  <span class="vad-val">50 ms</span>
2334
2507
  </div>
2335
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">
2336
- <span class="vad-lbl">Banda voz: low (Hz)</span>
2509
+ <span class="vad-lbl">${escapeHtml(tForLang(lang, 'settings.vad.bandLow'))}</span>
2337
2510
  <input type="range" class="vad-slider">
2338
2511
  <span class="vad-val">80</span>
2339
2512
  </div>
2340
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">
2341
- <span class="vad-lbl">Banda voz: high (Hz)</span>
2514
+ <span class="vad-lbl">${escapeHtml(tForLang(lang, 'settings.vad.bandHigh'))}</span>
2342
2515
  <input type="range" class="vad-slider">
2343
2516
  <span class="vad-val">400</span>
2344
2517
  </div>
2345
2518
  <div class="set-row">
2346
- <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>
2347
2520
  </div>
2348
2521
  <div class="set-row">
2349
- <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>
2350
2523
  <span class="set-status">~/.yujin-forge/vad_sessions.jsonl</span>
2351
2524
  </div>
2352
2525
  </div>
@@ -2356,42 +2529,42 @@ ${renderYujinSplash()}
2356
2529
  <div class="set-section brain-section">
2357
2530
  <div class="set-section-title">Brain</div>
2358
2531
  <div class="set-row">
2359
- <span class="set-lbl">Anthropic: modalidad</span>
2360
- <label class="vad-radio"><input type="radio" name="brain-anthropic-mode" value="plan"> Plan (suscripcion Claude)</label>
2361
- <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>
2362
2535
  </div>
2363
2536
  <div class="set-row">
2364
- <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>
2365
2538
  </div>
2366
2539
  <div class="set-row">
2367
- <label class="vad-check"><input type="checkbox" data-brain-eco-enabled> Modo eco habilitado</label>
2368
- <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>
2369
2542
  </div>
2370
2543
  <div class="set-row">
2371
2544
  <label class="vad-check"><input type="checkbox" data-startup-tabs-enabled> ${escapeHtml(tr.startupTabsLabel)}</label>
2372
2545
  </div>
2373
2546
  <div class="set-row" data-brain-tier-row="eco">
2374
- <span class="set-lbl">Modelo eco</span>
2547
+ <span class="set-lbl">${escapeHtml(tForLang(lang, 'settings.brain.modelEco'))}</span>
2375
2548
  <select class="set-select" data-brain-provider="eco"></select>
2376
2549
  <select class="set-select" data-brain-model="eco"></select>
2377
2550
  </div>
2378
2551
  <div class="set-row" data-brain-tier-row="frontier">
2379
- <span class="set-lbl">Modelo frontier</span>
2552
+ <span class="set-lbl">${escapeHtml(tForLang(lang, 'settings.brain.modelFrontier'))}</span>
2380
2553
  <select class="set-select" data-brain-provider="frontier"></select>
2381
2554
  <select class="set-select" data-brain-model="frontier"></select>
2382
2555
  </div>
2383
2556
  <div class="set-row" data-brain-tier-row="multimodal">
2384
- <span class="set-lbl">Modelo multimodal</span>
2557
+ <span class="set-lbl">${escapeHtml(tForLang(lang, 'settings.brain.modelMultimodal'))}</span>
2385
2558
  <select class="set-select" data-brain-provider="multimodal"></select>
2386
2559
  <select class="set-select" data-brain-model="multimodal"></select>
2387
- <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>
2388
2561
  </div>
2389
2562
  <div class="set-row">
2390
- <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>
2391
2564
  </div>
2392
2565
  <div class="set-row">
2393
- <label class="vad-check"><input type="checkbox" data-gemini-explicit-cache> Cache explicito de Gemini (CachedContent)</label>
2394
- <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>
2395
2568
  </div>
2396
2569
  <div class="set-row">
2397
2570
  <span class="set-status" data-brain-status></span>
@@ -2400,129 +2573,129 @@ ${renderYujinSplash()}
2400
2573
  <!-- F4 brain federation -- conectar al brain de otro Forge
2401
2574
  (child) y generar codigos para autorizar/rutear (parent). -->
2402
2575
  <div class="set-section federation-section">
2403
- <div class="set-section-title">Federacion de brain</div>
2576
+ <div class="set-section-title">${escapeHtml(tForLang(lang, 'settings.federation.title'))}</div>
2404
2577
  <div class="set-row">
2405
- <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>
2406
2579
  </div>
2407
2580
  <!-- Estado actual (cuando estas federado como hijo) -->
2408
2581
  <div class="set-row fed-linked" data-fed-linked hidden>
2409
- <span class="set-lbl">Conectado a</span>
2582
+ <span class="set-lbl">${escapeHtml(tForLang(lang, 'settings.federation.connectedTo'))}</span>
2410
2583
  <span class="set-status" data-fed-parent>--</span>
2411
- <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>
2412
2585
  </div>
2413
2586
  <!-- Conectarme a otro Forge (child) -->
2414
2587
  <div class="set-row" data-fed-join-row>
2415
- <span class="set-lbl">Conectarme</span>
2416
- <input type="text" class="set-input" data-fed-token placeholder="Pega el codigo de federacion" autocomplete="off" spellcheck="false">
2417
- <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>
2418
2591
  </div>
2419
2592
  <!-- Prestar mi brain (parent) -->
2420
2593
  <div class="set-row" data-fed-invite-row>
2421
- <span class="set-lbl">Prestar mi brain</span>
2422
- <button type="button" class="set-btn" data-fed-invite>Generar codigo</button>
2423
- <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>
2424
2597
  </div>
2425
2598
  <div class="set-row" data-fed-brain-row hidden>
2426
- <span class="set-lbl">Brain de federacion</span>
2599
+ <span class="set-lbl">${escapeHtml(tForLang(lang, 'settings.federation.fedBrain'))}</span>
2427
2600
  <select class="set-select" data-fed-brain-provider></select>
2428
2601
  <select class="set-select" data-fed-brain-model></select>
2429
2602
  </div>
2430
2603
  <div class="set-row" data-fed-brain-note hidden>
2431
- <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>
2432
2605
  </div>
2433
2606
  <div class="set-row" data-fed-token-out-row hidden>
2434
- <span class="set-lbl">Codigo (1 solo uso)</span>
2607
+ <span class="set-lbl">${escapeHtml(tForLang(lang, 'settings.federation.codeOnce'))}</span>
2435
2608
  <input type="text" class="set-input" data-fed-token-out readonly>
2436
- <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>
2437
2610
  </div>
2438
2611
  <div class="set-row">
2439
2612
  <span class="set-status" data-fed-status></span>
2440
2613
  </div>
2441
2614
  </div>
2442
2615
  <div class="set-section">
2443
- <div class="set-section-title">API keys (BYOK)</div>
2444
- <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>
2445
2618
  <span class="set-status" data-key-status>--</span>
2446
- <button type="button" class="set-btn" data-key-edit>Configurar</button>
2447
- <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>
2448
2621
  </div>
2449
- <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>
2450
2623
  <span class="set-status" data-key-status>--</span>
2451
- <button type="button" class="set-btn" data-key-edit>Configurar</button>
2452
- <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>
2453
2626
  </div>
2454
2627
  <!-- Bug 1 (Pablo 2026-06-10) -- faltaban los slots de los
2455
2628
  demas brain providers BYOK que el selector de modelo
2456
2629
  (PND-069) ya ofrece. Slots reales de provider_keys.ts. -->
2457
- <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>
2458
2631
  <span class="set-status" data-key-status>--</span>
2459
- <button type="button" class="set-btn" data-key-edit>Configurar</button>
2460
- <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>
2461
2634
  </div>
2462
- <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>
2463
2636
  <span class="set-status" data-key-status>--</span>
2464
- <button type="button" class="set-btn" data-key-edit>Configurar</button>
2465
- <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>
2466
2639
  </div>
2467
- <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>
2468
2641
  <span class="set-status" data-key-status>--</span>
2469
- <button type="button" class="set-btn" data-key-edit>Configurar</button>
2470
- <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>
2471
2644
  </div>
2472
- <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>
2473
2646
  <span class="set-status" data-key-status>--</span>
2474
- <button type="button" class="set-btn" data-key-edit>Configurar</button>
2475
- <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>
2476
2649
  </div>
2477
- <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>
2478
2651
  <span class="set-status" data-key-status>--</span>
2479
- <button type="button" class="set-btn" data-key-edit>Configurar</button>
2480
- <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>
2481
2654
  </div>
2482
- <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>
2483
2656
  <span class="set-status" data-key-status>--</span>
2484
- <button type="button" class="set-btn" data-key-edit>Configurar</button>
2485
- <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>
2486
2659
  </div>
2487
- <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>
2488
2661
  <span class="set-status" data-key-status>--</span>
2489
- <button type="button" class="set-btn" data-key-edit>Configurar</button>
2490
- <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>
2491
2664
  </div>
2492
- <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>
2493
2666
  <span class="set-status" data-key-status>--</span>
2494
- <button type="button" class="set-btn" data-key-edit>Configurar</button>
2495
- <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>
2496
2669
  </div>
2497
- <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>
2498
2671
  <span class="set-status" data-key-status>--</span>
2499
- <button type="button" class="set-btn" data-key-edit>Configurar</button>
2500
- <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>
2501
2674
  </div>
2502
2675
  </div>
2503
2676
  <div class="set-section">
2504
- <div class="set-section-title">Idioma</div>
2505
- <div class="set-row"><span class="set-lbl">Panel + voz</span>
2506
- <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>
2507
2680
  </div>
2508
2681
  </div>
2509
2682
  <div class="set-section">
2510
- <div class="set-section-title">Vault (encriptado)</div>
2511
- <div class="set-row"><span class="set-lbl">Slots</span>
2512
- <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>
2513
2686
  </div>
2514
2687
  </div>
2515
2688
  <div class="set-section">
2516
- <div class="set-section-title">License</div>
2517
- <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>
2518
2691
  <span class="set-status" id="set-license-status">--</span>
2519
2692
  </div>
2520
2693
  <div class="set-row" id="set-license-user-handle-row" style="display:none;">
2521
- <span class="set-lbl">Email</span>
2694
+ <span class="set-lbl">${escapeHtml(tForLang(lang, 'settings.license.email'))}</span>
2522
2695
  <span class="set-status" id="set-license-user-handle">--</span>
2523
2696
  </div>
2524
2697
  <div class="set-row" id="set-license-validated-row" style="display:none;">
2525
- <span class="set-lbl">Validado</span>
2698
+ <span class="set-lbl">${escapeHtml(tForLang(lang, 'settings.license.validated'))}</span>
2526
2699
  <span class="set-status" id="set-license-validated">--</span>
2527
2700
  </div>
2528
2701
 
@@ -2531,23 +2704,22 @@ ${renderYujinSplash()}
2531
2704
  /api/forge/license-handle -> writes handle + syncs. -->
2532
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);">
2533
2706
  <div style="font-size:11px;opacity:0.85;margin-bottom:8px;">
2534
- Conecta tu cuenta de Polar. Pone el email con el que pagaste y
2535
- sincronizamos tu plan automaticamente.
2707
+ ${escapeHtml(tForLang(lang, 'settings.license.connectHint'))}
2536
2708
  </div>
2537
2709
  <div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap;">
2538
2710
  <input type="email" id="set-license-connect-email"
2539
- placeholder="email@ejemplo.com"
2711
+ placeholder="${escapeHtml(tForLang(lang, 'settings.license.emailPlaceholder'))}"
2540
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;"
2541
2713
  autocomplete="email" />
2542
- <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>
2543
2715
  </div>
2544
2716
  <div id="set-license-connect-msg" style="font-size:11px;opacity:0.85;margin-top:6px;display:none;"></div>
2545
2717
  </div>
2546
2718
 
2547
2719
  <div class="set-row" id="set-license-sync-row" style="gap:6px;display:none;">
2548
- <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>
2549
2721
  <a href="https://yujin.app/yujin-forge/"
2550
- 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>
2551
2723
  </div>
2552
2724
  <div class="set-row" id="set-license-sync-msg-row" style="display:none;">
2553
2725
  <span class="set-status" id="set-license-sync-msg" style="font-size:11px;opacity:0.85;">--</span>
@@ -2557,15 +2729,15 @@ ${renderYujinSplash()}
2557
2729
  tier + renewal date + buy-extension link. Auto-refreshes
2558
2730
  on settings open + after each chat response. -->
2559
2731
  <div class="set-section" data-nac-id="yujin.panel.budget" data-nac-role="region">
2560
- <div class="set-section-title">Presupuesto este mes</div>
2561
- <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>
2562
2734
  <span class="set-status" id="set-budget-plan">--</span>
2563
2735
  </div>
2564
- <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>
2565
2737
  <span class="set-status" id="set-budget-renewal">--</span>
2566
2738
  </div>
2567
2739
  <div class="set-row" id="set-budget-eco-row">
2568
- <span class="set-lbl">Tokens eco</span>
2740
+ <span class="set-lbl">${escapeHtml(tForLang(lang, 'settings.budget.tokensEco'))}</span>
2569
2741
  <span class="set-status" id="set-budget-eco-numbers">--</span>
2570
2742
  </div>
2571
2743
  <div class="set-row" id="set-budget-eco-bar-row" style="margin-top:-6px;">
@@ -2574,7 +2746,7 @@ ${renderYujinSplash()}
2574
2746
  </div>
2575
2747
  </div>
2576
2748
  <div class="set-row" id="set-budget-frontier-row">
2577
- <span class="set-lbl">Tokens frontier</span>
2749
+ <span class="set-lbl">${escapeHtml(tForLang(lang, 'settings.budget.tokensFrontier'))}</span>
2578
2750
  <span class="set-status" id="set-budget-frontier-numbers">--</span>
2579
2751
  </div>
2580
2752
  <div class="set-row" id="set-budget-frontier-bar-row" style="margin-top:-6px;">
@@ -2583,18 +2755,18 @@ ${renderYujinSplash()}
2583
2755
  </div>
2584
2756
  </div>
2585
2757
  <div class="set-row" id="set-budget-extension-row" style="display:none;">
2586
- <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>
2587
2759
  </div>
2588
2760
  </div>
2589
2761
  <!-- alpha.59c.2 -- Limits card. Cada limite es un input
2590
2762
  numerico; el default va al lado en una pill chica.
2591
2763
  Save por blur. Reset restaura todos. -->
2592
2764
  <div class="set-section" data-nac-id="yujin.panel.limits" data-nac-role="region">
2593
- <div class="set-section-title">Limites (avanzado)</div>
2594
- <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>
2595
2767
  <div id="set-limits-fields"></div>
2596
2768
  <div class="set-row" style="justify-content:flex-end;gap:6px;">
2597
- <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>
2598
2770
  </div>
2599
2771
  <div id="set-limits-msg" class="ke-msg"></div>
2600
2772
  </div>
@@ -2604,10 +2776,10 @@ ${renderYujinSplash()}
2604
2776
  Click row -> envia mensaje hidden al LLM con el
2605
2777
  comando para invocar yujin.reader.bookmark-jump. -->
2606
2778
  <div class="set-section" data-nac-id="yujin.panel.reader-bookmarks" data-nac-role="region">
2607
- <div class="set-section-title">Reader bookmarks</div>
2779
+ <div class="set-section-title">${escapeHtml(tForLang(lang, 'settings.bookmarks.title'))}</div>
2608
2780
  <div id="set-bookmarks-list" class="bookmark-list"></div>
2609
2781
  <div class="set-row" style="justify-content:flex-end;">
2610
- <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>
2611
2783
  </div>
2612
2784
  </div>
2613
2785
 
@@ -2616,9 +2788,9 @@ ${renderYujinSplash()}
2616
2788
  user know whether the inverse-hallucination guard is
2617
2789
  populated. CLI for full CRUD: yf graph add-node. -->
2618
2790
  <div class="set-section" data-nac-id="yujin.panel.graph-status" data-nac-role="region">
2619
- <div class="set-section-title">Project knowledge graph</div>
2791
+ <div class="set-section-title">${escapeHtml(tForLang(lang, 'settings.graph.title'))}</div>
2620
2792
  <div class="set-row" id="set-graph-summary">
2621
- <span class="set-lbl">Cargando...</span>
2793
+ <span class="set-lbl">${escapeHtml(tForLang(lang, 'vault.list.loading'))}</span>
2622
2794
  </div>
2623
2795
  <div id="set-graph-samples" class="graph-samples"></div>
2624
2796
  <div class="set-row" style="justify-content:space-between;align-items:center;">
@@ -2630,18 +2802,18 @@ ${renderYujinSplash()}
2630
2802
  <!-- Vincular telefono (dogfood 2026-06-10; Option 3 2026-06-16 --
2631
2803
  code-less: el QR es autocontenido, se escanea y listo). -->
2632
2804
  <div class="set-section">
2633
- <div class="set-section-title">Vincular telefono</div>
2805
+ <div class="set-section-title">${escapeHtml(tForLang(lang, 'settings.pair.title'))}</div>
2634
2806
  <div class="set-row">
2635
- <span class="set-lbl" style="opacity:0.7;font-size:11px;">Abri Forge Mobile, toca "Vincular" y escanea este QR. Listo.</span>
2636
- <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>
2637
2809
  </div>
2638
2810
  <div id="set-pair-result" class="hidden" style="text-align:center;padding:8px 0;">
2639
2811
  <div id="set-pair-qr" style="display:inline-block;background:#ffffff;padding:10px;border-radius:8px;width:220px;height:220px;"></div>
2640
2812
  <div id="set-pair-expiry" style="font-size:11px;opacity:0.6;margin-top:6px;"></div>
2641
- <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>
2642
2814
  <div style="display:flex;gap:6px;align-items:center;justify-content:center;margin-top:4px;">
2643
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);" />
2644
- <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>
2645
2817
  </div>
2646
2818
  </div>
2647
2819
  <div id="set-pair-error" class="hidden" style="font-size:12px;color:var(--err,#c0392b);padding:6px 0;"></div>
@@ -2652,18 +2824,18 @@ ${renderYujinSplash()}
2652
2824
  del config. Click en "Ver completo" abre el audit modal
2653
2825
  con filtros + lista paginada + detail inline. -->
2654
2826
  <div class="set-section audit-log" data-nac-id="yujin.panel.audit-log" data-nac-role="region">
2655
- <div class="set-section-title">Audit log</div>
2827
+ <div class="set-section-title">${escapeHtml(tForLang(lang, 'settings.audit.title'))}</div>
2656
2828
  <div id="set-audit-preview" class="audit-preview">
2657
- <div class="audit-preview-empty">Cargando...</div>
2829
+ <div class="audit-preview-empty">${escapeHtml(tForLang(lang, 'vault.list.loading'))}</div>
2658
2830
  </div>
2659
2831
  <div class="set-row" style="justify-content:flex-end;gap:6px;margin-top:6px;">
2660
- <button type="button" class="set-btn" id="set-audit-refresh">Refrescar</button>
2661
- <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>
2662
2834
  </div>
2663
2835
  </div>
2664
2836
  <!-- z.111 -- About / legal docs. Populated by fetchAndRenderAbout() on first settings open. -->
2665
2837
  <div class="set-section about-section" data-nac-id="yujin.panel.about" data-nac-role="region">
2666
- <div class="set-section-title">About</div>
2838
+ <div class="set-section-title">${escapeHtml(tForLang(lang, 'settings.about.title'))}</div>
2667
2839
  <div class="set-row" style="font-size:11px; color:var(--ink-2);">
2668
2840
  Forge <span id="set-about-version">--</span>
2669
2841
  </div>
@@ -2678,12 +2850,10 @@ ${renderYujinSplash()}
2678
2850
  aparece + cada accion se loguea con
2679
2851
  approval_mode='danger_zone_bypass'. -->
2680
2852
  <div class="set-section danger-zone" data-nac-id="yujin.panel.danger-zone" data-nac-role="region">
2681
- <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>
2682
2854
  <div class="set-row" style="opacity:0.8;">
2683
2855
  <span class="set-lbl" style="font-size:11px;">
2684
- Cuando esta ON, Yujin ejecuta TODA accion sin pedir confirmacion
2685
- (incluye shell.exec, rm, git push --force, etc). Velocidad sobre
2686
- seguridad. Todo queda registrado en audit log.
2856
+ ${escapeHtml(tForLang(lang, 'settings.danger.warning'))}
2687
2857
  </span>
2688
2858
  </div>
2689
2859
  <div id="set-danger-toggles"></div>
@@ -2698,22 +2868,22 @@ ${renderYujinSplash()}
2698
2868
  <div class="audit-modal-backdrop" data-audit-close></div>
2699
2869
  <div class="audit-modal-frame" role="dialog" aria-modal="true">
2700
2870
  <div class="audit-modal-header">
2701
- <div class="audit-modal-title">Audit log</div>
2702
- <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>
2703
2873
  </div>
2704
2874
  <div class="audit-modal-toolbar">
2705
2875
  <select id="audit-modal-filter-kind" class="audit-filter">
2706
- <option value="">Todos los kinds</option>
2876
+ <option value="">${escapeHtml(tForLang(lang, 'settings.audit.allKinds'))}</option>
2707
2877
  </select>
2708
2878
  <select id="audit-modal-filter-approval" class="audit-filter">
2709
- <option value="">Todos los modes</option>
2879
+ <option value="">${escapeHtml(tForLang(lang, 'settings.audit.allModes'))}</option>
2710
2880
  <option value="auto_approved">auto_approved</option>
2711
2881
  <option value="user_approved">user_approved</option>
2712
2882
  <option value="user_rejected">user_rejected</option>
2713
2883
  <option value="danger_zone_bypass">danger_zone_bypass</option>
2714
2884
  <option value="not_applicable">not_applicable</option>
2715
2885
  </select>
2716
- <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>
2717
2887
  <span id="audit-modal-count" style="font-size:11px;opacity:0.6;margin-left:auto;"></span>
2718
2888
  </div>
2719
2889
  <div id="audit-modal-list" class="audit-modal-list"></div>
@@ -2724,8 +2894,8 @@ ${renderYujinSplash()}
2724
2894
  <div class="audit-modal-detail-sub" id="audit-modal-detail-sub">--</div>
2725
2895
  </div>
2726
2896
  <div style="display:flex;gap:6px;">
2727
- <button type="button" class="set-btn" id="audit-modal-detail-copy">Copiar JSON</button>
2728
- <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>
2729
2899
  </div>
2730
2900
  </div>
2731
2901
  <pre class="audit-modal-detail-body" id="audit-modal-detail-body"></pre>
@@ -2738,20 +2908,20 @@ ${renderYujinSplash()}
2738
2908
  <div class="legal-modal-backdrop"></div>
2739
2909
  <div class="legal-modal-frame">
2740
2910
  <div class="legal-modal-header">
2741
- <div class="legal-modal-title" id="legal-modal-title">Legal</div>
2742
- <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>
2743
2913
  </div>
2744
- <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>
2745
2915
  </div>
2746
2916
  </div>
2747
2917
  <!-- inline edit form (single, repositioned over the active key row) -->
2748
2918
  <div id="yf-key-editor" class="hidden key-editor">
2749
- <div class="ke-title">Configurar <span id="ke-slot-name"></span></div>
2750
- <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"/>
2751
2921
  <div class="ke-actions">
2752
- <button type="button" id="ke-save" class="set-btn">Guardar</button>
2753
- <button type="button" id="ke-clear" class="set-btn set-btn-danger">Quitar</button>
2754
- <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>
2755
2925
  </div>
2756
2926
  <div id="ke-msg" class="ke-msg"></div>
2757
2927
  </div>
@@ -2764,7 +2934,7 @@ ${renderYujinSplash()}
2764
2934
  <form id="bar-full" data-nac-id="yujin.chat.bar-full" class="bar" onsubmit="return false">
2765
2935
  <button type="button" class="mic" data-mic data-mic-btn aria-label="${escapeHtml(tr.micButton)}" title="${escapeHtml(tr.micButton)}">🎙</button>
2766
2936
  <button type="button" class="tts-toggle" data-tts-toggle aria-label="${escapeHtml(tr.ttsButton)}" title="${escapeHtml(tr.ttsButton)}">🔊</button>
2767
- <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>
2768
2938
  <textarea class="composer-input" rows="1" placeholder="${escapeHtml(tr.inputPlaceholder)}" aria-label="${escapeHtml(tr.inputPlaceholder)}"></textarea>
2769
2939
  <button type="submit" class="send">${escapeHtml(tr.sendButton)}</button>
2770
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>
@@ -2786,7 +2956,7 @@ ${renderYujinSplash()}
2786
2956
  data-nac-id="yujin.pizarron.clear-all"
2787
2957
  data-nac-role="action"
2788
2958
  data-nac-action="clear_pizarron"
2789
- 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>
2790
2960
  </div>
2791
2961
  <div class="pz-tabs" id="pizarron-tabs"
2792
2962
  data-nac-id="yujin.pizarron.tabs"
@@ -2796,17 +2966,12 @@ ${renderYujinSplash()}
2796
2966
  data-nac-role="region">
2797
2967
  <div class="pizarra-empty" id="pizarron-empty">
2798
2968
  <div class="kanji-big">板</div>
2799
- <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>
2800
2970
  <!-- PND-064 -- aviso cuando el brain corre en modo Plan
2801
2971
  (claude-code-cli): en ese modo el pizarron no es
2802
2972
  alcanzable. Se muestra solo si el endpoint reporta
2803
2973
  ese provider activo. -->
2804
- <p id="pizarron-plan-hint" class="pizarra-plan-hint hidden">
2805
- Estas en modo Plan (suscripcion). El pizarron se dibuja
2806
- cuando el brain corre en modo API: cambialo en
2807
- <strong>Ajustes &gt; Brain &gt; Anthropic: modalidad &gt; Console</strong>
2808
- y reabri el panel.
2809
- </p>
2974
+ <p id="pizarron-plan-hint" class="pizarra-plan-hint hidden">${tForLang(lang, 'panel.hint.planMode')}</p>
2810
2975
  </div>
2811
2976
  </div>
2812
2977
  <!-- Action trace (tool calls log) -- siempre visible
@@ -2903,6 +3068,14 @@ function setProj() {
2903
3068
  el.textContent = label;
2904
3069
  el.setAttribute('title', title);
2905
3070
  }
3071
+ /* base path (project root) in the top bar -- follows project switches */
3072
+ const base = CONFIG.projectRoot || '';
3073
+ for (const id of ['#proj-base-mini', '#proj-base-full']) {
3074
+ const el = $(id);
3075
+ if (!el) continue;
3076
+ el.textContent = base;
3077
+ el.setAttribute('title', base);
3078
+ }
2906
3079
  }
2907
3080
 
2908
3081
  /* Project selector (task #33) -- the project label in the header is a
@@ -2987,10 +3160,10 @@ async function switchProject(slug) {
2987
3160
  setProj();
2988
3161
  setStatus('');
2989
3162
  } else {
2990
- setStatus((j && j.error) || 'No se pudo cambiar de proyecto.', true);
3163
+ setStatus((j && j.error) || CONFIG.i18n.panelStatusProjectSwitchFail, true);
2991
3164
  }
2992
3165
  } catch (e) {
2993
- setStatus('No se pudo cambiar de proyecto.', true);
3166
+ setStatus(CONFIG.i18n.panelStatusProjectSwitchFail, true);
2994
3167
  }
2995
3168
  }
2996
3169
  /* Remove a project from the list (the "X"). Confirms first; on success drops
@@ -3011,10 +3184,10 @@ async function removeProjectViaClick(slug, rowEl) {
3011
3184
  setStatus(((CONFIG.i18n && CONFIG.i18n.projectRemoved) || 'Proyecto quitado de la lista') + ': ' + slug);
3012
3185
  setTimeout(function () { setStatus(''); }, 2500);
3013
3186
  } else {
3014
- setStatus((j && j.error) || 'No se pudo quitar el proyecto.', true);
3187
+ setStatus((j && j.error) || CONFIG.i18n.panelStatusProjectRemoveFail, true);
3015
3188
  }
3016
3189
  } catch (e) {
3017
- setStatus('No se pudo quitar el proyecto.', true);
3190
+ setStatus(CONFIG.i18n.panelStatusProjectRemoveFail, true);
3018
3191
  }
3019
3192
  }
3020
3193
  async function addProjectViaPrompt() {
@@ -3027,10 +3200,10 @@ async function addProjectViaPrompt() {
3027
3200
  body: JSON.stringify({ path: path.trim() }),
3028
3201
  });
3029
3202
  const j = await r.json();
3030
- 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; }
3031
3204
  /* reopen the menu so the user can pick the freshly added project */
3032
3205
  openProjectMenu($('#proj-full') || $('#proj-mini'));
3033
- } catch (e) { setStatus('No se pudo anadir el proyecto.', true); }
3206
+ } catch (e) { setStatus(CONFIG.i18n.panelStatusProjectAddFail, true); }
3034
3207
  }
3035
3208
 
3036
3209
  function escapeHtml(s) {
@@ -3086,7 +3259,7 @@ function renderMd(src) {
3086
3259
  * /api/forge/open-in-editor. */
3087
3260
  out = out.replace(
3088
3261
  /(?<![\\w/])((?:src|tests|docs|packages|apps|scripts)\\/[\\w./-]+(?:\\.[\\w]+)(?::\\d+(?::\\d+)?)?)/g,
3089
- '<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>',
3090
3263
  );
3091
3264
  return out;
3092
3265
  }
@@ -3132,7 +3305,7 @@ function renderMdRich(src) {
3132
3305
  /* file paths */
3133
3306
  r = r.replace(
3134
3307
  /(?<![\\w/])((?:src|tests|docs|packages|apps|scripts)\\/[\\w./-]+(?:\\.[\\w]+)(?::\\d+(?::\\d+)?)?)/g,
3135
- '<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>',
3136
3309
  );
3137
3310
  return r;
3138
3311
  }
@@ -3386,7 +3559,7 @@ function renderStream() {
3386
3559
  '<div class="tp-hint">' + hint + ' <span class="tp-count">(' + sp.round + '/' + sp.max_rounds + ')</span></div>' +
3387
3560
  '</div>';
3388
3561
  } else {
3389
- 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>';
3390
3563
  }
3391
3564
  target.appendChild(tdiv);
3392
3565
  }
@@ -3410,7 +3583,7 @@ function renderStream() {
3410
3583
  const body = await r.json();
3411
3584
  if (!body.ok && body.path) {
3412
3585
  navigator.clipboard.writeText(body.path).catch(function () {});
3413
- alert('No editor en PATH. Copie el path: ' + body.path);
3586
+ alert(CONFIG.i18n.alertNoEditor.replace('{path}', body.path));
3414
3587
  }
3415
3588
  fetch('/api/forge/trust-event', {
3416
3589
  method: 'POST',
@@ -3496,13 +3669,13 @@ async function openLegalModal(title, url) {
3496
3669
  const titleEl = document.getElementById('legal-modal-title');
3497
3670
  const bodyEl = document.getElementById('legal-modal-body');
3498
3671
  if (!modal || !titleEl || !bodyEl) return;
3499
- titleEl.textContent = title || 'Legal';
3500
- bodyEl.textContent = 'Cargando...';
3672
+ titleEl.textContent = title || CONFIG.i18n.legalDefaultTitle;
3673
+ bodyEl.textContent = CONFIG.i18n.legalLoading;
3501
3674
  modal.classList.remove('hidden');
3502
3675
  try {
3503
3676
  const r = await fetch(url);
3504
3677
  if (!r.ok) {
3505
- bodyEl.textContent = 'Error ' + r.status + ' cargando el documento.';
3678
+ bodyEl.textContent = CONFIG.i18n.legalLoadError.replace('{status}', r.status);
3506
3679
  return;
3507
3680
  }
3508
3681
  const md = await r.text();
@@ -3512,7 +3685,7 @@ async function openLegalModal(title, url) {
3512
3685
  bodyEl.textContent = md;
3513
3686
  bodyEl.scrollTop = 0;
3514
3687
  } catch (err) {
3515
- 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'));
3516
3689
  }
3517
3690
  }
3518
3691
 
@@ -3935,20 +4108,20 @@ async function refreshLimitsCard() {
3935
4108
  if (jj && jj.ok) {
3936
4109
  input.dataset.original = String(v);
3937
4110
  if (msgEl) {
3938
- msgEl.textContent = fieldPath + ' = ' + v + ' (guardado)';
4111
+ msgEl.textContent = fieldPath + ' = ' + v + ' (' + CONFIG.i18n.fbSavedShort + ')';
3939
4112
  msgEl.style.color = '#5fb35f';
3940
4113
  setTimeout(function () { if (msgEl) msgEl.textContent = ''; }, 2000);
3941
4114
  }
3942
4115
  } else {
3943
4116
  if (msgEl) {
3944
- msgEl.textContent = jj && jj.error ? String(jj.error) : 'error guardando';
4117
+ msgEl.textContent = jj && jj.error ? String(jj.error) : CONFIG.i18n.fbSaveError;
3945
4118
  msgEl.style.color = '#d97706';
3946
4119
  }
3947
4120
  input.value = original || String(f.value);
3948
4121
  }
3949
4122
  } catch (e) {
3950
4123
  if (msgEl) {
3951
- msgEl.textContent = 'error de red guardando';
4124
+ msgEl.textContent = CONFIG.i18n.fbNetErrorSaving;
3952
4125
  msgEl.style.color = '#d97706';
3953
4126
  }
3954
4127
  }
@@ -3989,7 +4162,7 @@ async function pairGenerate() {
3989
4162
  const btn = document.getElementById('set-pair-generate');
3990
4163
  const result = document.getElementById('set-pair-result');
3991
4164
  const errEl = document.getElementById('set-pair-error');
3992
- if (btn) { btn.disabled = true; btn.textContent = 'Generando...'; }
4165
+ if (btn) { btn.disabled = true; btn.textContent = CONFIG.i18n.pairGenerating; }
3993
4166
  if (errEl) errEl.classList.add('hidden');
3994
4167
  try {
3995
4168
  const r = await fetch('/api/forge/mobile-pair', { method: 'POST' });
@@ -4005,25 +4178,25 @@ async function pairGenerate() {
4005
4178
  if (copyBtn && !copyBtn.__wired) {
4006
4179
  copyBtn.__wired = true;
4007
4180
  copyBtn.addEventListener('click', function () {
4008
- 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); }
4009
4182
  catch (e) { codeEl.select(); }
4010
4183
  });
4011
4184
  }
4012
4185
  }
4013
4186
  if (exp && j.expires_at) {
4014
4187
  const mins = Math.max(0, Math.round((new Date(j.expires_at).getTime() - Date.now()) / 60000));
4015
- exp.textContent = 'Expira en ~' + mins + ' min. Generalo de nuevo si caduca.';
4188
+ exp.textContent = CONFIG.i18n.pairExpiry.replace('{mins}', mins);
4016
4189
  }
4017
4190
  if (result) result.classList.remove('hidden');
4018
4191
  } else if (errEl) {
4019
- errEl.textContent = (j && j.error ? j.error : 'No pude generar el QR.')
4192
+ errEl.textContent = (j && j.error ? j.error : CONFIG.i18n.pairQrFailed)
4020
4193
  + (j && j.hint ? ' (corre: ' + j.hint + ')' : '');
4021
4194
  errEl.classList.remove('hidden');
4022
4195
  }
4023
4196
  } catch (e) {
4024
- 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'); }
4025
4198
  }
4026
- if (btn) { btn.disabled = false; btn.textContent = 'Generar QR'; }
4199
+ if (btn) { btn.disabled = false; btn.textContent = CONFIG.i18n.pairGenerateQr; }
4027
4200
  }
4028
4201
 
4029
4202
  /* alpha.59r -- project knowledge graph status card. Reads
@@ -4134,7 +4307,7 @@ async function refreshBookmarksList() {
4134
4307
  const j = await r.json();
4135
4308
  if (!j || !j.ok || !Array.isArray(j.bookmarks)) return;
4136
4309
  if (j.bookmarks.length === 0) {
4137
- 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>';
4138
4311
  return;
4139
4312
  }
4140
4313
  host.innerHTML = j.bookmarks.slice(0, 40).map((bm) => {
@@ -4312,8 +4485,8 @@ function showAuditDetailInline(entry) {
4312
4485
  copyBtn.onclick = async function () {
4313
4486
  try {
4314
4487
  await navigator.clipboard.writeText(pretty);
4315
- copyBtn.textContent = 'Copiado';
4316
- setTimeout(function () { copyBtn.textContent = 'Copiar JSON'; }, 1200);
4488
+ copyBtn.textContent = CONFIG.i18n.fbCopied;
4489
+ setTimeout(function () { copyBtn.textContent = CONFIG.i18n.auditCopyJson; }, 1200);
4317
4490
  } catch (_) { /* swallow */ }
4318
4491
  };
4319
4492
  }
@@ -4347,11 +4520,11 @@ function openAuditDetail(entry) {
4347
4520
  '<div class="audit-detail-title">' + escapeHtml(kind) + ' <span class="a-mode ' + escapeHtml(mode) + '" style="margin-left:6px;">' + escapeHtml(mode) + '</span></div>' +
4348
4521
  '<div class="audit-detail-sub">' + escapeHtml(ts) + ' -- ' + escapeHtml(summary) + '</div>' +
4349
4522
  '</div>' +
4350
- '<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>' +
4351
4524
  '</div>' +
4352
4525
  '<pre class="audit-detail-body"></pre>' +
4353
4526
  '<div class="audit-detail-actions">' +
4354
- '<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>' +
4355
4528
  '<span class="audit-detail-msg" id="audit-detail-msg"></span>' +
4356
4529
  '</div>' +
4357
4530
  '</div>';
@@ -4376,14 +4549,14 @@ function openAuditDetail(entry) {
4376
4549
  try {
4377
4550
  if (navigator && navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
4378
4551
  navigator.clipboard.writeText(pretty).then(function () {
4379
- 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); }
4380
4553
  }, function () {
4381
- if (msg) { msg.textContent = 'Fallo el copiado'; }
4554
+ if (msg) { msg.textContent = CONFIG.i18n.fbCopyFailed; }
4382
4555
  });
4383
4556
  } else if (msg) {
4384
- msg.textContent = 'Clipboard no disponible';
4557
+ msg.textContent = CONFIG.i18n.fbClipboardUnavailable;
4385
4558
  }
4386
- } catch (_) { if (msg) msg.textContent = 'Fallo el copiado'; }
4559
+ } catch (_) { if (msg) msg.textContent = CONFIG.i18n.fbCopyFailed; }
4387
4560
  });
4388
4561
  }
4389
4562
  }
@@ -4399,7 +4572,7 @@ async function resetLimitsCard() {
4399
4572
  const j = await r.json();
4400
4573
  if (j && j.ok) {
4401
4574
  if (msgEl) {
4402
- msgEl.textContent = 'Reset a defaults aplicado';
4575
+ msgEl.textContent = CONFIG.i18n.limitsResetApplied;
4403
4576
  msgEl.style.color = '#5fb35f';
4404
4577
  setTimeout(function () { if (msgEl) msgEl.textContent = ''; }, 2500);
4405
4578
  }
@@ -4459,14 +4632,14 @@ async function refreshDangerZoneToggles() {
4459
4632
  } else {
4460
4633
  cb.checked = !newVal;
4461
4634
  if (msgEl) {
4462
- msgEl.textContent = jj && jj.error ? String(jj.error) : 'error guardando';
4635
+ msgEl.textContent = jj && jj.error ? String(jj.error) : CONFIG.i18n.fbSaveError;
4463
4636
  msgEl.style.color = '#d97706';
4464
4637
  }
4465
4638
  }
4466
4639
  } catch (_e) {
4467
4640
  cb.checked = !newVal;
4468
4641
  if (msgEl) {
4469
- msgEl.textContent = 'error de red';
4642
+ msgEl.textContent = CONFIG.i18n.fbNetError;
4470
4643
  msgEl.style.color = '#d97706';
4471
4644
  }
4472
4645
  }
@@ -4619,10 +4792,10 @@ async function killShellTab(tabId) {
4619
4792
  if (tab) tab.content = tab.content + '\\n[CANCELLED by user]\\n';
4620
4793
  renderPizarron();
4621
4794
  } else {
4622
- 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);
4623
4796
  }
4624
4797
  } catch (e) {
4625
- setStatus('Error de red al cancelar shell.', true);
4798
+ setStatus(CONFIG.i18n.panelStatusCancelNetError, true);
4626
4799
  }
4627
4800
  }
4628
4801
 
@@ -4789,27 +4962,27 @@ function nac3OpenApprovalModal(envelope) {
4789
4962
 
4790
4963
  const sideEffects = document.createElement('div');
4791
4964
  sideEffects.style.cssText = 'font-size:11px;opacity:0.7;margin-bottom:16px;';
4792
- 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));
4793
4966
 
4794
4967
  const btnRow = document.createElement('div');
4795
4968
  btnRow.style.cssText = 'display:flex;gap:8px;justify-content:flex-end;';
4796
4969
 
4797
4970
  const btnCancel = document.createElement('button');
4798
- btnCancel.textContent = 'Cancelar';
4971
+ btnCancel.textContent = CONFIG.i18n.nac3Cancel;
4799
4972
  btnCancel.setAttribute('data-nac-id', 'yujin.modal.nac3-approval.cancel');
4800
4973
  btnCancel.setAttribute('data-nac-role', 'button');
4801
4974
  btnCancel.setAttribute('data-nac-action', 'cancel');
4802
4975
  btnCancel.style.cssText = 'padding:8px 16px;border-radius:6px;border:1px solid #3a4258;background:transparent;color:inherit;cursor:pointer;font-size:13px;';
4803
4976
 
4804
4977
  const btnReject = document.createElement('button');
4805
- btnReject.textContent = 'Rechazar';
4978
+ btnReject.textContent = CONFIG.i18n.nac3Reject;
4806
4979
  btnReject.setAttribute('data-nac-id', 'yujin.modal.nac3-approval.reject');
4807
4980
  btnReject.setAttribute('data-nac-role', 'button');
4808
4981
  btnReject.setAttribute('data-nac-action', 'reject');
4809
4982
  btnReject.style.cssText = 'padding:8px 16px;border-radius:6px;border:1px solid #803030;background:#3a1818;color:#f4b8b8;cursor:pointer;font-size:13px;';
4810
4983
 
4811
4984
  const btnApprove = document.createElement('button');
4812
- btnApprove.textContent = verb.policy === 'destructive' ? 'Aprobar (destructivo)' : 'Aprobar';
4985
+ btnApprove.textContent = verb.policy === 'destructive' ? CONFIG.i18n.nac3ApproveDestructive : CONFIG.i18n.nac3Approve;
4813
4986
  btnApprove.setAttribute('data-nac-id', 'yujin.modal.nac3-approval.approve');
4814
4987
  btnApprove.setAttribute('data-nac-role', 'button');
4815
4988
  btnApprove.setAttribute('data-nac-action', 'approve');
@@ -4850,7 +5023,7 @@ function nac3OpenApprovalModal(envelope) {
4850
5023
 
4851
5024
  btnApprove.addEventListener('click', async () => {
4852
5025
  btnApprove.disabled = true; btnReject.disabled = true;
4853
- btnApprove.textContent = 'Ejecutando...';
5026
+ btnApprove.textContent = CONFIG.i18n.nac3Executing;
4854
5027
  let payloadNote = '';
4855
5028
  try {
4856
5029
  const r = await fetch('/api/forge/nac3-approve', {
@@ -4984,7 +5157,7 @@ function pizarronCloseItem(id) {
4984
5157
  if (idx < 0) return;
4985
5158
  const it = state.pizarron.items[idx];
4986
5159
  if (it.dirty) {
4987
- 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;
4988
5161
  }
4989
5162
  /* Startup tabs (dogfood 2026-06-10) -- si el usuario cierra una
4990
5163
  * pestania de startup (o el lienzo Coding), recordarlo: no se
@@ -5007,7 +5180,7 @@ function pizarronCloseItem(id) {
5007
5180
 
5008
5181
  function pizarronClearAll() {
5009
5182
  const dirty = state.pizarron.items.find((x) => x.dirty);
5010
- if (dirty && !confirm('Hay pestañas con cambios sin guardar. ¿Cerrar todo igual?')) return;
5183
+ if (dirty && !confirm(CONFIG.i18n.panelConfirmTabsUnsaved)) return;
5011
5184
  state.pizarron.items = [];
5012
5185
  state.pizarron.activeId = null;
5013
5186
  renderPizarron();
@@ -5017,7 +5190,7 @@ function pizarronStartEdit(id) {
5017
5190
  const it = state.pizarron.items.find((x) => x.id === id);
5018
5191
  if (!it) return;
5019
5192
  if (it.kind !== 'code' && it.kind !== 'markdown' && it.kind !== 'document') {
5020
- setStatus('Este tipo de contenido no es editable.', true);
5193
+ setStatus(CONFIG.i18n.panelStatusNotEditable, true);
5021
5194
  return;
5022
5195
  }
5023
5196
  it.isEditing = true;
@@ -5048,22 +5221,22 @@ async function pizarronSaveAs(id) {
5048
5221
  it.dirty = false;
5049
5222
  it.file_path = handle.name; /* solo el nombre; el browser
5050
5223
  no expone full path */
5051
- setStatus('Guardado como: ' + handle.name + ' (en la ruta que elegiste en el dialog).');
5224
+ setStatus(CONFIG.i18n.panelStatusSavedAsPicker.replace('{name}', handle.name));
5052
5225
  renderPizarron();
5053
5226
  return;
5054
5227
  } catch (err) {
5055
5228
  /* AbortError = el user cancelo el dialog -- silencioso. */
5056
5229
  if (err && err.name === 'AbortError') return;
5057
- 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);
5058
5231
  }
5059
5232
  }
5060
5233
  /* Fallback: prompt con path relativo al projectRoot. */
5061
5234
  const initial = it.file_path || 'untitled.txt';
5062
- const nextPath = prompt('Guardar como (path relativo al proyecto):', initial);
5235
+ const nextPath = prompt(CONFIG.i18n.panelPromptSaveAs, initial);
5063
5236
  if (!nextPath || nextPath.trim() === '') return;
5064
5237
  const rel = nextPath.trim();
5065
5238
  if (rel.startsWith('/') || rel.includes('..')) {
5066
- alert('El path debe ser relativo al proyecto (sin "/" inicial ni "..").');
5239
+ alert(CONFIG.i18n.alertPathRelative);
5067
5240
  return;
5068
5241
  }
5069
5242
  try {
@@ -5074,17 +5247,17 @@ async function pizarronSaveAs(id) {
5074
5247
  });
5075
5248
  const data = await r.json();
5076
5249
  if (!r.ok || !data.ok) {
5077
- setStatus('Error al guardar: ' + (data.error || r.status), true);
5250
+ setStatus(CONFIG.i18n.panelStatusSaveFail.replace('{error}', (data.error || r.status)), true);
5078
5251
  return;
5079
5252
  }
5080
5253
  it.content = it.editValue;
5081
5254
  it.file_path = rel;
5082
5255
  it.title = rel.split('/').pop() || rel;
5083
5256
  it.dirty = false;
5084
- setStatus('Guardado como: ' + rel + ' (' + data.bytes_written + ' bytes)');
5257
+ setStatus(CONFIG.i18n.panelStatusSavedAs.replace('{path}', rel).replace('{bytes}', data.bytes_written));
5085
5258
  renderPizarron();
5086
5259
  } catch (err) {
5087
- 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);
5088
5261
  }
5089
5262
  }
5090
5263
 
@@ -5095,7 +5268,7 @@ async function pizarronSave(id) {
5095
5268
  /* Snippet-only edit: persist back to the in-memory item. */
5096
5269
  it.content = it.editValue;
5097
5270
  it.dirty = false;
5098
- setStatus('Cambios guardados en la pestaña (no en disco -- sin file_path).');
5271
+ setStatus(CONFIG.i18n.panelStatusSavedTabOnly);
5099
5272
  renderPizarron();
5100
5273
  return;
5101
5274
  }
@@ -5108,22 +5281,22 @@ async function pizarronSave(id) {
5108
5281
  });
5109
5282
  const data = await r.json();
5110
5283
  if (!r.ok || !data.ok) {
5111
- setStatus('Error al guardar: ' + (data.error || r.status), true);
5284
+ setStatus(CONFIG.i18n.panelStatusSaveFail.replace('{error}', (data.error || r.status)), true);
5112
5285
  return;
5113
5286
  }
5114
5287
  it.content = it.editValue;
5115
5288
  it.dirty = false;
5116
- setStatus('Guardado: ' + it.file_path + ' (' + data.bytes_written + ' bytes)');
5289
+ setStatus(CONFIG.i18n.panelStatusSaved.replace('{path}', it.file_path).replace('{bytes}', data.bytes_written));
5117
5290
  renderPizarron();
5118
5291
  } catch (err) {
5119
- 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);
5120
5293
  }
5121
5294
  }
5122
5295
 
5123
5296
  function pizarronDiscard(id) {
5124
5297
  const it = state.pizarron.items.find((x) => x.id === id);
5125
5298
  if (!it) return;
5126
- if (it.dirty && !confirm('Descartar los cambios sin guardar?')) return;
5299
+ if (it.dirty && !confirm(CONFIG.i18n.panelConfirmDiscardChanges)) return;
5127
5300
  it.isEditing = false;
5128
5301
  it.editValue = it.content;
5129
5302
  it.dirty = false;
@@ -5136,9 +5309,9 @@ function pizarronReadAloud(id) {
5136
5309
  /* Strip markdown structure to make it readable, then TTS. */
5137
5310
  let txt = it.content;
5138
5311
  if (it.kind === 'code') {
5139
- txt = 'Código' + (it.language ? ' en ' + it.language : '') + '. '
5140
- + (it.file_path ? 'Archivo ' + it.file_path + '. ' : '')
5141
- + '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);
5142
5315
  }
5143
5316
  playTtsForText(txt);
5144
5317
  }
@@ -5149,8 +5322,8 @@ function pizarronCopy(id) {
5149
5322
  const value = it.isEditing ? it.editValue : it.content;
5150
5323
  if (navigator.clipboard && navigator.clipboard.writeText) {
5151
5324
  navigator.clipboard.writeText(value)
5152
- .then(() => setStatus('Copiado al portapapeles.'))
5153
- .catch(() => setStatus('No se pudo copiar.', true));
5325
+ .then(() => setStatus(CONFIG.i18n.panelStatusCopied))
5326
+ .catch(() => setStatus(CONFIG.i18n.panelStatusCopyFail, true));
5154
5327
  }
5155
5328
  }
5156
5329
 
@@ -5167,11 +5340,11 @@ function renderPizarronCardBody(it) {
5167
5340
  + ' <button type="button" class="pz-save" data-pz-save data-pz-item-id="' + it.id + '"'
5168
5341
  + ' data-nac-id="yujin.pizarron.editor.' + it.id + '.save"'
5169
5342
  + ' data-nac-role="action" data-nac-action="save_file"'
5170
- + ' 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>'
5171
5344
  + ' <button type="button" data-pz-save-as data-pz-item-id="' + it.id + '"'
5172
5345
  + ' data-nac-id="yujin.pizarron.editor.' + it.id + '.save-as"'
5173
5346
  + ' data-nac-role="action" data-nac-action="save_file_as"'
5174
- + ' title="Guardar como -- elegí nuevo path (relativo al proyecto)">Guardar como…</button>'
5347
+ + ' title="' + escapeHtml(CONFIG.i18n.tipSaveAs) + '">' + escapeHtml(CONFIG.i18n.btnSaveAs) + '</button>'
5175
5348
  + ' <button type="button" data-pz-discard data-pz-item-id="' + it.id + '"'
5176
5349
  + ' data-nac-id="yujin.pizarron.editor.' + it.id + '.discard"'
5177
5350
  + ' data-nac-role="action" data-nac-action="discard_changes">Descartar</button>'
@@ -5231,8 +5404,8 @@ function renderPizarronCardBody(it) {
5231
5404
  const cmode = codingMode();
5232
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">'
5233
5406
  + '<div class="pz-coding-switch" role="tablist">'
5234
- + '<button type="button" class="pz-cmode' + (cmode === 'detail' ? ' on' : '') + '" data-pz-coding-mode="detail" title="Detallado: archivos, diffs y comandos en vivo">codewrapper</button>'
5235
- + '<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>'
5236
5409
  + '</div>'
5237
5410
  + (cmode === 'zen'
5238
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>'
@@ -5244,7 +5417,7 @@ function renderPizarronCardBody(it) {
5244
5417
  if (it.kind === 'tasks') {
5245
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">'
5246
5419
  + '<div class="pz-tasks-new">'
5247
- + '<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) + '" />'
5248
5421
  + '<button type="button" class="pz-tasks-run" data-pz-tasks-run>Lanzar</button>'
5249
5422
  + '</div>'
5250
5423
  + '<div class="pz-tasks-list" data-pz-tasks-list><div class="pz-tasks-empty">cargando...</div></div>'
@@ -5322,7 +5495,7 @@ function renderPizarron() {
5322
5495
  const empty = $('#pizarron-empty');
5323
5496
  const count = $('#pizarron-count');
5324
5497
  if (!tabs || !body || !count) return;
5325
- 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);
5326
5499
  /* Render tabs */
5327
5500
  applySavedTabOrder(); /* PND-137 -- respeta el orden elegido por drag */
5328
5501
  const tabsHtml = state.pizarron.items.map((it) => {
@@ -5340,12 +5513,12 @@ function renderPizarron() {
5340
5513
  + '<span class="pz-tab-name">' + escapeHtml(it.title) + '</span>'
5341
5514
  + (showKill
5342
5515
  ? '<button type="button" class="pz-tab-kill" data-pz-kill data-pz-item-id="' + it.id + '"'
5343
- + ' title="Cancelar comando shell" aria-label="cancel">⏹</button>'
5516
+ + ' title="' + escapeHtml(CONFIG.i18n.tipCancelShell) + '" aria-label="cancel">⏹</button>'
5344
5517
  : '')
5345
5518
  + '<button type="button" class="pz-tab-close" data-pz-close data-pz-item-id="' + it.id + '"'
5346
5519
  + ' data-nac-id="yujin.pizarron.tab.' + it.id + '.close"'
5347
5520
  + ' data-nac-role="action" data-nac-action="close_tab"'
5348
- + ' title="Cerrar pestaña" aria-label="cerrar">×</button>'
5521
+ + ' title="' + escapeHtml(CONFIG.i18n.tipCloseTab) + '" aria-label="cerrar">×</button>'
5349
5522
  + '</div>';
5350
5523
  }).join('');
5351
5524
  /* "+" para reabrir pestanas nativas cerradas (Grafo/Workflow/
@@ -5354,7 +5527,7 @@ function renderPizarron() {
5354
5527
  let restoreHtml = '';
5355
5528
  if (closedNative.length) {
5356
5529
  restoreHtml = '<div class="pz-restore-wrap">'
5357
- + '<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>'
5358
5531
  + '<div class="pz-restore-menu hidden" data-pz-restore-menu>'
5359
5532
  + '<div class="pz-restore-head">Reabrir</div>'
5360
5533
  + closedNative.map(function (k) {
@@ -5368,7 +5541,7 @@ function renderPizarron() {
5368
5541
  if (state.pizarron.items.length === 0) {
5369
5542
  if (empty) empty.style.display = '';
5370
5543
  body.innerHTML = '<div class="pizarra-empty" id="pizarron-empty"><div class="kanji-big">板</div>'
5371
- + '<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>';
5372
5545
  return;
5373
5546
  }
5374
5547
  const it = pizarronGetActive() || state.pizarron.items[state.pizarron.items.length - 1];
@@ -5494,7 +5667,7 @@ function renderTasksList(tasks) {
5494
5667
  + (t.status === 'running'
5495
5668
  ? '<button type="button" class="pz-task-kill" data-pz-task-kill="' + escapeHtml(t.id) + '">stop</button>'
5496
5669
  : '<button type="button" class="pz-task-log" data-pz-task-log="' + escapeHtml(t.id) + '">log</button>')
5497
- + '<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>'
5498
5671
  + '</div>'
5499
5672
  + '</div>'
5500
5673
  + '<div class="pz-task-logbox" data-pz-task-logbox="' + escapeHtml(t.id) + '"></div>';
@@ -7662,7 +7835,7 @@ async function handleSlashCommand(raw) {
7662
7835
  };
7663
7836
  const target = map[arg];
7664
7837
  if (!target) {
7665
- setStatus('Modos disponibles: didactico, tecnico. Uso: /modo tecnico', true);
7838
+ setStatus(CONFIG.i18n.panelStatusModeAvailable, true);
7666
7839
  return true;
7667
7840
  }
7668
7841
  try {
@@ -7673,13 +7846,13 @@ async function handleSlashCommand(raw) {
7673
7846
  });
7674
7847
  const data = await r.json();
7675
7848
  if (!data.ok) {
7676
- setStatus('No pude cambiar el modo: ' + (data.error || 'unknown'), true);
7849
+ setStatus(CONFIG.i18n.panelStatusModeChangeFail.replace('{error}', (data.error || 'unknown')), true);
7677
7850
  return true;
7678
7851
  }
7679
7852
  try { document.cookie = 'yf-mode=' + target + '; path=/; max-age=' + (60*60*24*365) + '; SameSite=Lax'; } catch (_) {}
7680
- setStatus('Modo cambiado a ' + target + '. El proximo turno ya usa este modo.', false);
7853
+ setStatus(CONFIG.i18n.panelStatusModeChanged.replace('{target}', target), false);
7681
7854
  } catch (err) {
7682
- 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);
7683
7856
  }
7684
7857
  return true;
7685
7858
  }
@@ -7692,8 +7865,8 @@ async function handleSlashCommand(raw) {
7692
7865
  state.brainTierOverride = target;
7693
7866
  setStatus(
7694
7867
  target === null
7695
- ? 'Brain tier vuelve a auto (router decide segun verbo / phase).'
7696
- : 'Brain tier forzado a "' + target + '" para esta sesion. /auto vuelve al router.',
7868
+ ? CONFIG.i18n.panelStatusBrainTierAuto
7869
+ : CONFIG.i18n.panelStatusBrainTierForced.replace('{target}', target),
7697
7870
  false,
7698
7871
  );
7699
7872
  return true;
@@ -7743,7 +7916,7 @@ async function vaultRefreshList() {
7743
7916
  return;
7744
7917
  }
7745
7918
  const rows = data.slots.map((s) => {
7746
- let testCell = '<span class="t-pending" title="not tested yet">--</span>';
7919
+ let testCell = '<span class="t-pending" title="' + escapeHtml(CONFIG.i18n.tipNotTestedYet) + '">--</span>';
7747
7920
  if (s.last_test_ok === true) testCell = '<span class="t-ok" title="tested ok at ' + escapeHtml(s.last_test_at || '') + '">ok</span>';
7748
7921
  else if (s.last_test_ok === false) testCell = '<span class="t-fail" title="tested fail at ' + escapeHtml(s.last_test_at || '') + '">fail</span>';
7749
7922
  const expiryCell = s.expiry ? '<span class="t-exp" title="expires ' + escapeHtml(s.expiry) + '">exp</span>' : '';
@@ -7814,7 +7987,7 @@ async function vaultAdd() {
7814
7987
  }
7815
7988
  }
7816
7989
  async function vaultRemove(slot) {
7817
- if (!confirm('Remove slot "' + slot + '"? This cannot be undone.')) return;
7990
+ if (!confirm(CONFIG.i18n.panelConfirmRemoveSlot.replace('{slot}', slot))) return;
7818
7991
  try {
7819
7992
  const r = await fetch('/api/vault/remove', {
7820
7993
  method: 'POST',
@@ -7903,9 +8076,8 @@ function cycleTtsProvider() {
7903
8076
  function updateTtsProviderButtons() {
7904
8077
  const p = ttsProvider();
7905
8078
  document.querySelectorAll('[data-tts-provider-btn]').forEach((b) => {
7906
- b.textContent = p === 'browser' ? 'TTS navegador' : 'TTS Google';
7907
- b.title = 'Click para alternar entre navegador (SpeechSynthesis '
7908
- + '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;
7909
8081
  });
7910
8082
  }
7911
8083
 
@@ -7929,13 +8101,12 @@ function setSttProvider(p) {
7929
8101
  }
7930
8102
  function updateSttProviderButtons() {
7931
8103
  const p = sttProvider();
7932
- const label = p === 'browser' ? 'STT navegador'
7933
- : p === 'google' ? 'STT Google'
7934
- : 'STT Whisper';
8104
+ const label = p === 'browser' ? CONFIG.i18n.sttProviderBrowser
8105
+ : p === 'google' ? CONFIG.i18n.sttProviderGoogle
8106
+ : CONFIG.i18n.sttProviderWhisper;
7935
8107
  document.querySelectorAll('[data-stt-provider-btn]').forEach((b) => {
7936
8108
  b.textContent = label;
7937
- b.title = 'Click para alternar entre navegador (rapido + es-AR nativo + gratis), '
7938
- + 'Google Cloud STT (server-side, BYOK) o Whisper (server-side, BYOK).';
8109
+ b.title = CONFIG.i18n.sttProviderTitle;
7939
8110
  });
7940
8111
  }
7941
8112
  function cycleSttProvider() {
@@ -7993,10 +8164,9 @@ function updateTtsVoiceButtons() {
7993
8164
  const meta = TTS_VOICES.find((v) => v.id === cur) || TTS_VOICES[0];
7994
8165
  document.querySelectorAll('[data-tts-voice-btn]').forEach((b) => {
7995
8166
  b.textContent = meta.persona + ' [' + meta.style + ']';
7996
- b.title = 'Voz actual: ' + meta.id + ' / ' + meta.persona
7997
- + ' (' + meta.gender + ') -- ' + meta.style + '. '
7998
- + 'Click para ciclar. Studio = articula s claro; Neural2 = '
7999
- + '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);
8000
8170
  });
8001
8171
  }
8002
8172
 
@@ -8130,9 +8300,23 @@ function materializePanelDispatch(d) {
8130
8300
  /* Project selector (task #33) -- update the header label to the new
8131
8301
  * project name the server reported. */
8132
8302
  try {
8133
- if (d.args && d.args.name) { CONFIG.projectName = String(d.args.name); setProj(); }
8303
+ if (d.args && d.args.name) { CONFIG.projectName = String(d.args.name); }
8304
+ if (d.args && d.args.root) { CONFIG.projectRoot = String(d.args.root); }
8305
+ setProj();
8134
8306
  } catch (e) { /* */ }
8135
- loadStartupTabs(false).catch(function () {});
8307
+ /* new project root -> the file-explorer tab must follow it. Drop the old
8308
+ * project's expanded-paths memory, then re-fetch the tree once the tabs
8309
+ * have re-materialized (the placeholder content is identical across
8310
+ * projects, so the upsert alone may not re-run initFilesTab). */
8311
+ try { for (var _k in _filesExpandByTab) delete _filesExpandByTab[_k]; } catch (e) { /* */ }
8312
+ loadStartupTabs(false).then(function () {
8313
+ setTimeout(function () {
8314
+ try {
8315
+ var f = state.pizarron.items.find(function (x) { return x.id === 'startup_files'; });
8316
+ if (f) initFilesTab(f);
8317
+ } catch (e) { /* */ }
8318
+ }, 30);
8319
+ }).catch(function () {});
8136
8320
  return;
8137
8321
  }
8138
8322
  if (d.verb === 'system.remote-turn') {
@@ -8339,18 +8523,18 @@ function brainConfigBind() {
8339
8523
  radio.addEventListener('change', async () => {
8340
8524
  if (!radio.checked) return;
8341
8525
  const note = document.querySelector('[data-brain-mode-note]');
8342
- const modeLabel = radio.value === 'plan' ? 'Plan (suscripcion)' : 'Console (API key)';
8343
- 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));
8344
8528
  /* Hot-swap (server applies live now). Only show the restart hint if
8345
8529
  * the swap did NOT apply live (e.g. test client). */
8346
8530
  const live = data && data.applied_live;
8347
8531
  if (note) {
8348
8532
  note.hidden = false;
8349
8533
  note.textContent = live
8350
- ? modeLabel + ' aplicado. El proximo turno ya usa este modo.'
8351
- : 'Se aplica al reabrir el panel.';
8534
+ ? CONFIG.i18n.brainApplied.replace('{mode}', modeLabel)
8535
+ : CONFIG.i18n.brainReopenHint;
8352
8536
  }
8353
- if (live) brainStatus(modeLabel + ' activo');
8537
+ if (live) brainStatus(CONFIG.i18n.brainActive.replace('{mode}', modeLabel));
8354
8538
  });
8355
8539
  });
8356
8540
  const ecoToggle = document.querySelector('[data-brain-eco-enabled]');
@@ -8468,7 +8652,7 @@ async function federationLoad() {
8468
8652
  if (!note) {
8469
8653
  const span = document.createElement('span');
8470
8654
  span.className = 'set-status'; span.setAttribute('data-fed-invite-note', '');
8471
- span.textContent = 'activa tu licencia para prestar tu brain';
8655
+ span.textContent = CONFIG.i18n.licLendNeedsLicense;
8472
8656
  inviteRow.appendChild(span);
8473
8657
  }
8474
8658
  }
@@ -8491,7 +8675,7 @@ async function federationLoad() {
8491
8675
  await fedBrainModelsPopulate(d.host_brain.provider, d.host_brain.model);
8492
8676
  }
8493
8677
  } catch (err) {
8494
- fedStatus('error: ' + (err && err.message ? err.message : String(err)));
8678
+ fedStatus(CONFIG.i18n.licErrorWithCode.replace('{code}', (err && err.message ? err.message : String(err))));
8495
8679
  }
8496
8680
  }
8497
8681
 
@@ -8547,7 +8731,7 @@ async function fedBrainSave() {
8547
8731
  if (!r.ok || !d.ok) { fedStatus('no pude guardar el brain: ' + (d.error || ('HTTP ' + r.status))); return; }
8548
8732
  fedStatus('brain de federacion: ' + provider + (model ? ' / ' + model : ''));
8549
8733
  } catch (err) {
8550
- fedStatus('error: ' + (err && err.message ? err.message : String(err)));
8734
+ fedStatus(CONFIG.i18n.licErrorWithCode.replace('{code}', (err && err.message ? err.message : String(err))));
8551
8735
  }
8552
8736
  }
8553
8737
 
@@ -8570,7 +8754,7 @@ function federationBind() {
8570
8754
  fedStatus('conectado a ' + d.parent_handle + '. El proximo turno usa su brain.');
8571
8755
  await federationLoad();
8572
8756
  } catch (err) {
8573
- fedStatus('error: ' + (err && err.message ? err.message : String(err)));
8757
+ fedStatus(CONFIG.i18n.licErrorWithCode.replace('{code}', (err && err.message ? err.message : String(err))));
8574
8758
  }
8575
8759
  });
8576
8760
  }
@@ -8585,7 +8769,7 @@ function federationBind() {
8585
8769
  fedStatus('desconectado. Volves a tu brain local.');
8586
8770
  await federationLoad();
8587
8771
  } catch (err) {
8588
- fedStatus('error: ' + (err && err.message ? err.message : String(err)));
8772
+ fedStatus(CONFIG.i18n.licErrorWithCode.replace('{code}', (err && err.message ? err.message : String(err))));
8589
8773
  }
8590
8774
  });
8591
8775
  }
@@ -8607,7 +8791,7 @@ function federationBind() {
8607
8791
  const mins = Math.round((d.expires_in || 1800) / 60);
8608
8792
  fedStatus('codigo de 1 solo uso, vence en ' + mins + ' min. Compartilo y deja "Hospedar" activo.');
8609
8793
  } catch (err) {
8610
- fedStatus('error: ' + (err && err.message ? err.message : String(err)));
8794
+ fedStatus(CONFIG.i18n.licErrorWithCode.replace('{code}', (err && err.message ? err.message : String(err))));
8611
8795
  }
8612
8796
  });
8613
8797
  }
@@ -8645,7 +8829,7 @@ function federationBind() {
8645
8829
  : 'host detenido.');
8646
8830
  } catch (err) {
8647
8831
  hostChk.checked = !enabled;
8648
- fedStatus('error: ' + (err && err.message ? err.message : String(err)));
8832
+ fedStatus(CONFIG.i18n.licErrorWithCode.replace('{code}', (err && err.message ? err.message : String(err))));
8649
8833
  }
8650
8834
  });
8651
8835
  }
@@ -8674,7 +8858,7 @@ async function refreshKeysStatus() {
8674
8858
  const r = await fetch('/api/forge/keys-status');
8675
8859
  const data = await r.json();
8676
8860
  if (!r.ok || !data.ok) {
8677
- if (licEl) licEl.textContent = 'error: ' + (data.error || r.status);
8861
+ if (licEl) licEl.textContent = CONFIG.i18n.licErrorWithCode.replace('{code}', (data.error || r.status));
8678
8862
  return;
8679
8863
  }
8680
8864
  const k = data.keys || {};
@@ -8738,7 +8922,7 @@ async function refreshKeysStatus() {
8738
8922
  syncLicenseFromPanel({ silent: true });
8739
8923
  }
8740
8924
  } catch (err) {
8741
- 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'));
8742
8926
  }
8743
8927
  }
8744
8928
 
@@ -8759,13 +8943,13 @@ async function connectLicenseHandle() {
8759
8943
  if (!handle || handle.indexOf('@') < 1) {
8760
8944
  msg.style.display = '';
8761
8945
  msg.style.color = '#ff8b7a';
8762
- msg.textContent = 'Pone un email valido (con @).';
8946
+ msg.textContent = CONFIG.i18n.licEmailInvalid;
8763
8947
  return;
8764
8948
  }
8765
- if (btn) { btn.disabled = true; btn.textContent = 'Conectando...'; }
8949
+ if (btn) { btn.disabled = true; btn.textContent = CONFIG.i18n.licConnecting; }
8766
8950
  msg.style.display = '';
8767
8951
  msg.style.color = '';
8768
- msg.textContent = 'Guardando email + sincronizando con Polar...';
8952
+ msg.textContent = CONFIG.i18n.licSavingEmail;
8769
8953
  try {
8770
8954
  const r = await fetch('/api/forge/license-handle', {
8771
8955
  method: 'POST',
@@ -8775,19 +8959,19 @@ async function connectLicenseHandle() {
8775
8959
  const data = await r.json();
8776
8960
  if (!data.ok) {
8777
8961
  msg.style.color = '#ff8b7a';
8778
- msg.textContent = 'Email guardado, pero sync fallo: ' + (data.error || 'unknown')
8779
- + (data.http_status ? ' (HTTP ' + data.http_status + ')' : '')
8780
- + '. 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 + ')' : ''));
8781
8965
  } else {
8782
8966
  const plan = data.local_plan_after || 'none';
8783
8967
  msg.style.color = '#6dff8b';
8784
- msg.textContent = 'OK -- plan: ' + plan + '.';
8968
+ msg.textContent = CONFIG.i18n.licPlanOk.replace('{plan}', plan);
8785
8969
  }
8786
8970
  } catch (err) {
8787
8971
  msg.style.color = '#ff8b7a';
8788
- 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'));
8789
8973
  } finally {
8790
- if (btn) { btn.disabled = false; btn.textContent = 'Conectar'; }
8974
+ if (btn) { btn.disabled = false; btn.textContent = CONFIG.i18n.licConnect; }
8791
8975
  /* Always refresh -- if handle saved, the connect-block hides
8792
8976
  * and the sync row appears. */
8793
8977
  refreshKeysStatus();
@@ -8802,10 +8986,10 @@ async function syncLicenseFromPanel(opts) {
8802
8986
  const btn = document.getElementById('set-license-sync-btn');
8803
8987
  const msgRow = document.getElementById('set-license-sync-msg-row');
8804
8988
  const msgEl = document.getElementById('set-license-sync-msg');
8805
- if (btn) { btn.disabled = true; btn.textContent = 'Sincronizando...'; }
8989
+ if (btn) { btn.disabled = true; btn.textContent = CONFIG.i18n.licSyncing; }
8806
8990
  if (msgRow && msgEl && !silent) {
8807
8991
  msgRow.style.display = '';
8808
- msgEl.textContent = 'Consultando servidor...';
8992
+ msgEl.textContent = CONFIG.i18n.licQuerying;
8809
8993
  msgEl.style.color = '';
8810
8994
  }
8811
8995
  try {
@@ -8814,8 +8998,9 @@ async function syncLicenseFromPanel(opts) {
8814
8998
  if (!data.ok) {
8815
8999
  if (msgRow && msgEl) {
8816
9000
  msgRow.style.display = '';
8817
- msgEl.textContent = 'Sync error: ' + (data.error || 'unknown')
8818
- + (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 + ')' : ''));
8819
9004
  msgEl.style.color = '#ff8b7a';
8820
9005
  }
8821
9006
  return;
@@ -8824,8 +9009,8 @@ async function syncLicenseFromPanel(opts) {
8824
9009
  const before = data.local_plan_before || 'none';
8825
9010
  const after = data.local_plan_after || 'none';
8826
9011
  msgEl.textContent = before === after
8827
- ? 'Plan sin cambios: ' + after
8828
- : 'Plan actualizado: ' + before + ' -> ' + after;
9012
+ ? CONFIG.i18n.licPlanUnchanged.replace('{plan}', after)
9013
+ : CONFIG.i18n.licPlanUpdated.replace('{before}', before).replace('{after}', after);
8829
9014
  msgEl.style.color = '#6dff8b';
8830
9015
  }
8831
9016
  /* Refresh the keys status row to show the new plan. */
@@ -8833,11 +9018,13 @@ async function syncLicenseFromPanel(opts) {
8833
9018
  } catch (err) {
8834
9019
  if (msgRow && msgEl) {
8835
9020
  msgRow.style.display = '';
8836
- 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}', '');
8837
9024
  msgEl.style.color = '#ff8b7a';
8838
9025
  }
8839
9026
  } finally {
8840
- if (btn) { btn.disabled = false; btn.textContent = 'Sincronizar con Polar'; }
9027
+ if (btn) { btn.disabled = false; btn.textContent = CONFIG.i18n.licSyncWithPolar; }
8841
9028
  }
8842
9029
  }
8843
9030
 
@@ -8868,11 +9055,11 @@ async function saveKeyEditor() {
8868
9055
  if (!input || !msg || !_keyEditorSlot) return;
8869
9056
  const value = input.value.trim();
8870
9057
  if (value.length < 8) {
8871
- msg.textContent = 'Key demasiado corta (min 8 chars).';
9058
+ msg.textContent = CONFIG.i18n.keyEditorTooShort;
8872
9059
  msg.className = 'ke-msg error';
8873
9060
  return;
8874
9061
  }
8875
- msg.textContent = 'Guardando...';
9062
+ msg.textContent = CONFIG.i18n.fbSaving;
8876
9063
  msg.className = 'ke-msg';
8877
9064
  try {
8878
9065
  const r = await fetch('/api/forge/keys-set', {
@@ -8882,15 +9069,15 @@ async function saveKeyEditor() {
8882
9069
  });
8883
9070
  const data = await r.json();
8884
9071
  if (!r.ok || !data.ok) {
8885
- msg.textContent = 'Error: ' + (data.error || r.status);
9072
+ msg.textContent = CONFIG.i18n.fbErrorPrefix.replace('{detail}', (data.error || r.status));
8886
9073
  msg.className = 'ke-msg error';
8887
9074
  return;
8888
9075
  }
8889
- msg.textContent = 'Guardado.';
9076
+ msg.textContent = CONFIG.i18n.fbSaved;
8890
9077
  msg.className = 'ke-msg success';
8891
9078
  setTimeout(() => { closeKeyEditor(); refreshKeysStatus(); }, 600);
8892
9079
  } catch (err) {
8893
- msg.textContent = 'Error: ' + (err && err.message ? err.message : 'unknown');
9080
+ msg.textContent = CONFIG.i18n.fbErrorPrefix.replace('{detail}', (err && err.message ? err.message : 'unknown'));
8894
9081
  msg.className = 'ke-msg error';
8895
9082
  }
8896
9083
  }
@@ -9037,11 +9224,11 @@ async function micToggle(button) {
9037
9224
  return _micToggleWebSpeech(button);
9038
9225
  }
9039
9226
  if (provider === 'browser' && !_webSpeechAvailable()) {
9040
- setStatus('Browser STT unavailable; using server-side fallback.');
9227
+ setStatus(CONFIG.i18n.panelVoiceSttFallback);
9041
9228
  /* Fall through to MediaRecorder path. */
9042
9229
  }
9043
9230
  if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
9044
- setStatus('Your browser does not support microphone access.', true);
9231
+ setStatus(CONFIG.i18n.panelVoiceNoMicSupport, true);
9045
9232
  return;
9046
9233
  }
9047
9234
  try {
@@ -9255,12 +9442,12 @@ async function micToggle(button) {
9255
9442
  }
9256
9443
  const secLeft = Math.max(0, Math.ceil((stopAt - Date.now()) / 1000));
9257
9444
  if (secLeft <= 10) {
9258
- setStatus('Recording... auto-stop in ' + secLeft + 's');
9445
+ setStatus(CONFIG.i18n.panelVoiceRecordingCountdown.replace('{n}', secLeft));
9259
9446
  }
9260
9447
  if (secLeft <= 0) {
9261
9448
  clearInterval(countdown);
9262
9449
  try { mediaRecorder.stop(); } catch { /* ignore */ }
9263
- setStatus('Recording auto-stopped at 55s (Google STT 1-min limit). Try shorter.', true);
9450
+ setStatus(CONFIG.i18n.panelVoiceAutoStopped, true);
9264
9451
  }
9265
9452
  }, 1000);
9266
9453
  mediaRecorder.onstop = async () => {
@@ -9270,7 +9457,7 @@ async function micToggle(button) {
9270
9457
  mediaRecorder = null;
9271
9458
  if (blob.size === 0) {
9272
9459
  setMicButtonsState('idle');
9273
- setStatus('No audio captured. Try again.', true);
9460
+ setStatus(CONFIG.i18n.panelVoiceNoAudio, true);
9274
9461
  return;
9275
9462
  }
9276
9463
  /* Client-side size guard. 4 MB ~ 60s of WebM/Opus at
@@ -9278,7 +9465,7 @@ async function micToggle(button) {
9278
9465
  Refuse before even hitting the network. */
9279
9466
  if (blob.size > 4_000_000) {
9280
9467
  setMicButtonsState('idle');
9281
- setStatus('Audio too long (>1 min). Google STT sync limit. Try shorter.', true);
9468
+ setStatus(CONFIG.i18n.panelVoiceAudioTooLong, true);
9282
9469
  return;
9283
9470
  }
9284
9471
  setMicButtonsState('processing');
@@ -9318,7 +9505,7 @@ async function micToggle(button) {
9318
9505
  }
9319
9506
  const text = (data.transcript && data.transcript.text) || '';
9320
9507
  if (!text) {
9321
- setStatus('Did not catch that -- try again.', true);
9508
+ setStatus(CONFIG.i18n.panelVoiceDidNotCatch, true);
9322
9509
  return;
9323
9510
  }
9324
9511
  /* Mark this turn as voice-originated so playTtsForText
@@ -9351,8 +9538,7 @@ async function micToggle(button) {
9351
9538
  setMicButtonsState('recording');
9352
9539
  setStatus('');
9353
9540
  } catch (err) {
9354
- setStatus('Microphone permission denied or unavailable: '
9355
- + String(err && err.message ? err.message : err), true);
9541
+ setStatus(CONFIG.i18n.panelVoiceMicDenied.replace('{error}', String(err && err.message ? err.message : err)), true);
9356
9542
  }
9357
9543
  }
9358
9544
 
@@ -9546,7 +9732,7 @@ async function _fetchTtsChunk(text) {
9546
9732
  });
9547
9733
  if (!r.ok) {
9548
9734
  const data = await r.json().catch(() => ({}));
9549
- setStatus('Voice reply failed: ' + (data.error || ('HTTP ' + r.status)), true);
9735
+ setStatus(CONFIG.i18n.panelVoiceReplyFailed.replace('{err}', (data.error || ('HTTP ' + r.status))), true);
9550
9736
  return null;
9551
9737
  }
9552
9738
  const ab = await r.arrayBuffer();
@@ -9554,7 +9740,7 @@ async function _fetchTtsChunk(text) {
9554
9740
  const blob = new Blob([ab], { type: contentType });
9555
9741
  return URL.createObjectURL(blob);
9556
9742
  } catch (err) {
9557
- 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);
9558
9744
  return null;
9559
9745
  }
9560
9746
  }
@@ -9568,7 +9754,7 @@ function _playChunkUrl(url) {
9568
9754
  audio.addEventListener('ended', done, { once: true });
9569
9755
  audio.addEventListener('error', done, { once: true });
9570
9756
  audio.play().catch((err) => {
9571
- 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);
9572
9758
  done();
9573
9759
  });
9574
9760
  });
@@ -9592,7 +9778,7 @@ function _playChunkUrl(url) {
9592
9778
  */
9593
9779
  async function ingestSpecFile(file) {
9594
9780
  if (!file) return;
9595
- setStatus('Leyendo ' + file.name + '...');
9781
+ setStatus(CONFIG.i18n.panelStatusReading.replace('{filename}', file.name));
9596
9782
  /* Render an in-progress system message so the user sees
9597
9783
  immediate feedback. */
9598
9784
  state.messages.push({
@@ -9613,10 +9799,10 @@ async function ingestSpecFile(file) {
9613
9799
  const data = await r.json();
9614
9800
  if (!r.ok || !data.ok) {
9615
9801
  const msg = data && data.error ? data.error : ('HTTP ' + r.status);
9616
- setStatus('Ingest fallo: ' + msg, true);
9802
+ setStatus(CONFIG.i18n.panelStatusIngestFailed.replace('{error}', msg), true);
9617
9803
  state.messages.push({
9618
9804
  role: 'assistant',
9619
- content: 'No pude leer la especificacion: ' + msg,
9805
+ content: CONFIG.i18n.panelStatusReadFailed.replace('{error}', msg),
9620
9806
  });
9621
9807
  renderStream();
9622
9808
  return;
@@ -9649,7 +9835,7 @@ async function ingestSpecFile(file) {
9649
9835
  renderTrace();
9650
9836
  setStatus('');
9651
9837
  } catch (err) {
9652
- 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);
9653
9839
  }
9654
9840
  }
9655
9841
 
@@ -9799,11 +9985,11 @@ function closeMicForTts() {
9799
9985
  if (_webSpeechRecognition) {
9800
9986
  try { _webSpeechRecognition.abort(); } catch (_) {}
9801
9987
  _webSpeechRecognition = null;
9802
- setStatus('Mic closed while Yujin speaks (half-duplex).');
9988
+ setStatus(CONFIG.i18n.panelVoiceMicClosedHalfDuplex);
9803
9989
  }
9804
9990
  if (mediaRecorder && mediaRecorder.state === 'recording') {
9805
9991
  try { mediaRecorder.stop(); } catch { /* ignore */ }
9806
- setStatus('Mic closed while Yujin speaks (half-duplex).');
9992
+ setStatus(CONFIG.i18n.panelVoiceMicClosedHalfDuplex);
9807
9993
  }
9808
9994
  }
9809
9995
 
@@ -9823,11 +10009,11 @@ async function _micToggleWebSpeech(button) {
9823
10009
  } catch (err) {
9824
10010
  const name = (err && err.name) ? String(err.name) : '';
9825
10011
  if (name === 'NotAllowedError' || name === 'SecurityError') {
9826
- 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);
9827
10013
  } else if (name === 'NotFoundError' || name === 'OverconstrainedError') {
9828
- setStatus('No detecto microfono en este dispositivo.', true);
10014
+ setStatus(CONFIG.i18n.panelVoiceNoMicDevice, true);
9829
10015
  } else {
9830
- 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);
9831
10017
  }
9832
10018
  setMicButtonsState('idle');
9833
10019
  return;
@@ -9870,8 +10056,8 @@ async function _micToggleWebSpeech(button) {
9870
10056
  try { rec.stop(); } catch (_) {}
9871
10057
  }, SILENCE_MS);
9872
10058
  };
9873
- rec.onaudiostart = () => { audioStarted = true; setStatus('Mic abierto, escuchando...'); };
9874
- 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); };
9875
10061
  rec.onresult = (e) => {
9876
10062
  interim = '';
9877
10063
  for (let i = e.resultIndex; i < e.results.length; i += 1) {
@@ -9880,7 +10066,7 @@ async function _micToggleWebSpeech(button) {
9880
10066
  else interim += r[0].transcript;
9881
10067
  }
9882
10068
  if (interim || finalText) gotAnyResult = true;
9883
- if (interim) setStatus('Te escucho... ' + interim);
10069
+ if (interim) setStatus(CONFIG.i18n.panelVoiceListeningInterim.replace('{text}', interim));
9884
10070
  /* Only reset the silence countdown when something actually
9885
10071
  * changed since the last frame. A repeated interim is Chrome
9886
10072
  * keeping the channel open on background noise -- NOT user
@@ -9909,39 +10095,19 @@ async function _micToggleWebSpeech(button) {
9909
10095
  break;
9910
10096
  case 'not-allowed':
9911
10097
  case 'service-not-allowed':
9912
- setStatus(
9913
- 'Microfono bloqueado por el navegador. Hace click en el icono '
9914
- + 'del candado en la barra de URL y permiti microfono, despues '
9915
- + 'recarga la pagina. O cambia STT a "google" / "whisper" en '
9916
- + 'la configuracion si tenes la API key.',
9917
- true,
9918
- );
10098
+ setStatus(CONFIG.i18n.panelVoiceMicBlockedBrowser, true);
9919
10099
  break;
9920
10100
  case 'audio-capture':
9921
- setStatus(
9922
- 'No detecto microfono fisico. Conecta uno o cambia STT a '
9923
- + '"google" / "whisper" en la configuracion.',
9924
- true,
9925
- );
10101
+ setStatus(CONFIG.i18n.panelVoiceNoMicPhysical, true);
9926
10102
  break;
9927
10103
  case 'network':
9928
- setStatus(
9929
- 'Web Speech requiere conexion a internet (Chrome usa Google '
9930
- + 'detras). Probate sin conexion intermitente o cambia STT '
9931
- + 'a "whisper" si tenes API key.',
9932
- true,
9933
- );
10104
+ setStatus(CONFIG.i18n.panelVoiceNetworkRequired, true);
9934
10105
  break;
9935
10106
  case 'language-not-supported':
9936
- setStatus(
9937
- 'Tu navegador no soporta este idioma para STT. Cambia el '
9938
- + 'idioma del panel a uno soportado (en, es, pt) o cambia '
9939
- + 'STT a "google" / "whisper".',
9940
- true,
9941
- );
10107
+ setStatus(CONFIG.i18n.panelVoiceLangNotSupported, true);
9942
10108
  break;
9943
10109
  default:
9944
- setStatus('Mic error: ' + errKind, true);
10110
+ setStatus(CONFIG.i18n.panelVoiceMicError.replace('{error}', errKind), true);
9945
10111
  }
9946
10112
  };
9947
10113
  rec.onend = () => {
@@ -9968,20 +10134,20 @@ async function _micToggleWebSpeech(button) {
9968
10134
  /* Precise diagnostic so a mobile user (and we) can tell capture vs STT
9969
10135
  * apart instead of a generic "did not catch that". (Pablo 2026-06-19.) */
9970
10136
  if (!audioStarted) {
9971
- setStatus('No llego audio al reconocedor: el dictado del navegador no esta capturando en esta app.', true);
10137
+ setStatus(CONFIG.i18n.panelVoiceNoAudioToRecognizer, true);
9972
10138
  } else if (!speechStarted && !gotAnyResult) {
9973
- 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);
9974
10140
  } else if (!gotAnyResult) {
9975
- setStatus('Detecte voz pero el navegador no devolvio texto (STT gratis no disponible en esta PWA).', true);
10141
+ setStatus(CONFIG.i18n.panelVoiceVoiceNoText, true);
9976
10142
  } else {
9977
- setStatus('No entendi las palabras. Proba de nuevo.', true);
10143
+ setStatus(CONFIG.i18n.panelVoiceDidNotCatch, true);
9978
10144
  }
9979
10145
  };
9980
10146
  try {
9981
10147
  _webSpeechRecognition = rec;
9982
10148
  rec.start();
9983
10149
  setMicButtonsState('recording');
9984
- setStatus('Iniciando dictado...');
10150
+ setStatus(CONFIG.i18n.panelVoiceStartingDictation);
9985
10151
  /* IMPORTANT (alpha.29 bugfix): DO NOT arm the silence timer
9986
10152
  here. If we did, and the user takes >900ms to start
9987
10153
  talking (typical when they click + think), the timer would
@@ -9990,7 +10156,7 @@ async function _micToggleWebSpeech(button) {
9990
10156
  once the FIRST interim/final result lands. */
9991
10157
  } catch (err) {
9992
10158
  _webSpeechRecognition = null;
9993
- 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);
9994
10160
  }
9995
10161
  }
9996
10162
 
@@ -10003,7 +10169,7 @@ async function _micToggleWebSpeech(button) {
10003
10169
  function _speakBrowser(text, langHint) {
10004
10170
  return new Promise((resolve) => {
10005
10171
  if (typeof window === 'undefined' || !window.speechSynthesis) {
10006
- setStatus('Browser TTS not available; switch back to Google in settings.', true);
10172
+ setStatus(CONFIG.i18n.panelVoiceTtsUnavailable, true);
10007
10173
  resolve();
10008
10174
  return;
10009
10175
  }
@@ -10499,18 +10665,18 @@ document.addEventListener('DOMContentLoaded', () => {
10499
10665
  try { const r = await fetch('/api/forge/bridge/state'); data = await r.json(); }
10500
10666
  catch (_) { return; }
10501
10667
  if (!data || !data.ok || !data.enabled) {
10502
- statusEl.textContent = 'no disponible';
10503
- 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>';
10504
10670
  return;
10505
10671
  }
10506
10672
  const insts = Array.isArray(data.instances) ? data.instances : [];
10507
- statusEl.textContent = (data.role === 'host' ? 'host' : 'cliente')
10508
- + ' - ' + 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);
10509
10675
  rosterEl.innerHTML = insts.length ? insts.map((i) => {
10510
10676
  const self = i.id === data.self_id;
10511
10677
  const name = escapeHtml(i.project_name || i.project_slug || 'forge');
10512
10678
  return '<div class="bridge-inst' + (self ? ' is-self' : '') + '">'
10513
- + '<span class="dot"></span><span>' + name + (self ? ' (vos)' : '')
10679
+ + '<span class="dot"></span><span>' + name + (self ? ' (' + CONFIG.i18n.bridgeYou + ')' : '')
10514
10680
  + '</span><span class="meta">:' + (i.panel_port || '?') + '</span></div>';
10515
10681
  }).join('') : '<div class="bridge-empty">Sin otras instancias.</div>';
10516
10682
  const msgs = Array.isArray(data.messages) ? data.messages : [];
@@ -10632,7 +10798,7 @@ document.addEventListener('DOMContentLoaded', () => {
10632
10798
  $$('[data-key-clear]').forEach((b) => b.addEventListener('click', (e) => {
10633
10799
  const row = e.currentTarget.closest('[data-key-slot]');
10634
10800
  if (!row) return;
10635
- if (confirm('Quitar la key de ' + row.getAttribute('data-key-slot') + '?')) {
10801
+ if (confirm(CONFIG.i18n.panelConfirmRemoveKey.replace('{slot}', row.getAttribute('data-key-slot')))) {
10636
10802
  clearKey(row.getAttribute('data-key-slot'));
10637
10803
  }
10638
10804
  }));
@@ -10642,7 +10808,7 @@ document.addEventListener('DOMContentLoaded', () => {
10642
10808
  if (keSave) keSave.addEventListener('click', saveKeyEditor);
10643
10809
  if (keCancel) keCancel.addEventListener('click', closeKeyEditor);
10644
10810
  if (keClear) keClear.addEventListener('click', () => {
10645
- if (_keyEditorSlot && confirm('Quitar la key de ' + _keyEditorSlot + '?')) {
10811
+ if (_keyEditorSlot && confirm(CONFIG.i18n.panelConfirmRemoveKey.replace('{slot}', _keyEditorSlot))) {
10646
10812
  clearKey(_keyEditorSlot);
10647
10813
  closeKeyEditor();
10648
10814
  }
@@ -10731,7 +10897,7 @@ document.addEventListener('DOMContentLoaded', () => {
10731
10897
  /* Not a supported image -> keep the spec-ingest path. */
10732
10898
  if (SUPPORTED_IMG.indexOf(mt) < 0) { await ingestSpecFile(file); return; }
10733
10899
  if (file.size > 6 * 1024 * 1024) {
10734
- setStatus('Imagen muy grande (>6MB): ' + file.name, true);
10900
+ setStatus(CONFIG.i18n.panelStatusImageTooLarge.replace('{name}', file.name), true);
10735
10901
  return;
10736
10902
  }
10737
10903
  try {
@@ -10739,9 +10905,9 @@ document.addEventListener('DOMContentLoaded', () => {
10739
10905
  state.pendingAttachments = state.pendingAttachments || [];
10740
10906
  state.pendingAttachments.push({ media_type: mt, data: data, name: file.name });
10741
10907
  renderAttachChips();
10742
- setStatus('Imagen lista para enviar: ' + file.name);
10908
+ setStatus(CONFIG.i18n.panelStatusImageReady.replace('{name}', file.name));
10743
10909
  } catch (err) {
10744
- setStatus('No pude leer la imagen: ' + ((err && err.message) || err), true);
10910
+ setStatus(CONFIG.i18n.panelStatusImageReadFail.replace('{error}', ((err && err.message) || err)), true);
10745
10911
  }
10746
10912
  }
10747
10913
  /* Expose so send() + drag&drop can reach them. */
@@ -10827,7 +10993,7 @@ document.addEventListener('DOMContentLoaded', () => {
10827
10993
  /* Reload to apply translated strings everywhere. */
10828
10994
  location.reload();
10829
10995
  } catch (err) {
10830
- 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);
10831
10997
  }
10832
10998
  });
10833
10999
  });
@@ -10841,7 +11007,7 @@ document.addEventListener('DOMContentLoaded', () => {
10841
11007
  }
10842
11008
  const data = await loadLangData();
10843
11009
  if (!data) {
10844
- setStatus('Could not load language list', true);
11010
+ setStatus(CONFIG.i18n.panelStatusLanguageListFailed, true);
10845
11011
  return;
10846
11012
  }
10847
11013
  renderLangDropdown(data);
@@ -11240,7 +11406,7 @@ function mcpClose() {
11240
11406
 
11241
11407
  async function mcpLoadBridges(/* force */) {
11242
11408
  if (!yfMcp.contentEl) return;
11243
- yfMcp.contentEl.innerHTML = '<div class="mcp-empty">Cargando bridges...</div>';
11409
+ yfMcp.contentEl.innerHTML = '<div class="mcp-empty">' + escapeHtml(CONFIG.i18n.mcpLoadingBridges) + '</div>';
11244
11410
  try {
11245
11411
  const r = await fetch('/api/forge/mcp/bridges');
11246
11412
  const body = await r.json();
@@ -11327,7 +11493,7 @@ async function mcpLoadTools(bridgeId, cardEl) {
11327
11493
  const panel = document.createElement('div');
11328
11494
  panel.className = 'mcp-tools-list';
11329
11495
  panel.dataset.mcpToolsFor = bridgeId;
11330
- panel.textContent = 'Cargando tools...';
11496
+ panel.textContent = CONFIG.i18n.toolsLoading;
11331
11497
  cardEl.parentElement.insertBefore(panel, cardEl.nextSibling);
11332
11498
  try {
11333
11499
  const r = await fetch('/api/forge/mcp/tools?bridge_id=' + encodeURIComponent(bridgeId));
@@ -11689,7 +11855,7 @@ async function activityLoadBridgeMessages(pairId) {
11689
11855
  const msgs = Array.isArray(body.messages) ? body.messages : [];
11690
11856
  panel.innerHTML = '';
11691
11857
  const back = document.createElement('button');
11692
- back.textContent = '< Pares';
11858
+ back.textContent = CONFIG.i18n.agentsChatBackPairs;
11693
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;';
11694
11860
  back.addEventListener('click', () => { yfActivity.selectedPair = null; activityLoadBridgePairs(); });
11695
11861
  panel.appendChild(back);
@@ -11710,7 +11876,7 @@ async function activityLoadBridgeMessages(pairId) {
11710
11876
  if (msgs.length === 0) {
11711
11877
  const empty = document.createElement('div');
11712
11878
  empty.className = 'act-empty';
11713
- empty.textContent = 'Sin mensajes en este par.';
11879
+ empty.textContent = CONFIG.i18n.agentsChatNoMessages;
11714
11880
  panel.appendChild(empty);
11715
11881
  }
11716
11882
  } catch (err) {
@@ -11736,7 +11902,7 @@ async function activityPopulateChatSlugs() {
11736
11902
  if (agents.length === 0) {
11737
11903
  const opt = document.createElement('option');
11738
11904
  opt.value = '';
11739
- opt.textContent = 'no agents';
11905
+ opt.textContent = CONFIG.i18n.agentsChatNoAgents;
11740
11906
  select.appendChild(opt);
11741
11907
  }
11742
11908
  } catch (_err) { /* silent */ }
@@ -11750,8 +11916,8 @@ async function activityChatSend() {
11750
11916
  if (!select || !input || !result) return;
11751
11917
  const slug = String(select.value || '').trim();
11752
11918
  const text = String(input.value || '').trim();
11753
- if (!slug || !text) { result.textContent = 'Elegi un agente + escribe texto antes de enviar.'; return; }
11754
- result.textContent = 'Enviando...';
11919
+ if (!slug || !text) { result.textContent = CONFIG.i18n.agentsChatPickAgent; return; }
11920
+ result.textContent = CONFIG.i18n.agentsChatSending;
11755
11921
  try {
11756
11922
  const r = await fetch('/api/agent/inject', {
11757
11923
  method: 'POST',
@@ -11770,7 +11936,7 @@ async function activityChatSend() {
11770
11936
  /* Trigger a feed refresh so the user sees the new run. */
11771
11937
  if (yfActivity.activeTab === 'feed') setTimeout(() => activityRefresh(true), 800);
11772
11938
  } catch (err) {
11773
- result.textContent = 'Error: ' + (err.message || String(err));
11939
+ result.textContent = CONFIG.i18n.agentsChatErrorGeneric.replace('{err}', (err.message || String(err)));
11774
11940
  }
11775
11941
  }
11776
11942