@danieltmn/tamnora 0.20.1 → 0.21.1

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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,18 @@ Todos los cambios relevantes de este proyecto se documentan en este archivo.
5
5
  El formato sigue [Keep a Changelog](https://keepachangelog.com/es-ES/1.1.0/)
6
6
  y el proyecto usa [Versionado Semantico](https://semver.org/lang/es/).
7
7
 
8
+ ## [0.21.1] - 2026-10-16
9
+
10
+ ### Cambios
11
+
12
+ - feat(config): export/import de la configuracion de la casa
13
+
14
+ ## [0.21.0] - 2026-10-14
15
+
16
+ ### Cambios
17
+
18
+ - feat(consola): modulo de apariencia con temas, tipografia y tamano
19
+
8
20
  ## [0.20.1] - 2026-10-14
9
21
 
10
22
  ### Cambiado
@@ -10,6 +10,7 @@ import { bridgeCommand } from './commands/bridge.js';
10
10
  import { updateCommand } from './commands/update.js';
11
11
  import { autostartCommand } from './commands/autostart.js';
12
12
  import { doctorCommand } from './commands/doctor.js';
13
+ import { configCommand } from './commands/config.js';
13
14
  import { tuiCommand } from './commands/tui.js';
14
15
  import { ensureUtf8Console } from './lib/util.js';
15
16
  import { readPackageVersion } from './lib/core.js';
@@ -29,6 +30,7 @@ const COMMANDS = {
29
30
  update: updateCommand,
30
31
  autostart: autostartCommand,
31
32
  doctor: doctorCommand,
33
+ config: configCommand,
32
34
  };
33
35
 
34
36
  function readVersion() {
@@ -76,6 +78,7 @@ Uso:
76
78
  tamnora autostart install|remove Arranque automatico del server
77
79
  tamnora serve Consola web local (primer plano)
78
80
  tamnora doctor Verificar Node, casa, puerto y base
81
+ tamnora config export|import Exportar/importar la configuracion (proveedores + ajustes)
79
82
  tamnora update Buscar/instalar una version nueva
80
83
  tamnora init Crear tamnora.json
81
84
 
@@ -0,0 +1,141 @@
1
+ import { readFileSync, writeFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import {
4
+ appConfigPath,
5
+ databasePath,
6
+ exportConfig,
7
+ homeDir,
8
+ importConfig,
9
+ Store,
10
+ } from '../../../core/src/index.js';
11
+ import { parse } from '../lib/util.js';
12
+ import { applyHomeOverride, stopRunningServer } from '../lib/daemon.js';
13
+
14
+ const DEFAULT_FILE = 'tamnora-config.json';
15
+
16
+ const OPTIONS = {
17
+ home: { type: 'string' },
18
+ out: { type: 'string', short: 'o' },
19
+ 'with-secrets': { type: 'boolean' },
20
+ stdout: { type: 'boolean' },
21
+ 'dry-run': { type: 'boolean' },
22
+ help: { type: 'boolean', short: 'h' },
23
+ };
24
+
25
+ export async function configCommand(argv) {
26
+ const { values, positionals } = parse(argv, OPTIONS);
27
+ if (values.help) {
28
+ printHelp();
29
+ return 0;
30
+ }
31
+ applyHomeOverride(values);
32
+
33
+ const sub = (positionals[0] || 'path').toLowerCase();
34
+ const target = positionals[1] || values.out;
35
+
36
+ if (sub === 'export') return doExport(values, target);
37
+ if (sub === 'import') return doImport(values, target);
38
+ if (sub === 'path') {
39
+ printPaths();
40
+ return 0;
41
+ }
42
+ printHelp();
43
+ return 1;
44
+ }
45
+
46
+ function withStore(fn) {
47
+ const store = new Store(databasePath());
48
+ try {
49
+ return fn(store);
50
+ } finally {
51
+ store.close();
52
+ }
53
+ }
54
+
55
+ function doExport(values, target) {
56
+ const data = withStore((store) => exportConfig({ store, withSecrets: Boolean(values['with-secrets']) }));
57
+ const json = `${JSON.stringify(data, null, 2)}\n`;
58
+ if (values.stdout || target === '-') {
59
+ process.stdout.write(json);
60
+ return 0;
61
+ }
62
+ const file = path.resolve(target || DEFAULT_FILE);
63
+ writeFileSync(file, json);
64
+ console.log(`Configuracion exportada a ${file}`);
65
+ console.log(` proveedores: ${data.providers.length} · ajustes: ${Object.keys(data.settings).length}`);
66
+ console.log(` secretos: ${data.secrets ? 'incluidos' : 'enmascarados (usa --with-secrets para incluirlos)'}`);
67
+ return 0;
68
+ }
69
+
70
+ function doImport(values, target) {
71
+ if (!target) {
72
+ console.error('Falta el archivo: tamnora config import <archivo.json>');
73
+ return 1;
74
+ }
75
+ const file = path.resolve(target);
76
+ let data;
77
+ try {
78
+ data = JSON.parse(readFileSync(file, 'utf8'));
79
+ } catch (error) {
80
+ console.error(`No pude leer ${file}: ${error.message}`);
81
+ return 1;
82
+ }
83
+
84
+ const dryRun = Boolean(values['dry-run']);
85
+ if (!dryRun && stopRunningServer()) {
86
+ console.log('Habia un server corriendo; lo detuve para aplicar los cambios.');
87
+ }
88
+
89
+ let summary;
90
+ try {
91
+ summary = withStore((store) => importConfig(store, data, { dryRun }));
92
+ } catch (error) {
93
+ console.error(error.message);
94
+ return 1;
95
+ }
96
+
97
+ if (dryRun) {
98
+ console.log('Simulacion (--dry-run): no se cambio nada.');
99
+ printSummary(summary);
100
+ return 0;
101
+ }
102
+ printSummary(summary);
103
+ console.log('Volve a arrancar el server: tamnora server --start');
104
+ return 0;
105
+ }
106
+
107
+ function printSummary(summary) {
108
+ console.log(`Importado: ${summary.providers} proveedor(es), ${summary.settings} ajuste(s), app.json actualizado.`);
109
+ if (summary.missingKeys.length) {
110
+ console.log(` Ojo: ${summary.missingKeys.join(', ')} quedaron sin API key (venian enmascaradas); cargalas en la consola.`);
111
+ }
112
+ }
113
+
114
+ function printPaths() {
115
+ console.log(`Casa de Tamnora : ${homeDir()}`);
116
+ console.log(` config app : ${appConfigPath()}`);
117
+ console.log(` base de datos : ${databasePath()}`);
118
+ console.log(' por proyecto : <cwd>/tamnora.json y <cwd>/.tamnora/');
119
+ }
120
+
121
+ function printHelp() {
122
+ console.log(`Uso: tamnora config <export|import|path> [archivo] [opciones]
123
+
124
+ Exporta o importa la configuracion de la casa (app.json + proveedores + ajustes).
125
+ No incluye sesiones, historial, MCP servers ni tareas programadas.
126
+
127
+ export [archivo] Vuelca la configuracion a JSON (default: ${DEFAULT_FILE})
128
+ import <archivo> Aplica el JSON sobre esta instalacion
129
+ path Muestra donde vive la configuracion
130
+
131
+ Opciones:
132
+ --with-secrets Incluye API keys y secretos reales (default: enmascarados)
133
+ -o, --out <archivo> Archivo de salida para export
134
+ --stdout Imprime el JSON por stdout (export)
135
+ --dry-run Muestra que haria el import sin escribir nada
136
+ --home <path> Casa de Tamnora (default: TAMNORA_HOME o datos del sistema)
137
+
138
+ Ejemplos:
139
+ tamnora config export tamnora-config.json --with-secrets
140
+ tamnora config import tamnora-config.json`);
141
+ }
@@ -0,0 +1,138 @@
1
+ import { readApp, writeApp } from './app.js';
2
+
3
+ /**
4
+ * Export/import portable de la configuracion de la casa (app.json + tablas
5
+ * `providers` y `settings` de la base global). Los secretos (API keys, hash de
6
+ * usuario, token de Telegram, `authSecret`) se enmascaran salvo `withSecrets`;
7
+ * al importar, los valores enmascarados se saltan y se conserva lo existente.
8
+ */
9
+
10
+ export const CONFIG_KIND = 'tamnora-config';
11
+ export const CONFIG_VERSION = 1;
12
+ export const MASKED = '<<tamnora:secreto>>';
13
+
14
+ const LINKAGE_PREFIXES = ['telegram:', 'scheduleSession:'];
15
+
16
+ function isSecretValue(value) {
17
+ return typeof value === 'string' && value !== '' && !value.startsWith('env:');
18
+ }
19
+
20
+ function isLinkageKey(key) {
21
+ return LINKAGE_PREFIXES.some((prefix) => key.startsWith(prefix));
22
+ }
23
+
24
+ function maskApp(app) {
25
+ const out = structuredClone(app);
26
+ if (isSecretValue(out.authSecret)) out.authSecret = MASKED;
27
+ if (out.user) out.user = MASKED;
28
+ if (out.telegram && isSecretValue(out.telegram.token)) out.telegram.token = MASKED;
29
+ if (out.tunnel && isSecretValue(out.tunnel.token)) out.tunnel.token = MASKED;
30
+ return out;
31
+ }
32
+
33
+ function providerToPortable(provider, withSecrets) {
34
+ const apiKey = withSecrets || !isSecretValue(provider.apiKey) ? provider.apiKey ?? '' : MASKED;
35
+ return {
36
+ id: provider.id,
37
+ label: provider.label,
38
+ baseUrl: provider.baseUrl ?? '',
39
+ apiKey,
40
+ models: provider.models ?? [],
41
+ requiresKey: provider.requiresKey,
42
+ custom: Boolean(provider.custom),
43
+ enabled: provider.enabled !== false,
44
+ };
45
+ }
46
+
47
+ function exportableSettings(all) {
48
+ const out = {};
49
+ for (const [key, value] of Object.entries(all)) {
50
+ if (isLinkageKey(key)) continue;
51
+ out[key] = value;
52
+ }
53
+ return out;
54
+ }
55
+
56
+ function normalizePayload(data) {
57
+ if (!data || typeof data !== 'object') {
58
+ throw new Error('El archivo no es un objeto JSON valido.');
59
+ }
60
+ if (data.kind !== CONFIG_KIND) {
61
+ throw new Error(`No parece un export de Tamnora (kind: ${data.kind ?? 'desconocido'}).`);
62
+ }
63
+ if (Number(data.version) > CONFIG_VERSION) {
64
+ throw new Error(`Version de export no soportada: ${data.version} (max ${CONFIG_VERSION}).`);
65
+ }
66
+ return data;
67
+ }
68
+
69
+ function mergeMaskAware(target, source) {
70
+ for (const [key, value] of Object.entries(source)) {
71
+ if (value === MASKED) continue;
72
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
73
+ const base = target[key] && typeof target[key] === 'object' && !Array.isArray(target[key]) ? target[key] : {};
74
+ target[key] = mergeMaskAware({ ...base }, value);
75
+ } else {
76
+ target[key] = value;
77
+ }
78
+ }
79
+ return target;
80
+ }
81
+
82
+ /** Arma el payload portable a partir del store y `app.json`. */
83
+ export function exportConfig({ store, withSecrets = false, now } = {}) {
84
+ const app = readApp();
85
+ return {
86
+ kind: CONFIG_KIND,
87
+ version: CONFIG_VERSION,
88
+ exportedAt: new Date(now ?? Date.now()).toISOString(),
89
+ secrets: withSecrets,
90
+ app: withSecrets ? app : maskApp(app),
91
+ providers: store.listProviders().map((provider) => providerToPortable(provider, withSecrets)),
92
+ settings: exportableSettings(store.allSettings()),
93
+ };
94
+ }
95
+
96
+ /**
97
+ * Aplica un payload portable sobre el store y `app.json`. Con `dryRun` no
98
+ * escribe nada. Devuelve un resumen (proveedores, ajustes y claves faltantes).
99
+ */
100
+ export function importConfig(store, data, { dryRun = false } = {}) {
101
+ const payload = normalizePayload(data);
102
+ const summary = { app: true, providers: 0, settings: 0, missingKeys: [] };
103
+
104
+ const app = readApp();
105
+ mergeMaskAware(app, payload.app ?? {});
106
+
107
+ for (const provider of payload.providers ?? []) {
108
+ if (!provider?.id) continue;
109
+ const existing = store.getProvider(provider.id);
110
+ let apiKey = provider.apiKey ?? '';
111
+ if (apiKey === MASKED) {
112
+ apiKey = existing?.apiKey ?? '';
113
+ if (!apiKey) summary.missingKeys.push(provider.id);
114
+ }
115
+ if (!dryRun) {
116
+ store.saveProvider({
117
+ id: provider.id,
118
+ label: provider.label,
119
+ baseUrl: provider.baseUrl,
120
+ apiKey,
121
+ models: provider.models,
122
+ requiresKey: provider.requiresKey,
123
+ custom: provider.custom,
124
+ enabled: provider.enabled !== false,
125
+ });
126
+ }
127
+ summary.providers += 1;
128
+ }
129
+
130
+ for (const [key, value] of Object.entries(payload.settings ?? {})) {
131
+ if (isLinkageKey(key)) continue;
132
+ if (!dryRun) store.setSetting(key, value);
133
+ summary.settings += 1;
134
+ }
135
+
136
+ if (!dryRun) writeApp(app);
137
+ return summary;
138
+ }
@@ -70,6 +70,7 @@ export {
70
70
  migrateLegacyDb,
71
71
  } from './config/home.js';
72
72
  export { DEFAULT_APP, readApp, writeApp, ensureApp, randomToken } from './config/app.js';
73
+ export { exportConfig, importConfig, CONFIG_KIND, CONFIG_VERSION, MASKED } from './config/portable.js';
73
74
  export { hashPassword, verifyPassword, userValidName, makeUser } from './config/identity.js';
74
75
  export { composeProviderSettings } from './config/providers.js';
75
76
  export {
@@ -0,0 +1,150 @@
1
+ import { test, afterEach } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtemp } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import path from 'node:path';
6
+ import {
7
+ setHome,
8
+ databasePath,
9
+ Store,
10
+ ensureApp,
11
+ readApp,
12
+ writeApp,
13
+ exportConfig,
14
+ importConfig,
15
+ MASKED,
16
+ } from '../src/index.js';
17
+
18
+ const prevHome = process.env.TAMNORA_HOME;
19
+ const prevDb = process.env.TAMNORA_DB;
20
+
21
+ afterEach(() => {
22
+ setHome(null);
23
+ if (prevHome === undefined) delete process.env.TAMNORA_HOME;
24
+ else process.env.TAMNORA_HOME = prevHome;
25
+ if (prevDb === undefined) delete process.env.TAMNORA_DB;
26
+ else process.env.TAMNORA_DB = prevDb;
27
+ });
28
+
29
+ async function freshHome() {
30
+ const dir = await mkdtemp(path.join(tmpdir(), 'tamnora-portable-'));
31
+ setHome(dir);
32
+ process.env.TAMNORA_DB = path.join(dir, 'tamnora.db');
33
+ return dir;
34
+ }
35
+
36
+ test('exportConfig enmascara los secretos y respeta las referencias env:', async () => {
37
+ await freshHome();
38
+ ensureApp();
39
+ const app = readApp();
40
+ app.user = { name: 'daniel', password: { algo: 'scrypt', salt: 'x', hash: 'y' } };
41
+ app.telegram = { token: '123:abc', allowedChatIds: ['7'] };
42
+ writeApp(app);
43
+
44
+ const store = new Store(databasePath());
45
+ store.saveProvider({ id: 'deepseek', apiKey: 'sk-real', baseUrl: 'https://deepseek', enabled: true });
46
+ store.saveProvider({ id: 'env', apiKey: 'env:OPENAI_API_KEY' });
47
+ store.setSetting('activeProvider', 'deepseek');
48
+ store.setSetting('telegram:chat:7', 'sess-1');
49
+ const data = exportConfig({ store, now: 0 });
50
+ store.close();
51
+
52
+ assert.equal(data.kind, 'tamnora-config');
53
+ assert.equal(data.version, 1);
54
+ assert.equal(data.secrets, false);
55
+ assert.equal(data.app.authSecret, MASKED);
56
+ assert.equal(data.app.user, MASKED);
57
+ assert.equal(data.app.telegram.token, MASKED);
58
+ assert.deepEqual(data.app.telegram.allowedChatIds, ['7']);
59
+ assert.equal(data.providers.find((p) => p.id === 'deepseek').apiKey, MASKED);
60
+ assert.equal(data.providers.find((p) => p.id === 'env').apiKey, 'env:OPENAI_API_KEY');
61
+ assert.equal(data.settings.activeProvider, 'deepseek');
62
+ assert.equal('telegram:chat:7' in data.settings, false);
63
+ });
64
+
65
+ test('exportConfig con withSecrets incluye las claves reales', async () => {
66
+ await freshHome();
67
+ ensureApp();
68
+ const app = readApp();
69
+ app.authSecret = 'super-secret';
70
+ writeApp(app);
71
+
72
+ const store = new Store(databasePath());
73
+ store.saveProvider({ id: 'deepseek', apiKey: 'sk-real' });
74
+ const data = exportConfig({ store, withSecrets: true });
75
+ store.close();
76
+
77
+ assert.equal(data.secrets, true);
78
+ assert.equal(data.app.authSecret, 'super-secret');
79
+ assert.equal(data.providers[0].apiKey, 'sk-real');
80
+ });
81
+
82
+ test('importConfig aplica proveedores y ajustes y conserva los secretos enmascarados', async () => {
83
+ await freshHome();
84
+ ensureApp();
85
+ const current = readApp();
86
+ current.authSecret = 'keep-me';
87
+ writeApp(current);
88
+
89
+ const store = new Store(databasePath());
90
+ store.saveProvider({ id: 'deepseek', apiKey: 'old-key' });
91
+ store.close();
92
+
93
+ const payload = {
94
+ kind: 'tamnora-config',
95
+ version: 1,
96
+ app: { port: 4599, authSecret: MASKED, user: MASKED, profile: { name: 'Daniel' } },
97
+ providers: [
98
+ { id: 'deepseek', baseUrl: 'https://api.deepseek.com', apiKey: MASKED, models: ['chat'], enabled: true },
99
+ { id: 'groq', baseUrl: 'https://groq', apiKey: 'gsk-new', enabled: true },
100
+ ],
101
+ settings: { activeProvider: 'groq', strictScope: true, 'telegram:chat:7': 'ignored' },
102
+ };
103
+
104
+ const store2 = new Store(databasePath());
105
+ const summary = importConfig(store2, payload);
106
+ const deepseek = store2.getProvider('deepseek');
107
+ const groq = store2.getProvider('groq');
108
+ const strict = store2.getSetting('strictScope');
109
+ store2.close();
110
+
111
+ assert.equal(summary.providers, 2);
112
+ assert.equal(summary.settings, 2);
113
+ assert.equal(readApp().port, 4599);
114
+ assert.equal(readApp().authSecret, 'keep-me');
115
+ assert.equal(readApp().profile.name, 'Daniel');
116
+ assert.equal(deepseek.apiKey, 'old-key');
117
+ assert.equal(deepseek.baseUrl, 'https://api.deepseek.com');
118
+ assert.equal(groq.apiKey, 'gsk-new');
119
+ assert.equal(strict, true);
120
+ });
121
+
122
+ test('importConfig avisa cuando una clave enmascarada no tiene existente y no escribe con dryRun', async () => {
123
+ await freshHome();
124
+ const store = new Store(databasePath());
125
+ const payload = {
126
+ kind: 'tamnora-config',
127
+ version: 1,
128
+ providers: [{ id: 'nuevo', apiKey: MASKED }],
129
+ settings: { activeModel: 'x' },
130
+ };
131
+
132
+ const dry = importConfig(store, payload, { dryRun: true });
133
+ assert.deepEqual(dry.missingKeys, ['nuevo']);
134
+ assert.equal(store.getProvider('nuevo'), null);
135
+ assert.equal(store.getSetting('activeModel'), null);
136
+
137
+ const real = importConfig(store, payload);
138
+ assert.deepEqual(real.missingKeys, ['nuevo']);
139
+ assert.ok(store.getProvider('nuevo'));
140
+ assert.equal(store.getSetting('activeModel'), 'x');
141
+ store.close();
142
+ });
143
+
144
+ test('importConfig rechaza archivos que no son un export de Tamnora', async () => {
145
+ await freshHome();
146
+ const store = new Store(databasePath());
147
+ assert.throws(() => importConfig(store, { hello: 'world' }), /no parece un export/i);
148
+ assert.throws(() => importConfig(store, { kind: 'tamnora-config', version: 99 }), /no soportada/i);
149
+ store.close();
150
+ });
@@ -3,6 +3,17 @@
3
3
  import { renderMarkdown, speechText, highlight } from './markdown.js';
4
4
  import { streamSse, followRun } from './runStream.js';
5
5
  import { diffState, pollDelayMs, localState, summarizeArgs } from './reconcile.js';
6
+ import {
7
+ THEMES,
8
+ FONT_PRESETS,
9
+ DEFAULTS,
10
+ normalizeAppearance,
11
+ nextTheme,
12
+ readLocalAppearance,
13
+ applyAppearance,
14
+ clampChatFont,
15
+ clampScale,
16
+ } from './appearance.js';
6
17
 
7
18
  const $ = (sel) => document.querySelector(sel);
8
19
  let csrfToken = '';
@@ -297,7 +308,8 @@ const PALETTE_COMMANDS = [
297
308
  { id: 'ajustes', label: 'MCP y Skills (ajustes)', run: () => activateTab('ajustes') },
298
309
  { id: 'explorer', label: 'Mostrar u ocultar archivos', run: () => setExplorerOpen(!state.explorerOpen) },
299
310
  { id: 'sidebar', label: 'Mostrar u ocultar sesiones', run: () => setSidebarOpen(!state.sidebarOpen) },
300
- { id: 'theme', label: 'Cambiar tema', run: () => applyTheme(document.documentElement.dataset.theme === 'paper' ? 'dark' : 'paper') },
311
+ { id: 'theme', label: 'Cambiar tema', run: () => cycleTheme() },
312
+ { id: 'apariencia', label: 'Apariencia (tema, tipografía, tamaño)', run: () => openAppearance() },
301
313
  ];
302
314
 
303
315
  const palette = {
@@ -459,6 +471,11 @@ async function loadModes() {
459
471
  if (legacy) writeDefaultCwd(legacy);
460
472
  }
461
473
  syncDefaultCwd();
474
+ if (settings?.settings?.appearance) {
475
+ appearance = normalizeAppearance(settings.settings.appearance);
476
+ applyAppearance(appearance, { persist: true });
477
+ renderAppearanceControls();
478
+ }
462
479
  updateStatus();
463
480
  }
464
481
 
@@ -582,16 +599,110 @@ function updateStatus(extra = {}) {
582
599
  if (extra.running) $('#sb-rounds').textContent = 'pensando…';
583
600
  }
584
601
 
602
+ /* --- apariencia (tema, tipografía, tamaño) -------------------------------- */
603
+
604
+ let appearance = normalizeAppearance(readLocalAppearance());
585
605
  const themeToggle = $('#theme-toggle');
586
- function applyTheme(theme) {
587
- document.documentElement.dataset.theme = theme;
588
- themeToggle.title = theme === 'paper' ? 'cambiar a oscuro' : 'cambiar a papel';
589
- storage.set('tamnora-console-theme', theme);
606
+ const themeGrid = $('#apariencia-themes');
607
+ const fontSelect = $('#apariencia-font');
608
+ const chatFontRange = $('#apariencia-chat-font');
609
+ const chatFontOut = $('#apariencia-chat-font-out');
610
+ const scaleRange = $('#apariencia-scale');
611
+ const scaleOut = $('#apariencia-scale-out');
612
+
613
+ function renderAppearanceControls() {
614
+ if (themeGrid) {
615
+ themeGrid
616
+ .querySelectorAll('.theme-swatch')
617
+ .forEach((btn) => btn.classList.toggle('active', btn.dataset.themePick === appearance.theme));
618
+ }
619
+ if (fontSelect) fontSelect.value = appearance.font;
620
+ if (chatFontRange) chatFontRange.value = appearance.chatFont;
621
+ if (chatFontOut) chatFontOut.textContent = `${appearance.chatFont}px`;
622
+ if (scaleRange) scaleRange.value = Math.round(appearance.scale * 100);
623
+ if (scaleOut) scaleOut.textContent = `${Math.round(appearance.scale * 100)}%`;
624
+ if (themeToggle) {
625
+ const label = THEMES.find((t) => t.id === appearance.theme)?.label ?? appearance.theme;
626
+ themeToggle.title = `tema: ${label}`;
627
+ }
628
+ }
629
+
630
+ function persistAppearance() {
631
+ applyAppearance(appearance, { persist: true });
632
+ renderAppearanceControls();
633
+ api('/api/settings', { method: 'PUT', body: { appearance } }).catch(() => null);
634
+ }
635
+
636
+ function buildAppearanceControls() {
637
+ if (themeGrid) {
638
+ themeGrid.replaceChildren();
639
+ for (const theme of THEMES) {
640
+ const swatch = document.createElement('button');
641
+ swatch.type = 'button';
642
+ swatch.className = 'theme-swatch';
643
+ swatch.dataset.themePick = theme.id;
644
+ swatch.title = theme.label;
645
+ swatch.setAttribute('aria-label', `tema ${theme.label}`);
646
+ const dot = document.createElement('span');
647
+ dot.className = 'theme-swatch-dot';
648
+ dot.style.background = theme.metaColor;
649
+ const label = document.createElement('span');
650
+ label.className = 'theme-swatch-label';
651
+ label.textContent = theme.label;
652
+ swatch.append(dot, label);
653
+ swatch.addEventListener('click', () => {
654
+ appearance.theme = theme.id;
655
+ persistAppearance();
656
+ });
657
+ themeGrid.appendChild(swatch);
658
+ }
659
+ }
660
+ if (fontSelect) {
661
+ fontSelect.replaceChildren();
662
+ for (const font of FONT_PRESETS) {
663
+ const option = document.createElement('option');
664
+ option.value = font.id;
665
+ option.textContent = font.label;
666
+ fontSelect.appendChild(option);
667
+ }
668
+ fontSelect.addEventListener('change', () => {
669
+ appearance.font = fontSelect.value;
670
+ persistAppearance();
671
+ });
672
+ }
673
+ chatFontRange?.addEventListener('input', () => {
674
+ appearance.chatFont = clampChatFont(Number(chatFontRange.value));
675
+ applyAppearance(appearance, { persist: false });
676
+ if (chatFontOut) chatFontOut.textContent = `${appearance.chatFont}px`;
677
+ });
678
+ chatFontRange?.addEventListener('change', persistAppearance);
679
+ scaleRange?.addEventListener('input', () => {
680
+ appearance.scale = clampScale(Number(scaleRange.value) / 100);
681
+ applyAppearance(appearance, { persist: false });
682
+ if (scaleOut) scaleOut.textContent = `${Math.round(appearance.scale * 100)}%`;
683
+ });
684
+ scaleRange?.addEventListener('change', persistAppearance);
685
+ $('#apariencia-reset')?.addEventListener('click', () => {
686
+ appearance = normalizeAppearance(DEFAULTS);
687
+ persistAppearance();
688
+ setStatus('#apariencia-msg', 'restablecido', 'ok');
689
+ });
690
+ renderAppearanceControls();
590
691
  }
591
- themeToggle.addEventListener('click', () =>
592
- applyTheme(document.documentElement.dataset.theme === 'paper' ? 'dark' : 'paper')
593
- );
594
- applyTheme(document.documentElement.dataset.theme || 'paper');
692
+
693
+ function cycleTheme() {
694
+ appearance.theme = nextTheme(appearance.theme);
695
+ persistAppearance();
696
+ }
697
+
698
+ function openAppearance() {
699
+ activateTab('ajustes');
700
+ activateAjustesPanel('apariencia');
701
+ }
702
+
703
+ themeToggle?.addEventListener('click', cycleTheme);
704
+ appearance = applyAppearance(appearance, { persist: false });
705
+ buildAppearanceControls();
595
706
 
596
707
  const logout = $('#logout');
597
708
  if (logout) {
@@ -3379,7 +3490,7 @@ function renderHandsFree() {
3379
3490
  }
3380
3491
  if (handsFree.active && handsFree.phase === 'listening') renderFooterActivity('listening');
3381
3492
  else if (handsFree.active && handsFree.phase === 'thinking') renderFooterActivity('thinking');
3382
- else if (handsFree.active) renderFooterActivity(null);
3493
+ else renderFooterActivity(null);
3383
3494
  }
3384
3495
 
3385
3496
  function setHandsFreePhase(phase) {