@danieltmn/tamnora 0.20.1 → 0.21.0

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,12 @@ 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.0] - 2026-10-14
9
+
10
+ ### Cambios
11
+
12
+ - feat(consola): modulo de apariencia con temas, tipografia y tamano
13
+
8
14
  ## [0.20.1] - 2026-10-14
9
15
 
10
16
  ### Cambiado
@@ -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) {
@@ -0,0 +1,227 @@
1
+ // Apariencia de la consola: temas (paletas), presets de tipografía y escala.
2
+ // Registro puro + helpers de aplicación. Los ids de tema/fuente deben coincidir
3
+ // con src/lib/appearance.js (validación del server).
4
+
5
+ export const THEMES = [
6
+ { id: 'paper', label: 'Papel', scheme: 'light', metaColor: '#f5efe2' },
7
+ { id: 'dark', label: 'Oscuro', scheme: 'dark', metaColor: '#101107' },
8
+ { id: 'midnight', label: 'Medianoche', scheme: 'dark', metaColor: '#0b1220' },
9
+ { id: 'nord', label: 'Nord', scheme: 'dark', metaColor: '#2e3440' },
10
+ { id: 'dracula', label: 'Dracula', scheme: 'dark', metaColor: '#282a36' },
11
+ { id: 'solarized', label: 'Solarized', scheme: 'light', metaColor: '#fdf6e3' },
12
+ { id: 'solarized-dark', label: 'Solarized oscuro', scheme: 'dark', metaColor: '#002b36' },
13
+ { id: 'bosque', label: 'Bosque', scheme: 'light', metaColor: '#f2f4ec' },
14
+ { id: 'vino', label: 'Vino', scheme: 'light', metaColor: '#f7eeee' },
15
+ { id: 'vercel', label: 'Vercel', scheme: 'dark', metaColor: '#000000' },
16
+ { id: 'dark-plus', label: 'Dark+', scheme: 'dark', metaColor: '#1e1e1e' },
17
+ { id: 'github', label: 'GitHub', scheme: 'light', metaColor: '#ffffff' },
18
+ { id: 'github-dark', label: 'GitHub oscuro', scheme: 'dark', metaColor: '#0d1117' },
19
+ ];
20
+
21
+ export const FONT_PRESETS = [
22
+ {
23
+ id: 'clasico',
24
+ label: 'Clásico · Manrope / Libre Baskerville / DM Mono',
25
+ sans: 'Manrope, system-ui, "Segoe UI", Arial, sans-serif',
26
+ serif: '"Libre Baskerville", Georgia, "Times New Roman", serif',
27
+ mono: '"DM Mono", ui-monospace, "Cascadia Code", "JetBrains Mono", "SF Mono", Consolas, monospace',
28
+ google: 'family=DM+Mono:wght@400;500&family=Libre+Baskerville:wght@400;700&family=Manrope:wght@400;500;600;700',
29
+ },
30
+ {
31
+ id: 'sistema',
32
+ label: 'Sistema · sin fuentes externas',
33
+ sans: 'system-ui, "Segoe UI", Arial, sans-serif',
34
+ serif: 'Georgia, "Times New Roman", serif',
35
+ mono: 'ui-monospace, "Cascadia Code", "SF Mono", Consolas, monospace',
36
+ google: null,
37
+ },
38
+ {
39
+ id: 'plex',
40
+ label: 'IBM Plex · Sans / Serif / Mono',
41
+ sans: '"IBM Plex Sans", system-ui, "Segoe UI", Arial, sans-serif',
42
+ serif: '"IBM Plex Serif", Georgia, "Times New Roman", serif',
43
+ mono: '"IBM Plex Mono", ui-monospace, Consolas, monospace',
44
+ google: 'family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@400;500;600;700&family=IBM+Plex+Serif:wght@400;600',
45
+ },
46
+ {
47
+ id: 'inter',
48
+ label: 'Inter · Inter / Newsreader / JetBrains Mono',
49
+ sans: 'Inter, system-ui, "Segoe UI", Arial, sans-serif',
50
+ serif: 'Newsreader, Georgia, "Times New Roman", serif',
51
+ mono: '"JetBrains Mono", ui-monospace, Consolas, monospace',
52
+ google: 'family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&family=Newsreader:wght@400;600',
53
+ },
54
+ {
55
+ id: 'jetbrains',
56
+ label: 'JetBrains Mono · código',
57
+ sans: '"JetBrains Mono", ui-monospace, "Cascadia Code", Consolas, monospace',
58
+ serif: 'Georgia, "Times New Roman", serif',
59
+ mono: '"JetBrains Mono", ui-monospace, "Cascadia Code", Consolas, monospace',
60
+ google: 'family=JetBrains+Mono:wght@400;500;700',
61
+ },
62
+ {
63
+ id: 'fira',
64
+ label: 'Fira Code · código',
65
+ sans: '"Fira Code", ui-monospace, "Cascadia Code", Consolas, monospace',
66
+ serif: 'Georgia, "Times New Roman", serif',
67
+ mono: '"Fira Code", ui-monospace, "Cascadia Code", Consolas, monospace',
68
+ google: 'family=Fira+Code:wght@400;500;600;700',
69
+ },
70
+ {
71
+ id: 'source',
72
+ label: 'Source Code Pro · código',
73
+ sans: '"Source Code Pro", ui-monospace, "Cascadia Code", Consolas, monospace',
74
+ serif: 'Georgia, "Times New Roman", serif',
75
+ mono: '"Source Code Pro", ui-monospace, "Cascadia Code", Consolas, monospace',
76
+ google: 'family=Source+Code+Pro:wght@400;500;600;700',
77
+ },
78
+ ];
79
+
80
+ export const DEFAULTS = { theme: 'paper', font: 'clasico', chatFont: 14.5, scale: 1 };
81
+
82
+ const MIN_CHAT_FONT = 12;
83
+ const MAX_CHAT_FONT = 20;
84
+ const MIN_SCALE = 0.8;
85
+ const MAX_SCALE = 1.3;
86
+
87
+ const LS_KEY = 'tamnora-console-appearance';
88
+ const THEME_KEY = 'tamnora-console-theme';
89
+
90
+ function lsGet(key) {
91
+ try {
92
+ return localStorage.getItem(key);
93
+ } catch {
94
+ return null;
95
+ }
96
+ }
97
+
98
+ function lsSet(key, value) {
99
+ try {
100
+ localStorage.setItem(key, value);
101
+ } catch {
102
+ // storage unavailable
103
+ }
104
+ }
105
+
106
+ export function themeIds() {
107
+ return THEMES.map((t) => t.id);
108
+ }
109
+
110
+ export function fontIds() {
111
+ return FONT_PRESETS.map((f) => f.id);
112
+ }
113
+
114
+ export function resolveTheme(id) {
115
+ const legacy = id === 'light' ? 'paper' : id;
116
+ return THEMES.some((t) => t.id === legacy) ? legacy : DEFAULTS.theme;
117
+ }
118
+
119
+ export function resolveFont(id) {
120
+ return FONT_PRESETS.some((f) => f.id === id) ? id : DEFAULTS.font;
121
+ }
122
+
123
+ export function clampChatFont(value) {
124
+ const n = Number(value);
125
+ if (!Number.isFinite(n)) return DEFAULTS.chatFont;
126
+ return Math.min(MAX_CHAT_FONT, Math.max(MIN_CHAT_FONT, Math.round(n * 2) / 2));
127
+ }
128
+
129
+ export function clampScale(value) {
130
+ const n = Number(value);
131
+ if (!Number.isFinite(n)) return DEFAULTS.scale;
132
+ return Math.min(MAX_SCALE, Math.max(MIN_SCALE, Math.round(n * 100) / 100));
133
+ }
134
+
135
+ export function normalizeAppearance(raw) {
136
+ const value = raw && typeof raw === 'object' ? raw : {};
137
+ return {
138
+ theme: resolveTheme(value.theme),
139
+ font: resolveFont(value.font),
140
+ chatFont: clampChatFont(value.chatFont),
141
+ scale: clampScale(value.scale),
142
+ };
143
+ }
144
+
145
+ export function nextTheme(id) {
146
+ const ids = themeIds();
147
+ const index = ids.indexOf(resolveTheme(id));
148
+ return ids[(index + 1) % ids.length];
149
+ }
150
+
151
+ /** Lee la apariencia cacheada en localStorage (para aplicar antes del render). */
152
+ export function readLocalAppearance() {
153
+ let data = null;
154
+ try {
155
+ data = JSON.parse(lsGet(LS_KEY) || 'null');
156
+ } catch {
157
+ data = null;
158
+ }
159
+ const stored = data && typeof data === 'object' ? data : {};
160
+ return {
161
+ theme: stored.theme ?? lsGet(THEME_KEY) ?? undefined,
162
+ font: stored.font,
163
+ chatFont: stored.chatFont,
164
+ scale: stored.scale,
165
+ };
166
+ }
167
+
168
+ /** Inyecta (o quita) la hoja de Google Fonts del preset elegido. */
169
+ export function loadFontPreset(preset) {
170
+ const existing = document.getElementById('tamnora-fonts');
171
+ if (!preset || !preset.google) {
172
+ existing?.remove();
173
+ return;
174
+ }
175
+ const href = `https://fonts.googleapis.com/css2?${preset.google}&display=swap`;
176
+ if (!existing) {
177
+ const link = document.createElement('link');
178
+ link.rel = 'stylesheet';
179
+ link.id = 'tamnora-fonts';
180
+ link.href = href;
181
+ document.head.appendChild(link);
182
+ return;
183
+ }
184
+ if (existing.getAttribute('href') !== href) existing.setAttribute('href', href);
185
+ }
186
+
187
+ /** Guarda la apariencia resuelta en localStorage (con la tipografía ya resuelta). */
188
+ export function writeLocalAppearance(raw) {
189
+ const a = normalizeAppearance(raw);
190
+ const font = FONT_PRESETS.find((f) => f.id === a.font) ?? FONT_PRESETS[0];
191
+ lsSet(
192
+ LS_KEY,
193
+ JSON.stringify({
194
+ theme: a.theme,
195
+ font: a.font,
196
+ fontCss: { sans: font.sans, serif: font.serif, mono: font.mono },
197
+ google: font.google,
198
+ chatFont: a.chatFont,
199
+ scale: a.scale,
200
+ })
201
+ );
202
+ lsSet(THEME_KEY, a.theme);
203
+ }
204
+
205
+ /** Aplica tema, tipografía, tamaño de chat y escala al documento. */
206
+ export function applyAppearance(raw, { persist = true } = {}) {
207
+ const a = normalizeAppearance(raw);
208
+ const theme = THEMES.find((t) => t.id === a.theme) ?? THEMES[0];
209
+ const font = FONT_PRESETS.find((f) => f.id === a.font) ?? FONT_PRESETS[0];
210
+ const root = document.documentElement;
211
+
212
+ root.dataset.theme = theme.id;
213
+ root.dataset.scheme = theme.scheme;
214
+ loadFontPreset(font);
215
+ root.style.setProperty('--sans', font.sans);
216
+ root.style.setProperty('--serif', font.serif);
217
+ root.style.setProperty('--mono', font.mono);
218
+ root.style.setProperty('--font', 'var(--sans)');
219
+ root.style.setProperty('--chat-font', `${a.chatFont}px`);
220
+ root.style.zoom = String(a.scale);
221
+
222
+ const meta = document.querySelector('meta[name="theme-color"]');
223
+ if (meta) meta.setAttribute('content', theme.metaColor);
224
+
225
+ if (persist) writeLocalAppearance(a);
226
+ return a;
227
+ }
@@ -10,14 +10,51 @@
10
10
  <meta name="theme-color" content="#231a17" />
11
11
  <script>
12
12
  (function () {
13
- var theme = null;
13
+ var root = document.documentElement;
14
+ var THEMES = ['paper', 'dark', 'midnight', 'nord', 'dracula', 'solarized', 'solarized-dark', 'bosque', 'vino', 'vercel', 'dark-plus', 'github', 'github-dark'];
15
+ var DARK = ['dark', 'midnight', 'nord', 'dracula', 'solarized-dark', 'vercel', 'dark-plus', 'github-dark'];
16
+ var appearance = null;
14
17
  try {
15
- theme = localStorage.getItem('tamnora-console-theme');
18
+ appearance = JSON.parse(localStorage.getItem('tamnora-console-appearance') || 'null');
16
19
  } catch {
17
- theme = null;
20
+ appearance = null;
21
+ }
22
+ var theme = appearance && appearance.theme;
23
+ if (!theme) {
24
+ try {
25
+ theme = localStorage.getItem('tamnora-console-theme');
26
+ } catch {
27
+ theme = null;
28
+ }
18
29
  }
19
30
  if (theme === 'light') theme = 'paper';
20
- document.documentElement.dataset.theme = theme === 'dark' ? 'dark' : 'paper';
31
+ if (THEMES.indexOf(theme) < 0) {
32
+ theme = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'paper';
33
+ }
34
+ root.dataset.theme = theme;
35
+ root.dataset.scheme = DARK.indexOf(theme) >= 0 ? 'dark' : 'light';
36
+ if (!appearance) return;
37
+ if (appearance.fontCss) {
38
+ root.style.setProperty('--sans', appearance.fontCss.sans);
39
+ root.style.setProperty('--serif', appearance.fontCss.serif);
40
+ root.style.setProperty('--mono', appearance.fontCss.mono);
41
+ root.style.setProperty('--font', 'var(--sans)');
42
+ }
43
+ var href = appearance.google ? 'https://fonts.googleapis.com/css2?' + appearance.google + '&display=swap' : null;
44
+ var flink = document.getElementById('tamnora-fonts');
45
+ if (!href) {
46
+ if (flink) flink.remove();
47
+ } else if (!flink) {
48
+ flink = document.createElement('link');
49
+ flink.rel = 'stylesheet';
50
+ flink.id = 'tamnora-fonts';
51
+ flink.href = href;
52
+ document.head.appendChild(flink);
53
+ } else if (flink.getAttribute('href') !== href) {
54
+ flink.setAttribute('href', href);
55
+ }
56
+ if (appearance.chatFont) root.style.setProperty('--chat-font', appearance.chatFont + 'px');
57
+ if (appearance.scale) root.style.zoom = String(appearance.scale);
21
58
  })();
22
59
  (function () {
23
60
  // La consola resuelve el acceso con /api/auth/me; mientras tanto, oculta.
@@ -30,6 +67,7 @@
30
67
  <link rel="preconnect" href="https://fonts.googleapis.com" />
31
68
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
32
69
  <link
70
+ id="tamnora-fonts"
33
71
  rel="stylesheet"
34
72
  href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=Libre+Baskerville:wght@400;700&family=Manrope:wght@400;500;600;700&display=swap"
35
73
  />
@@ -37,7 +75,7 @@
37
75
  </head>
38
76
  <body>
39
77
  <header class="topbar">
40
- <div class="brand"><span class="logo"><img src="/icons/logo.png" alt="Tamnora" /></span> <span id="topbar-section" class="topbar-section">chat</span> <span class="ver">v0.20.1</span></div>
78
+ <div class="brand"><span class="logo"><img src="/icons/logo.png" alt="Tamnora" /></span> <span id="topbar-section" class="topbar-section">chat</span> <span class="ver">v0.21.0</span></div>
41
79
  <nav class="tabs">
42
80
  <button class="tab active" data-tab="chat">chat</button>
43
81
  <button class="tab" data-tab="providers">proveedores</button>
@@ -490,6 +528,7 @@
490
528
  <button type="button" class="ajustes-tab" data-panel="asesor" role="tab">Asesor</button>
491
529
  <button type="button" class="ajustes-tab" data-panel="transcripcion" role="tab">Transcripción</button>
492
530
  <button type="button" class="ajustes-tab" data-panel="limites" role="tab">Límites</button>
531
+ <button type="button" class="ajustes-tab" data-panel="apariencia" role="tab">Apariencia</button>
493
532
  </nav>
494
533
 
495
534
  <div class="panel ajustes-panel active" data-panel="agente">
@@ -920,6 +959,40 @@
920
959
  <span id="limits-msg" class="summary"></span>
921
960
  </div>
922
961
  </div>
962
+
963
+ <div class="panel ajustes-panel" data-panel="apariencia">
964
+ <div class="row between">
965
+ <h2>Apariencia</h2>
966
+ </div>
967
+ <p class="muted verify-line">
968
+ Tema, tipografía y tamaño de la consola. Se guardan en tu casa y te siguen entre
969
+ dispositivos.
970
+ </p>
971
+ <label class="field"><span>tema</span></label>
972
+ <div id="apariencia-themes" class="theme-grid"></div>
973
+ <label class="field"><span>tipografía</span>
974
+ <select id="apariencia-font"></select>
975
+ </label>
976
+ <div id="apariencia-preview" class="apariencia-preview">
977
+ <strong>Tamnora Harness</strong>
978
+ <span>El veloz murciélago hindú comía feliz cardillo y kiwi.</span>
979
+ <code>const saludo = 'hola';</code>
980
+ </div>
981
+ <div class="voz-range">
982
+ <span class="muted">tamaño del chat</span>
983
+ <input id="apariencia-chat-font" type="range" min="12" max="20" step="0.5" />
984
+ <output id="apariencia-chat-font-out">14.5px</output>
985
+ </div>
986
+ <div class="voz-range">
987
+ <span class="muted">escala de la interfaz</span>
988
+ <input id="apariencia-scale" type="range" min="80" max="130" step="5" />
989
+ <output id="apariencia-scale-out">100%</output>
990
+ </div>
991
+ <div class="row">
992
+ <button id="apariencia-reset" class="btn" type="button">restablecer</button>
993
+ <span id="apariencia-msg" class="summary"></span>
994
+ </div>
995
+ </div>
923
996
  </div>
924
997
  </section>
925
998