@danieltmn/tamnora 0.24.1 → 0.25.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.
Files changed (50) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/harness/packages/server/public/app.js +833 -834
  3. package/harness/packages/server/public/appearance.js +4 -0
  4. package/harness/packages/server/public/boot.js +49 -0
  5. package/harness/packages/server/public/chat.js +17 -0
  6. package/harness/packages/server/public/chrome.js +855 -852
  7. package/harness/packages/server/public/commands.js +448 -403
  8. package/harness/packages/server/public/core.js +229 -216
  9. package/harness/packages/server/public/favicon.ico +0 -0
  10. package/harness/packages/server/public/icons/icon-128x128.png +0 -0
  11. package/harness/packages/server/public/icons/icon-144x144.png +0 -0
  12. package/harness/packages/server/public/icons/icon-152x152.png +0 -0
  13. package/harness/packages/server/public/icons/icon-192x192.png +0 -0
  14. package/harness/packages/server/public/icons/icon-384x384.png +0 -0
  15. package/harness/packages/server/public/icons/icon-512x512.png +0 -0
  16. package/harness/packages/server/public/icons/icon-72x72.png +0 -0
  17. package/harness/packages/server/public/icons/icon-96x96.png +0 -0
  18. package/harness/packages/server/public/icons/logo.png +0 -0
  19. package/harness/packages/server/public/icons/logo_dark.png +0 -0
  20. package/harness/packages/server/public/icons/logo_light.png +0 -0
  21. package/harness/packages/server/public/icons/tamnora.ico +0 -0
  22. package/harness/packages/server/public/index.html +72 -101
  23. package/harness/packages/server/public/landing/boot.js +16 -0
  24. package/harness/packages/server/public/landing/index.html +123 -59
  25. package/harness/packages/server/public/landing/landing.js +11 -9
  26. package/harness/packages/server/public/logo.png +0 -0
  27. package/harness/packages/server/public/markdown.js +1 -1
  28. package/harness/packages/server/public/settings.js +1155 -1080
  29. package/harness/packages/server/public/settingsNav.js +81 -0
  30. package/harness/packages/server/public/styles.css +2818 -2515
  31. package/harness/packages/server/public/system.js +495 -495
  32. package/harness/packages/server/src/lib/appearance.js +4 -0
  33. package/harness/packages/server/src/lib/restart.js +20 -0
  34. package/harness/packages/server/src/lib/securityHeaders.js +53 -0
  35. package/harness/packages/server/src/routes/reload.js +46 -0
  36. package/harness/packages/server/src/server.js +6 -1
  37. package/harness/packages/server/test/accessibility.test.js +72 -0
  38. package/harness/packages/server/test/appearance.test.js +4 -0
  39. package/harness/packages/server/test/consoleModules.test.js +133 -57
  40. package/harness/packages/server/test/consoleTabs.test.js +228 -0
  41. package/harness/packages/server/test/docsSync.test.js +73 -0
  42. package/harness/packages/server/test/landingLogin.test.js +89 -0
  43. package/harness/packages/server/test/markdownSecurity.test.js +80 -0
  44. package/harness/packages/server/test/reload.test.js +95 -0
  45. package/harness/packages/server/test/securityHeaders.test.js +109 -0
  46. package/harness/packages/server/test/settingsNav.test.js +90 -0
  47. package/harness/packages/server/test/themeCss.js +110 -0
  48. package/harness/packages/server/test/themes.test.js +72 -0
  49. package/harness/scripts/restart.mjs +101 -0
  50. package/package.json +1 -1
