nio-cat 0.8.3 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/nio.js CHANGED
@@ -19,6 +19,7 @@ const directory = require('../src/directory');
19
19
  const voice = require('../src/voice');
20
20
  const { Telegram } = require('../src/telegram');
21
21
  const { Control } = require('../src/control');
22
+ const { Remote } = require('../src/remote');
22
23
  const { Jobs } = require('../src/jobs');
23
24
  const { addServer } = require('../src/ssh');
24
25
  const { pairLink, terminalQr, writePairPage, makeCode, codeLink, terminalQrSize } = require('../src/pair');
@@ -216,7 +217,8 @@ async function start() {
216
217
  tg.start();
217
218
  if (cfg.telegram?.token) console.log(` ${c.dim('Telegram:')} actiu ${c.dim(`(${(cfg.telegram.allowed || []).length} xats autoritzats)`)}`);
218
219
  const control = new Control({ cfg, hub, audit });
219
- const routes = [...memory.routes(), ...jobs.routes(), ...voice.routes(cfg), ...tg.routes(), ...control.routes()];
220
+ const remote = new Remote({ cfg, audit });
221
+ const routes = [...memory.routes(), ...jobs.routes(), ...voice.routes(cfg), ...tg.routes(), ...control.routes(), ...remote.routes()];
220
222
  const server = createServer({ cfg, hub, auth, audit, routes, beforeApproval: (t, i) => control.beforeApproval(t, i), info: async () => ({ control: await control.caps().catch(() => null) }), getPublicUrl: () => publicUrl, getApp: () => flags.app || cfg.app });
221
223
 
222
224
  server.on('error', (e) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nio-cat",
3
- "version": "0.8.3",
3
+ "version": "0.9.1",
4
4
  "description": "nio: fes servir Claude Code, Codex, Grok, Antigravity i Ollama del teu ordinador des del mòbil.",
5
5
  "license": "UNLICENSED",
6
6
  "author": "f.cat",
package/src/remote.js ADDED
@@ -0,0 +1,295 @@
1
+ 'use strict';
2
+ // Pantalla remota: veure i controlar l'escriptori d'aquest ordinador des del mòbil, com un AnyDesk senzill.
3
+ // Fotogrames JPEG sota demanda (no cal cap programa extra) i entrada per macOS (CGEvent + System Events)
4
+ // o Linux/X11 (xdotool). Només funciona si el control de l'escriptori està activat en aquest ordinador.
5
+ const fs = require('fs');
6
+ const os = require('os');
7
+ const path = require('path');
8
+ const crypto = require('crypto');
9
+ const { execFile } = require('child_process');
10
+ const { which, childEnv } = require('./proc');
11
+ const { HOME } = require('./config');
12
+
13
+ const TMP = path.join(HOME, 'shots');
14
+ const DARWIN = process.platform === 'darwin';
15
+ const httpErr = (status, message) => Object.assign(new Error(message), { status });
16
+
17
+ function run(bin, args, timeout = 8000) {
18
+ return new Promise((resolve) => {
19
+ execFile(bin, args, { timeout, env: childEnv(), maxBuffer: 8 * 1024 * 1024 }, (err, stdout, stderr) => resolve({ ok: !err, out: String(stdout || ''), err: String(stderr || err?.message || '') }));
20
+ });
21
+ }
22
+
23
+ // Codis de tecla de macOS (disposició independent del teclat).
24
+ const MAC_KEYS = { Enter: 36, Tab: 48, ' ': 49, Backspace: 51, Escape: 53, Delete: 117, Home: 115, End: 119, PageUp: 116, PageDown: 121, ArrowLeft: 123, ArrowRight: 124, ArrowDown: 125, ArrowUp: 126, F1: 122, F2: 120, F3: 99, F4: 118, F5: 96, F6: 97, F7: 98, F8: 100, F9: 101, F10: 109, F11: 103, F12: 111 };
25
+ const MAC_MODS = { meta: 'command down', ctrl: 'control down', alt: 'option down', shift: 'shift down' };
26
+ const X_KEYS = { Enter: 'Return', Tab: 'Tab', ' ': 'space', Backspace: 'BackSpace', Escape: 'Escape', Delete: 'Delete', Home: 'Home', End: 'End', PageUp: 'Prior', PageDown: 'Next', ArrowLeft: 'Left', ArrowRight: 'Right', ArrowDown: 'Down', ArrowUp: 'Up' };
27
+ const X_MODS = { meta: 'super', ctrl: 'ctrl', alt: 'alt', shift: 'shift' };
28
+
29
+ // JXA: un únic script per a tots els esdeveniments de ratolí i per consultar mides i permisos.
30
+ const JXA = `
31
+ ObjC.import('ApplicationServices');
32
+ ObjC.import('CoreGraphics');
33
+ ObjC.import('Foundation');
34
+ function post(type, x, y, button, count) {
35
+ const ev = $.CGEventCreateMouseEvent(null, type, $.CGPointMake(x, y), button);
36
+ if (count) $.CGEventSetIntegerValueField(ev, $.kCGMouseEventClickState, count);
37
+ $.CGEventPost($.kCGHIDEventTap, ev);
38
+ }
39
+ function run(argv) {
40
+ const op = argv[0];
41
+ const b = $.CGDisplayBounds($.CGMainDisplayID());
42
+ const W = b.size.width, H = b.size.height, OX = b.origin.x, OY = b.origin.y;
43
+ if (op === 'info') {
44
+ let screen = null;
45
+ try { screen = !!$.CGPreflightScreenCaptureAccess(); } catch (e) {}
46
+ return JSON.stringify({ w: W, h: H, screen: screen, accessibility: !!$.AXIsProcessTrusted() });
47
+ }
48
+ const px = (v) => OX + Math.max(0, Math.min(1, parseFloat(v))) * W;
49
+ const py = (v) => OY + Math.max(0, Math.min(1, parseFloat(v))) * H;
50
+ if (op === 'click' || op === 'rclick') {
51
+ const x = px(argv[1]), y = py(argv[2]), n = parseInt(argv[3] || '1', 10);
52
+ const r = op === 'rclick';
53
+ post($.kCGEventMouseMoved, x, y, 0, 0);
54
+ post(r ? $.kCGEventRightMouseDown : $.kCGEventLeftMouseDown, x, y, r ? $.kCGMouseButtonRight : $.kCGMouseButtonLeft, n);
55
+ post(r ? $.kCGEventRightMouseUp : $.kCGEventLeftMouseUp, x, y, r ? $.kCGMouseButtonRight : $.kCGMouseButtonLeft, n);
56
+ } else if (op === 'move') {
57
+ post($.kCGEventMouseMoved, px(argv[1]), py(argv[2]), 0, 0);
58
+ } else if (op === 'drag') {
59
+ const pts = JSON.parse(argv[1]);
60
+ const first = pts[0];
61
+ post($.kCGEventMouseMoved, px(first[0]), py(first[1]), 0, 0);
62
+ post($.kCGEventLeftMouseDown, px(first[0]), py(first[1]), $.kCGMouseButtonLeft, 1);
63
+ for (let i = 1; i < pts.length; i++) { post($.kCGEventLeftMouseDragged, px(pts[i][0]), py(pts[i][1]), $.kCGMouseButtonLeft, 1); $.NSThread.sleepForTimeInterval(0.012); }
64
+ const last = pts[pts.length - 1];
65
+ post($.kCGEventLeftMouseUp, px(last[0]), py(last[1]), $.kCGMouseButtonLeft, 1);
66
+ } else if (op === 'scroll') {
67
+ if (argv[1] !== 'here') post($.kCGEventMouseMoved, px(argv[1]), py(argv[2]), 0, 0);
68
+ const ev = $.CGEventCreateScrollWheelEvent2(null, $.kCGScrollEventUnitPixel, 2, parseInt(argv[4], 10), parseInt(argv[3], 10), 0);
69
+ $.CGEventPost($.kCGHIDEventTap, ev);
70
+ }
71
+ return 'ok';
72
+ }
73
+ `;
74
+
75
+ class Remote {
76
+ constructor({ cfg, audit }) {
77
+ this.cfg = cfg;
78
+ this.audit = audit || { add() {} };
79
+ this.infoAt = 0;
80
+ this.frame = null;
81
+ this.busy = null;
82
+ this.queue = Promise.resolve();
83
+ this.jxa = path.join(os.tmpdir(), `nio-remote-${process.pid}.js`);
84
+ this.xdo = !DARWIN ? which('xdotool') : null;
85
+ fs.mkdirSync(TMP, { recursive: true, mode: 0o700 });
86
+ }
87
+
88
+ enabled() {
89
+ return !!this.cfg.control?.escriptori;
90
+ }
91
+ need() {
92
+ if (!this.enabled()) throw httpErr(409, 'Activa primer «Apps de l’escriptori» en aquest ordinador.');
93
+ }
94
+
95
+ async osa(...args) {
96
+ if (!fs.existsSync(this.jxa)) fs.writeFileSync(this.jxa, JXA, { mode: 0o600 });
97
+ const r = await run('/usr/bin/osascript', ['-l', 'JavaScript', this.jxa, ...args.map(String)], 10000);
98
+ if (!r.ok) throw httpErr(500, `No he pogut enviar-ho a l’ordinador: ${r.err.trim().slice(0, 200)}`);
99
+ return r.out.trim();
100
+ }
101
+
102
+ async info() {
103
+ if (this.infoCache && Date.now() - this.infoAt < 20000) return this.infoCache;
104
+ let i;
105
+ if (DARWIN) {
106
+ try { i = JSON.parse(await this.osa('info')); } catch { i = { w: 0, h: 0, screen: null, accessibility: null }; }
107
+ } else {
108
+ let w = 0, h = 0;
109
+ if (this.xdo) {
110
+ const r = await run(this.xdo, ['getdisplaygeometry'], 3000);
111
+ [w, h] = r.out.trim().split(/\s+/).map(Number);
112
+ }
113
+ i = { w: w || 0, h: h || 0, screen: null, accessibility: null };
114
+ }
115
+ this.infoCache = { ...i, platform: process.platform, input: DARWIN || !!this.xdo, capture: this.captureTool() !== null, enabled: this.enabled() };
116
+ this.infoAt = Date.now();
117
+ return this.infoCache;
118
+ }
119
+
120
+ captureTool() {
121
+ if (DARWIN) return ['screencapture'];
122
+ if (which('grim')) return ['grim'];
123
+ if (which('scrot')) return ['scrot'];
124
+ if (which('gnome-screenshot')) return ['gnome-screenshot'];
125
+ return null;
126
+ }
127
+
128
+ async grab(width, quality) {
129
+ const id = crypto.randomBytes(6).toString('hex');
130
+ const png = path.join(TMP, `live-${id}.png`), jpg = path.join(TMP, `live-${id}.jpg`);
131
+ try {
132
+ let ok = false;
133
+ if (DARWIN) {
134
+ ok = (await run('/usr/sbin/screencapture', ['-x', '-C', '-D', '1', '-t', 'png', png], 8000)).ok;
135
+ if (ok) ok = (await run('/usr/bin/sips', ['-s', 'format', 'jpeg', '-s', 'formatOptions', String(quality), '--resampleWidth', String(width), png, '--out', jpg], 8000)).ok;
136
+ } else {
137
+ const t = this.captureTool();
138
+ if (!t) throw httpErr(409, 'No hi ha cap eina per fer captures (cal grim, scrot o gnome-screenshot).');
139
+ const args = t[0] === 'grim' ? ['-t', 'jpeg', '-q', String(quality), jpg] : t[0] === 'scrot' ? ['-o', '-q', String(quality), jpg] : ['-f', jpg];
140
+ ok = (await run(which(t[0]), args, 8000)).ok;
141
+ }
142
+ if (!ok || !fs.existsSync(jpg)) throw httpErr(409, 'No he pogut capturar la pantalla (sense pantalla accessible o sense permís).');
143
+ return fs.readFileSync(jpg);
144
+ } finally {
145
+ fs.rm(png, { force: true }, () => {});
146
+ fs.rm(jpg, { force: true }, () => {});
147
+ }
148
+ }
149
+
150
+ // Fotograma: si ja n'hi ha un de molt recent, el reaprofita; si n'hi ha un en marxa, s'hi espera.
151
+ async getFrame(width, quality) {
152
+ this.need();
153
+ const w = Math.max(320, Math.min(2400, Math.round(width) || 1280));
154
+ const q = Math.max(25, Math.min(90, Math.round(quality) || 55));
155
+ const f = this.frame;
156
+ if (f && f.w === w && f.q === q && Date.now() - f.t < 90) return f;
157
+ if (this.busy && this.busyKey === `${w}/${q}`) return this.busy;
158
+ this.busyKey = `${w}/${q}`;
159
+ this.busy = (async () => {
160
+ try {
161
+ const buf = await this.grab(w, q);
162
+ const info = await this.info();
163
+ // Un JPEG de pantalla tota negra pesa molt poc: l'ordinador és bloquejat/adormit o falta el permís.
164
+ this.frame = { buf, t: Date.now(), w, q, blank: buf.length < 5000, sw: info.w, sh: info.h };
165
+ return this.frame;
166
+ } finally {
167
+ this.busy = null;
168
+ }
169
+ })();
170
+ return this.busy;
171
+ }
172
+
173
+ async wake() {
174
+ if (DARWIN) await run('/usr/bin/caffeinate', ['-u', '-t', '2'], 4000);
175
+ else if (this.xdo) await run(which('xset') || 'xset', ['dpms', 'force', 'on'], 3000);
176
+ }
177
+
178
+ // Esdeveniments d'entrada: en cua, perquè arribin en ordre.
179
+ input(ev) {
180
+ this.need();
181
+ const job = this.queue.then(() => this.doInput(ev));
182
+ this.queue = job.catch(() => {});
183
+ return job;
184
+ }
185
+
186
+ async doInput(ev) {
187
+ const n = (v) => Math.max(0, Math.min(1, Number(v)));
188
+ const mods = (Array.isArray(ev.mods) ? ev.mods : []).filter((m) => Object.hasOwn(MAC_MODS, m));
189
+ const kind = String(ev.type);
190
+ const info = await this.info();
191
+ if (!info.input) throw httpErr(501, DARWIN ? 'No es pot controlar aquest ordinador.' : 'Cal xdotool per controlar l’escriptori (sudo apt install xdotool).');
192
+ if (DARWIN) {
193
+ if (kind === 'click' || kind === 'rclick') return this.osa(kind, n(ev.x), n(ev.y), ev.count === 2 ? 2 : ev.count === 3 ? 3 : 1);
194
+ if (kind === 'move') return this.osa('move', n(ev.x), n(ev.y));
195
+ if (kind === 'drag') {
196
+ const pts = (Array.isArray(ev.path) ? ev.path : []).slice(0, 120).map((p) => [n(p[0]), n(p[1])]);
197
+ if (pts.length < 2) throw httpErr(400, 'Arrossegament no vàlid');
198
+ return this.osa('drag', JSON.stringify(pts));
199
+ }
200
+ if (kind === 'scroll') {
201
+ const dy = Math.max(-1500, Math.min(1500, Math.round(Number(ev.dy) || 0))), dx = Math.max(-1500, Math.min(1500, Math.round(Number(ev.dx) || 0)));
202
+ return this.osa('scroll', ev.x == null ? 'here' : n(ev.x), n(ev.y || 0), dx, dy);
203
+ }
204
+ if (kind === 'text') {
205
+ const t = String(ev.text || '').slice(0, 2000);
206
+ if (!t) return 'ok';
207
+ return this.script('on run argv\ntell application "System Events" to keystroke (item 1 of argv)\nend run', [t]);
208
+ }
209
+ if (kind === 'key') {
210
+ const using = mods.length ? ` using {${mods.map((m) => MAC_MODS[m]).join(', ')}}` : '';
211
+ const key = String(ev.key || '');
212
+ if (Object.hasOwn(MAC_KEYS, key)) return this.script(`tell application "System Events" to key code ${MAC_KEYS[key]}${using}`, []);
213
+ if (key.length === 1 || [...key].length === 1) return this.script(`on run argv\ntell application "System Events" to keystroke (item 1 of argv)${using}\nend run`, [key]);
214
+ throw httpErr(400, 'Tecla no admesa');
215
+ }
216
+ throw httpErr(400, 'Esdeveniment no admès');
217
+ }
218
+ // Linux / X11
219
+ const px = (v, max) => String(Math.round(n(v) * Math.max(0, max - 1)));
220
+ const xd = (...a) => run(this.xdo, a, 5000).then((r) => (r.ok ? 'ok' : Promise.reject(httpErr(500, r.err.trim().slice(0, 200)))));
221
+ if (kind === 'click' || kind === 'rclick') {
222
+ await xd('mousemove', px(ev.x, info.w), px(ev.y, info.h));
223
+ return xd('click', '--repeat', String(ev.count === 2 ? 2 : 1), '--delay', '40', kind === 'rclick' ? '3' : '1');
224
+ }
225
+ if (kind === 'move') return xd('mousemove', px(ev.x, info.w), px(ev.y, info.h));
226
+ if (kind === 'drag') {
227
+ const pts = (Array.isArray(ev.path) ? ev.path : []).slice(0, 120);
228
+ if (pts.length < 2) throw httpErr(400, 'Arrossegament no vàlid');
229
+ await xd('mousemove', px(pts[0][0], info.w), px(pts[0][1], info.h));
230
+ await xd('mousedown', '1');
231
+ for (const p of pts.slice(1)) await xd('mousemove', px(p[0], info.w), px(p[1], info.h));
232
+ return xd('mouseup', '1');
233
+ }
234
+ if (kind === 'scroll') {
235
+ const dy = Number(ev.dy) || 0, dx = Number(ev.dx) || 0;
236
+ if (ev.x != null) await xd('mousemove', px(ev.x, info.w), px(ev.y, info.h));
237
+ const steps = (v, neg, pos) => (v ? Array(Math.min(20, Math.max(1, Math.round(Math.abs(v) / 40)))).fill(v > 0 ? pos : neg) : []);
238
+ for (const b of [...steps(dy, '5', '4'), ...steps(dx, '7', '6')]) await xd('click', b);
239
+ return 'ok';
240
+ }
241
+ if (kind === 'text') return xd('type', '--delay', '8', '--', String(ev.text || '').slice(0, 2000));
242
+ if (kind === 'key') {
243
+ const key = String(ev.key || '');
244
+ const k = Object.hasOwn(X_KEYS, key) ? X_KEYS[key] : [...key].length === 1 ? key : null;
245
+ if (!k) throw httpErr(400, 'Tecla no admesa');
246
+ return xd('key', '--', [...mods.map((m) => X_MODS[m]), k].join('+'));
247
+ }
248
+ throw httpErr(400, 'Esdeveniment no admès');
249
+ }
250
+
251
+ async script(src, args) {
252
+ const r = await run('/usr/bin/osascript', ['-e', src, ...args], 8000);
253
+ if (!r.ok) {
254
+ const hint = /not allowed|assistive|1002|accessib/i.test(r.err) ? ' Dona permís d’Accessibilitat al Terminal (Ajustos del Sistema → Privadesa i seguretat).' : '';
255
+ throw httpErr(500, `No he pogut teclejar.${hint}`);
256
+ }
257
+ return 'ok';
258
+ }
259
+
260
+ routes() {
261
+ return [
262
+ { method: 'GET', path: '/api/desktop/status', handler: async () => this.info() },
263
+ {
264
+ method: 'GET',
265
+ path: '/api/desktop/frame',
266
+ handler: async ({ res, url }) => {
267
+ const f = await this.getFrame(Number(url.searchParams.get('w')), Number(url.searchParams.get('q')));
268
+ res.writeHead(200, {
269
+ 'content-type': 'image/jpeg',
270
+ 'content-length': f.buf.length,
271
+ 'cache-control': 'no-store',
272
+ 'x-screen-w': String(f.sw || 0),
273
+ 'x-screen-h': String(f.sh || 0),
274
+ 'x-blank': f.blank ? '1' : '0',
275
+ 'access-control-expose-headers': 'x-screen-w, x-screen-h, x-blank',
276
+ });
277
+ res.end(f.buf);
278
+ },
279
+ },
280
+ { method: 'POST', path: '/api/desktop/input', limit: 16384, handler: async ({ body }) => ({ ok: true, r: await this.input(body || {}) }) },
281
+ {
282
+ method: 'POST',
283
+ path: '/api/desktop/session',
284
+ handler: async ({ body, device }) => {
285
+ this.need();
286
+ if (body?.wake) await this.wake();
287
+ if (body?.event === 'open' || body?.event === 'control') this.audit.add({ type: 'control', what: body.event === 'control' ? 'Pantalla remota: control amb ratolí i teclat' : 'Pantalla remota oberta', by: device?.name });
288
+ return { ok: true };
289
+ },
290
+ },
291
+ ];
292
+ }
293
+ }
294
+
295
+ module.exports = { Remote };
package/src/telegram.js CHANGED
@@ -12,6 +12,22 @@ const httpErr = (status, message) => Object.assign(new Error(message), { status
12
12
  const TOKEN_RE = /^\d{5,15}:[\w-]{30,60}$/;
13
13
  const clip = (s, n) => (String(s).length > n ? `${String(s).slice(0, n)}…` : String(s));
14
14
 
15
+ // Markdown de l'agent -> HTML de Telegram (negreta, cursiva, codi, enllaços, títols i llistes).
16
+ const esc = (t) => t.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
17
+ function toHtml(src) {
18
+ const keep = [];
19
+ const hold = (h) => `\u0000${keep.push(h) - 1}\u0000`;
20
+ let t = String(src).replace(/```[^\n`]*\n?([\s\S]*?)```/g, (_, c) => hold(`<pre>${esc(c.replace(/\n$/, ''))}</pre>`));
21
+ t = t.replace(/`([^`\n]+)`/g, (_, c) => hold(`<code>${esc(c)}</code>`));
22
+ t = esc(t);
23
+ t = t.replace(/^#{1,6}\s+(.+)$/gm, '<b>$1</b>')
24
+ .replace(/^\s*[-*+]\s+/gm, '• ')
25
+ .replace(/\[([^\]\n]+)\]\((https?:\/\/[^\s)]+)\)/g, '<a href="$2">$1</a>')
26
+ .replace(/\*\*([^*\n]+?)\*\*/g, '<b>$1</b>')
27
+ .replace(/(^|[\s(])\*([^*\s][^*\n]*?)\*(?=[\s).,;:!?]|$)/g, '$1<i>$2</i>');
28
+ return t.replace(/\u0000(\d+)\u0000/g, (_, i) => keep[Number(i)]);
29
+ }
30
+
15
31
  class Telegram {
16
32
  constructor({ cfg, hub, audit, available = () => ['claude', 'codex'] }) {
17
33
  this.cfg = cfg;
@@ -153,7 +169,8 @@ class Telegram {
153
169
  this.chatOf.set(sid, chat);
154
170
  this.hub.send(sid, text, this.hookUrl ? this.hookUrl() : undefined);
155
171
  this.audit.add({ type: 'telegram', what: `Missatge de Telegram: ${clip(text, 120)}`, by: `Telegram · ${who}`, session: sid });
156
- return this.send(chat, '👌 Hi treballo.');
172
+ this.call('sendChatAction', { chat_id: chat, action: 'typing' }).catch(() => {});
173
+ return;
157
174
  } catch (e) {
158
175
  return this.send(chat, `No he pogut començar: ${e.message}`);
159
176
  }
@@ -211,7 +228,9 @@ class Telegram {
211
228
  }
212
229
 
213
230
  send(chat, text) {
214
- return this.call('sendMessage', { chat_id: chat, text: clip(text, 4000) }).catch(() => {});
231
+ const raw = clip(text, 3500);
232
+ return this.call('sendMessage', { chat_id: chat, text: toHtml(raw), parse_mode: 'HTML', link_preview_options: { is_disabled: true } })
233
+ .catch(() => this.call('sendMessage', { chat_id: chat, text: raw }).catch(() => {}));
215
234
  }
216
235
 
217
236
  async download(fileId) {
package/web/app/app.js CHANGED
@@ -552,12 +552,60 @@ function computerCard(m, i) {
552
552
  // Ajuda sempre a mà: com es fa cada cosa, i què mirar si no connecta.
553
553
  function helpPanel(open) {
554
554
  const q = (t, body) => `<details><summary>${t}</summary><div class="help-body">${body}</div></details>`;
555
+ const k = (t) => `<span class="mono">${t}</span>`;
556
+ const blk = (t) => `<pre class="cmdb"><code>${t}</code></pre>`;
557
+ const rows = (list) => `<div class="cmdlist">${list.map(([c, d]) => `<div><span class="mono">${c}</span><span>${d}</span></div>`).join('')}</div>`;
558
+ const grp = (t) => `<h3 class="help-grp">${t}</h3>`;
555
559
  return `<section class="panel help" id="help"><header><h2>Com es fa</h2></header><div class="body">
556
- ${q('Connectar aquest ordinador', `<p>Al terminal, des de la carpeta dels teus projectes, executa <span class="mono">npx nio-cat</span>. S’obre nio.cat ja connectat. Cal Node.js 18 o més nou.</p>`)}
557
- ${q('Fer-lo servir des del mòbil', `<p>A l’ordinador, a Ordinadors, prem <b>Continua al mòbil</b>. Surt un QR i un codi <span class="mono">nio-…</span>.</p><p>Al mòbil, obre <b>www.nio.cat/app</b> → Ordinadors → Afegeix ordinador, i escaneja el QR o enganxa el codi.</p><p>Per connectar-t’hi des de qualsevol xarxa cal <span class="mono">cloudflared</span> a l’ordinador (<span class="mono">brew install cloudflared</span>). Sense ell, el mòbil ha d’estar a la mateixa wifi.</p>`)}
558
- ${q('Afegir un servidor (VPS) amb Claude Code', `<p>Ordinadors → Afegeix ordinador → <b>Servidor (SSH)</b> → escriu <span class="mono">usuari@servidor</span>. L’ordinador hi entra, hi engega nio i el servidor surt a la llista.</p><p>Després, prem <b>Continua al mòbil</b> amb el servidor actiu per fer-lo servir des del mòbil.</p>`)}
559
- ${q('Què cal al servidor', `<ul><li>Entrar-hi per SSH sense contrasenya. Si no, executa <span class="mono">ssh-copy-id usuari@servidor</span> a l’ordinador.</li><li>Node.js 18 o més nou.</li><li>Claude Code instal·lat i amb sessió iniciada: entra al servidor i executa <span class="mono">claude</span> un cop.</li><li>Linux amb <span class="mono">curl</span> o <span class="mono">wget</span> (nio hi instal·la <span class="mono">cloudflared</span> sol).</li></ul>`)}
560
- ${q('No es connecta', `<ul><li><b>«No arribo a l’ordinador»</b>: comprova que nio continua engegat al terminal.</li><li><b>El servidor ha deixat de respondre</b>: si s’ha reiniciat, l’adreça canvia. Prem <b>Torna a connectar</b> a la seva targeta.</li><li><b>Safari no connecta</b>: obre nio amb <span class="mono">npx nio-cat --local</span>, o fes servir Chrome.</li><li><b>La clau no és vàlida</b>: torna a afegir l’ordinador amb el codi nou.</li></ul>`)}
560
+ ${grp('Per començar')}
561
+ ${q('Què és nio', `<p>nio et deixa donar feina als agents d’IA del teu ordinador (Claude Code, Codex, Grok, Antigravity i Ollama) des del mòbil. Els agents <b>treballen a l’ordinador o al servidor</b>, amb els teus comptes. El mòbil és el comandament: hi segueixes la feina en directe i aproves el que facin.</p>`)}
562
+ ${q('Connectar aquest ordinador', `<p>Al terminal, des de la carpeta dels teus projectes, executa:</p>${blk('npx nio-cat')}<p>S’obre nio.cat ja connectat. Cal Node.js 18 o més nou. Pots indicar més carpetes: ${k('npx nio-cat ~/projectes')}.</p>`)}
563
+ ${q('Fer-lo servir des del mòbil', `<p>A l’ordinador, a Ordinadors, prem <b>Continua al mòbil</b>. Surt un QR i un codi ${k('nio-…')}.</p><p>Al mòbil, obre <b>www.nio.cat/app</b> → Ordinadors → Afegeix ordinador, i escaneja el QR o enganxa el codi. Guarda el codi com una contrasenya: qui el té, pot manar a l’ordinador.</p><p>Per connectar-t’hi des de qualsevol xarxa cal ${k('cloudflared')} a l’ordinador (${k('brew install cloudflared')}). Sense ell, el mòbil ha d’estar a la mateixa wifi. Si reinicies nio, l’adreça del túnel canvia: torna a escanejar el codi.</p>`)}
564
+ ${q('Afegir un servidor (VPS) amb Claude Code', `<p>Ordinadors → Afegeix ordinador → <b>Servidor (SSH)</b> → escriu ${k('usuari@servidor')}. L’ordinador hi entra, hi engega nio i el servidor surt a la llista. Després, prem <b>Continua al mòbil</b> amb el servidor actiu.</p><p>Des del terminal també es pot: ${k('npx nio-cat ssh usuari@servidor')}.</p>`)}
565
+ ${q('Què cal al servidor', `<ul><li>Entrar-hi per SSH sense contrasenya. Si no, executa ${k('ssh-copy-id usuari@servidor')} a l’ordinador.</li><li>Node.js 18 o més nou.</li><li>Claude Code instal·lat i amb sessió iniciada: entra al servidor i executa ${k('claude')} un cop.</li><li>Linux amb ${k('curl')} o ${k('wget')} (nio hi instal·la ${k('cloudflared')} sol).</li></ul>`)}
566
+
567
+ ${grp('Treballar amb els agents')}
568
+ ${q('Crear una sessió', `<p>A <b>Sessions</b>, prem <b>Nova sessió</b>: tria l’agent, la carpeta (hi ha un explorador de carpetes de l’ordinador) i el nivell de permisos. Escriu què vols i l’agent es posa a treballar. La resposta et va arribant en directe.</p><p>També pots <b>clonar un repositori de GitHub</b> des de Nova sessió: nio fa servir el ${k('gh')} de l’ordinador (cal haver fet ${k('gh auth login')}).</p>`)}
569
+ ${q('Els tres nivells de permisos', `<ul><li><b>Només llegir</b>: l’agent llegeix i proposa, però no toca cap fitxer.</li><li><b>Demana permís</b>: llegeix sol; abans d’editar o executar alguna cosa, t’ho pregunta (Claude Code).</li><li><b>Via lliure</b>: treballa sense preguntar. Fes-ho servir només en carpetes de confiança.</li></ul><p>Es pot canviar enmig d’una sessió amb ${k('/mode')}.</p>`)}
570
+ ${q('Aprovacions', `<p>Quan un agent vol editar un fitxer o executar una ordre, surt una targeta amb el que farà. <b>Aprova</b> o <b>rebutja</b> amb un toc. A <b>Aprovacions</b> tens totes les pendents de totes les sessions juntes.</p>`)}
571
+ ${q('Comandes amb «/»', `<p>Al missatge d’una sessió, escriu ${k('/')} i surt la llista amb suggeriments. Les de nio funcionen sempre; les de l’agent (comandes i skills) se li envien tal qual.</p>${rows([
572
+ ['/mode', 'Canvia els permisos: ' + k('l') + ' llegir, ' + k('p') + ' preguntar, ' + k('t') + ' tot'],
573
+ ['/model', 'Canvia de model'],
574
+ ['/stop', 'Atura l’agent ara mateix'],
575
+ ['/nova', 'Nova sessió amb el mateix agent i carpeta'],
576
+ ['/carpeta', 'Canvia la carpeta de la sessió'],
577
+ ['/git', 'Git i GitHub: veure canvis, fer commit i crear un PR'],
578
+ ['/nom', 'Posa nom a la sessió (' + k('/nom Feina web') + ')'],
579
+ ['/opcions', 'Opcions de la sessió'],
580
+ ['/ajuda', 'Llista de comandes']
581
+ ])}<p>Exemple: ${k('/mode p')} passa la sessió a «Demana permís».</p>`)}
582
+ ${q('Git i GitHub', `<p>Dins una sessió, ${k('/git')} (o Opcions → Git i GitHub) mostra els canvis, fa <b>commit</b> (en una branca nova si ets a ${k('main')}) i crea un <b>PR</b>. Cap clau no passa per nio: fa servir el ${k('git')} i el ${k('gh')} de l’ordinador.</p>`)}
583
+ ${q('Avisos al mòbil', `<p>A <b>Ajustos → Avisos</b>, prem <b>Activa els avisos</b>. Rebràs una notificació quan un agent et demana permís o acaba la feina. L’avís arriba buit; el detall el demana el mòbil directament a l’ordinador.</p><p>A l’iPhone, primer afegeix nio a la pantalla d’inici (Comparteix → Afegeix a la pantalla d’inici) i obre’l des d’allà.</p>`)}
584
+ ${q('Veu', `<p>A <b>Ajustos → Veu</b> pots posar la teva clau d’ElevenLabs per <b>dictar</b> els missatges amb el micròfon i <b>escoltar</b> les respostes. La clau es queda només en aquest navegador.</p>`)}
585
+
586
+ ${grp('Eines')}
587
+ ${q('Pantalla remota', `<p>Mira i controla l’escriptori d’un ordinador des del mòbil. A <b>Ordinadors</b>, baixa fins a la secció <b>Pantalla</b> i prem <b>Obre la pantalla</b>. S’obre a pantalla completa. Cal activar abans <b>Apps de l’escriptori</b> a la mateixa secció.</p><ul><li><b>Mirar</b>: només veus la pantalla, en directe. Amb un dit la mous quan has fet zoom.</li><li><b>Controlar</b>: toc = clic; doble toc = doble clic; mantenir premut = clic dret; dos dits = desplaçar o fer zoom. El botó <b>1 dit</b> canvia entre desplaçar i arrossegar, i el botó <b>2 dits</b> entre desplaçar l’ordinador i fer zoom amb pessics. <b>Teclat</b> obre el teclat del mòbil, i dalt hi tens ${k('⌘')} ${k('⌥')} ${k('⌃')} ${k('⇧')}, ${k('Esc')}, ${k('Tab')} i fletxes.</li><li><b>Zoom amb dos dits</b>: pessiga per apropar i arrossega amb dos dits per moure’t. Al mode Mirar sempre va així, amb doble toc per alternar entre enquadrar i apropar. Al mode Controlar, tria al botó <b>2 dits</b> si els dos dits desplacen l’ordinador o fan zoom. A l’ordinador, pessiga al trackpad o ${k('Ctrl')} + roda. Els botons <b>− 100% +</b> i <b>1:1</b> fan el mateix i funcionen en tots dos modes.</li><li><b>Desa captura</b>: baixa el fotograma actual com a imatge. <b>Captura</b> (a la secció) en fa una de sola, sense obrir la pantalla.</li><li><b>Si surt negra</b>: al Mac, dona el permís de <b>Gravació de pantalla</b> (i <b>Accessibilitat</b> per controlar) al Terminal on corre <span class="mono">nio-cat</span> i reinicia’l. A Linux, per controlar cal <span class="mono">xdotool</span>.</li></ul>`)}
588
+ ${q('Pissarra', `<p>Un llenç infinit per fer diagrames a mà alçada: fluxos, mapes mentals i organigrames. Hi arribes des de <b>Més → Pissarra</b> (al mòbil) o al menú (a l’ordinador).</p><ul><li><b>Eines</b>: selecciona, mou el llenç, rectangle, el·lipse, rombe, fletxa, línia, llapis, text i goma. El cadenat manté l’eina activa.</li><li><b>Zoom amb dos dits</b>: al mòbil, pessiga sobre el llenç; amb els dos dits alhora també el mous. A l’ordinador, pessiga al trackpad o fes ${k('Ctrl')} + roda del ratolí.</li><li><b>Moure’s</b>: amb un dit, a l’espai buit (eina Selecciona), o amb ${k('Espai')} + arrossegar, o la roda del ratolí.</li><li><b>Text</b>: doble toc o doble clic per editar; doble toc a l’espai buit crea un text nou.</li><li><b>Zoom ràpid</b>: la xifra del percentatge torna al 100%; el botó de les cantonades ho enquadra tot.</li><li><b>Desfés / refés</b>: els botons de dalt a l’esquerra, o ${k('Ctrl Z')} i ${k('Ctrl ⇧ Z')}.</li><li><b>Estil</b>: color, gruix i traç (al mòbil, el botó de la paleta). Menú ⋯: exporta a PNG, SVG o JSON, i quadrícula.</li></ul>`)}
589
+ ${q('Demanar-li el dibuix a en Nio', `<p>Prem <b>Nio</b> (dalt a la dreta) per obrir el xat de la pissarra. Descriu què vols i ho dibuixa. Exemples:</p>${rows([
590
+ ['Flux: A -> B -> C', 'Una fletxa per línia; afegeix ' + k(': etiqueta') + ' si vols'],
591
+ ['Mapa mental de X: a, b, c', 'Tema central amb branques'],
592
+ ['Organigrama: Direcció > Finances, Operacions', 'Qui depèn de qui'],
593
+ ['Afegeix un rombe “Quadra?”', 'Afegeix una figura'],
594
+ ['Connecta Banc amb ERP', 'Uneix dues figures amb una fletxa'],
595
+ ['Pinta Banc de verd', 'Canvia el color'],
596
+ ['Reanomena Banc a Caixa', 'Canvia el text'],
597
+ ['Esborra ERP', 'Treu una figura'],
598
+ ['Ordena-ho · Centra · Desfés', 'Ordena, enquadra o desfà'],
599
+ ['Plantilla agent', 'També ' + k('Plantilla d’aprovació') + ' i ' + k('Plantilla conciliació')],
600
+ ['Exporta PNG', 'També ' + k('SVG') + ' o ' + k('JSON')]
601
+ ])}<p>Al desplegable <b>Amb</b> tries què t’entén: les <b>ordres ràpides</b> (sense gastar res) o un model.</p>`)}
602
+ ${q('Xat local', `<p>A <b>Més → Xat local</b> tens un model petit que funciona <b>dins del navegador del mòbil</b>, sense ordinador ni connexió un cop descarregat. Necessita un navegador amb WebGPU. Si la GPU del mòbil es reinicia, el xat es recupera sol.</p>`)}
603
+ ${q('Models d’Ollama', `<p>A <b>Models</b> veus els models d’Ollama de l’ordinador, en pots descarregar de nous amb el progrés en directe, esborrar-los i obrir-hi sessions. Funcionen al teu ordinador i no gasten res.</p>`)}
604
+ ${q('Buscador i dreceres', `<p>Prem ${k('Ctrl K')} (o ${k('⌘ K')}) per obrir el buscador d’accions: nova sessió, afegir ordinador, configurar la veu, fer el tutorial… Al mòbil, llisca cap als costats per canviar de pàgina.</p>`)}
605
+
606
+ ${grp('Si alguna cosa no va')}
607
+ ${q('No es connecta', `<ul><li><b>«No arribo a l’ordinador»</b>: comprova que nio continua engegat al terminal.</li><li><b>El servidor ha deixat de respondre</b>: si s’ha reiniciat, l’adreça canvia. Prem <b>Torna a connectar</b> a la seva targeta.</li><li><b>Safari no connecta</b>: obre nio amb ${k('npx nio-cat --local')}, o fes servir Chrome.</li><li><b>La clau no és vàlida</b>: torna a afegir l’ordinador amb el codi nou. Per invalidar tots els codis antics: ${k('npx nio-cat reset-token')}.</li><li><b>Quins agents té l’ordinador?</b> ${k('npx nio-cat doctor')} els llista.</li></ul>`)}
608
+ ${q('Seguretat', `<ul><li>L’enllaç de connexió porta una clau aleatòria que el navegador no envia a cap servidor web.</li><li>Cap dada no passa per servidors de nio: el mòbil parla directament amb el teu ordinador.</li><li>Totes les crides demanen la clau, que només tens tu.</li></ul>`)}
561
609
  ${q('D’on es gasta', `<p>nio no fa cap crida pel seu compte. Cada agent treballa a l’ordinador o al servidor amb el teu compte (Claude, Codex, Grok…). Ollama funciona al teu ordinador i no gasta res.</p>`)}
562
610
  </div></section>`;
563
611
  }
@@ -1824,7 +1872,8 @@ async function panicNow() {
1824
1872
  try { const r = await api('/api/panic', { method: 'POST' }); toast(`Aturat tot (${r.stopped} agents)`); } catch (e) { toast(e.message, 4500); }
1825
1873
  }
1826
1874
  function screenSection() {
1827
- return `<div class="sec-head" id="screenSec"><div><h2 class="sec">Pantalla</h2><p class="muted small">Els agents poden fer servir el navegador (agent-browser) i les apps de l’escriptori (agent-desktop). Mirar passa sol; clicar, escriure o enviar sempre et demana permís, amb una captura.</p></div><button class="btn" data-act="screen-now" type="button" ${controlOn() ? '' : 'disabled'}>${svg(ICON.screen)}Veure pantalla ara</button></div><div id="screenBody">${screenHtml()}</div>`;
1875
+ const on = controlOn();
1876
+ return `<div class="sec-head" id="screenSec"><div><h2 class="sec">Pantalla</h2><p class="muted small">Mira i controla l’escriptori d’aquest ordinador des del mòbil, com un escriptori remot. A més, els agents poden fer servir el navegador (agent-browser) i les apps (agent-desktop): mirar passa sol; clicar, escriure o enviar sempre et demana permís, amb una captura.</p></div><div class="row"><button class="btn" data-act="screen-now" type="button" ${on ? '' : 'disabled'}>${svg(ICON.screen)}Captura</button><button class="btn ink" data-act="remote-open" type="button" ${on ? '' : 'disabled'}>${svg(ICON.screen)}Obre la pantalla</button></div></div><div id="screenBody">${screenHtml()}</div>`;
1828
1877
  }
1829
1878
  function screenHtml() {
1830
1879
  if (!screen) return `<div class="panel empty">${bot('work')}<h2>Carregant…</h2></div>`;
@@ -1834,26 +1883,282 @@ function screenHtml() {
1834
1883
  const st = !x.installed ? `No instal·lat · <span class="mono">${hint}</span>` : x.ready === false ? esc(x.why || 'No està a punt') : x.enabled ? 'Activat' : 'Desactivat';
1835
1884
  return `<div class="agent-row"><i class="dot ${x.enabled ? 'on' : ''}"></i><span class="grow"><b>${name}</b><div class="muted small">${st}</div></span>${x.installed ? `<button class="btn sm ${x.enabled ? '' : 'ink'}" data-ctl="${k}" data-on="${!x.enabled}" type="button">${x.enabled ? 'Desactiva' : 'Activa'}</button>` : ''}</div>`;
1836
1885
  };
1886
+ const d = screen.desk, warn = [];
1887
+ if (d && c.escriptori?.enabled) {
1888
+ if (d.screen === false) warn.push('Falta el permís de <b>Gravació de pantalla</b>: per això les captures surten negres. Ajustos del Sistema → Privadesa i seguretat → Gravació de pantalla: activa el Terminal (o l’app des d’on obres nio) i reinicia nio.');
1889
+ if (d.accessibility === false) warn.push('Falta el permís d’<b>Accessibilitat</b> per controlar el ratolí i el teclat. Ajustos del Sistema → Privadesa i seguretat → Accessibilitat: activa el Terminal (o l’app des d’on obres nio) i reinicia nio.');
1890
+ if (d.input === false && d.platform === 'linux') warn.push('Per controlar l’escriptori cal <span class="mono">xdotool</span>: <span class="mono">sudo apt install xdotool</span>.');
1891
+ }
1837
1892
  return `<div class="grid2 one">
1838
1893
  <div class="stack">
1894
+ ${warn.length ? `<section class="panel"><header><h2>Falten permisos</h2></header><div class="body stack">${warn.map((w) => `<p class="small">${w}</p>`).join('')}</div></section>` : ''}
1839
1895
  <section class="panel"><header><h2>En aquest ordinador</h2></header><div class="body">${row('navegador', 'Navegador web', 'npm i -g agent-browser && agent-browser install')}${row('escriptori', 'Apps de l’escriptori', 'npm i -g agent-desktop')}<p class="muted small" style="margin-top:10px">En activar-ho, nio dona a Claude Code i a Codex les guies oficials. Desactivat per defecte. Claude Code demana permís per a cada acció; Codex no pot preguntar, així que només segueix les normes de la guia.</p></div></section>
1840
1896
  <section class="panel"><header><h2>Última captura</h2>${screen.last ? `<span class="muted small">${ago(screen.last.t)} · ${esc(screen.last.from)}</span>` : ''}</header><div class="body">${screen.last ? `<img class="screen-img" alt="Captura de la pantalla de l’ordinador" data-shot="${esc(screen.last.id)}">` : '<p class="muted small">Encara no n’hi ha cap. Prem «Veure pantalla ara».</p>'}</div></section>
1841
1897
  <section class="panel"><header><h2>Accions fetes</h2></header><div class="body">${screen.actions.length ? screen.actions.map((a) => `<div class="act-row"><span class="act-t mono">${new Date(a.t).toLocaleTimeString('ca', { hour: '2-digit', minute: '2-digit' })}</span><div class="grow"><div class="mono small act-what">${esc(a.what)}</div><div class="muted small">${esc(a.title || '')}</div></div></div>`).join('') : '<p class="muted small">Cap encara.</p>'}</div></section>
1842
1898
  <section class="panel"><header><h2>Aturada d’emergència</h2></header><div class="body stack"><p class="small">Atura tots els agents, rebutja el que estigui pendent i tanca el navegador.</p><button class="btn danger" data-act="panic" type="button">${svg(ICON.panic)}Atura-ho tot</button></div></section>
1843
- <section class="panel"><header><h2>Vols fer-ho tu?</h2></header><div class="body"><p class="muted small">nio no és un escriptori remot. Si necessites controlar la pantalla tu mateix, fes servir <a href="https://rustdesk.com" target="_blank" rel="noopener">RustDesk</a> o <a href="https://remotedesktop.google.com" target="_blank" rel="noopener">Chrome Remote Desktop</a>.</p></div></section>
1844
1899
  </div>
1845
1900
  </div>`;
1846
1901
  }
1847
1902
  async function loadScreen() {
1848
1903
  try {
1849
- const [caps, cur, log] = await Promise.all([api('/api/control'), api('/api/screen'), api('/api/audit?type=tool&limit=300')]);
1850
- screen = { caps, last: cur.last, actions: log.filter((e) => /^(npx\s+)?agent-(browser|desktop)\b/.test(e.what || '')).slice(0, 30) };
1904
+ const [caps, cur, log, desk] = await Promise.all([api('/api/control'), api('/api/screen'), api('/api/audit?type=tool&limit=300'), api('/api/desktop/status').catch(() => null)]);
1905
+ screen = { caps, desk, last: cur.last, actions: log.filter((e) => /^(npx\s+)?agent-(browser|desktop)\b/.test(e.what || '')).slice(0, 30) };
1851
1906
  } catch (e) { screen = { error: e.message }; }
1852
1907
  const box = $('#screenBody'); if (box) { box.innerHTML = screenHtml(); hydrateShots(box); }
1853
1908
  renderPanic();
1854
1909
  }
1855
1910
  function syncScreenBtn() { const b = $('[data-act=screen-now]'); if (b) b.disabled = !controlOn(); }
1856
1911
  function onScreen(msg) { if (screen && !screen.error) { screen.last = msg.shot; { const box = $('#screenBody'); if (box) box.innerHTML = screenHtml(); hydrateShots(box); } } }
1912
+ /* ---------------------------------------------------------------- pantalla remota (veure i controlar l'ordinador) */
1913
+ const RD = { on: false, ctl: false, mods: new Set(), drag: false, q: 55, blank: 0, err: 0, objUrl: null, chain: Promise.resolve(), sw: 0, tap: null, scroll: null, errAt: 0 };
1914
+ const RD_KEYS = [['Escape', 'Esc'], ['Tab', 'Tab'], ['ArrowUp', '↑'], ['ArrowDown', '↓'], ['ArrowLeft', '←'], ['ArrowRight', '→'], ['Enter', '⏎'], ['Backspace', '⌫']];
1915
+ const RD_MODS = [['meta', '⌘'], ['alt', '⌥'], ['ctrl', '⌃'], ['shift', '⇧']];
1916
+ const RD_SPECIAL = /^(Escape|Tab|Arrow(Up|Down|Left|Right)|Home|End|PageUp|PageDown|Delete|F([1-9]|1[0-2]))$/;
1917
+
1918
+ async function remoteOpen() {
1919
+ const m = machine(); if (!m || RD.on) return;
1920
+ if (!(state.info?.control?.escriptori?.enabled || screen?.caps?.escriptori?.enabled)) return toast('Activa primer «Apps de l’escriptori» en aquest ordinador.', 5000);
1921
+ Object.assign(RD, { two: 'scroll', z: 1, px: 0, py: 0, on: true, ctl: false, mods: new Set(), drag: false, blank: 0, err: 0, tap: null, scroll: null });
1922
+ const root = document.createElement('div');
1923
+ root.className = 'rd'; root.id = 'rd'; root.setAttribute('role', 'dialog'); root.setAttribute('aria-label', 'Pantalla remota');
1924
+ root.innerHTML = `<div class="rd-bar"><button class="rd-btn" data-rd="close" type="button">Tanca</button><b class="rd-title">${esc(m.name || 'Ordinador')}</b><span class="rd-stat" id="rdStat"></span><span class="grow"></span><button class="rd-btn" data-rd="full" type="button" id="rdFull" hidden>Pantalla completa</button><div class="rd-seg" role="group" aria-label="Mode"><button data-rd="view" type="button" aria-pressed="true">Mirar</button><button data-rd="ctl" type="button" aria-pressed="false">Controlar</button></div></div>
1925
+ <div class="rd-stage" id="rdStage"><img id="rdImg" alt="Pantalla de l’ordinador" draggable="false"><div class="rd-msg" id="rdMsg">Connectant…</div><div class="rd-tools" role="group" aria-label="Zoom i captura"><button class="rd-btn" data-rd="zout" type="button" aria-label="Allunya">−</button><button class="rd-btn rd-pct" data-rd="zfit" type="button" id="rdPct" aria-label="Enquadra">100%</button><button class="rd-btn" data-rd="zin" type="button" aria-label="Apropa">+</button><button class="rd-btn" data-rd="z11" type="button" aria-label="Mida real">1:1</button><span class="rd-sep"></span><button class="rd-btn" data-rd="save" type="button">Desa captura</button></div></div>
1926
+ <div class="rd-keys" id="rdKeys" hidden><button class="rd-btn" data-rd="kbd" type="button">Teclat</button>${RD_MODS.map(([k, l]) => `<button class="rd-btn" data-mod="${k}" type="button" aria-pressed="false">${l}</button>`).join('')}${RD_KEYS.map(([k, l]) => `<button class="rd-btn" data-key="${k}" type="button">${l}</button>`).join('')}<button class="rd-btn" data-rd="drag" type="button" id="rdDrag">1 dit: desplaça</button><button class="rd-btn" data-rd="two" type="button" id="rdTwo">2 dits: desplaça</button></div>
1927
+ <textarea class="rd-type" id="rdType" autocapitalize="off" autocorrect="off" autocomplete="off" spellcheck="false" aria-label="Teclat de l’ordinador"></textarea>`;
1928
+ document.body.appendChild(root);
1929
+ document.documentElement.classList.add('rd-open');
1930
+ if (document.fullscreenEnabled && !matchMedia('(pointer: coarse)').matches) $('#rdFull').hidden = false;
1931
+ rdBind(root);
1932
+ api('/api/desktop/session', { method: 'POST', body: { event: 'open', wake: true } }).catch(() => {});
1933
+ api('/api/desktop/status').then((s) => { RD.status = s; rdHint(); }).catch(() => {});
1934
+ rdLoop();
1935
+ }
1936
+ function remoteClose() {
1937
+ if (!RD.on) return;
1938
+ RD.on = false;
1939
+ if (document.fullscreenElement) document.exitFullscreen?.().catch(() => {});
1940
+ if (RD.objUrl) URL.revokeObjectURL(RD.objUrl); RD.objUrl = null;
1941
+ $('#rd')?.remove();
1942
+ document.documentElement.classList.remove('rd-open');
1943
+ }
1944
+ function rdMsg(text) { const el = $('#rdMsg'); if (!el) return; el.textContent = text || ''; el.hidden = !text; }
1945
+ function rdHint() {
1946
+ const s = RD.status; if (!s || !RD.on) return;
1947
+ if (RD.blank >= 2) {
1948
+ rdMsg(s.screen === false
1949
+ ? 'La pantalla surt negra perquè falta el permís de Gravació de pantalla. Ajustos del Sistema → Privadesa i seguretat → Gravació de pantalla: activa el Terminal (o l’app des d’on obres nio) i reinicia nio.'
1950
+ : 'La pantalla surt negra: l’ordinador pot estar bloquejat o amb la pantalla apagada. Desbloqueja’l, o mou-hi el ratolí.');
1951
+ } else if (RD.ctl && s.accessibility === false) rdMsg('Per controlar-lo cal el permís d’Accessibilitat. Ajustos del Sistema → Privadesa i seguretat → Accessibilitat: activa el Terminal (o l’app des d’on obres nio) i reinicia nio.');
1952
+ else if (RD.ctl && s.input === false) rdMsg(s.platform === 'linux' ? 'Per controlar-lo cal xdotool: sudo apt install xdotool.' : 'Aquest ordinador no es pot controlar.');
1953
+ else rdMsg('');
1954
+ }
1955
+ async function rdLoop() {
1956
+ const m = machine();
1957
+ let n = 0, t0 = performance.now();
1958
+ while (RD.on) {
1959
+ if (document.hidden) { await sleep(500); continue; }
1960
+ const started = performance.now();
1961
+ try {
1962
+ const stage = $('#rdStage'); if (!stage) break;
1963
+ const w = Math.min(2400, Math.max(640, Math.round(stage.clientWidth * Math.min(devicePixelRatio || 1, 2) * Math.min(RD.z, 2))));
1964
+ const res = await fetch(`${m.url}/api/desktop/frame?w=${w}&q=${RD.q}&_=${Date.now()}`, { headers: { Authorization: `Bearer ${m.token}` }, cache: 'no-store' });
1965
+ if (!res.ok) { const d = await res.json().catch(() => ({})); throw new Error(d.error || `Error ${res.status}`); }
1966
+ const blob = await res.blob();
1967
+ if (!RD.on) break;
1968
+ const img = $('#rdImg'), old = RD.objUrl, url = URL.createObjectURL(blob);
1969
+ RD.objUrl = url; img.onload = () => { if (old) URL.revokeObjectURL(old); }; img.src = url;
1970
+ RD.sw = Number(res.headers.get('x-screen-w')) || RD.sw;
1971
+ RD.blank = res.headers.get('x-blank') === '1' ? RD.blank + 1 : 0;
1972
+ if (RD.err || RD.blank === 0 || RD.blank === 2) { RD.err = 0; rdHint(); }
1973
+ if (RD.blank === 2) api('/api/desktop/session', { method: 'POST', body: { wake: true } }).catch(() => {});
1974
+ n++; const dt = performance.now() - t0;
1975
+ if (dt > 1500) { const st = $('#rdStat'); if (st) st.textContent = `${(n * 1000 / dt).toFixed(1)} img/s`; n = 0; t0 = performance.now(); }
1976
+ } catch (e) {
1977
+ RD.err++; rdMsg(RD.err > 1 ? `${e.message || 'Sense connexió'}${/^Error|Activa|captura/.test(e.message) ? '' : ' · reconnectant…'}` : '');
1978
+ await sleep(Math.min(3000, 500 * RD.err));
1979
+ }
1980
+ await sleep(Math.max(30, 110 - (performance.now() - started)));
1981
+ }
1982
+ }
1983
+ function rdSend(ev) {
1984
+ RD.chain = RD.chain.then(() => api('/api/desktop/input', { method: 'POST', body: ev })).catch((e) => { if (Date.now() - RD.errAt > 4000) { RD.errAt = Date.now(); toast(e.message, 5000); } });
1985
+ }
1986
+ function rdKey(key, extra = []) {
1987
+ const mods = [...new Set([...RD.mods, ...extra])];
1988
+ if (RD.mods.size) { RD.mods.clear(); rdSyncMods(); }
1989
+ rdSend({ type: 'key', key, mods });
1990
+ }
1991
+ function rdSyncMods() { $$('#rd [data-mod]').forEach((b) => b.setAttribute('aria-pressed', RD.mods.has(b.dataset.mod))); }
1992
+ function rdPos(e) {
1993
+ const r = $('#rdImg').getBoundingClientRect(); if (!r.width) return null;
1994
+ const x = (e.clientX - r.left) / r.width, y = (e.clientY - r.top) / r.height;
1995
+ return x < 0 || x > 1 || y < 0 || y > 1 ? null : [x, y];
1996
+ }
1997
+ function rdScroll(dx, dy, pos) {
1998
+ const s = RD.scroll || (RD.scroll = { dx: 0, dy: 0, pos, t: null });
1999
+ s.dx += dx; s.dy += dy; if (pos) s.pos = pos;
2000
+ if (s.t) return;
2001
+ s.t = setTimeout(() => {
2002
+ const k = RD.scroll; RD.scroll = null;
2003
+ const r = $('#rdImg')?.getBoundingClientRect(), f = r && r.width ? (RD.sw || r.width) / r.width : 1;
2004
+ const ev = { type: 'scroll', dx: Math.round(k.dx * f), dy: Math.round(k.dy * f) };
2005
+ if (k.pos) { ev.x = k.pos[0]; ev.y = k.pos[1]; }
2006
+ if (ev.dx || ev.dy) rdSend(ev);
2007
+ }, 70);
2008
+ }
2009
+
2010
+ function rdView() {
2011
+ const img = $('#rdImg'), st = $('#rdStage'); if (!img || !st) return;
2012
+ const z = RD.z, mx = Math.max(0, (img.offsetWidth * z - st.clientWidth) / 2), my = Math.max(0, (img.offsetHeight * z - st.clientHeight) / 2);
2013
+ RD.px = Math.max(-mx, Math.min(mx, RD.px)); RD.py = Math.max(-my, Math.min(my, RD.py));
2014
+ if (z === 1) RD.px = RD.py = 0;
2015
+ img.style.transform = z === 1 ? '' : `translate(${RD.px}px,${RD.py}px) scale(${z})`;
2016
+ const pct = $('#rdPct'); if (pct) pct.textContent = Math.round(z * 100) + '%';
2017
+ }
2018
+ function rdZoom(z, cx, cy) {
2019
+ const st = $('#rdStage'); if (!st) return;
2020
+ z = Math.max(1, Math.min(6, z));
2021
+ const r = st.getBoundingClientRect(), ox = (cx ?? r.left + r.width / 2) - (r.left + r.width / 2), oy = (cy ?? r.top + r.height / 2) - (r.top + r.height / 2), k = z / RD.z;
2022
+ RD.px = ox - (ox - RD.px) * k; RD.py = oy - (oy - RD.py) * k; RD.z = z; rdView();
2023
+ }
2024
+ function rdSave() {
2025
+ const img = $('#rdImg'); if (!img?.src) return toast('Encara no hi ha cap imatge');
2026
+ const d = new Date(), p = (n) => String(n).padStart(2, '0');
2027
+ const a = document.createElement('a'); a.href = img.src; a.download = `nio-pantalla-${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}.jpg`;
2028
+ document.body.appendChild(a); a.click(); a.remove(); toast('Captura desada');
2029
+ }
2030
+ function rdGestures(stage) {
2031
+ const pts = new Map(); let g = null, pan = null, tapT = 0;
2032
+ const dist = () => { const [a, b] = [...pts.values()]; return Math.hypot(a.x - b.x, a.y - b.y) || 1; };
2033
+ const mid = () => { const [a, b] = [...pts.values()]; return [(a.x + b.x) / 2, (a.y + b.y) / 2]; };
2034
+ stage.addEventListener('pointerdown', (e) => {
2035
+ if (e.target.closest('.rd-tools') || (RD.ctl && (RD.two !== 'zoom' || e.pointerType === 'mouse'))) return;
2036
+ try { stage.setPointerCapture(e.pointerId); } catch {}
2037
+ pts.set(e.pointerId, { x: e.clientX, y: e.clientY });
2038
+ if (pts.size === 2) { const [mx, my] = mid(); g = { d: dist(), z: RD.z, mx, my, px: RD.px, py: RD.py }; pan = null; }
2039
+ else if (pts.size === 1 && !RD.ctl) pan = { x: e.clientX, y: e.clientY, px: RD.px, py: RD.py };
2040
+ });
2041
+ stage.addEventListener('pointermove', (e) => {
2042
+ const p = pts.get(e.pointerId); if (!p) return; p.x = e.clientX; p.y = e.clientY;
2043
+ if (pts.size >= 2 && g) {
2044
+ const [mx, my] = mid(), st = stage.getBoundingClientRect(), z = Math.max(1, Math.min(6, g.z * dist() / g.d)), k = z / g.z;
2045
+ const ox = g.mx - (st.left + st.width / 2), oy = g.my - (st.top + st.height / 2);
2046
+ RD.z = z; RD.px = g.px * k + ox * (1 - k) + (mx - g.mx); RD.py = g.py * k + oy * (1 - k) + (my - g.my); rdView();
2047
+ } else if (pan && RD.z > 1 && !RD.ctl) { RD.px = pan.px + e.clientX - pan.x; RD.py = pan.py + e.clientY - pan.y; rdView(); }
2048
+ });
2049
+ const up = (e) => {
2050
+ if (!pts.has(e.pointerId)) return;
2051
+ const moved = pan && Math.hypot(e.clientX - pan.x, e.clientY - pan.y) > 8;
2052
+ pts.delete(e.pointerId); g = null;
2053
+ if (pts.size === 1 && !RD.ctl) { const [q] = pts.values(); pan = { x: q.x, y: q.y, px: RD.px, py: RD.py }; }
2054
+ else { pan = null; if (!pts.size && e.type === 'pointerup' && !moved && !RD.ctl) { const now = performance.now(); if (now - tapT < 320) { RD.z > 1 ? (RD.z = 1, rdView()) : rdZoom(2.5, e.clientX, e.clientY); tapT = 0; } else tapT = now; } }
2055
+ };
2056
+ stage.addEventListener('pointerup', up); stage.addEventListener('pointercancel', up);
2057
+ stage.addEventListener('wheel', (e) => { if (!(e.ctrlKey || e.metaKey) && RD.ctl) return; e.preventDefault(); if (e.ctrlKey || e.metaKey) rdZoom(RD.z * Math.exp(-Math.max(-40, Math.min(40, e.deltaY)) * 0.01), e.clientX, e.clientY); else if (RD.z > 1) { RD.px -= e.deltaX; RD.py -= e.deltaY; rdView(); } }, { passive: false });
2058
+ let gz = 1;
2059
+ stage.addEventListener('gesturestart', (e) => { e.preventDefault(); gz = RD.z; });
2060
+ stage.addEventListener('gesturechange', (e) => { e.preventDefault(); rdZoom(gz * e.scale, e.clientX, e.clientY); });
2061
+ window.addEventListener('resize', rdView);
2062
+ }
2063
+ function rdBind(root) {
2064
+ const stage = $('#rdStage', root), typer = $('#rdType', root);
2065
+ const ptrs = new Map();
2066
+ rdGestures(stage);
2067
+ const click = (pos, e) => {
2068
+ const now = performance.now(), prev = RD.tap, near = prev && Math.hypot(e.clientX - prev.x, e.clientY - prev.y) < 28;
2069
+ const count = prev && near && now - prev.t < 340 ? (prev.count >= 2 ? 3 : 2) : 1;
2070
+ RD.tap = { t: now, x: e.clientX, y: e.clientY, count };
2071
+ rdSend({ type: 'click', x: pos[0], y: pos[1], count });
2072
+ };
2073
+ stage.addEventListener('pointerdown', (e) => {
2074
+ if (!RD.ctl || e.target.closest('.rd-tools') || (e.pointerType === 'mouse' && e.button !== 0)) return;
2075
+ try { stage.setPointerCapture(e.pointerId); } catch {}
2076
+ const p = { x0: e.clientX, y0: e.clientY, x: e.clientX, y: e.clientY, touch: e.pointerType !== 'mouse', moved: false, long: false, path: [], pt: 0, lp: null };
2077
+ const pos = rdPos(e); if (pos) p.path.push(pos);
2078
+ if (p.touch) p.lp = setTimeout(() => { if (!p.moved && ptrs.size === 1 && ptrs.has(e.pointerId)) { p.long = true; navigator.vibrate?.(15); const q = rdPos(e); if (q) rdSend({ type: 'rclick', x: q[0], y: q[1] }); } }, 550);
2079
+ ptrs.set(e.pointerId, p);
2080
+ if (RD.two === 'zoom' && p.touch && ptrs.size > 1) for (const q of ptrs.values()) { q.moved = q.zoomed = true; clearTimeout(q.lp); }
2081
+ if (!typer.matches(':focus') && !p.touch) typer.focus({ preventScroll: true });
2082
+ e.preventDefault();
2083
+ });
2084
+ stage.addEventListener('pointermove', (e) => {
2085
+ const p = ptrs.get(e.pointerId); if (!p) return;
2086
+ const dx = e.clientX - p.x, dy = e.clientY - p.y; p.x = e.clientX; p.y = e.clientY;
2087
+ if (!p.moved && Math.hypot(p.x - p.x0, p.y - p.y0) > 8) { p.moved = true; clearTimeout(p.lp); }
2088
+ if (!p.moved || p.long || p.zoomed) return;
2089
+ if (p.touch && (ptrs.size > 1 || !RD.drag)) { if (ptrs.size > 1 && e.isPrimary === false) return; rdScroll(dx, dy, rdPos(e)); return; }
2090
+ const now = performance.now(), pos = rdPos(e);
2091
+ if (pos && now - p.pt > 22) { p.path.push(pos); p.pt = now; }
2092
+ });
2093
+ const end = (e) => {
2094
+ const p = ptrs.get(e.pointerId); if (!p) return;
2095
+ ptrs.delete(e.pointerId); clearTimeout(p.lp);
2096
+ if (p.long || p.zoomed) return;
2097
+ const pos = rdPos(e);
2098
+ if (!p.moved) { if (pos && e.type === 'pointerup') click(pos, e); return; }
2099
+ if (p.touch && (ptrs.size > 0 || !RD.drag)) return;
2100
+ if (pos) p.path.push(pos);
2101
+ if (p.path.length >= 2) rdSend({ type: 'drag', path: p.path.length > 100 ? p.path.filter((_, i) => i % Math.ceil(p.path.length / 100) === 0 || i === p.path.length - 1) : p.path });
2102
+ };
2103
+ stage.addEventListener('pointerup', end);
2104
+ stage.addEventListener('pointercancel', end);
2105
+ stage.addEventListener('contextmenu', (e) => { e.preventDefault(); if (!RD.ctl) return; const pos = rdPos(e); if (pos && e.pointerType !== 'touch' && !('ontouchstart' in window && e.sourceCapabilities?.firesTouchEvents)) rdSend({ type: 'rclick', x: pos[0], y: pos[1] }); });
2106
+ stage.addEventListener('wheel', (e) => {
2107
+ if (!RD.ctl) return; e.preventDefault();
2108
+ const k = e.deltaMode === 1 ? 16 : 1;
2109
+ rdScroll(-e.deltaX * k, -e.deltaY * k, rdPos(e));
2110
+ }, { passive: false });
2111
+ // Teclat: el text va per «beforeinput» (serveix per al teclat del mòbil); les tecles especials i les dreceres, per «keydown».
2112
+ typer.addEventListener('keydown', (e) => {
2113
+ if (!RD.ctl || e.isComposing || e.key === 'Process' || e.key === 'Unidentified') return;
2114
+ if (['Meta', 'Control', 'Alt', 'Shift', 'CapsLock'].includes(e.key)) return;
2115
+ const combo = e.metaKey || e.ctrlKey || e.altKey;
2116
+ if (!combo && !RD_SPECIAL.test(e.key)) return;
2117
+ e.preventDefault();
2118
+ const mods = [e.metaKey && 'meta', e.ctrlKey && 'ctrl', e.altKey && 'alt', e.shiftKey && 'shift'].filter(Boolean);
2119
+ rdKey(e.key.length === 1 ? e.key.toLowerCase() : e.key, mods);
2120
+ });
2121
+ typer.addEventListener('beforeinput', (e) => {
2122
+ if (!RD.ctl) return;
2123
+ const t = e.inputType;
2124
+ if (t === 'insertCompositionText') return;
2125
+ e.preventDefault();
2126
+ if (t === 'insertText' || t === 'insertFromPaste' || t === 'insertReplacementText') {
2127
+ const text = e.data ?? e.dataTransfer?.getData('text/plain') ?? '';
2128
+ if (!text) return;
2129
+ if (RD.mods.size && [...text].length === 1) rdKey(text.toLowerCase()); else rdSend({ type: 'text', text });
2130
+ } else if (t === 'deleteContentBackward') rdKey('Backspace');
2131
+ else if (t === 'deleteContentForward') rdKey('Delete');
2132
+ else if (t === 'insertLineBreak' || t === 'insertParagraph') rdKey('Enter');
2133
+ });
2134
+ typer.addEventListener('compositionend', (e) => { if (RD.ctl && e.data) rdSend({ type: 'text', text: e.data }); typer.value = ''; });
2135
+ root.addEventListener('click', (e) => {
2136
+ const b = e.target.closest('button'); if (!b) return;
2137
+ if (b.dataset.key) { rdKey(b.dataset.key); return; }
2138
+ if (b.dataset.mod) { RD.mods.has(b.dataset.mod) ? RD.mods.delete(b.dataset.mod) : RD.mods.add(b.dataset.mod); rdSyncMods(); return; }
2139
+ switch (b.dataset.rd) {
2140
+ case 'close': remoteClose(); break;
2141
+ case 'full': document.fullscreenElement ? document.exitFullscreen() : root.requestFullscreen?.().catch(() => {}); break;
2142
+ case 'view': case 'ctl': {
2143
+ RD.ctl = b.dataset.rd === 'ctl';
2144
+ $$('.rd-seg button', root).forEach((x) => x.setAttribute('aria-pressed', x.dataset.rd === b.dataset.rd));
2145
+ $('#rdKeys').hidden = !RD.ctl; root.classList.toggle('rd-ctl', RD.ctl);
2146
+ if (RD.ctl) { api('/api/desktop/session', { method: 'POST', body: { event: 'control' } }).catch(() => {}); if (!matchMedia('(pointer: coarse)').matches) typer.focus({ preventScroll: true }); }
2147
+ rdHint(); break;
2148
+ }
2149
+ case 'kbd': typer.focus(); break;
2150
+ case 'zin': rdZoom(RD.z * 1.5); break;
2151
+ case 'zout': rdZoom(RD.z / 1.5); break;
2152
+ case 'zfit': RD.z = 1; rdView(); break;
2153
+ case 'z11': { const im = $('#rdImg'); rdZoom(RD.sw && im.offsetWidth ? RD.sw / im.offsetWidth : 2); break; }
2154
+ case 'save': rdSave(); break;
2155
+ case 'two': RD.two = RD.two === 'zoom' ? 'scroll' : 'zoom'; b.textContent = RD.two === 'zoom' ? '2 dits: zoom' : '2 dits: desplaça'; break;
2156
+ case 'drag': RD.drag = !RD.drag; b.textContent = RD.drag ? '1 dit: arrossega' : '1 dit: desplaça'; break;
2157
+ }
2158
+ });
2159
+ }
2160
+ document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && RD.on && !RD.ctl) remoteClose(); });
2161
+
1857
2162
  document.addEventListener('click', async (e) => {
1858
2163
  const b = e.target.closest('[data-ctl]'); if (!b) return;
1859
2164
  b.disabled = true;
@@ -2044,6 +2349,7 @@ document.addEventListener('click', async (e) => {
2044
2349
  case 'revoke': return revokeDevice(a.dataset.dev);
2045
2350
  case 'job-new': return newJobSheet();
2046
2351
  case 'panic': return panicNow();
2352
+ case 'remote-open': remoteOpen(); return;
2047
2353
  case 'screen-now': try { const s = await api('/api/screen', { method: 'POST' }); onScreen({ shot: s }); } catch (err) { toast(err.message, 4500); } return;
2048
2354
  case 'mem-new': return memEdit(null);
2049
2355
  case 'mem-all': if (mem) delete mem.hits; return loadMemory();
package/web/app/style.css CHANGED
@@ -477,6 +477,14 @@ video.scan { width: 100%; aspect-ratio: 1; object-fit: cover; border-radius: 14p
477
477
  .help-body { display: grid; gap: 8px; padding: 0 0 14px; font-size: 14px; color: var(--fg-muted); }
478
478
  .help-body ul { padding-left: 18px; display: grid; gap: 4px; }
479
479
  .help-body b { color: var(--fg); font-weight: 600; }
480
+ .help-body .mono { padding: 2px 7px; border-radius: 6px; background: var(--code); color: var(--fg); white-space: nowrap; }
481
+ .help-body .cmdb { margin: 2px 0; padding: 12px 14px; border-radius: 10px; background: var(--code); color: var(--fg); font: 14px/1.4 var(--mono); overflow-x: auto; }
482
+ .help-body .cmdlist { display: grid; gap: 6px; margin: 2px 0; }
483
+ .help-body .cmdlist > div { display: grid; grid-template-columns: minmax(0, 15rem) minmax(0, 1fr); gap: 4px 14px; align-items: baseline; padding: 8px 10px; border-radius: 10px; background: color-mix(in srgb, var(--code) 45%, transparent); }
484
+ .help-body .cmdlist .mono { justify-self: start; white-space: normal; overflow-wrap: anywhere; }
485
+ .help-grp { margin: 22px 0 2px; font-size: 12px; font-weight: 600; letter-spacing: .06em; text-transform: uppercase; color: var(--fg-muted); }
486
+ .help-grp:first-child { margin-top: 0; }
487
+ @media (max-width: 620px) { .help-body .cmdlist > div { grid-template-columns: 1fr; } }
480
488
  .empty .stepper { text-align: left; max-width: 640px; margin: 16px auto 18px; }
481
489
  a.kbd { text-decoration: none; color: inherit; }
482
490
 
@@ -645,3 +653,34 @@ dialog.sheet.wiz.compact .sheet-in, .compact .wiz-body { flex: 0 1 auto; }
645
653
  .sec-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; flex-wrap: wrap; margin: 36px 0 14px; }
646
654
  .sec-head .sec { margin: 0; }
647
655
  .sec-head p { margin-top: 4px; max-width: 640px; }
656
+
657
+ /* Pantalla remota */
658
+ html.rd-open, html.rd-open body { overflow: hidden; }
659
+ .rd { position: fixed; inset: 0; z-index: 200; display: flex; flex-direction: column; background: #0b0b0c; color: #f2f1ee; padding: var(--safe-t, 0px) 0 var(--safe-b, 0px); }
660
+ .rd-bar { display: flex; align-items: center; gap: 10px; padding: 10px 12px; background: #161617; border-bottom: 1px solid #2a2a2c; flex: none; }
661
+ .rd-title { font-size: 14px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
662
+ .rd-stat { font-size: 12px; color: #8d8c88; white-space: nowrap; }
663
+ .rd-bar .grow { flex: 1; }
664
+ .rd-btn { appearance: none; border: 1px solid #36363a; background: #232326; color: #f2f1ee; font: inherit; font-size: 13px; line-height: 1; padding: 9px 12px; border-radius: 9px; cursor: pointer; white-space: nowrap; flex: none; }
665
+ .rd-btn:hover { background: #2d2d31; }
666
+ .rd-btn[aria-pressed=true] { background: #f2f1ee; color: #161617; border-color: #f2f1ee; }
667
+ .rd-seg { display: flex; border: 1px solid #36363a; border-radius: 10px; overflow: hidden; flex: none; }
668
+ .rd-seg button { appearance: none; border: 0; background: transparent; color: #b9b8b3; font: inherit; font-size: 13px; padding: 9px 14px; cursor: pointer; }
669
+ .rd-seg button[aria-pressed=true] { background: #f2f1ee; color: #161617; font-weight: 600; }
670
+ .rd-stage { position: relative; flex: 1; min-height: 0; display: flex; align-items: center; justify-content: center; background: #000; touch-action: none; user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; overflow: hidden; }
671
+ .rd-stage img { max-width: 100%; max-height: 100%; display: block; object-fit: contain; -webkit-user-drag: none; }
672
+ .rd.rd-ctl .rd-stage { cursor: crosshair; }
673
+ .rd-msg { position: absolute; left: 50%; bottom: 18px; transform: translateX(-50%); max-width: min(560px, calc(100% - 32px)); padding: 12px 16px; border-radius: 12px; background: rgba(22, 22, 23, .92); border: 1px solid #36363a; color: #f2f1ee; font-size: 13px; line-height: 1.45; text-align: center; pointer-events: none; }
674
+ .rd-msg[hidden] { display: none; }
675
+ .rd-keys { display: flex; gap: 8px; padding: 10px 12px; background: #161617; border-top: 1px solid #2a2a2c; overflow-x: auto; flex: none; -webkit-overflow-scrolling: touch; }
676
+ .rd-keys[hidden] { display: none; }
677
+ .rd-type { position: fixed; left: 0; top: 0; width: 1px; height: 1px; opacity: 0; border: 0; padding: 0; resize: none; font-size: 16px; pointer-events: none; }
678
+ @media (max-width: 560px) { .rd-bar { padding: 8px 10px; gap: 8px; } .rd-stat, #rdFull { display: none; } .rd-seg button { padding: 9px 11px; } }
679
+
680
+ .rd-stage img { transform-origin: center; will-change: transform; }
681
+ .rd-tools { position: absolute; left: 50%; bottom: calc(14px + env(safe-area-inset-bottom, 0px)); transform: translateX(-50%); display: flex; align-items: center; gap: 6px; padding: 6px; border-radius: 14px; background: rgba(22, 22, 23, .86); border: 1px solid #36363a; backdrop-filter: blur(8px); z-index: 2; max-width: calc(100% - 20px); }
682
+ .rd-tools .rd-btn { padding: 10px 12px; min-width: 40px; }
683
+ .rd-pct { min-width: 56px !important; font-variant-numeric: tabular-nums; }
684
+ .rd-sep { width: 1px; align-self: stretch; background: #36363a; }
685
+ .rd-msg { bottom: 84px; }
686
+ @media (max-width: 560px) { .rd-tools { gap: 4px; padding: 5px; } .rd-tools .rd-btn { padding: 12px 10px; min-width: 44px; font-size: 14px; } .rd-pct { min-width: 52px !important; } }
package/web/board.css CHANGED
@@ -190,3 +190,53 @@
190
190
  .bd-chat header { padding-top: 22px; }
191
191
  .bd-chat .cin textarea { font-size: 16px; }
192
192
  }
193
+
194
+ /* Tàctil: botons còmodes, sense menú de pressió llarga ni selecció */
195
+ .bd-stage, .bd-svg { -webkit-touch-callout: none; }
196
+ @media (pointer: coarse) {
197
+ .bd .ib, .bd .pct { height: 40px; }
198
+ .bd .ib { width: 40px; }
199
+ .bd .pct { min-width: 52px; font-size: 13px; }
200
+ .bd .tb { width: 44px; height: 44px; }
201
+ .bd .chat-t { height: 44px; }
202
+ .bd-menu { top: 50px; min-width: 250px; }
203
+ .bd-menu button, .bd-menu label { padding: 13px 12px; font-size: 15px; }
204
+ .bd-style .sw-c { width: 34px; height: 34px; }
205
+ .bd-style .seg2 button { height: 36px; }
206
+ .bd-chat .chip-s { height: 38px; line-height: 36px; font-size: 13px; }
207
+ .bd-chat .send { width: 46px; height: 46px; }
208
+ }
209
+ @media (pointer: coarse) and (max-width: 820px) {
210
+ .bd-zoom [data-bd="zin"], .bd-zoom [data-bd="zout"] { display: none; }
211
+ .bd-tools { gap: 0; scroll-snap-type: x proximity; -webkit-overflow-scrolling: touch; }
212
+ .bd .tb { scroll-snap-align: center; }
213
+ }
214
+ @container stage (max-width: 440px) {
215
+ .bd .tb { width: 40px; height: 44px; }
216
+ .bd-zoom { max-width: calc(100% - 128px); }
217
+ }
218
+ @container stage (max-width: 400px) {
219
+ .bd-tools .tb { min-width: 34px; height: 44px; }
220
+ }
221
+
222
+ /* Teclat obert: el xat puja per damunt del teclat */
223
+ @media (max-width: 820px) {
224
+ .bd-chat { bottom: var(--kb, 0px); transition: transform .34s var(--bd-ease), bottom .2s, visibility 0s linear .34s; }
225
+ .chat-open .bd-chat { transition: transform .34s var(--bd-ease), bottom .2s, visibility 0s; }
226
+ .bd-chat.dragging { transition: none !important; }
227
+ }
228
+ @container stage (max-width: 720px) {
229
+ .bd-chat { bottom: var(--kb, 0px); height: min(78%, 600px, calc(100% - var(--kb, 0px) - 8px)); }
230
+ }
231
+
232
+ /* Mòbil en horitzontal: poc d'alçada */
233
+ @media (max-height: 480px) and (max-width: 940px) {
234
+ .bd-chat { height: min(92%, 600px); }
235
+ .bd-tools { bottom: calc(6px + env(safe-area-inset-bottom)); left: auto; right: auto; max-width: calc(100% - 20px); }
236
+ .bd-tools { left: 50%; transform: translateX(-50%); }
237
+ .bd .tb { height: 40px; }
238
+ .style-open .bd-style:not([hidden]) { bottom: calc(58px + env(safe-area-inset-bottom)); max-height: calc(100% - 130px); overflow-y: auto; grid-template-columns: repeat(2, 1fr); }
239
+ .bd-help-in { padding: 14px; }
240
+ .bd-empty { display: none; }
241
+ }
242
+
package/web/board.js CHANGED
@@ -42,6 +42,7 @@ const uid = () => Math.random().toString(36).slice(2, 10);
42
42
  const norm = s => String(s || '').normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/·/g, '').toLowerCase().trim();
43
43
  const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
44
44
  const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
45
+ const coarse = matchMedia('(pointer: coarse)').matches;
45
46
 
46
47
  /* ---------- Estat ---------- */
47
48
  function fresh() {
@@ -311,13 +312,13 @@ function drawSel() {
311
312
  if (list.length) {
312
313
  const p = 6 / z;
313
314
  if (list.length === 1 && isLinear(list[0])) {
314
- const [a, b] = ends(list[0]), r = 6 / z;
315
+ const [a, b] = ends(list[0]), r = (coarse ? 11 : 6) / z;
315
316
  s += `<circle class="hdl" data-ep="0" cx="${a[0]}" cy="${a[1]}" r="${r}" stroke-width="${1.5 / z}"/><circle class="hdl" data-ep="1" cx="${b[0]}" cy="${b[1]}" r="${r}" stroke-width="${1.5 / z}"/>`;
316
317
  } else {
317
318
  const b = union(list);
318
319
  s += `<rect class="selbox" x="${b.x - p}" y="${b.y - p}" width="${b.w + p * 2}" height="${b.h + p * 2}" stroke-width="${1.2 / z}" stroke-dasharray="${4 / z} ${3 / z}"/>`;
319
320
  if (list.length === 1 && list[0].type !== 'draw') {
320
- const H = 9 / z, bx = b.x - p, by = b.y - p, bw = b.w + p * 2, bh = b.h + p * 2;
321
+ const H = (coarse ? 17 : 9) / z, bx = b.x - p, by = b.y - p, bw = b.w + p * 2, bh = b.h + p * 2;
321
322
  const pos = { nw: [bx, by], n: [bx + bw / 2, by], ne: [bx + bw, by], e: [bx + bw, by + bh / 2], se: [bx + bw, by + bh], s: [bx + bw / 2, by + bh], sw: [bx, by + bh], w: [bx, by + bh / 2] };
322
323
  HANDLES.forEach(k => { if (list[0].type === 'text' && (k === 'n' || k === 's' || k === 'e' || k === 'w')) return; s += `<rect class="hdl" data-h="${k}" x="${pos[k][0] - H / 2}" y="${pos[k][1] - H / 2}" width="${H}" height="${H}" rx="${2 / z}" stroke-width="${1.5 / z}"/>`; });
323
324
  }
@@ -395,7 +396,7 @@ function onDown(e) {
395
396
  if (editing) { endEdit(); if (tool === 'select') return; }
396
397
  R.svg.setPointerCapture(e.pointerId);
397
398
  pointers.set(e.pointerId, [e.clientX, e.clientY]);
398
- if (pointers.size === 2) { const [a, b] = [...pointers.values()]; drag = { mode: 'pinch', d0: Math.hypot(a[0] - b[0], a[1] - b[1]), z0: B.view.z, v0: { ...B.view }, m0: [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2] }; marquee = null; return; }
399
+ if (pointers.size === 2) { abortDrag(); const [a, b] = [...pointers.values()]; drag = { mode: 'pinch', d0: Math.hypot(a[0] - b[0], a[1] - b[1]), z0: B.view.z, v0: { ...B.view }, m0: [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2] }; marquee = null; return; }
399
400
  const [wx, wy] = toWorld(e.clientX, e.clientY);
400
401
  const hitG = e.target.closest('[data-id]'), hit = hitG ? byId(hitG.dataset.id) : null;
401
402
  const touch = e.pointerType === 'touch';
@@ -439,6 +440,13 @@ function onDown(e) {
439
440
  drag = { mode: 'draw', el, last: [wx, wy] };
440
441
  }
441
442
  }
443
+ function abortDrag() {
444
+ const d = drag; if (!d) return;
445
+ if (d.mode === 'create' || d.mode === 'line' || d.mode === 'draw') { B.els = B.els.filter(x => x !== d.el); nodes.get(d.el.id)?.remove(); nodes.delete(d.el.id); hist.pop(); }
446
+ else if (d.mode === 'move' && d.rec) { Object.entries(d.orig).forEach(([id, o]) => { const el = byId(id); if (el) { el.x = o.x; el.y = o.y; } }); hist.pop(); full(); }
447
+ else if (d.mode === 'resize' && d.rec) { Object.assign(d.el, d.o); hist.pop(); full(); }
448
+ marquee = null; R.stage.classList.remove('panning'); drag = null; R.empty.hidden = !!B.els.length; drawSel();
449
+ }
442
450
  function snapshotSel() { const m = {}; sel.forEach(id => { const el = byId(id); if (el) m[id] = { x: el.x, y: el.y }; }); return m; }
443
451
  function onMove(e) {
444
452
  if (!R) return;
@@ -559,8 +567,18 @@ function onWheel(e) {
559
567
  if (!R) return;
560
568
  e.preventDefault();
561
569
  const r = R.svg.getBoundingClientRect();
562
- if (e.ctrlKey || e.metaKey) zoomAt(B.view.z * Math.exp(-e.deltaY * .0028), e.clientX - r.left, e.clientY - r.top);
563
- else { B.view.x -= e.shiftKey && !e.deltaX ? e.deltaY : e.deltaX; B.view.y -= e.shiftKey && !e.deltaX ? 0 : e.deltaY; applyView(); drawSel(); save(); }
570
+ const k = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? r.height : 1, dx = e.deltaX * k, dy = e.deltaY * k;
571
+ if (e.ctrlKey || e.metaKey) zoomAt(B.view.z * Math.exp(-clamp(dy, -40, 40) * .01), e.clientX - r.left, e.clientY - r.top);
572
+ else { B.view.x -= e.shiftKey && !dx ? dy : dx; B.view.y -= e.shiftKey && !dx ? 0 : dy; applyView(); drawSel(); save(); }
573
+ }
574
+ let gz = null;
575
+ function onGesture(e) {
576
+ if (!R) return;
577
+ e.preventDefault();
578
+ const r = R.svg.getBoundingClientRect();
579
+ if (e.type === 'gesturestart') { gz = { z0: B.view.z }; return; }
580
+ if (e.type === 'gestureend') { gz = null; return; }
581
+ if (gz) zoomAt(gz.z0 * e.scale, e.clientX - r.left, e.clientY - r.top);
564
582
  }
565
583
 
566
584
  /* ---------- Accions ---------- */
@@ -1169,6 +1187,7 @@ function mount(root) {
1169
1187
  if (first) { const r = R.svg.getBoundingClientRect(); B.view = { x: r.width / 2, y: r.height / 2, z: 1 }; }
1170
1188
  on(R.svg, 'pointerdown', onDown); on(window, 'pointermove', onMove); on(window, 'pointerup', onUp); on(window, 'pointercancel', onUp);
1171
1189
  on(R.svg, 'wheel', onWheel, { passive: false });
1190
+ ['gesturestart', 'gesturechange', 'gestureend'].forEach(t => on(R.svg, t, onGesture, { passive: false }));
1172
1191
  on(window, 'keydown', onKey); on(window, 'keyup', onKeyUp);
1173
1192
  on(root, 'click', onClick);
1174
1193
  on(R.editor, 'input', placeEditor); on(R.editor, 'blur', () => setTimeout(endEdit, 0));
@@ -1180,6 +1199,11 @@ function mount(root) {
1180
1199
  on(R.root.querySelector('#bdImport'), 'change', e => { const f = e.target.files[0]; if (f) importJSON(f); e.target.value = ''; });
1181
1200
  on(document, 'click', e => { if (R && !e.target.closest('.bd-menuwrap')) { const m = R.root.querySelector('#bdMenu'); if (m) m.hidden = true; } });
1182
1201
  on(window, 'resize', () => { applyView(); drawSel(); });
1202
+ const vv = window.visualViewport;
1203
+ if (vv) {
1204
+ const kb = () => R && R.root.style.setProperty('--kb', Math.max(0, Math.round(innerHeight - vv.height - vv.offsetTop)) + 'px');
1205
+ on(vv, 'resize', kb); on(vv, 'scroll', kb); kb();
1206
+ }
1183
1207
  if (document.fonts) document.fonts.load(`500 24px Caveat`).then(() => { if (R) { B.els.forEach(el => { if (el.type === 'text' && el.text) fitText(el); }); full(); } }).catch(() => {});
1184
1208
  chatSwipe(R.chat);
1185
1209
  full(); drawChat(); drawBrains(); setTool(tool); openChat(B.opts.chat && !matchMedia('(max-width: 820px)').matches, false, false);
package/web/index.html CHANGED
@@ -79,6 +79,15 @@ main .cmd code::-webkit-scrollbar { display: none; }
79
79
  @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation: none; transition: none; } }
80
80
  .copy.done .ic { display: none; }
81
81
  main strong { font-weight: 600; }
82
+ .feat { list-style: none; display: grid; gap: 14px; padding: 0; margin-top: 4px; }
83
+ .feat li { padding-left: 18px; position: relative; text-wrap: pretty; }
84
+ .feat li::before { content: ""; position: absolute; left: 0; top: .72em; width: 6px; height: 6px; border-radius: 50%; background: var(--accent); }
85
+ .cmds { display: grid; gap: 8px; margin: 16px 0; }
86
+ .cmds > div { display: grid; grid-template-columns: minmax(0, 15.5rem) minmax(0, 1fr); gap: 4px 16px; align-items: baseline; padding: 10px 14px; border-radius: 12px; background: #e6e1d8; }
87
+ .cmds dt code { background: #f6f3ee; }
88
+ .cmds dd { font-size: .86em; color: var(--fg-muted); }
89
+ @media (max-width: 620px) { .cmds > div { grid-template-columns: 1fr; } }
90
+
82
91
 
83
92
  .footer .page { padding: 40px 0 56px; text-align: center; font-size: 13px; color: var(--fg-muted); }
84
93
  .footer a { color: var(--fg); text-decoration: underline; text-decoration-color: var(--red); text-decoration-thickness: 1px; text-underline-offset: 3px; }
@@ -117,16 +126,66 @@ main strong { font-weight: 600; }
117
126
 
118
127
  <section aria-labelledby="que">
119
128
  <h2 id="que">Què hi pots fer</h2>
120
- <ul class="list">
121
- <li><strong>Sessions</strong> amb Claude Code i Codex, en directe, a qualsevol dels teus ordinadors.</li>
122
- <li><strong>Aprovacions</strong>: abans d’editar, executar o clicar res, te’n demana permís amb el motiu. Tot queda registrat.</li>
123
- <li><strong>Feines</strong> que es fan soles: ara, més tard o cada dia, i encadenades («quan Claude Code acabi, que Codex ho revisi»).</li>
124
- <li><strong>Memòria compartida</strong>: el que recorda un agent, ho sap l’altre. Fitxers markdown al teu ordinador.</li>
125
- <li><strong>Telegram i veu</strong>: parla-hi amb el teu bot o amb notes de veu, i aprova amb un botó.</li>
126
- <li><strong>Navegador i escriptori</strong>: els agents poden mirar webs i apps; per actuar, sempre et demanen permís amb una captura.</li>
129
+ <ul class="feat">
130
+ <li><strong>Sessions en directe.</strong> Claude Code i Codex a qualsevol dels teus ordinadors: tria l’agent, la carpeta i el nivell de permisos, i la resposta et va arribant mentre treballa, amb les eines que fa servir.</li>
131
+ <li><strong>Aprovacions.</strong> Abans d’editar, executar o clicar res, te’n demana permís amb el motiu, i ho aproves o ho rebutges amb un toc. Tot queda registrat a Activitat.</li>
132
+ <li><strong>Avisos al mòbil.</strong> Una notificació quan un agent et demana permís o acaba la feina. Arriba buida: el detall el demana el mòbil directament a l’ordinador.</li>
133
+ <li><strong>Feines.</strong> Que es facin soles: ara, més tard o cada dia, i encadenades («quan Claude Code acabi, que Codex ho revisi»).</li>
134
+ <li><strong>Memòria compartida.</strong> El que recorda un agent, ho sap l’altre. Són fitxers markdown al teu ordinador.</li>
135
+ <li><strong>Ordinadors i servidors.</strong> El teu ordinador o un servidor (VPS), tots a la mateixa llista. Els afegeixes escrivint <code>usuari@servidor</code>.</li>
136
+ <li><strong>Pantalla remota.</strong> Mira i controla l’escriptori de l’ordinador des del mòbil, amb zoom amb dos dits i captures. Més avall.</li>
137
+ <li><strong>Navegador i escriptori per als agents.</strong> Poden mirar webs i apps; per actuar, sempre et demanen permís amb una captura.</li>
138
+ <li><strong>Telegram i veu.</strong> Parla amb el teu bot o amb notes de veu, aprova amb un botó, i escolta les respostes (ElevenLabs o OpenAI). Les claus es queden a l’ordinador.</li>
139
+ <li><strong>Git i GitHub.</strong> Clona un repositori, mira els canvis, fes commit i crea un PR des del mòbil, amb el <code>git</code> i el <code>gh</code> de l’ordinador.</li>
140
+ <li><strong>Models d’Ollama i xat local.</strong> Descarrega i fes servir models gratuïts a l’ordinador, o un model petit dins del navegador del mòbil.</li>
141
+ <li><strong>Pissarra.</strong> Un llenç per fer diagrames, i en Nio te’ls dibuixa si li ho expliques. Més avall.</li>
142
+ <li><strong>Com una app.</strong> Afegeix nio a la pantalla d’inici del mòbil; llisca als costats per canviar de pàgina i <code>Ctrl K</code> obre el buscador a l’ordinador.</li>
127
143
  </ul>
128
144
  </section>
129
145
 
146
+ <section aria-labelledby="cmds">
147
+ <h2 id="cmds">Comandes amb «/»</h2>
148
+ <p>Al missatge d’una sessió, escriu <code>/</code> i surten els suggeriments. Les de nio funcionen amb tots els agents; les de l’agent (comandes i skills) se li envien tal qual.</p>
149
+ <dl class="cmds">
150
+ <div><dt><code>/mode</code></dt><dd>Canvia els permisos: <code>l</code> llegir, <code>p</code> preguntar, <code>t</code> tot</dd></div>
151
+ <div><dt><code>/model</code></dt><dd>Canvia de model</dd></div>
152
+ <div><dt><code>/stop</code></dt><dd>Atura l’agent ara mateix</dd></div>
153
+ <div><dt><code>/nova</code></dt><dd>Nova sessió amb el mateix agent i carpeta</dd></div>
154
+ <div><dt><code>/carpeta</code></dt><dd>Canvia la carpeta de la sessió</dd></div>
155
+ <div><dt><code>/git</code></dt><dd>Veure canvis, fer commit i crear un PR</dd></div>
156
+ <div><dt><code>/nom</code></dt><dd>Posa nom a la sessió</dd></div>
157
+ <div><dt><code>/opcions</code></dt><dd>Opcions de la sessió</dd></div>
158
+ <div><dt><code>/ajuda</code></dt><dd>Llista de comandes</dd></div>
159
+ </dl>
160
+ </section>
161
+
162
+ <section aria-labelledby="screen">
163
+ <h2 id="screen">La pantalla remota</h2>
164
+ <p>Obre l’escriptori d’un dels teus ordinadors a pantalla completa, a la mateixa amplada que el mòbil, com un escriptori remot. Al mode <strong>Mirar</strong> només veus què fa l’agent; al mode <strong>Controlar</strong> pots clicar, fer doble clic, clic dret, arrossegar, desplaçar i escriure.</p>
165
+ <dl class="cmds">
166
+ <div><dt><code>− 100% +</code></dt><dd>Allunya, apropa o torna a enquadrar la imatge</dd></div>
167
+ <div><dt><code>1:1</code></dt><dd>Mida real, per llegir text petit</dd></div>
168
+ <div><dt><code>Desa captura</code></dt><dd>Baixa el fotograma actual com a imatge</dd></div>
169
+ <div><dt><code>Pantalla completa</code></dt><dd>A l’ordinador, amaga tota la resta</dd></div>
170
+ </dl>
171
+ <p><strong>Zoom amb dos dits.</strong> Pessiga per apropar i arrossega amb dos dits per moure’t; al mode Mirar, doble toc alterna entre enquadrar i apropar. Al mode Controlar, el botó «2 dits» tria si els dos dits desplacen l’ordinador o fan zoom. A l’ordinador, pessiga al trackpad o fes servir <code>Ctrl</code> i la roda. Cal activar «Apps de l’escriptori» a l’ordinador, i al Mac donar al terminal els permisos de Gravació de pantalla i Accessibilitat.</p>
172
+ </section>
173
+
174
+ <section aria-labelledby="board">
175
+ <h2 id="board">La pissarra</h2>
176
+ <p>Un llenç infinit per fer fluxos, mapes mentals i organigrames, al mòbil i a l’ordinador. Rectangles, el·lipses, rombes, fletxes que s’enganxen a les figures, línies, llapis, text i goma. Desfés i refés, quadrícula, i exportació a PNG, SVG o JSON.</p>
177
+ <p><strong>Zoom amb dos dits.</strong> Al mòbil, pessiga sobre el llenç per apropar o allunyar, i mou-lo amb els dos dits alhora. A l’ordinador, pessiga al trackpad o fes servir <code>Ctrl</code> i la roda del ratolí.</p>
178
+ <p><strong>Demana-ho a en Nio.</strong> Obre el xat de la pissarra i escriu què vols:</p>
179
+ <dl class="cmds">
180
+ <div><dt><code>Flux: A -> B -> C</code></dt><dd>Una fletxa per línia</dd></div>
181
+ <div><dt><code>Mapa mental de X: a, b, c</code></dt><dd>Tema central amb branques</dd></div>
182
+ <div><dt><code>Organigrama: Direcció &gt; Finances</code></dt><dd>Qui depèn de qui</dd></div>
183
+ <div><dt><code>Connecta Banc amb ERP</code></dt><dd>Uneix dues figures</dd></div>
184
+ <div><dt><code>Pinta Banc de verd</code></dt><dd>Canvia el color</dd></div>
185
+ <div><dt><code>Plantilla d’aprovació</code></dt><dd>Un diagrama de partida</dd></div>
186
+ </dl>
187
+ </section>
188
+
130
189
  <section aria-labelledby="teu">
131
190
  <h2 id="teu">Tot és teu</h2>
132
191
  <p>Fas servir els teus agents i les teves claus (Anthropic, OpenAI, ElevenLabs, el bot de Telegram). Les converses, la memòria i les claus es queden als teus ordinadors. nio.cat només serveix l’app i guarda, signada, l’adreça on trobar cada ordinador. Més detalls a <a href="/privadesa">privadesa</a>.</p>
package/web/sw.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // nio · service worker: obre l'app encara que no hi hagi connexió i mostra els avisos.
2
- const SHELL = 'nio-shell-v22';
2
+ const SHELL = 'nio-shell-v25';
3
3
  const FILES = ['/app', '/app/style.css', '/app/app.js', '/bot.css', '/bot.js', '/board.css', '/board.js', '/favicon.svg', '/manifest.webmanifest'];
4
4
 
5
5
  self.addEventListener('install', (e) => {