nio-cat 0.9.6 → 0.9.7

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/web/app/demo.js CHANGED
@@ -3,6 +3,7 @@
3
3
  // text en directe, eines i aprovacions de mentida. No surt res del navegador i es reinicia en recarregar.
4
4
  (() => {
5
5
  const ROOT = '/Users/tu/projectes';
6
+ const DEMO_USAGE = { claude: { windows: [{ label: '5 h', used: 0.32, resetsAt: Math.floor(Date.now() / 1000) + 7200 }, { label: 'setmana', used: 0.57, resetsAt: Math.floor(Date.now() / 1000) + 3 * 86400 }], at: Date.now() }, codex: { windows: [{ label: '5 h', used: 0.85, resetsAt: Math.floor(Date.now() / 1000) + 3600 }, { label: 'setmana', used: 0.17, resetsAt: Math.floor(Date.now() / 1000) + 5 * 86400 }], at: Date.now() } };
6
7
  const AGENTS = [
7
8
  { id: 'claude', name: 'Claude Code', beta: false, approvals: true, noModes: false, login: '', available: true, version: 'simulat' },
8
9
  { id: 'codex', name: 'Codex', beta: false, approvals: true, noModes: false, login: '', available: true, version: 'simulat' },
@@ -73,7 +74,7 @@
73
74
  }
74
75
  async function work(s, text) {
75
76
  const run = { stopped: false }; runs.set(s.id, run);
76
- const p = plan(text, s), tool = (name, input, output, error) => { const id = rid(); push(s, 'tool', { id, name, input }); return { id, done: () => push(s, 'tool_result', { id, name, output, error: !!error }) }; };
77
+ const p = plan(text, s), tool = (name, input, output, error) => { const id = rid(); push(s, 'tool', { id, name, input }); logTool(s, name, input); return { id, done: () => push(s, 'tool_result', { id, name, output, error: !!error }) }; };
77
78
  const allowed = async (name, input) => s.mode === 'tot' || (await ask(s, name, input)) === 'allow';
78
79
  try {
79
80
  await sleep(500); await stream(s, p.intro, run); if (run.stopped) return;
@@ -94,8 +95,10 @@
94
95
  runs.delete(s.id);
95
96
  for (const a of approvals.values()) if (a.session === s.id && a.state === 'pending') decide(a.id, 'deny');
96
97
  s.state = 'idle'; push(s, 'status', { state: 'idle', code: run.stopped ? 130 : 0 });
98
+ runEnded(s, run.stopped);
97
99
  }
98
100
  }
101
+ const logTool = (s, tool, input) => audit.unshift({ t: Date.now(), type: 'tool', session: s.id, agent: s.agent, title: s.title, tool, what: input.command || input.file_path || tool, mode: s.mode });
99
102
  function create({ agent, cwd, title, mode }) {
100
103
  if (!AGENTS.some((a) => a.id === agent)) throw err(400, `A la demo només hi ha ${AGENTS.map((a) => a.name).join(' i ')}`);
101
104
  const s = { id: rid(), agent, cwd: cwd || `${ROOT}/web-botiga`, title: title || '', mode: ['llegir', 'preguntar', 'tot'].includes(mode) ? mode : 'preguntar', state: 'idle', created: Date.now(), updated: Date.now(), cost: 0, events: [] };
@@ -145,6 +148,161 @@
145
148
  }
146
149
  seed();
147
150
 
151
+ // ---------- Feines: cua, cadenes i programades. Cada feina obre una sessió i acaba quan l'agent acaba.
152
+ const jobs = [];
153
+ const jobOut = (j) => out({ type: 'job', job: { ...j } });
154
+ function runJob(j) {
155
+ const prev = j.after && jobs.find((x) => x.id === j.after);
156
+ const prompt = j.prompt.replace('{{anterior}}', prev?.result || '');
157
+ const s = create({ agent: j.agent, cwd: j.cwd, title: j.title, mode: j.mode });
158
+ Object.assign(j, { state: 'en curs', session: s.id, started: Date.now(), runAt: null });
159
+ jobOut(j); audit.unshift({ t: Date.now(), type: 'job', what: `Feina començada: ${j.title}`, agent: j.agent, job: j.id });
160
+ send(s, prompt);
161
+ }
162
+ function runEnded(s, stopped) {
163
+ const j = jobs.find((x) => x.session === s.id && x.state === 'en curs'); if (!j) return;
164
+ Object.assign(j, { state: stopped ? 'cancel·lada' : 'feta', finished: Date.now(), result: [...s.events].reverse().find((e) => e.kind === 'text')?.text || '' });
165
+ jobOut(j); audit.unshift({ t: Date.now(), type: 'job', what: `Feina ${stopped ? 'aturada' : 'feta'}: ${j.title}`, agent: j.agent, job: j.id });
166
+ if (!stopped) for (const n of jobs) if (n.after === j.id && n.state === 'pendent') runJob(n);
167
+ }
168
+ function schedule(j) {
169
+ if (j.state === 'pendent' && !j.after && (!j.runAt || j.runAt <= Date.now())) return runJob(j);
170
+ if (j.state === 'pendent' && j.runAt && j.runAt - Date.now() < 3600000) setTimeout(() => j.state === 'pendent' && runJob(j), j.runAt - Date.now());
171
+ }
172
+ function addJob(b) {
173
+ const prompt = String(b.prompt || '').trim(); if (!prompt) throw err(400, 'Explica què ha de fer la feina');
174
+ const agent = AGENTS.some((a) => a.id === b.agent) ? b.agent : 'claude';
175
+ if (b.after && !jobs.some((x) => x.id === b.after)) throw err(400, 'La feina anterior no existeix');
176
+ const at = b.at ? Number(new Date(b.at)) : null;
177
+ const j = { id: rid(), title: String(b.title || prompt.replace(/\s+/g, ' ').slice(0, 70)), prompt, agent, cwd: b.cwd || `${ROOT}/web-botiga`, mode: ['llegir', 'preguntar', 'tot'].includes(b.mode) ? b.mode : 'preguntar', rule: null, after: b.after || null, cron: b.cron || null, state: b.cron ? 'programada' : 'pendent', runAt: b.cron ? nextNine() : at, created: Date.now() };
178
+ jobs.unshift(j); jobOut(j);
179
+ audit.unshift({ t: Date.now(), type: 'job', what: `Feina ${j.cron ? 'programada' : 'a la cua'}: ${j.title}`, agent: j.agent, job: j.id });
180
+ setTimeout(() => schedule(j), 300);
181
+ return j;
182
+ }
183
+ // La propera vegada que toca una feina programada (a la demo, sempre el proper dia a les 9).
184
+ const nextNine = () => { const d = new Date(); d.setHours(9, 0, 0, 0); if (d <= new Date()) d.setDate(d.getDate() + 1); return +d; };
185
+
186
+ // ---------- Pantalla: un escriptori dibuixat aquí mateix, que respon als clics, al teclat i als arrossegaments.
187
+ const caps = { navegador: { installed: true, ready: true, enabled: true }, escriptori: { installed: true, ready: true, why: '', enabled: true, screenshot: true }, platform: 'darwin' };
188
+ const W = 1600, H = 1000;
189
+ const desk = {
190
+ ver: 1,
191
+ wins: [
192
+ { id: 'code', x: 70, y: 70, w: 860, h: 540, title: 'styles.css — web-botiga' },
193
+ { id: 'term', x: 720, y: 400, w: 780, h: 440, title: 'Terminal — api' },
194
+ ],
195
+ term: ['$ npm test', '✓ 14 tests passen (0,8 s)', '$ git status --short', ' M src/styles.css'],
196
+ typed: '', mouse: null, ripple: 0, menu: null,
197
+ };
198
+ const bump = () => { desk.ver++; };
199
+ const hit = (x, y) => [...desk.wins].reverse().find((w) => x >= w.x && x <= w.x + w.w && y >= w.y && y <= w.y + w.h);
200
+ const front = (w) => { desk.wins = desk.wins.filter((x) => x !== w).concat(w); };
201
+ function input(ev) {
202
+ const px = (p) => [p[0] * W, p[1] * H];
203
+ if (ev.type === 'click' || ev.type === 'rclick') {
204
+ const [x, y] = px([ev.x, ev.y]); desk.mouse = [x, y]; desk.ripple = Date.now(); desk.menu = ev.type === 'rclick' ? [x, y, Date.now()] : null;
205
+ const w = hit(x, y); if (w) front(w);
206
+ } else if (ev.type === 'drag' && ev.path?.length > 1) {
207
+ const [x0, y0] = px(ev.path[0]), [x1, y1] = px(ev.path[ev.path.length - 1]), w = hit(x0, y0);
208
+ if (w && y0 - w.y < 36) { w.x = Math.max(-w.w + 80, Math.min(W - 80, w.x + x1 - x0)); w.y = Math.max(28, Math.min(H - 60, w.y + y1 - y0)); front(w); }
209
+ desk.mouse = [x1, y1];
210
+ } else if (ev.type === 'text') desk.typed = (desk.typed + ev.text).slice(-60);
211
+ else if (ev.type === 'key') {
212
+ if (ev.key === 'Backspace' || ev.key === 'BackSpace') desk.typed = desk.typed.slice(0, -1);
213
+ else if (ev.key === 'Enter' || ev.key === 'Return') { const cmd = desk.typed.trim(); desk.term.push(`$ ${cmd}`); if (cmd) desk.term.push(cmd === 'ls' ? 'api notes web-botiga' : cmd === 'pwd' ? ROOT : cmd === 'date' ? new Date().toString() : `demo: «${cmd.split(' ')[0]}» és una ordre simulada`); desk.typed = ''; desk.term = desk.term.slice(-12); }
214
+ }
215
+ bump();
216
+ return { ok: true };
217
+ }
218
+ const C = { bar: 'rgba(255,255,255,.72)', ink: '#272224', code: '#1f1d1c', term: '#141313' };
219
+ function rr(c, x, y, w, h, r) { c.beginPath(); c.roundRect ? c.roundRect(x, y, w, h, r) : c.rect(x, y, w, h); }
220
+ function paint(c) {
221
+ const g = c.createLinearGradient(0, 0, W, H); g.addColorStop(0, '#f6b896'); g.addColorStop(.55, '#e8a7b8'); g.addColorStop(1, '#8ea6dc');
222
+ c.fillStyle = g; c.fillRect(0, 0, W, H);
223
+ c.fillStyle = C.bar; c.fillRect(0, 0, W, 28);
224
+ c.fillStyle = C.ink; c.font = '600 14px -apple-system, Segoe UI, sans-serif'; c.textBaseline = 'middle';
225
+ c.fillText('nio', 18, 14); c.font = '14px -apple-system, Segoe UI, sans-serif';
226
+ let mx = 60; for (const t of ['Fitxer', 'Edita', 'Visualització', 'Finestra', 'Ajuda']) { c.fillText(t, mx, 14); mx += c.measureText(t).width + 24; }
227
+ const now = new Date(); c.textAlign = 'right';
228
+ c.fillText(now.toLocaleString('ca', { weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }), W - 18, 14); c.textAlign = 'left';
229
+ for (const w of desk.wins) {
230
+ c.save(); c.shadowColor = 'rgba(0,0,0,.28)'; c.shadowBlur = 40; c.shadowOffsetY = 14;
231
+ c.fillStyle = w.id === 'code' ? C.code : C.term; rr(c, w.x, w.y, w.w, w.h, 12); c.fill(); c.restore();
232
+ c.save(); rr(c, w.x, w.y, w.w, w.h, 12); c.clip();
233
+ c.fillStyle = w.id === 'code' ? '#2b2826' : '#242120'; c.fillRect(w.x, w.y, w.w, 36);
234
+ ['#ff5f57', '#febc2e', '#28c840'].forEach((col, i) => { c.fillStyle = col; c.beginPath(); c.arc(w.x + 20 + i * 20, w.y + 18, 6, 0, 7); c.fill(); });
235
+ c.fillStyle = '#bdb6ae'; c.font = '13px -apple-system, Segoe UI, sans-serif'; c.textAlign = 'center'; c.fillText(w.title, w.x + w.w / 2, w.y + 18); c.textAlign = 'left';
236
+ c.font = '16px ui-monospace, SF Mono, Menlo, monospace';
237
+ if (w.id === 'code') {
238
+ const lines = [['#7f8c98', '/* Colors de la botiga */'], ['#e8e2d8', ':root {'], ['#9cdcfe', ' --bg: #fff;'], ['#9cdcfe', ' --text: #1a1a1a;'], ['#e8e2d8', '}'], ['', ''], ['#c586c0', '@media (prefers-color-scheme: dark) {'], ['#9cdcfe', ' :root { --bg: #141414; --text: #eee; }'], ['#e8e2d8', '}'], ['', ''], ['#d7ba7d', '.boto-comprar {'], ['#9cdcfe', ' background: var(--accent);'], ['#9cdcfe', ' border-radius: 999px;'], ['#e8e2d8', '}']];
239
+ lines.forEach(([col, t], i) => { c.fillStyle = '#5c5652'; c.fillText(String(i + 1).padStart(2), w.x + 16, w.y + 66 + i * 26); c.fillStyle = col; c.fillText(t, w.x + 56, w.y + 66 + i * 26); });
240
+ } else {
241
+ const lines = [...desk.term, `$ ${desk.typed}`];
242
+ lines.slice(-14).forEach((t, i, a) => { c.fillStyle = t.startsWith('✓') ? '#7bd88f' : t.startsWith('$') ? '#f2ede6' : '#b9b2aa'; c.fillText(t, w.x + 20, w.y + 64 + i * 26); if (i === a.length - 1 && Math.floor(Date.now() / 500) % 2) { c.fillStyle = '#f2ede6'; c.fillRect(w.x + 20 + c.measureText(t).width + 2, w.y + 54 + i * 26, 9, 20); } });
243
+ }
244
+ c.restore();
245
+ }
246
+ // Dock
247
+ c.fillStyle = 'rgba(255,255,255,.45)'; rr(c, W / 2 - 230, H - 82, 460, 66, 20); c.fill();
248
+ ['#f46a3b', '#2b5bd8', '#5b6551', '#e7e66a', '#272224', '#e2382b'].forEach((col, i) => { c.fillStyle = col; rr(c, W / 2 - 205 + i * 70, H - 70, 50, 50, 12); c.fill(); });
249
+ if (desk.menu && Date.now() - desk.menu[2] < 2500) {
250
+ const [x, y] = desk.menu; c.fillStyle = 'rgba(248,246,242,.97)'; rr(c, x, y, 210, 128, 10); c.fill();
251
+ c.fillStyle = C.ink; c.font = '15px -apple-system, Segoe UI, sans-serif'; ['Obre', 'Copia', 'Mostra informació', 'Mou a la paperera'].forEach((t, i) => c.fillText(t, x + 16, y + 22 + i * 28));
252
+ }
253
+ if (desk.mouse) {
254
+ const [x, y] = desk.mouse, age = Date.now() - desk.ripple;
255
+ if (age < 600) { c.strokeStyle = `rgba(244,106,59,${1 - age / 600})`; c.lineWidth = 3; c.beginPath(); c.arc(x, y, 10 + age / 15, 0, 7); c.stroke(); }
256
+ c.fillStyle = '#fff'; c.strokeStyle = '#000'; c.lineWidth = 1.5; c.beginPath(); c.moveTo(x, y); c.lineTo(x, y + 22); c.lineTo(x + 6, y + 17); c.lineTo(x + 11, y + 27); c.lineTo(x + 15, y + 25); c.lineTo(x + 10, y + 15); c.lineTo(x + 17, y + 15); c.closePath(); c.fill(); c.stroke();
257
+ }
258
+ }
259
+ // Clau de l'estat visible: si no canvia, el panell rep un 204 i no es redibuixa res.
260
+ const deskHash = () => `${desk.ver}-${Math.floor(Date.now() / 500)}-${Date.now() - desk.ripple < 700 ? Date.now() : 0}`;
261
+ async function frame(w, q) {
262
+ const scale = Math.min(1, Math.max(.3, (Number(w) || W) / W));
263
+ const cv = typeof OffscreenCanvas !== 'undefined' ? new OffscreenCanvas(Math.round(W * scale), Math.round(H * scale)) : Object.assign(document.createElement('canvas'), { width: Math.round(W * scale), height: Math.round(H * scale) });
264
+ const c = cv.getContext('2d'); c.scale(scale, scale); paint(c);
265
+ const quality = Math.min(.92, Math.max(.3, (Number(q) || 70) / 100));
266
+ return cv.convertToBlob ? cv.convertToBlob({ type: 'image/jpeg', quality }) : new Promise((r) => cv.toBlob(r, 'image/jpeg', quality));
267
+ }
268
+ const shots = new Map();
269
+ let lastShot = null;
270
+ async function takeShot(from) {
271
+ const id = rid(); shots.set(id, await frame(1600, 80));
272
+ lastShot = { id, t: Date.now(), from: from || 'Aquest navegador' };
273
+ audit.unshift({ t: Date.now(), type: 'tool', agent: 'claude', title: 'Captura de pantalla', tool: 'Bash', what: 'agent-desktop screenshot', mode: 'preguntar' });
274
+ return lastShot;
275
+ }
276
+
277
+ // ---------- Activitat d'exemple: el que haurien fet els agents abans que obrissis la demo.
278
+ function seedExtra() {
279
+ const ago = (min) => Date.now() - min * 60000;
280
+ const s1 = [...sessions.values()].find((s) => s.agent === 'claude' && /fosc/.test(s.title));
281
+ const s3 = [...sessions.values()].find((s) => /tests per/.test(s.title));
282
+ jobs.push(
283
+ { id: rid(), title: 'Revisa les dependències i obre un PR si n’hi ha de noves', prompt: 'Revisa les dependències de l’API i, si n’hi ha de noves, actualitza-les i obre un PR.', agent: 'codex', cwd: `${ROOT}/api`, mode: 'preguntar', rule: null, after: null, cron: '0 9 * * 1-5', state: 'programada', runAt: nextNine(), created: ago(60 * 48) },
284
+ { id: rid(), title: 'Afegeix el mode fosc a la botiga', prompt: 'Afegeix el mode fosc a la botiga', agent: 'claude', cwd: `${ROOT}/web-botiga`, mode: 'preguntar', rule: null, after: null, cron: null, state: 'feta', session: s1?.id, created: ago(20), started: ago(18), finished: ago(12), result: 'Fet.' },
285
+ { id: rid(), title: 'Escriu tests per a /usuaris', prompt: 'Escriu tests per a /usuaris', agent: 'claude', cwd: `${ROOT}/api`, mode: 'preguntar', rule: null, after: null, cron: null, state: 'feta', session: s3?.id, created: ago(60 * 27), started: ago(60 * 26 + 5), finished: ago(60 * 26), result: 'Fet.' },
286
+ );
287
+ audit.push(
288
+ { t: ago(12), type: 'tool', agent: 'claude', title: 'Afegeix el mode fosc a la botiga', tool: 'Bash', what: 'npm run build', mode: 'preguntar' },
289
+ { t: ago(12.5), type: 'approval', agent: 'claude', title: 'Afegeix el mode fosc a la botiga', tool: 'Edit', what: `${ROOT}/web-botiga/src/styles.css`, why: 'Afegeixo els colors del mode fosc', decision: 'allowed', by: 'iPhone', asked: ago(13) },
290
+ { t: ago(13), type: 'tool', agent: 'claude', title: 'Afegeix el mode fosc a la botiga', tool: 'Read', what: 'src/styles.css', mode: 'preguntar' },
291
+ { t: ago(35), type: 'tool', agent: 'claude', title: 'Comprova la botiga al navegador', tool: 'Bash', what: 'agent-browser open http://localhost:4321', mode: 'preguntar' },
292
+ { t: ago(34), type: 'tool', agent: 'claude', title: 'Comprova la botiga al navegador', tool: 'Bash', what: 'agent-desktop screenshot', mode: 'preguntar' },
293
+ { t: ago(60 * 5), type: 'device', what: 'iPhone s’ha connectat amb un codi', by: 'iPhone' },
294
+ { t: ago(60 * 26), type: 'job', what: 'Feina feta: Escriu tests per a /usuaris', agent: 'claude' },
295
+ );
296
+ lastShot = { id: 'inicial', t: ago(34), from: 'Claude Code' };
297
+ }
298
+ seedExtra();
299
+
300
+ const now = Date.now(), NOTES = [
301
+ { id: 'n1', title: 'Reunió de dimarts', text: 'Parlar amb la Marta del pressupost.\nRevisar el contracte del fuster abans de divendres.', items: null, color: 'lemon', pinned: true, archived: false, trashed: 0, labels: ['feina'], audio: [], created: now - 9e5, updated: now - 9e5 },
302
+ { id: 'n2', title: 'Compra', text: '', items: [{ id: 'i1', text: 'llet', done: false }, { id: 'i2', text: 'pa de pagès', done: true }, { id: 'i3', text: 'pintura blanca', done: false }], color: 'sky', pinned: false, archived: false, trashed: 0, labels: ['casa'], audio: [], created: now - 8e5, updated: now - 8e5 },
303
+ { id: 'n3', title: 'Idees per al logo', text: 'El bot que es converteix en la O. Animació suau, fons crema.', items: null, color: 'lilac', pinned: false, archived: false, trashed: 0, labels: ['nio'], audio: [], created: now - 7e5, updated: now - 7e5 },
304
+ ];
305
+ const noteId = () => Math.random().toString(36).slice(2, 10);
148
306
  const MEMORY = {
149
307
  'preferencies.md': '# Preferències\n\n- Respon en català.\n- Fes commits petits i explica’ls bé.\n',
150
308
  'projectes/web-botiga.md': '# web-botiga\n\nBotiga en línia feta amb Astro. Els colors són a src/styles.css.\n',
@@ -154,8 +312,11 @@
154
312
  async function route(method, p, q, body) {
155
313
  let m;
156
314
  if (method === 'GET') {
157
- if (p === '/api/info') return { name: 'nio', version: 'demo', host: 'Ordinador de demo', platform: 'darwin', user: 'tu', publicUrl: null, agents: AGENTS, roots: [ROOT], vapidKey: null, id: 'demo', device: { id: 'demo', name: 'Aquest navegador', primary: true }, demo: true, control: null };
158
- if (p === '/api/sessions') return list();
315
+ if (p === '/api/info') return { name: 'nio', version: 'demo', host: 'Ordinador de demo', platform: 'darwin', user: 'tu', publicUrl: null, agents: AGENTS, roots: [ROOT], vapidKey: null, id: 'demo', device: { id: 'demo', name: 'Aquest navegador', primary: true }, demo: true, control: caps };
316
+ if (p === '/api/usage') return DEMO_USAGE;
317
+ if (p === '/api/notes') return { notes: NOTES };
318
+ if (p === '/api/notes/engines') return { engines: [{ id: 'ollama:qwen3:4b', label: 'qwen3:4b · 4.0B', local: true, group: 'Local (Ollama)', params: 4 }] };
319
+ if (p === '/api/sessions') return list();
159
320
  if (p === '/api/approvals') return pending();
160
321
  if ((m = p.match(/^\/api\/sessions\/([\w-]+)$/))) { const s = sessions.get(m[1]); if (!s) throw err(404, 'Aquesta sessió no existeix'); const since = Number(q.get('since') || 0); return { session: summary(s), events: s.events.filter((e) => e.seq > since) }; }
161
322
  if (/^\/api\/sessions\/[\w-]+\/commands$/.test(p)) return [];
@@ -165,13 +326,13 @@
165
326
  if (p === '/api/memory') return { dir: '~/.nio/memory', files: Object.entries(MEMORY).map(([path, c]) => ({ path, size: c.length, updated: Date.now() - 86400000 })) };
166
327
  if ((m = p.match(/^\/api\/memory\/f\/(.+)$/))) { const path = decodeURIComponent(m[1]); if (!(path in MEMORY)) throw err(404, 'Aquest fitxer no existeix'); return { path, content: MEMORY[path], updated: Date.now() - 86400000 }; }
167
328
  if (p === '/api/memory/search') { const t = (q.get('q') || '').toLowerCase(), r = []; for (const [path, c] of Object.entries(MEMORY)) c.split('\n').forEach((l, i) => l.toLowerCase().includes(t) && t && r.push({ path, line: i + 1, text: l })); return r; }
168
- if (p === '/api/jobs') return { jobs: [], routing: [], max: 2 };
169
- if (p === '/api/audit') return audit.slice(0, 300);
170
- if (p === '/api/devices') return [{ id: 'demo', name: 'Aquest navegador', primary: true, created: null, lastSeen: Date.now(), current: true }];
329
+ if (p === '/api/jobs') return { jobs: jobs.map((j) => ({ ...j })), routing: [], max: 2 };
330
+ if (p === '/api/audit') return audit.filter((e) => !q.get('type') || e.type === q.get('type')).sort((a, b) => b.t - a.t).slice(0, 300);
331
+ if (p === '/api/devices') return [{ id: 'demo', name: 'Aquest navegador', primary: true, created: null, lastSeen: Date.now(), current: true }, { id: 'iphone', name: 'iPhone', primary: false, created: Date.now() - 3 * 86400000, lastSeen: Date.now() - 5 * 3600000 }];
171
332
  if (p === '/api/voice') return { providers: {}, stt: null, tts: null, speak: false, lang: 'ca', voice: {}, demo: true };
172
- if (p === '/api/control') return { navegador: { installed: false, ready: false, enabled: false }, escriptori: { installed: false, ready: false, why: 'A la demo no hi ha escriptori', enabled: false, screenshot: false }, platform: 'darwin' };
173
- if (p === '/api/screen') return { last: null, enabled: false };
174
- if (p === '/api/desktop/status') return { w: 0, h: 0, screen: null, accessibility: null, platform: 'darwin', input: false, capture: false, enabled: false };
333
+ if (p === '/api/control') return caps;
334
+ if (p === '/api/screen') return { last: lastShot, enabled: caps.escriptori.enabled || caps.navegador.enabled };
335
+ if (p === '/api/desktop/status') return { w: W, h: H, screen: true, accessibility: true, platform: 'darwin', input: true, capture: true, enabled: caps.escriptori.enabled };
175
336
  if (p === '/api/telegram') return { configured: false, bot: null, running: false, allowed: [], pending: [], agent: null, cwd: ROOT, mode: 'preguntar', notify: true, voice: false };
176
337
  if (p === '/api/ollama') return { available: false, host: '', models: [], running: [], pulling: [] };
177
338
  if (p === '/api/ollama/models' || p === '/api/notices' || p === '/api/ssh/hosts') return [];
@@ -182,13 +343,33 @@
182
343
  if ((m = p.match(/^\/api\/sessions\/([\w-]+)\/message$/))) { const s = sessions.get(m[1]); if (!s) throw err(404, 'Aquesta sessió no existeix'); return send(s, body.text); }
183
344
  if ((m = p.match(/^\/api\/sessions\/([\w-]+)\/stop$/))) { const r = runs.get(m[1]), s = sessions.get(m[1]); if (!r) return { stopped: false }; r.stopped = true; for (const a of approvals.values()) if (a.session === m[1] && a.state === 'pending') decide(a.id, 'deny'); if (s) push(s, 'info', { text: 'Has aturat l’agent' }); return { stopped: true }; }
184
345
  if ((m = p.match(/^\/api\/approvals\/([\w-]+)$/))) { const a = decide(m[1], body.decision === 'allow' ? 'allow' : 'deny'); if (!a) throw err(404, 'Aquesta petició ja s’ha resolt'); return a; }
346
+ if (p === '/api/notes') { const t = Date.now(), n = { id: noteId(), title: '', text: '', items: null, color: '', pinned: false, archived: false, trashed: 0, labels: [], audio: [], created: t, updated: t, ...body }; NOTES.unshift(n); return n; }
347
+ if (p === '/api/notes/ai') { const n = NOTES.find((x) => x.id === body.id), t = (n?.text || '').split('\n').filter(Boolean); return { engine: 'ollama:qwen3:4b', local: true, action: body.action, result: { text: t.map((x) => `- ${x}`).join('\n') || '- (res a resumir)', items: t, title: n?.title || 'Nota', labels: ['demo'] } }; }
348
+ if (p === '/api/jobs') return addJob(body);
349
+ if ((m = p.match(/^\/api\/jobs\/([\w-]+)\/(run|cancel)$/))) {
350
+ const j = jobs.find((x) => x.id === m[1]); if (!j) throw err(404, 'Aquesta feina no existeix');
351
+ if (m[2] === 'run') { if (j.cron) { const c = { ...j, id: rid(), cron: null, state: 'pendent', created: Date.now() }; jobs.unshift(c); runJob(c); } else runJob(j); return j; }
352
+ if (j.state === 'en curs') { const r = runs.get(j.session); if (r) r.stopped = true; }
353
+ Object.assign(j, { state: 'cancel·lada', finished: Date.now() }); jobOut(j); return j;
354
+ }
355
+ if (p === '/api/screen') return takeShot('Aquest navegador');
356
+ if (p === '/api/desktop/session') return { ok: true };
357
+ if (p === '/api/desktop/input') { if (!caps.escriptori.enabled) throw err(400, 'Activa primer «Apps de l’escriptori».'); return input(body); }
358
+ if (p === '/api/panic') { let n = 0; for (const [id, r] of runs) { r.stopped = true; n++; const s = sessions.get(id); if (s) push(s, 'info', { text: 'Aturat des de l’aturada d’emergència' }); } for (const a of approvals.values()) if (a.state === 'pending') decide(a.id, 'deny'); return { stopped: n }; }
185
359
  if (p === '/api/voice/stt') throw err(400, 'A la demo no hi ha micròfon. Amb nio instal·lat, dictes amb Whisper, gratis, a l’ordinador.');
186
360
  }
361
+ if ((m = p.match(/^\/api\/notes\/([\w-]+)$/))) {
362
+ const n = NOTES.find((x) => x.id === m[1]); if (!n) throw err(404, 'Aquesta nota no existeix');
363
+ if (method === 'PATCH') { Object.assign(n, body, { updated: Date.now() }); if ('trashed' in body) n.trashed = body.trashed ? Date.now() : 0; return n; }
364
+ if (method === 'DELETE') { if (n.trashed || q.get('forever') === '1') NOTES.splice(NOTES.indexOf(n), 1); else n.trashed = Date.now(); return { ok: true }; }
365
+ }
187
366
  if ((m = p.match(/^\/api\/sessions\/([\w-]+)$/))) {
188
367
  const s = sessions.get(m[1]); if (!s) throw err(404, 'Aquesta sessió no existeix');
189
368
  if (method === 'PATCH') { if (['llegir', 'preguntar', 'tot'].includes(body.mode)) s.mode = body.mode; if (typeof body.title === 'string') s.title = body.title.slice(0, 120); touch(s); return summary(s); }
190
369
  if (method === 'DELETE') { runs.get(s.id) && (runs.get(s.id).stopped = true); sessions.delete(s.id); out({ type: 'removed', session: s.id }); return { ok: true }; }
191
370
  }
371
+ if (method === 'DELETE' && (m = p.match(/^\/api\/jobs\/([\w-]+)$/))) { const i = jobs.findIndex((x) => x.id === m[1]); if (i >= 0) jobs.splice(i, 1); out({ type: 'job', removed: m[1] }); return { ok: true }; }
372
+ if (method === 'PUT' && p === '/api/control') { for (const k of ['navegador', 'escriptori']) if (typeof body[k] === 'boolean') caps[k].enabled = body[k]; return { ...caps, notes: [] }; }
192
373
  if ((m = p.match(/^\/api\/memory\/f\/(.+)$/))) {
193
374
  if (method === 'PUT') { MEMORY[decodeURIComponent(m[1])] = String(body.content ?? ''); return { ok: true }; }
194
375
  if (method === 'DELETE') { delete MEMORY[decodeURIComponent(m[1])]; return { ok: true }; }
@@ -205,12 +386,23 @@
205
386
  if (u.pathname === '/api/stream') {
206
387
  let ctl;
207
388
  const body = new ReadableStream({
208
- start(c) { ctl = c; streams.add(c); c.enqueue(new TextEncoder().encode(`data: ${JSON.stringify({ type: 'hello', sessions: list(), approvals: pending() })}\n\n`)); },
389
+ start(c) { ctl = c; streams.add(c); c.enqueue(new TextEncoder().encode(`data: ${JSON.stringify({ type: 'hello', sessions: list(), approvals: pending(), usage: DEMO_USAGE })}\n\n`)); },
209
390
  cancel() { streams.delete(ctl); },
210
391
  });
211
392
  opts.signal?.addEventListener('abort', () => { streams.delete(ctl); try { ctl.close(); } catch { /* ja tancat */ } });
212
393
  return new Response(body, { status: 200, headers: { 'content-type': 'text/event-stream' } });
213
394
  }
395
+ if (u.pathname === '/api/desktop/frame') {
396
+ if (!caps.escriptori.enabled) return json(400, { error: 'Activa primer «Apps de l’escriptori».' });
397
+ const h = deskHash(), hd = { 'x-hash': h, 'x-screen-w': String(W), 'x-screen-h': String(H), 'x-blank': '0' };
398
+ if (u.searchParams.get('h') === h) return new Response(null, { status: 204, headers: hd });
399
+ return new Response(await frame(u.searchParams.get('w'), u.searchParams.get('q')), { status: 200, headers: { ...hd, 'content-type': 'image/jpeg' } });
400
+ }
401
+ let sm;
402
+ if ((sm = u.pathname.match(/^\/api\/screen\/shot\/([\w-]+)$/))) {
403
+ if (!shots.has(sm[1])) shots.set(sm[1], await frame(1600, 80));
404
+ return new Response(shots.get(sm[1]), { status: 200, headers: { 'content-type': 'image/jpeg' } });
405
+ }
214
406
  await sleep(60);
215
407
  let body = {};
216
408
  try { body = typeof opts.body === 'string' ? JSON.parse(opts.body) : {}; } catch { /* àudio o res */ }
@@ -17,14 +17,14 @@
17
17
  <meta property="og:url" content="https://www.nio.cat/app">
18
18
  <meta property="og:title" content="nio // Panell">
19
19
  <meta property="og:description" content="Dona feina als agents d’IA del teu ordinador des del mòbil, segueix-los en directe i aprova cada pas.">
20
- <meta property="og:image" content="https://www.nio.cat/og.png">
20
+ <meta property="og:image" content="https://www.nio.cat/og.jpg">
21
21
  <meta property="og:image:width" content="1200">
22
22
  <meta property="og:image:height" content="630">
23
23
  <meta property="og:image:alt" content="nio">
24
24
  <meta name="twitter:card" content="summary_large_image">
25
25
  <meta name="twitter:title" content="nio // Panell">
26
26
  <meta name="twitter:description" content="Dona feina als agents d’IA del teu ordinador des del mòbil, segueix-los en directe i aprova cada pas.">
27
- <meta name="twitter:image" content="https://www.nio.cat/og.png">
27
+ <meta name="twitter:image" content="https://www.nio.cat/og.jpg">
28
28
  <link rel="manifest" href="/manifest.webmanifest">
29
29
  <meta name="format-detection" content="telephone=no">
30
30
  <link rel="icon" href="/favicon.svg" type="image/svg+xml">
@@ -35,6 +35,7 @@
35
35
  <link rel="stylesheet" href="/bot.css">
36
36
  <link rel="stylesheet" href="/board.css">
37
37
  <link rel="stylesheet" href="/app/style.css">
38
+ <link rel="stylesheet" href="/app/notes.css">
38
39
  </head>
39
40
  <body>
40
41
  <header class="mbar">
@@ -0,0 +1,154 @@
1
+ /* nio · Notes */
2
+ .page:has(.nt) { max-width: 1240px; }
3
+ .nt-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 20px; flex-wrap: wrap; }
4
+ .nt-title h1 { font-size: 30px; font-weight: 500; letter-spacing: -.05em; line-height: 1.1; }
5
+ .nt-title p { margin-top: 4px; color: var(--fg-muted); }
6
+ .nt-bar { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; }
7
+ .nt-search { flex: 1; position: relative; display: flex; align-items: center; }
8
+ .nt-search .ico { position: absolute; left: 13px; width: 17px; height: 17px; color: var(--fg-muted); pointer-events: none; }
9
+ .nt-search input { width: 100%; height: 42px; padding: 0 14px 0 38px; border: 1px solid var(--border); border-radius: 12px; background: var(--white); font: inherit; color: var(--fg); transition: border-color .2s, box-shadow .2s; }
10
+ .nt-search input:focus { outline: none; border-color: var(--fg); box-shadow: 0 0 0 3px color-mix(in srgb, var(--fg) 10%, transparent); }
11
+ .nt-new { display: flex; align-items: center; justify-content: space-between; gap: 10px; width: min(620px, 100%); margin: 0 auto 18px; padding: 12px 12px 12px 18px; border: 1px solid var(--border); border-radius: 14px; background: var(--white); color: var(--fg-muted); cursor: text; box-shadow: 0 1px 2px rgba(39,34,36,.05), 0 6px 20px -14px rgba(39,34,36,.35); transition: box-shadow .25s var(--ease), transform .25s var(--ease), border-color .2s; }
12
+ .nt-new:hover { border-color: color-mix(in srgb, var(--fg) 30%, var(--border)); box-shadow: 0 1px 2px rgba(39,34,36,.06), 0 10px 28px -14px rgba(39,34,36,.45); }
13
+ .nt-new:active { transform: scale(.995); }
14
+ .nt-new-b { display: flex; gap: 2px; }
15
+ .nt-new-b button, .nt-ib { display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-width: 36px; height: 36px; padding: 0 8px; border: 0; border-radius: 10px; background: transparent; color: var(--fg-muted); cursor: pointer; font: inherit; font-size: 13px; font-weight: 500; transition: background .15s, color .15s, transform .15s var(--ease); }
16
+ .nt-new-b button:hover, .nt-ib:hover { background: color-mix(in srgb, var(--fg) 8%, transparent); color: var(--fg); }
17
+ .nt-ib:active, .nt-new-b button:active { transform: scale(.92); }
18
+ .nt-ib.on { color: var(--fg); background: color-mix(in srgb, var(--fg) 10%, transparent); }
19
+ .nt-ib.sm { min-width: 30px; height: 30px; }
20
+ .nt .ico, .nt-ed .ico, .nt-pop .ico { width: 19px; height: 19px; fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; flex: none; }
21
+ .nt-ib.on .ico { fill: currentColor; fill-opacity: .15; }
22
+
23
+ .nt-filters { display: flex; gap: 8px; overflow-x: auto; padding: 2px 2px 12px; margin: 0 -2px 6px; scrollbar-width: none; }
24
+ .nt-filters::-webkit-scrollbar { display: none; }
25
+ .nt-chip { display: inline-flex; align-items: center; gap: 6px; flex: none; height: 32px; padding: 0 13px; border: 1px solid var(--border); border-radius: 999px; background: transparent; color: var(--fg-muted); font: inherit; font-size: 13px; font-weight: 500; cursor: pointer; transition: background .18s, color .18s, border-color .18s, transform .18s var(--ease); }
26
+ .nt-chip .ico { width: 15px; height: 15px; }
27
+ .nt-chip:hover { border-color: var(--fg); color: var(--fg); }
28
+ .nt-chip[aria-current="true"] { background: var(--fg); color: var(--bg); border-color: var(--fg); }
29
+ .nt-chip:active { transform: scale(.96); }
30
+
31
+ .nt-sec { margin: 8px 2px 10px; font-size: 11px; font-weight: 600; letter-spacing: .09em; text-transform: uppercase; color: var(--fg-muted); }
32
+ .nt-grid { columns: 250px; column-gap: 14px; margin-bottom: 22px; }
33
+ .nt[data-layout="list"] .nt-grid { columns: 1; max-width: 640px; margin-inline: auto; }
34
+ .nt-card { position: relative; display: block; break-inside: avoid; margin: 0 0 14px; padding: 14px 15px 12px; border: 1px solid color-mix(in srgb, var(--fg) 13%, transparent); border-radius: 14px; background: var(--nc, var(--white)); cursor: pointer; transition: box-shadow .25s var(--ease), transform .25s var(--ease), opacity .2s; }
35
+ .nt-card:hover { box-shadow: 0 10px 28px -16px rgba(39,34,36,.5); transform: translateY(-2px); }
36
+ .nt-card:focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
37
+ .nt-card h3 { margin: 0 28px 6px 0; font-size: 15px; font-weight: 600; letter-spacing: -.01em; line-height: 1.3; overflow-wrap: anywhere; }
38
+ .nt-txt { font-size: 14px; line-height: 1.5; white-space: pre-wrap; overflow-wrap: anywhere; display: -webkit-box; -webkit-line-clamp: 10; -webkit-box-orient: vertical; overflow: hidden; }
39
+ .nt-txt.muted { color: var(--fg-muted); }
40
+ .nt-its { display: grid; gap: 3px; margin-top: 4px; }
41
+ .nt-it { display: flex; align-items: flex-start; gap: 8px; font-size: 14px; line-height: 1.4; }
42
+ .nt-it input { margin: 3px 0 0; accent-color: var(--fg); flex: none; cursor: pointer; width: 15px; height: 15px; }
43
+ .nt-it span { overflow-wrap: anywhere; }
44
+ .nt-it.done span { text-decoration: line-through; color: var(--fg-muted); }
45
+ .nt-more { font-size: 12px; color: var(--fg-muted); margin-left: 23px; }
46
+ .nt-pin { position: absolute; top: 8px; right: 8px; width: 30px; height: 30px; display: grid; place-items: center; border: 0; border-radius: 50%; background: transparent; color: var(--fg-muted); cursor: pointer; opacity: 0; transition: opacity .18s, background .15s; }
47
+ .nt-pin .ico { width: 17px; height: 17px; }
48
+ .nt-card:hover .nt-pin, .nt-pin.on, .nt-pin:focus-visible { opacity: 1; }
49
+ .nt-pin.on { color: var(--fg); } .nt-pin.on .ico { fill: currentColor; fill-opacity: .2; }
50
+ .nt-pin:hover { background: color-mix(in srgb, var(--fg) 10%, transparent); }
51
+ .nt-badge { display: inline-flex; align-items: center; gap: 5px; margin-top: 8px; font-size: 12px; font-weight: 500; color: var(--fg-muted); }
52
+ .nt-badge .ico { width: 14px; height: 14px; } .nt-badge.err { color: var(--red); }
53
+ .nt-labels { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
54
+ .nt-label { display: inline-flex; align-items: center; gap: 2px; height: 22px; padding: 0 9px; border-radius: 999px; background: color-mix(in srgb, var(--fg) 9%, transparent); font-size: 12px; font-weight: 500; }
55
+ .nt-label button { display: grid; place-items: center; width: 16px; height: 16px; margin: 0 -5px 0 2px; padding: 0; border: 0; border-radius: 50%; background: transparent; color: inherit; cursor: pointer; }
56
+ .nt-label button .ico { width: 11px; height: 11px; }
57
+ .nt-acts { display: flex; gap: 2px; justify-content: flex-end; height: 0; overflow: hidden; opacity: 0; margin-top: 0; transition: height .2s var(--ease), opacity .2s, margin .2s; }
58
+ .nt-card:hover .nt-acts, .nt-card:focus-within .nt-acts { height: 32px; opacity: 1; margin-top: 8px; }
59
+ .nt-acts button { width: 32px; height: 32px; display: grid; place-items: center; border: 0; border-radius: 9px; background: transparent; color: var(--fg-muted); cursor: pointer; }
60
+ .nt-acts button:hover { background: color-mix(in srgb, var(--fg) 10%, transparent); color: var(--fg); }
61
+ .nt-acts .ico { width: 17px; height: 17px; }
62
+ @media (hover: none) { .nt-acts { height: 32px; opacity: 1; margin-top: 8px; } .nt-pin { opacity: 1; } }
63
+ .nt-empty { padding: 56px 12px; text-align: center; color: var(--fg-muted); }
64
+ .nt-trash-bar { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 14px; color: var(--fg-muted); font-size: 13px; }
65
+ .nt-skel { display: block; height: 110px; margin-bottom: 14px; border-radius: 14px; background: linear-gradient(100deg, var(--bg-2) 30%, color-mix(in srgb, var(--bg-2) 55%, var(--white)) 50%, var(--bg-2) 70%) 0 0 / 220% 100%; animation: nt-sh 1.3s linear infinite; break-inside: avoid; }
66
+ .nt-skel.tall { height: 170px; }
67
+ @keyframes nt-sh { to { background-position: -120% 0; } }
68
+ .nt-undo { margin-left: 8px; padding: 0 2px; border: 0; background: none; color: var(--lemon); font: inherit; font-weight: 600; cursor: pointer; text-decoration: underline; text-underline-offset: 3px; }
69
+
70
+ /* colors (targetes i editor) */
71
+ [data-color="lemon"] { --nc: #f1efa4; } [data-color="peach"] { --nc: #f8d9c8; } [data-color="moss"] { --nc: #d6dccb; }
72
+ [data-color="sky"] { --nc: #d3e3ea; } [data-color="lilac"] { --nc: #e0d9ec; } [data-color="rose"] { --nc: #f1d5d1; } [data-color="sand"] { --nc: #e7ddc9; }
73
+ [data-color=""] { --nc: var(--white); }
74
+
75
+ /* editor */
76
+ .nt-ov { position: fixed; inset: 0; z-index: 70; display: flex; align-items: center; justify-content: center; padding: 24px; background: rgba(39,34,36,.32); backdrop-filter: blur(2px); -webkit-backdrop-filter: blur(2px); }
77
+ .nt-ed { position: relative; display: flex; flex-direction: column; width: min(660px, 100%); max-height: min(88vh, 820px); border-radius: 18px; background: var(--nc, var(--white)); border: 1px solid color-mix(in srgb, var(--fg) 14%, transparent); box-shadow: 0 30px 80px -24px rgba(39,34,36,.6); transition: background .25s; }
78
+ .nt-ed-head { display: flex; align-items: center; gap: 6px; padding: 14px 12px 4px 20px; }
79
+ .nt-ed input.nt-ed-title { flex: 1; width: auto; padding: 0; border-radius: 0; box-shadow: none; min-width: 0; height: 40px; border: 0; background: transparent; font: inherit; font-size: 20px; font-weight: 600; letter-spacing: -.02em; color: var(--fg); outline: none; }
80
+ .nt-ed-title::placeholder, .nt-ed-text::placeholder, .nt-item input[type=text]::placeholder { color: color-mix(in srgb, var(--fg) 40%, transparent); }
81
+ .nt-ed-body { flex: 1; min-height: 80px; overflow-y: auto; padding: 4px 20px 10px; overscroll-behavior: contain; }
82
+ .nt-ed-text { display: block; width: 100%; min-height: 96px; max-height: 52vh; padding: 0; border: 0; background: transparent; font: inherit; font-size: 15px; line-height: 1.6; color: var(--fg); resize: none; outline: none; overflow-y: auto; }
83
+ .nt-ed-text.nt-desc { min-height: 0; margin-bottom: 10px; }
84
+ .nt-item { display: flex; align-items: center; gap: 10px; padding: 3px 0; }
85
+ .nt-item input[type=checkbox] { width: 17px; height: 17px; accent-color: var(--fg); flex: none; cursor: pointer; }
86
+ .nt-item input[type=text] { flex: 1; min-width: 0; height: 30px; padding: 0; border: 0; background: transparent; font: inherit; font-size: 15px; color: var(--fg); outline: none; }
87
+ .nt-item.done input[type=text] { text-decoration: line-through; color: var(--fg-muted); }
88
+ .nt-x { width: 26px; height: 26px; display: grid; place-items: center; border: 0; border-radius: 50%; background: transparent; color: var(--fg-muted); cursor: pointer; opacity: 0; transition: opacity .15s; }
89
+ .nt-item:hover .nt-x, .nt-x:focus-visible { opacity: 1; } .nt-x .ico { width: 14px; height: 14px; }
90
+ @media (hover: none) { .nt-x { opacity: .7; } }
91
+ .nt-add { display: flex; align-items: center; gap: 10px; margin: 2px 0 6px; padding: 4px 0; border: 0; background: transparent; color: var(--fg-muted); font: inherit; font-size: 14px; cursor: pointer; }
92
+ .nt-add .ico { width: 17px; height: 17px; } .nt-add:hover { color: var(--fg); }
93
+ .nt-donehd { margin: 10px 0 4px; font-size: 12px; font-weight: 600; color: var(--fg-muted); }
94
+ .nt-audio { display: grid; gap: 6px; padding: 0 20px 8px; }
95
+ .nt-au { display: flex; align-items: center; gap: 8px; padding: 6px 10px 6px 6px; border-radius: 12px; background: color-mix(in srgb, var(--fg) 6%, transparent); font-size: 13px; }
96
+ .nt-au .nt-ib { background: var(--fg); color: var(--bg); } .nt-au .nt-ib .ico { width: 14px; height: 14px; fill: currentColor; }
97
+ .nt-au-d { font-weight: 600; font-variant-numeric: tabular-nums; }
98
+ .nt-au-s { flex: 1; min-width: 0; display: flex; align-items: center; gap: 6px; color: var(--fg-muted); }
99
+ .nt-au-s .ico { width: 14px; height: 14px; color: var(--moss); }
100
+ .nt-au[data-st="error"] .nt-au-s { color: var(--red); }
101
+ .nt-spin { width: 13px; height: 13px; border: 2px solid color-mix(in srgb, var(--fg) 20%, transparent); border-top-color: var(--fg); border-radius: 50%; animation: nt-rot .7s linear infinite; flex: none; }
102
+ @keyframes nt-rot { to { transform: rotate(360deg); } }
103
+ .nt-ed-labels { padding: 0 20px 6px; margin: 0; }
104
+ .nt-ed-foot { padding: 6px 10px 10px; }
105
+ .nt-tb { display: flex; align-items: center; gap: 2px; }
106
+ .nt-sp { flex: 1; }
107
+ .nt-ai-b { padding: 0 11px; } .nt-ai-b span { display: inline; }
108
+ .nt-done { height: 36px; padding: 0 16px; margin-left: 6px; }
109
+ .nt-rec { display: flex; align-items: center; gap: 10px; margin: 0 14px 12px; padding: 10px 12px; border-radius: 14px; background: color-mix(in srgb, var(--red) 10%, var(--white)); }
110
+ .nt-rec[hidden], .nt-ai[hidden], .nt-ed-foot[hidden], .nt-audio[hidden], .nt-ed-labels[hidden] { display: none; }
111
+ .nt-rec-dot { width: 12px; height: 12px; border-radius: 50%; background: var(--red); animation: nt-pulse 1.1s ease-in-out infinite; flex: none; }
112
+ @keyframes nt-pulse { 0%, 100% { transform: scale(1); box-shadow: 0 0 0 0 color-mix(in srgb, var(--red) 45%, transparent); } 50% { transform: scale(1.15); box-shadow: 0 0 0 8px transparent; } }
113
+ .nt-rec-t { font-weight: 600; font-variant-numeric: tabular-nums; }
114
+ .nt-rec-l { flex: 1; color: var(--fg-muted); font-size: 13px; }
115
+ .nt .btn.sm, .nt-ed .btn.sm, .nt-pop .btn.sm { height: 30px; padding: 0 12px; font-size: 13px; }
116
+
117
+ /* finestretes */
118
+ .nt-pop { position: absolute; z-index: 5; min-width: 220px; max-width: min(340px, calc(100% - 16px)); padding: 8px; border: 1px solid var(--border); border-radius: 14px; background: var(--white); box-shadow: 0 18px 50px -14px rgba(39,34,36,.5); animation: nt-pop .18s var(--ease); transform-origin: bottom left; }
119
+ @keyframes nt-pop { from { opacity: 0; transform: translateY(6px) scale(.97); } }
120
+ .nt-pad { display: block; padding: 6px 8px; }
121
+ .nt-colors { display: flex; gap: 8px; padding: 6px; flex-wrap: wrap; }
122
+ .nt-dot { width: 28px; height: 28px; border-radius: 50%; border: 1.5px solid color-mix(in srgb, var(--fg) 22%, transparent); background: var(--nc, var(--white)); cursor: pointer; transition: transform .15s var(--ease); }
123
+ .nt-dot:hover { transform: scale(1.12); } .nt-dot[aria-pressed="true"] { box-shadow: 0 0 0 2px var(--white), 0 0 0 3.5px var(--fg); }
124
+ .nt-lf { display: flex; gap: 6px; padding: 4px; }
125
+ .nt-lf input { flex: 1; min-width: 0; height: 30px; padding: 0 10px; border: 1px solid var(--border); border-radius: 9px; background: var(--white); font: inherit; font-size: 13px; color: var(--fg); }
126
+ .nt-lsug { display: flex; flex-wrap: wrap; gap: 6px; padding: 6px 4px 2px; }
127
+ .nt-sel { width: 100%; height: 34px; margin-top: 4px; padding: 0 8px; border: 1px solid var(--border); border-radius: 9px; background: var(--white); font: inherit; font-size: 13px; color: var(--fg); }
128
+ .nt-acts-list { display: grid; gap: 2px; padding: 4px 0; }
129
+ .nt-acts-list button { display: grid; gap: 1px; width: 100%; padding: 8px 10px; border: 0; border-radius: 10px; background: transparent; text-align: left; font: inherit; color: var(--fg); cursor: pointer; transition: background .15s; }
130
+ .nt-acts-list button:hover:not(:disabled) { background: color-mix(in srgb, var(--fg) 7%, transparent); }
131
+ .nt-acts-list button:disabled { opacity: .45; cursor: default; }
132
+ .nt-acts-list b { font-size: 14px; font-weight: 600; } .nt-acts-list span { font-size: 12px; color: var(--fg-muted); }
133
+ .nt-note { padding: 4px 8px 2px; }
134
+ .nt-steps { margin: 0; padding-left: 20px; display: grid; gap: 6px; } .nt-steps .ico { width: 15px; height: 15px; vertical-align: -3px; }
135
+
136
+ /* panell de l'assistent dins l'editor */
137
+ .nt-ai { margin: 0 14px 10px; padding: 12px 14px; border-radius: 14px; background: rgba(255,255,255,.62); border: 1px solid color-mix(in srgb, var(--fg) 10%, transparent); animation: nt-pop .22s var(--ease); max-height: 38vh; overflow: auto; }
138
+ .nt-ai-run, .nt-ai-err { display: flex; align-items: center; gap: 10px; font-size: 13px; color: var(--fg-muted); } .nt-ai-err { color: var(--red); justify-content: space-between; }
139
+ .nt-ai-h { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 8px; font-size: 12px; font-weight: 600; letter-spacing: .04em; text-transform: uppercase; }
140
+ .nt-ai-h span { display: inline-flex; align-items: center; gap: 6px; } .nt-ai-h .ico { width: 15px; height: 15px; } .nt-ai-h small { font-weight: 500; letter-spacing: 0; text-transform: none; color: var(--fg-muted); }
141
+ .nt-ai-b { font-size: 14px; line-height: 1.5; } .nt-ai-b pre { margin: 6px 0 0; white-space: pre-wrap; font: inherit; } .nt-ai-b ul { margin: 0; padding-left: 18px; } .nt-ai-b p { margin: 0 0 6px; }
142
+ .nt-ai-f { display: flex; justify-content: flex-end; gap: 8px; margin-top: 10px; }
143
+
144
+ @media (max-width: 700px) {
145
+ .page:has(.nt) { padding-inline: 14px; }
146
+ .nt-title h1 { font-size: 26px; } .nt-title p { display: none; }
147
+ .nt-top { margin-bottom: 12px; } .nt-tools .btn span { display: none; } .nt-tools .btn { padding: 0 11px; }
148
+ .nt-grid { columns: 2 150px; column-gap: 10px; } .nt-card { margin-bottom: 10px; padding: 12px; }
149
+ .nt-ov { padding: 0; align-items: flex-end; }
150
+ .nt-ed { width: 100%; max-height: none; height: 100dvh; border-radius: 0; border: 0; padding-top: var(--safe-t); padding-bottom: var(--safe-b); }
151
+ .nt-ai-b span { display: none; } .nt-ai-b { padding: 0 9px; }
152
+ .nt-ed-title { font-size: 19px; }
153
+ }
154
+ @media (prefers-reduced-motion: reduce) { .nt-card, .nt-new, .nt-pop, .nt-ai { transition: none; animation: none; } .nt-rec-dot, .nt-spin, .nt-skel { animation-duration: 2s; } }