nio-cat 0.9.0 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nio-cat",
3
- "version": "0.9.0",
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/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
  }
@@ -1870,12 +1918,12 @@ const RD_SPECIAL = /^(Escape|Tab|Arrow(Up|Down|Left|Right)|Home|End|PageUp|PageD
1870
1918
  async function remoteOpen() {
1871
1919
  const m = machine(); if (!m || RD.on) return;
1872
1920
  if (!(state.info?.control?.escriptori?.enabled || screen?.caps?.escriptori?.enabled)) return toast('Activa primer «Apps de l’escriptori» en aquest ordinador.', 5000);
1873
- Object.assign(RD, { on: true, ctl: false, mods: new Set(), drag: false, blank: 0, err: 0, tap: null, scroll: null });
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 });
1874
1922
  const root = document.createElement('div');
1875
1923
  root.className = 'rd'; root.id = 'rd'; root.setAttribute('role', 'dialog'); root.setAttribute('aria-label', 'Pantalla remota');
1876
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>
1877
- <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>
1878
- <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></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>
1879
1927
  <textarea class="rd-type" id="rdType" autocapitalize="off" autocorrect="off" autocomplete="off" spellcheck="false" aria-label="Teclat de l’ordinador"></textarea>`;
1880
1928
  document.body.appendChild(root);
1881
1929
  document.documentElement.classList.add('rd-open');
@@ -1912,7 +1960,7 @@ async function rdLoop() {
1912
1960
  const started = performance.now();
1913
1961
  try {
1914
1962
  const stage = $('#rdStage'); if (!stage) break;
1915
- const w = Math.min(1800, Math.max(640, Math.round(stage.clientWidth * Math.min(devicePixelRatio || 1, 2))));
1963
+ const w = Math.min(2400, Math.max(640, Math.round(stage.clientWidth * Math.min(devicePixelRatio || 1, 2) * Math.min(RD.z, 2))));
1916
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' });
1917
1965
  if (!res.ok) { const d = await res.json().catch(() => ({})); throw new Error(d.error || `Error ${res.status}`); }
1918
1966
  const blob = await res.blob();
@@ -1958,9 +2006,64 @@ function rdScroll(dx, dy, pos) {
1958
2006
  if (ev.dx || ev.dy) rdSend(ev);
1959
2007
  }, 70);
1960
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
+ }
1961
2063
  function rdBind(root) {
1962
2064
  const stage = $('#rdStage', root), typer = $('#rdType', root);
1963
2065
  const ptrs = new Map();
2066
+ rdGestures(stage);
1964
2067
  const click = (pos, e) => {
1965
2068
  const now = performance.now(), prev = RD.tap, near = prev && Math.hypot(e.clientX - prev.x, e.clientY - prev.y) < 28;
1966
2069
  const count = prev && near && now - prev.t < 340 ? (prev.count >= 2 ? 3 : 2) : 1;
@@ -1968,12 +2071,13 @@ function rdBind(root) {
1968
2071
  rdSend({ type: 'click', x: pos[0], y: pos[1], count });
1969
2072
  };
1970
2073
  stage.addEventListener('pointerdown', (e) => {
1971
- if (!RD.ctl || (e.pointerType === 'mouse' && e.button !== 0)) return;
2074
+ if (!RD.ctl || e.target.closest('.rd-tools') || (e.pointerType === 'mouse' && e.button !== 0)) return;
1972
2075
  try { stage.setPointerCapture(e.pointerId); } catch {}
1973
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 };
1974
2077
  const pos = rdPos(e); if (pos) p.path.push(pos);
1975
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);
1976
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); }
1977
2081
  if (!typer.matches(':focus') && !p.touch) typer.focus({ preventScroll: true });
1978
2082
  e.preventDefault();
1979
2083
  });
@@ -1981,7 +2085,7 @@ function rdBind(root) {
1981
2085
  const p = ptrs.get(e.pointerId); if (!p) return;
1982
2086
  const dx = e.clientX - p.x, dy = e.clientY - p.y; p.x = e.clientX; p.y = e.clientY;
1983
2087
  if (!p.moved && Math.hypot(p.x - p.x0, p.y - p.y0) > 8) { p.moved = true; clearTimeout(p.lp); }
1984
- if (!p.moved || p.long) return;
2088
+ if (!p.moved || p.long || p.zoomed) return;
1985
2089
  if (p.touch && (ptrs.size > 1 || !RD.drag)) { if (ptrs.size > 1 && e.isPrimary === false) return; rdScroll(dx, dy, rdPos(e)); return; }
1986
2090
  const now = performance.now(), pos = rdPos(e);
1987
2091
  if (pos && now - p.pt > 22) { p.path.push(pos); p.pt = now; }
@@ -1989,7 +2093,7 @@ function rdBind(root) {
1989
2093
  const end = (e) => {
1990
2094
  const p = ptrs.get(e.pointerId); if (!p) return;
1991
2095
  ptrs.delete(e.pointerId); clearTimeout(p.lp);
1992
- if (p.long) return;
2096
+ if (p.long || p.zoomed) return;
1993
2097
  const pos = rdPos(e);
1994
2098
  if (!p.moved) { if (pos && e.type === 'pointerup') click(pos, e); return; }
1995
2099
  if (p.touch && (ptrs.size > 0 || !RD.drag)) return;
@@ -2043,6 +2147,12 @@ function rdBind(root) {
2043
2147
  rdHint(); break;
2044
2148
  }
2045
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;
2046
2156
  case 'drag': RD.drag = !RD.drag; b.textContent = RD.drag ? '1 dit: arrossega' : '1 dit: desplaça'; break;
2047
2157
  }
2048
2158
  });
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
 
@@ -668,3 +676,11 @@ html.rd-open, html.rd-open body { overflow: hidden; }
668
676
  .rd-keys[hidden] { display: none; }
669
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; }
670
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-v23';
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) => {