nio-cat 0.9.6 → 0.9.9

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,12 @@
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/claude-sessions') return { sessions: [{ sessionId: 'demo-terminal', pid: 1, cwd: `${ROOT}/web-botiga`, kind: 'interactive', status: 'busy', name: '', title: 'Arregla el pagament amb targeta', startedAt: Date.now() - 40 * 60000, updated: Date.now() - 2 * 60000, ask: 'Els pagaments amb targeta fallen quan el client canvia de moneda. Mira-ho i arregla-ho.', last: '', terminal: { app: 'iTerm', tmux: false, screen: false, ssh: false }, transcript: true }] };
318
+ if (p === '/api/notes') return { notes: NOTES };
319
+ if (p === '/api/notes/engines') return { engines: [{ id: 'ollama:qwen3:4b', label: 'qwen3:4b · 4.0B', local: true, group: 'Local (Ollama)', params: 4 }] };
320
+ if (p === '/api/sessions') return list();
159
321
  if (p === '/api/approvals') return pending();
160
322
  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
323
  if (/^\/api\/sessions\/[\w-]+\/commands$/.test(p)) return [];
@@ -165,13 +327,13 @@
165
327
  if (p === '/api/memory') return { dir: '~/.nio/memory', files: Object.entries(MEMORY).map(([path, c]) => ({ path, size: c.length, updated: Date.now() - 86400000 })) };
166
328
  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
329
  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 }];
330
+ if (p === '/api/jobs') return { jobs: jobs.map((j) => ({ ...j })), routing: [], max: 2 };
331
+ 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);
332
+ 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
333
  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 };
334
+ if (p === '/api/control') return caps;
335
+ if (p === '/api/screen') return { last: lastShot, enabled: caps.escriptori.enabled || caps.navegador.enabled };
336
+ 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
337
  if (p === '/api/telegram') return { configured: false, bot: null, running: false, allowed: [], pending: [], agent: null, cwd: ROOT, mode: 'preguntar', notify: true, voice: false };
176
338
  if (p === '/api/ollama') return { available: false, host: '', models: [], running: [], pulling: [] };
177
339
  if (p === '/api/ollama/models' || p === '/api/notices' || p === '/api/ssh/hosts') return [];
@@ -182,13 +344,33 @@
182
344
  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
345
  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
346
  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; }
347
+ 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; }
348
+ 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'] } }; }
349
+ if (p === '/api/jobs') return addJob(body);
350
+ if ((m = p.match(/^\/api\/jobs\/([\w-]+)\/(run|cancel)$/))) {
351
+ const j = jobs.find((x) => x.id === m[1]); if (!j) throw err(404, 'Aquesta feina no existeix');
352
+ 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; }
353
+ if (j.state === 'en curs') { const r = runs.get(j.session); if (r) r.stopped = true; }
354
+ Object.assign(j, { state: 'cancel·lada', finished: Date.now() }); jobOut(j); return j;
355
+ }
356
+ if (p === '/api/screen') return takeShot('Aquest navegador');
357
+ if (p === '/api/desktop/session') return { ok: true };
358
+ if (p === '/api/desktop/input') { if (!caps.escriptori.enabled) throw err(400, 'Activa primer «Apps de l’escriptori».'); return input(body); }
359
+ 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
360
  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
361
  }
362
+ if ((m = p.match(/^\/api\/notes\/([\w-]+)$/))) {
363
+ const n = NOTES.find((x) => x.id === m[1]); if (!n) throw err(404, 'Aquesta nota no existeix');
364
+ if (method === 'PATCH') { Object.assign(n, body, { updated: Date.now() }); if ('trashed' in body) n.trashed = body.trashed ? Date.now() : 0; return n; }
365
+ if (method === 'DELETE') { if (n.trashed || q.get('forever') === '1') NOTES.splice(NOTES.indexOf(n), 1); else n.trashed = Date.now(); return { ok: true }; }
366
+ }
187
367
  if ((m = p.match(/^\/api\/sessions\/([\w-]+)$/))) {
188
368
  const s = sessions.get(m[1]); if (!s) throw err(404, 'Aquesta sessió no existeix');
189
369
  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
370
  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
371
  }