@@ -1,216 +1,229 @@
1
- import { streamSse } from './runStream.js';
2
-
3
- let csrfToken = '';
4
-
5
- /** El token CSRF lo fija `boot()` tras leer `/api/auth/me`. */
6
- export function setCsrfToken(token) {
7
- csrfToken = token || '';
8
- }
9
-
10
- export const $ = (sel) => document.querySelector(sel);
11
-
12
- export const state = {
13
- sessionId: null,
14
- streaming: false,
15
- providers: [],
16
- active: { provider: '', model: '' },
17
- // Catalogo de modelos del proveedor y su seleccion. Se cachea porque el
18
- // catalogo son cientos de modelos (~591 KB) y `loadModels` corre en cada
19
- // cambio de pestana: refetchearlo hacia parpadear la consola.
20
- modelCatalog: [],
21
- modelCatalogProvider: null,
22
- modelSignature: null,
23
- visibleModels: [],
24
- pendingInput: null,
25
- permissionCards: new Map(),
26
- questionCards: new Map(),
27
- attachments: [],
28
- home: null,
29
- cwd: '',
30
- explorerOpen: false,
31
- sidebarOpen: true,
32
- panes: new Map(),
33
- tabOrder: [],
34
- activeTab: null,
35
- queues: new Map(),
36
- // Grupos de sesiones abiertos a mano. Arranca vacio y NO se persiste: el
37
- // sidebar siempre empieza con todas las carpetas sin expandir.
38
- expandedGroups: new Set(),
39
- // Sesiones con un run vivo segun el canal global de eventos. El sidebar lo
40
- // usa para el punto de actividad antes de abrir la sesion.
41
- remoteActive: new Set(),
42
- // Permisos esperando decision: askId -> { sessionId, name, args }. Vive fuera
43
- // del stream a proposito: un cliente sin stream abierto igual tiene que poder
44
- // ver que la IA esta esperando una autorizacion suya.
45
- pendingAsks: new Map(),
46
- // Formularios interactivos (`ask_user`) esperando respuesta: askId -> { sessionId }.
47
- pendingQuestions: new Map(),
48
- // Ponerlo en cola para reconciliar apenas termine la que esta en curso.
49
- reconcileAgain: false,
50
- };
51
-
52
- /* --- DOM helpers --------------------------------------------------------- */
53
-
54
- export function el(tag, className = '', text = '') {
55
- const node = document.createElement(tag);
56
- if (className) node.className = className;
57
- if (text) node.textContent = text;
58
- return node;
59
- }
60
-
61
- export function button(label, onClick, className = 'btn') {
62
- const node = el('button', className, label);
63
- node.type = 'button';
64
- node.addEventListener('click', onClick);
65
- return node;
66
- }
67
-
68
- export function clear(node) {
69
- node.replaceChildren();
70
- }
71
-
72
- export const ICON_PATHS = {
73
- x: ['M18 6 6 18', 'm6 6 12 12'],
74
- 'chevron-right': ['m9 18 6-6-6-6'],
75
- 'chevron-down': ['m6 9 6 6 6-6'],
76
- folder: ['M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z'],
77
- 'folder-open': ['m6 14 1.45-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.55 6a2 2 0 0 1-1.94 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2'],
78
- file: ['M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z', 'M14 2v4a2 2 0 0 0 2 2h4'],
79
- 'refresh-cw': ['M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8', 'M21 3v5h-5', 'M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16', 'M8 16H3v5'],
80
- undo: ['M3 7v6h6', 'M3 13a9 9 0 1 1 2.64 6.36'],
81
- trash: ['M3 6h18', 'M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6', 'M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2', 'M10 11v6', 'M14 11v6'],
82
- 'dots-vertical': ['M12 5h.01', 'M12 12h.01', 'M12 19h.01'],
83
- 'dots-horizontal': ['M5 12h.01', 'M12 12h.01', 'M19 12h.01'],
84
- play: ['m6 4 14 8-14 8z'],
85
- pause: ['M8 5v14', 'M16 5v14'],
86
- square: ['M6 6h12v12H6z'],
87
- copy: ['M8 8V5a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-3', 'M5 8h9a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-9a2 2 0 0 1 2-2Z'],
88
- pencil: ['M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z', 'm15 5 4 4'],
89
- };
90
-
91
- export function icon(name, size = 16, className = '') {
92
- const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
93
- if (className) svg.setAttribute('class', className);
94
- for (const [key, value] of Object.entries({ viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-width': '1.8', 'stroke-linecap': 'round', 'stroke-linejoin': 'round', width: String(size), height: String(size), 'aria-hidden': 'true' })) {
95
- svg.setAttribute(key, value);
96
- }
97
- for (const d of ICON_PATHS[name] ?? []) {
98
- const node = document.createElementNS('http://www.w3.org/2000/svg', 'path');
99
- node.setAttribute('d', d);
100
- svg.appendChild(node);
101
- }
102
- return svg;
103
- }
104
-
105
- /**
106
- * Confirmacion accesible (reemplaza `window.confirm`): `<dialog>` modal con
107
- * Escape, foco inicial en cancelar y respuesta booleana.
108
- */
109
- export function showConfirm(
110
- message,
111
- { title = 'Confirmar', confirmLabel = 'aceptar', cancelLabel = 'cancelar', danger = false } = {}
112
- ) {
113
- return new Promise((resolve) => {
114
- const dialog = document.createElement('dialog');
115
- dialog.className = 'modal confirm-modal';
116
- dialog.setAttribute('aria-label', title);
117
- const card = el('div', 'modal-card');
118
- card.append(el('h3', '', title), el('p', 'confirm-text', message));
119
- const actions = el('div', 'confirm-actions');
120
- const cancel = button(cancelLabel, () => dialog.close('cancel'), 'btn');
121
- const ok = button(confirmLabel, () => dialog.close('confirm'), danger ? 'btn danger' : 'btn primary');
122
- actions.append(cancel, ok);
123
- card.append(actions);
124
- dialog.append(card);
125
- dialog.addEventListener('close', () => {
126
- resolve(dialog.returnValue === 'confirm');
127
- dialog.remove();
128
- });
129
- document.body.append(dialog);
130
- dialog.showModal();
131
- cancel.focus();
132
- });
133
- }
134
-
135
- /* --- HTTP / SSE ---------------------------------------------------------- */
136
-
137
- // La cookie de sesion puede vencer con la pestana abierta: sin este interceptor
138
- // la UI mostraba tostadas genericas en cada accion. Un 401 = a la pantalla de
139
- // acceso, una sola vez.
140
- let sessionExpired = false;
141
-
142
- export async function api(path, { method = 'GET', body, headers = {} } = {}) {
143
- const init = {};
144
- if (body) init['Content-Type'] = 'application/json';
145
- if (method !== 'GET' && csrfToken) init['x-csrf'] = csrfToken;
146
- const res = await fetch(path, {
147
- method,
148
- headers: { ...init, ...headers },
149
- body: body ? JSON.stringify(body) : undefined,
150
- });
151
- if (!res.ok) {
152
- let message = res.statusText;
153
- try {
154
- message = (await res.json()).error ?? message;
155
- } catch {
156
- // keep statusText
157
- }
158
- const error = new Error(message);
159
- // El status importa: un 409 significa que otro cliente tiene el turno.
160
- error.status = res.status;
161
- if (res.status === 401 && !sessionExpired) {
162
- sessionExpired = true;
163
- location.assign('/landing');
164
- }
165
- throw error;
166
- }
167
- return res.json();
168
- }
169
-
170
- export const streamPost = (url, body, onEvent, signal) =>
171
- streamSse(url, { method: 'POST', body, csrf: csrfToken, signal }, onEvent);
172
-
173
- /** SSE GET de la consola (/api/sessions/:id/stream, /api/events). */
174
- export const openStream = (url, opts, onEvent) => streamSse(url, opts, onEvent);
175
-
176
- /* --- localStorage (todas las lecturas/escrituras pasan por aca) ------------ */
177
-
178
- export const storage = {
179
- get(key, fallback = null) {
180
- try {
181
- const raw = localStorage.getItem(key);
182
- return raw === null ? fallback : raw;
183
- } catch {
184
- return fallback;
185
- }
186
- },
187
- getJson(key, fallback) {
188
- try {
189
- const raw = localStorage.getItem(key);
190
- return raw === null ? fallback : JSON.parse(raw);
191
- } catch {
192
- return fallback;
193
- }
194
- },
195
- set(key, value) {
196
- try {
197
- localStorage.setItem(key, value);
198
- } catch {
199
- // storage unavailable
200
- }
201
- },
202
- setJson(key, value) {
203
- try {
204
- localStorage.setItem(key, JSON.stringify(value));
205
- } catch {
206
- // storage unavailable
207
- }
208
- },
209
- remove(key) {
210
- try {
211
- localStorage.removeItem(key);
212
- } catch {
213
- // storage unavailable
214
- }
215
- },
216
- };
1
+ import { streamSse } from './runStream.js';
2
+
3
+ let csrfToken = '';
4
+
5
+ /** El token CSRF lo fija `boot()` tras leer `/api/auth/me`. */
6
+ export function setCsrfToken(token) {
7
+ csrfToken = token || '';
8
+ }
9
+
10
+ export const $ = (sel) => document.querySelector(sel);
11
+
12
+ /** `smooth` salvo que el sistema pida movimiento reducido. */
13
+ export function scrollBehavior() {
14
+ return window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth';
15
+ }
16
+
17
+ export const state = {
18
+ sessionId: null,
19
+ streaming: false,
20
+ providers: [],
21
+ active: { provider: '', model: '' },
22
+ // Catalogo de modelos del proveedor y su seleccion. Se cachea porque el
23
+ // catalogo son cientos de modelos (~591 KB) y `loadModels` corre en cada
24
+ // cambio de pestana: refetchearlo hacia parpadear la consola.
25
+ modelCatalog: [],
26
+ modelCatalogProvider: null,
27
+ modelSignature: null,
28
+ visibleModels: [],
29
+ pendingInput: null,
30
+ permissionCards: new Map(),
31
+ questionCards: new Map(),
32
+ attachments: [],
33
+ home: null,
34
+ cwd: '',
35
+ explorerOpen: false,
36
+ sidebarOpen: true,
37
+ panes: new Map(),
38
+ tabOrder: [],
39
+ activeTab: null,
40
+ queues: new Map(),
41
+ // Grupos de sesiones abiertos a mano. Arranca vacio y NO se persiste: el
42
+ // sidebar siempre empieza con todas las carpetas sin expandir.
43
+ expandedGroups: new Set(),
44
+ // Sesiones con un run vivo segun el canal global de eventos. El sidebar lo
45
+ // usa para el punto de actividad antes de abrir la sesion.
46
+ remoteActive: new Set(),
47
+ // Permisos esperando decision: askId -> { sessionId, name, args }. Vive fuera
48
+ // del stream a proposito: un cliente sin stream abierto igual tiene que poder
49
+ // ver que la IA esta esperando una autorizacion suya.
50
+ pendingAsks: new Map(),
51
+ // Formularios interactivos (`ask_user`) esperando respuesta: askId -> { sessionId }.
52
+ pendingQuestions: new Map(),
53
+ // Ponerlo en cola para reconciliar apenas termine la que esta en curso.
54
+ reconcileAgain: false,
55
+ };
56
+
57
+ /* --- DOM helpers --------------------------------------------------------- */
58
+
59
+ export function el(tag, className = '', text = '') {
60
+ const node = document.createElement(tag);
61
+ if (className) node.className = className;
62
+ if (text) node.textContent = text;
63
+ return node;
64
+ }
65
+
66
+ /** Item de lista vacia que dice que paso y que hacer, en vez de una linea gris suelta. */
67
+ export function emptyState(title, hint = '') {
68
+ const node = el('li', 'empty-state');
69
+ node.appendChild(el('strong', '', title));
70
+ if (hint) node.appendChild(el('span', 'muted', hint));
71
+ return node;
72
+ }
73
+
74
+ export function button(label, onClick, className = 'btn') {
75
+ const node = el('button', className, label);
76
+ node.type = 'button';
77
+ node.addEventListener('click', onClick);
78
+ return node;
79
+ }
80
+
81
+ export function clear(node) {
82
+ node.replaceChildren();
83
+ }
84
+
85
+ export const ICON_PATHS = {
86
+ x: ['M18 6 6 18', 'm6 6 12 12'],
87
+ 'chevron-right': ['m9 18 6-6-6-6'],
88
+ 'chevron-down': ['m6 9 6 6 6-6'],
89
+ folder: ['M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z'],
90
+ 'folder-open': ['m6 14 1.45-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.55 6a2 2 0 0 1-1.94 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2'],
91
+ file: ['M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z', 'M14 2v4a2 2 0 0 0 2 2h4'],
92
+ 'refresh-cw': ['M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8', 'M21 3v5h-5', 'M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16', 'M8 16H3v5'],
93
+ undo: ['M3 7v6h6', 'M3 13a9 9 0 1 1 2.64 6.36'],
94
+ trash: ['M3 6h18', 'M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6', 'M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2', 'M10 11v6', 'M14 11v6'],
95
+ 'dots-vertical': ['M12 5h.01', 'M12 12h.01', 'M12 19h.01'],
96
+ 'dots-horizontal': ['M5 12h.01', 'M12 12h.01', 'M19 12h.01'],
97
+ play: ['m6 4 14 8-14 8z'],
98
+ pause: ['M8 5v14', 'M16 5v14'],
99
+ square: ['M6 6h12v12H6z'],
100
+ copy: ['M8 8V5a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-3', 'M5 8h9a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-9a2 2 0 0 1 2-2Z'],
101
+ pencil: ['M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z', 'm15 5 4 4'],
102
+ };
103
+
104
+ export function icon(name, size = 16, className = '') {
105
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
106
+ if (className) svg.setAttribute('class', className);
107
+ for (const [key, value] of Object.entries({ viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-width': '1.8', 'stroke-linecap': 'round', 'stroke-linejoin': 'round', width: String(size), height: String(size), 'aria-hidden': 'true' })) {
108
+ svg.setAttribute(key, value);
109
+ }
110
+ for (const d of ICON_PATHS[name] ?? []) {
111
+ const node = document.createElementNS('http://www.w3.org/2000/svg', 'path');
112
+ node.setAttribute('d', d);
113
+ svg.appendChild(node);
114
+ }
115
+ return svg;
116
+ }
117
+
118
+ /**
119
+ * Confirmacion accesible (reemplaza `window.confirm`): `<dialog>` modal con
120
+ * Escape, foco inicial en cancelar y respuesta booleana.
121
+ */
122
+ export function showConfirm(
123
+ message,
124
+ { title = 'Confirmar', confirmLabel = 'aceptar', cancelLabel = 'cancelar', danger = false } = {}
125
+ ) {
126
+ return new Promise((resolve) => {
127
+ const dialog = document.createElement('dialog');
128
+ dialog.className = 'modal confirm-modal';
129
+ dialog.setAttribute('aria-label', title);
130
+ const card = el('div', 'modal-card');
131
+ card.append(el('h3', '', title), el('p', 'confirm-text', message));
132
+ const actions = el('div', 'confirm-actions');
133
+ const cancel = button(cancelLabel, () => dialog.close('cancel'), 'btn');
134
+ const ok = button(confirmLabel, () => dialog.close('confirm'), danger ? 'btn danger' : 'btn primary');
135
+ actions.append(cancel, ok);
136
+ card.append(actions);
137
+ dialog.append(card);
138
+ dialog.addEventListener('close', () => {
139
+ resolve(dialog.returnValue === 'confirm');
140
+ dialog.remove();
141
+ });
142
+ document.body.append(dialog);
143
+ dialog.showModal();
144
+ cancel.focus();
145
+ });
146
+ }
147
+
148
+ /* --- HTTP / SSE ---------------------------------------------------------- */
149
+
150
+ // La cookie de sesion puede vencer con la pestana abierta: sin este interceptor
151
+ // la UI mostraba tostadas genericas en cada accion. Un 401 = a la pantalla de
152
+ // acceso, una sola vez.
153
+ let sessionExpired = false;
154
+
155
+ export async function api(path, { method = 'GET', body, headers = {} } = {}) {
156
+ const init = {};
157
+ if (body) init['Content-Type'] = 'application/json';
158
+ if (method !== 'GET' && csrfToken) init['x-csrf'] = csrfToken;
159
+ const res = await fetch(path, {
160
+ method,
161
+ headers: { ...init, ...headers },
162
+ body: body ? JSON.stringify(body) : undefined,
163
+ });
164
+ if (!res.ok) {
165
+ let message = res.statusText;
166
+ try {
167
+ message = (await res.json()).error ?? message;
168
+ } catch {
169
+ // keep statusText
170
+ }
171
+ const error = new Error(message);
172
+ // El status importa: un 409 significa que otro cliente tiene el turno.
173
+ error.status = res.status;
174
+ if (res.status === 401 && !sessionExpired) {
175
+ sessionExpired = true;
176
+ location.assign('/landing');
177
+ }
178
+ throw error;
179
+ }
180
+ return res.json();
181
+ }
182
+
183
+ export const streamPost = (url, body, onEvent, signal) =>
184
+ streamSse(url, { method: 'POST', body, csrf: csrfToken, signal }, onEvent);
185
+
186
+ /** SSE GET de la consola (/api/sessions/:id/stream, /api/events). */
187
+ export const openStream = (url, opts, onEvent) => streamSse(url, opts, onEvent);
188
+
189
+ /* --- localStorage (todas las lecturas/escrituras pasan por aca) ------------ */
190
+
191
+ export const storage = {
192
+ get(key, fallback = null) {
193
+ try {
194
+ const raw = localStorage.getItem(key);
195
+ return raw === null ? fallback : raw;
196
+ } catch {
197
+ return fallback;
198
+ }
199
+ },
200
+ getJson(key, fallback) {
201
+ try {
202
+ const raw = localStorage.getItem(key);
203
+ return raw === null ? fallback : JSON.parse(raw);
204
+ } catch {
205
+ return fallback;
206
+ }
207
+ },
208
+ set(key, value) {
209
+ try {
210
+ localStorage.setItem(key, value);
211
+ } catch {
212
+ // storage unavailable
213
+ }
214
+ },
215
+ setJson(key, value) {
216
+ try {
217
+ localStorage.setItem(key, JSON.stringify(value));
218
+ } catch {
219
+ // storage unavailable
220
+ }
221
+ },
222
+ remove(key) {
223
+ try {
224
+ localStorage.removeItem(key);
225
+ } catch {
226
+ // storage unavailable
227
+ }
228
+ },
229
+ };