372
+ 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 }; }
373
+ 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
374
  if ((m = p.match(/^\/api\/memory\/f\/(.+)$/))) {
193
375
  if (method === 'PUT') { MEMORY[decodeURIComponent(m[1])] = String(body.content ?? ''); return { ok: true }; }
194
376
  if (method === 'DELETE') { delete MEMORY[decodeURIComponent(m[1])]; return { ok: true }; }
@@ -205,12 +387,23 @@
205
387
  if (u.pathname === '/api/stream') {
206
388
  let ctl;
207
389
  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`)); },
390
+ 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
391
  cancel() { streams.delete(ctl); },
210
392
  });
211
393
  opts.signal?.addEventListener('abort', () => { streams.delete(ctl); try { ctl.close(); } catch { /* ja tancat */ } });
212
394
  return new Response(body, { status: 200, headers: { 'content-type': 'text/event-stream' } });
213
395
  }
396
+ if (u.pathname === '/api/desktop/frame') {
397
+ if (!caps.escriptori.enabled) return json(400, { error: 'Activa primer «Apps de l’escriptori».' });
398
+ const h = deskHash(), hd = { 'x-hash': h, 'x-screen-w': String(W), 'x-screen-h': String(H), 'x-blank': '0' };
399
+ if (u.searchParams.get('h') === h) return new Response(null, { status: 204, headers: hd });
400
+ return new Response(await frame(u.searchParams.get('w'), u.searchParams.get('q')), { status: 200, headers: { ...hd, 'content-type': 'image/jpeg' } });
401
+ }
402
+ let sm;
403
+ if ((sm = u.pathname.match(/^\/api\/screen\/shot\/([\w-]+)$/))) {
404
+ if (!shots.has(sm[1])) shots.set(sm[1], await frame(1600, 80));
405
+ return new Response(shots.get(sm[1]), { status: 200, headers: { 'content-type': 'image/jpeg' } });
406
+ }
214
407
  await sleep(60);
215
408
  let body = {};
216
409
  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,8 @@
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">
39
+ <link rel="stylesheet" href="/app/call.css">
38
40
  </head>
39
41
  <body>
40
42
  <header class="mbar">
@@ -0,0 +1,225 @@
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: 100%; margin: 0 0 20px; padding: 8px; border: 1px solid var(--border); border-radius: 14px; background: var(--white); color: var(--fg-muted); cursor: default; 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-write { display: flex; align-items: center; gap: 10px; flex: 1; min-width: 0; min-height: 44px; padding: 0 12px; border: 0; border-radius: 8px; background: transparent; text-align: left; font: inherit; color: var(--fg-muted); cursor: pointer; }
15
+ .nt-new-b { display: flex; gap: 2px; }
16
+ .nt-new-b button, .nt-ib { display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-width: 40px; height: 40px; 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); }
17
+ .nt-new-b button:hover, .nt-ib:hover { background: color-mix(in srgb, var(--fg) 8%, transparent); color: var(--fg); }
18
+ .nt-ib:active, .nt-new-b button:active { transform: scale(.92); }
19
+ .nt-ib.on { color: var(--fg); background: color-mix(in srgb, var(--fg) 10%, transparent); }
20
+ .nt-ib.sm { min-width: 30px; height: 30px; }
21
+ .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; }
22
+ .nt-ib.on .ico { fill: currentColor; fill-opacity: .15; }
23
+
24
+ .nt-filters { display: grid; overflow: visible; gap: 10px; margin-bottom: 0; padding: 2px 2px 12px; }
25
+ .nt-filter-views, .nt-filter-labels { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
26
+ .nt-filter-labels > span, .nt-results { font-size: 12px; color: var(--fg-muted); }
27
+ .nt-results { margin: 2px 2px 16px; overflow-wrap: anywhere; }
28
+ .nt-chip { display: inline-flex; align-items: center; gap: 6px; flex: none; min-height: 36px; height: auto; white-space: normal; overflow-wrap: anywhere; max-width: 100%; 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); }
29
+ .nt-chip .ico { width: 15px; height: 15px; }
30
+ .nt-chip:hover { border-color: var(--fg); color: var(--fg); }
31
+ .nt-chip[aria-current="true"] { background: var(--fg); color: var(--bg); border-color: var(--fg); }
32
+ .nt-chip:active { transform: scale(.96); }
33
+
34
+ .nt-sec { margin: 8px 2px 10px; font-size: 11px; font-weight: 600; letter-spacing: .09em; text-transform: uppercase; color: var(--fg-muted); }
35
+ .nt-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 260px), 1fr)); gap: 16px; align-items: start; margin-bottom: 28px; }
36
+ .nt[data-layout="list"] .nt-grid { grid-template-columns: 1fr; max-width: 800px; margin-inline: auto; }
37
+ .nt[data-layout="list"] .nt-txt { -webkit-line-clamp: 4; }
38
+ .nt-grid > .nt-card { margin-bottom: 0; min-width: 0; }
39
+ .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; }
40
+ .nt-card:hover { box-shadow: 0 10px 28px -16px rgba(39,34,36,.5); transform: translateY(-2px); }
41
+ .nt-card:focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
42
+ .nt-card h3 { margin: 0 28px 6px 0; font-size: 15px; font-weight: 600; letter-spacing: -.01em; line-height: 1.3; overflow-wrap: anywhere; }
43
+ .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; }
44
+ .nt-txt.muted { color: var(--fg-muted); }
45
+ .nt-its { display: grid; gap: 3px; margin-top: 4px; }
46
+ .nt-it { display: flex; align-items: flex-start; gap: 8px; font-size: 14px; line-height: 1.4; }
47
+ .nt-it input { margin: 3px 0 0; accent-color: var(--fg); flex: none; cursor: pointer; width: 15px; height: 15px; }
48
+ .nt-it span { overflow-wrap: anywhere; }
49
+ .nt-it.done span { text-decoration: line-through; color: var(--fg-muted); }
50
+ .nt-more { font-size: 12px; color: var(--fg-muted); margin-left: 23px; }
51
+ .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: .65; transition: opacity .18s, background .15s; }
52
+ .nt-pin .ico { width: 17px; height: 17px; }
53
+ .nt-card:hover .nt-pin, .nt-pin.on, .nt-pin:focus-visible { opacity: 1; }
54
+ .nt-pin.on { color: var(--fg); } .nt-pin.on .ico { fill: currentColor; fill-opacity: .2; }
55
+ .nt-pin:hover { background: color-mix(in srgb, var(--fg) 10%, transparent); }
56
+ .nt-badge { display: inline-flex; align-items: center; gap: 5px; margin-top: 8px; font-size: 12px; font-weight: 500; color: var(--fg-muted); }
57
+ .nt-badge .ico { width: 14px; height: 14px; } .nt-badge.err { color: var(--red); }
58
+ .nt-labels { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
59
+ .nt-label { display: inline-flex; align-items: center; gap: 2px; height: auto; min-height: 22px; max-width: 100%; overflow-wrap: anywhere; padding: 0 9px; border-radius: 999px; background: color-mix(in srgb, var(--fg) 9%, transparent); font-size: 12px; font-weight: 500; }
60
+ .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; }
61
+ .nt-label button .ico { width: 11px; height: 11px; }
62
+ .nt-acts { display: flex; gap: 2px; justify-content: flex-end; height: 36px; margin-top: 12px; }
63
+ .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; }
64
+ .nt-acts button:hover { background: color-mix(in srgb, var(--fg) 10%, transparent); color: var(--fg); }
65
+ .nt-acts .ico { width: 17px; height: 17px; }
66
+ @media (hover: none) { .nt-pin { opacity: 1; } }
67
+ .nt-empty { padding: 56px 12px; text-align: center; color: var(--fg-muted); }
68
+ .nt-trash-bar { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 14px; color: var(--fg-muted); font-size: 13px; }
69
+ .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; }
70
+ .nt-skel.tall { height: 170px; }
71
+ @keyframes nt-sh { to { background-position: -120% 0; } }
72
+ .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; }
73
+
74
+ /* colors (targetes i editor) */
75
+ [data-color="lemon"] { --nc: #f1efa4; } [data-color="peach"] { --nc: #f8d9c8; } [data-color="moss"] { --nc: #d6dccb; }
76
+ [data-color="sky"] { --nc: #d3e3ea; } [data-color="lilac"] { --nc: #e0d9ec; } [data-color="rose"] { --nc: #f1d5d1; } [data-color="sand"] { --nc: #e7ddc9; }
77
+ [data-color=""] { --nc: var(--white); }
78
+
79
+ /* editor */
80
+ .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); overscroll-behavior: contain; }
81
+ body:has(.nt-ov) { overflow: hidden; }
82
+ .nt-ed { position: relative; display: flex; flex-direction: column; width: min(800px, 100%); min-height: min(480px, 80dvh); 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; }
83
+ .nt-ed-nav { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 16px 0; }
84
+ .nt-ed-nav > span { font-size: 12px; color: var(--fg-muted); }
85
+ .nt-back { color: var(--fg); }
86
+ .nt-ed-head { display: flex; align-items: center; gap: 6px; padding: 12px 28px 8px; }
87
+ .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; }
88
+ .nt-ed-title::placeholder, .nt-ed-text::placeholder, .nt-item input[type=text]::placeholder { color: color-mix(in srgb, var(--fg) 40%, transparent); }
89
+ .nt-ed-body { flex: 1; min-height: 80px; overflow-y: auto; padding: 4px 28px 24px; overscroll-behavior: contain; }
90
+ .nt-ed-text { display: block; width: 100%; min-height: 220px; max-height: 52vh; padding: 0; border: 0; background: transparent; font: inherit; font-size: 16px; line-height: 1.6; color: var(--fg); resize: none; outline: none; overflow-y: auto; }
91
+ .nt-ed-text.nt-desc { min-height: 0; margin-bottom: 10px; }
92
+ .nt-item { display: flex; align-items: center; gap: 10px; padding: 3px 0; }
93
+ .nt-item input[type=checkbox] { width: 17px; height: 17px; accent-color: var(--fg); flex: none; cursor: pointer; }
94
+ .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; }
95
+ .nt-item.done input[type=text] { text-decoration: line-through; color: var(--fg-muted); }
96
+ .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; }
97
+ .nt-item:hover .nt-x, .nt-x:focus-visible { opacity: 1; } .nt-x .ico { width: 14px; height: 14px; }
98
+ @media (hover: none) { .nt-x { opacity: .7; } }
99
+ .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; }
100
+ .nt-add .ico { width: 17px; height: 17px; } .nt-add:hover { color: var(--fg); }
101
+ .nt-donehd { margin: 10px 0 4px; font-size: 12px; font-weight: 600; color: var(--fg-muted); }
102
+ .nt-audio { display: grid; gap: 6px; padding: 0 20px 8px; }
103
+ .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; }
104
+ .nt-au .nt-ib { background: var(--fg); color: var(--bg); } .nt-au .nt-ib .ico { width: 14px; height: 14px; fill: currentColor; }
105
+ .nt-au-d { font-weight: 600; font-variant-numeric: tabular-nums; }
106
+ .nt-au-s { flex: 1; min-width: 0; display: flex; align-items: center; gap: 6px; color: var(--fg-muted); }
107
+ .nt-au-s .ico { width: 14px; height: 14px; color: var(--moss); }
108
+ .nt-au[data-st="error"] .nt-au-s { color: var(--red); }
109
+ .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; }
110
+ @keyframes nt-rot { to { transform: rotate(360deg); } }
111
+ .nt-ed-labels { padding: 0 20px 6px; margin: 0; }
112
+ .nt-ed-nav, .nt-ed-head, .nt-ed-foot, .nt-audio, .nt-ed-labels, .nt-rec { flex-shrink: 0; }
113
+ .nt-save { display: flex; flex-wrap: wrap; justify-content: flex-end; align-items: center; gap: 4px 8px; min-width: 0; font-size: 12px; color: var(--fg-muted); text-align: right; }
114
+ .nt-save .err { color: var(--red); }
115
+ .nt-save button[hidden] { display: none; }
116
+ .nt-warning { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 10px; margin-bottom: 16px; padding: 12px; border: 1px solid var(--border); border-radius: 12px; background: var(--bg-2); font-size: 13px; }
117
+ .nt :is(button, input):focus-visible, .nt-ed :is(button, input, textarea):focus-visible { outline: 2px solid var(--fg); outline-offset: 3px; }
118
+ /* Dins la nota s'escriu com en un full: cap requadre al voltant del text, només el cursor. */
119
+ .nt-ed :is(.nt-ed-title, .nt-ed-text, .nt-item input[type=text]):is(:focus, :focus-visible), .nt-search input:focus-visible { outline: none; }
120
+ .nt-ed :is(.nt-ed-title, .nt-ed-text, .nt-item input[type=text]) { -webkit-tap-highlight-color: transparent; }
121
+ .nt-pop { overflow-y: auto; overscroll-behavior: contain; }
122
+ .nt-ed-foot { border-top: 1px solid color-mix(in srgb, var(--fg) 10%, transparent); padding: 12px 16px; }
123
+ .nt-tb { display: flex; align-items: center; gap: 2px; flex-wrap: wrap; }
124
+ .nt-edit-tools, .nt-note-tools { display: flex; align-items: center; gap: 2px; min-width: 0; }
125
+ .nt-note-tools { margin-left: auto; }
126
+ .nt-ai-b { padding: 0 11px; } .nt-ai-b span { display: inline; }
127
+ .nt-done { height: 36px; padding: 0 16px; margin-left: 6px; }
128
+ .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)); }
129
+ .nt-rec[hidden], .nt-ai[hidden], .nt-ed-foot[hidden], .nt-audio[hidden], .nt-ed-labels[hidden] { display: none; }
130
+ .nt-rec-dot { width: 12px; height: 12px; border-radius: 50%; background: var(--red); animation: nt-pulse 1.1s ease-in-out infinite; flex: none; }
131
+ @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; } }
132
+ .nt-rec-t { font-weight: 600; font-variant-numeric: tabular-nums; }
133
+ .nt-rec-l { flex: 1; color: var(--fg-muted); font-size: 13px; }
134
+ .nt .btn.sm, .nt-ed .btn.sm, .nt-pop .btn.sm { height: 30px; padding: 0 12px; font-size: 13px; }
135
+
136
+ /* Panell «Més» (mòbil): llista de text, sense icones; es tanca lliscant cap avall o tocant fora. */
137
+ .nt-more-btn { display: none; }
138
+ .nt-more-scrim { position: absolute; inset: 0; z-index: 5; background: rgba(39,34,36,.28); transition: opacity .25s; }
139
+ .nt-more-scrim[hidden] { display: none; }
140
+ .nt-more-sheet { position: absolute; bottom: 0; left: 0; right: 0; z-index: 6; display: grid; gap: 2px; padding: 6px 12px calc(12px + var(--safe-b)); border-radius: 18px 18px 0 0; background: var(--white); box-shadow: 0 -8px 30px rgba(39,34,36,.14); touch-action: none; transition: transform .3s cubic-bezier(.22,1,.36,1); }
141
+ .nt-more-sheet[hidden] { display: none; }
142
+ .nt-more-grab { width: 40px; height: 5px; margin: 4px auto 10px; border-radius: 5px; background: var(--border); }
143
+ .nt-more-sheet button { display: block; width: 100%; min-height: 52px; padding: 0 14px; border: 0; border-radius: 12px; background: transparent; text-align: left; font: inherit; font-size: 16px; font-weight: 500; color: var(--fg); cursor: pointer; transition: background .15s; }
144
+ .nt-more-sheet button:active { background: color-mix(in srgb, var(--fg) 8%, transparent); }
145
+ .nt-more-sheet button.danger { color: var(--red); }
146
+ .nt-more-sheet button.muted { color: var(--fg-muted); text-align: center; }
147
+
148
+ /* finestretes */
149
+ .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; }
150
+ @keyframes nt-pop { from { opacity: 0; transform: translateY(6px) scale(.97); } }
151
+ .nt-pad { display: block; padding: 6px 8px; }
152
+ .nt-colors { display: flex; gap: 8px; padding: 6px; flex-wrap: wrap; }
153
+ .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); }
154
+ .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); }
155
+ .nt-lf { display: flex; gap: 6px; padding: 4px; }
156
+ .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); }
157
+ .nt-lsug { display: flex; flex-wrap: wrap; gap: 6px; padding: 6px 4px 2px; }
158
+ .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); }
159
+ .nt-acts-list { display: grid; gap: 2px; padding: 4px 0; }
160
+ .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; }
161
+ .nt-acts-list button:hover:not(:disabled) { background: color-mix(in srgb, var(--fg) 7%, transparent); }
162
+ .nt-acts-list button:disabled { opacity: .45; cursor: default; }
163
+ .nt-acts-list b { font-size: 14px; font-weight: 600; } .nt-acts-list span { font-size: 12px; color: var(--fg-muted); }
164
+ .nt-note { padding: 4px 8px 2px; }
165
+ .nt-steps { margin: 0; padding-left: 20px; display: grid; gap: 6px; } .nt-steps .ico { width: 15px; height: 15px; vertical-align: -3px; }
166
+
167
+ /* panell de l'assistent dins l'editor */
168
+ .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; }
169
+ .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; }
170
+ .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; }
171
+ .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); }
172
+ .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; }
173
+ .nt-ai-f { display: flex; justify-content: flex-end; gap: 8px; margin-top: 10px; }
174
+
175
+ @media (max-width: 700px) {
176
+ .page:has(.nt) { padding-inline: 14px; }
177
+ .nt-title h1 { font-size: 26px; } .nt-title p { display: none; }
178
+ .nt-top { margin-bottom: 12px; } .nt-tools .btn span { display: none; } .nt-tools .btn { padding: 0 11px; }
179
+ .nt-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; } .nt-card { margin-bottom: 10px; padding: 12px; }
180
+ .nt-ov { padding: 0; align-items: flex-end; top: var(--vvt, 0px); bottom: auto; height: var(--vvh, 100dvh); background: var(--bg); backdrop-filter: none; -webkit-backdrop-filter: none; }
181
+ .nt-ed { width: 100%; min-height: 0; height: 100%; max-height: 100%; border-radius: 0; border: 0; padding-top: var(--safe-t); padding-bottom: var(--safe-b); }
182
+ .nt-ed-nav { padding-inline: 12px; } .nt-ed-nav > span { font-size: 11px; }
183
+ .nt-ed-head { padding: 10px 16px 8px; }
184
+ .nt-ed-body { padding: 4px 16px 16px; }
185
+ .nt-ed-foot { padding: 8px; }
186
+ .nt-ed-title { font-size: 19px; }
187
+ .nt-ai-b span { display: none; } .nt-ai-b { padding: 0 9px; }
188
+ .nt-new-b button, .nt-ib, .nt-acts button { min-width: 44px; height: 44px; }
189
+ .nt-acts { height: 44px; }
190
+ .nt-chip { min-height: 44px; }
191
+ .nt-tb { gap: 0; }
192
+ .nt-done { height: 44px; }
193
+ .nt-search input { height: 44px; font-size: 16px; }
194
+ .nt-filter-views { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; }
195
+ .nt-filter-views .nt-chip { padding: 8px 4px; justify-content: center; gap: 4px; font-size: 12px; }
196
+ .nt-filter-views .ico { width: 14px; height: 14px; }
197
+ .nt-edit-tools { width: 100%; justify-content: space-around; }
198
+ .nt-tb .nt-ib .ico { width: 22px; height: 22px; }
199
+ .nt-more-btn { display: inline-flex; }
200
+ .nt-note-tools { display: none; }
201
+ .nt-pin { width: 44px; height: 44px; top: 2px; right: 2px; }
202
+ .nt-card h3 { margin-right: 32px; }
203
+ .nt-item { min-height: 44px; }
204
+ .nt-x { width: 44px; height: 44px; opacity: .7; }
205
+ .nt-add { min-height: 44px; }
206
+ .nt-dot { width: 36px; height: 36px; }
207
+ .nt-colors { gap: 10px; }
208
+ .nt-lf input, .nt-sel { min-height: 44px; font-size: 16px; }
209
+ .nt-save { font-size: 11px; }
210
+ }
211
+ @media (max-width: 380px) { .nt-grid { grid-template-columns: 1fr; } }
212
+ /* Lliscar per arxivar (tàctil): el navegador només desplaça en vertical; l'horitzontal és nostre. */
213
+ @media (hover: none) { .nt-card { touch-action: pan-y; -webkit-user-select: none; user-select: none; -webkit-touch-callout: none; } .nt-card:hover { transform: none; box-shadow: none; } }
214
+ .nt-card.swiping { z-index: 2; box-shadow: 0 14px 34px -16px rgba(39,34,36,.55); cursor: grabbing; will-change: transform; }
215
+ /* Al mòbil, la llista de notes sense icones: només el contingut. Arxivar és lliscar; la resta, dins la nota. */
216
+ @media (max-width: 700px) {
217
+ .nt-card .nt-acts, .nt-card .nt-pin, .nt-chip .ico, .nt-badge .ico, .nt-filter-views .ico { display: none; }
218
+ .nt-card h3 { margin-right: 0; }
219
+ .nt-badge { margin-top: 6px; }
220
+ }
221
+ @media (min-width: 701px) {
222
+ .nt-ov { top: var(--vvt, 0px); bottom: auto; height: var(--vvh, 100dvh); }
223
+ .nt-ed { min-height: min(480px, calc(var(--vvh, 100dvh) - 48px)); max-height: min(820px, calc(var(--vvh, 100dvh) - 48px)); }
224
+ }
225
+ @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: none; } }