nio-cat 0.9.3 → 0.9.5

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
@@ -111,27 +111,19 @@ async function showPair(publicUrl) {
111
111
  const { code: secret } = auth.createCode({ secret: true });
112
112
  const link = pairLink(app, publicUrl, secret), code = makeCode(publicUrl, secret), qrLink = codeLink(app, code);
113
113
  const file = writePairPage(link, code, qrLink);
114
- // El QR ha de cabre al terminal: marge normal, marge d'1 mòdul i, si no hi cap, la pàgina del navegador.
115
- if (panelOpened) {
116
- console.log(`\n ${c.b('Connecta el mòbil')}`);
117
- console.log(` Al panell del navegador prem ${c.b('«Continua al mòbil»')} i escaneja el codi.`);
118
- console.log(c.dim(' També pots enganxar aquest codi a Sessions → Tria un ordinador:\n'));
119
- console.log(` ${code}\n`);
120
- console.log(c.dim(' Serveix per a un sol dispositiu i caduca en 15 minuts. Per a un altre: nio pair.\n'));
121
- } else {
122
- const rows = process.stdout.rows || 999, cols = process.stdout.columns || 999;
123
- const fits = (m) => { const z = terminalQrSize(qrLink, m); return z.rows + 9 <= rows && z.cols + 2 <= cols; };
124
- const margin = fits(2) ? 2 : fits(1) ? 1 : 0;
125
- console.log(`\n ${c.b('Connecta el mòbil')}\n`);
126
- if (margin) {
127
- console.log(` Escaneja aquest codi amb la càmera:\n`);
128
- console.log(terminalQr(qrLink, { margin }));
129
- console.log('');
130
- }
131
- console.log(` ${margin ? 'O copia' : 'Copia'} aquest codi i enganxa'l a Sessions → Tria un ordinador:\n`);
132
- console.log(` ${code}\n`);
133
- console.log(c.dim(' Serveix per a un sol dispositiu i caduca en 15 minuts. Per a un altre: nio pair.\n'));
134
- }
114
+ // El QR sempre al terminal, el més gran que hi càpiga: gran (2 caràcters per mòdul), compacte (mig bloc) i,
115
+ // si ni així hi cap d'ample, cap QR i només el codi. Amb poques files, el marge passa de 2 a 1 mòdul.
116
+ const rows = process.stdout.rows || 999, cols = process.stdout.columns || 999;
117
+ const fits = (m, big) => { const z = terminalQrSize(qrLink, m, big); return z.rows + 4 <= rows && z.cols <= cols; };
118
+ const opt = [[2, true], [2, false], [1, false]].find(([m, big]) => fits(m, big)) || (terminalQrSize(qrLink, 1).cols <= cols ? [1, false] : null);
119
+ console.log(`\n ${c.b('Connecta el mòbil')}\n`);
120
+ if (opt) {
121
+ console.log(` Escaneja aquest codi amb la càmera${panelOpened ? ' (també el tens al navegador)' : ''}:\n`);
122
+ console.log(terminalQr(qrLink, { margin: opt[0], big: opt[1] }));
123
+ } else console.log(` La finestra és massa estreta per al QR${panelOpened ? '; el tens al navegador' : '. Fes-la més ampla i torna-ho a provar: nio pair'}.`);
124
+ console.log(`\n ${opt ? 'O copia' : 'Copia'} aquest codi i enganxa'l a Sessions → Tria un ordinador:\n`);
125
+ console.log(` ${code}\n`);
126
+ console.log(c.dim(' Serveix per a un sol dispositiu i caduca en 15 minuts. Per a un altre: nio pair.\n'));
135
127
  if (flags.open) {
136
128
  const opener = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
137
129
  try {
@@ -340,6 +332,7 @@ async function start() {
340
332
  console.log(' Encara no hi ha cap adreça desada. Engega nio primer.');
341
333
  break;
342
334
  }
335
+ if (!flags.url) openDashboard();
343
336
  await showPair(flags.url || cfg.lastUrl);
344
337
  break;
345
338
  case 'reset-token': {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nio-cat",
3
- "version": "0.9.3",
3
+ "version": "0.9.5",
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/control.js CHANGED
@@ -9,7 +9,7 @@ const fs = require('fs');
9
9
  const os = require('os');
10
10
  const path = require('path');
11
11
  const crypto = require('crypto');
12
- const { execFile } = require('child_process');
12
+ const { spawn } = require('child_process');
13
13
  const { which, childEnv } = require('./proc');
14
14
  const config = require('./config');
15
15
  const { HOME } = config;
@@ -46,9 +46,32 @@ function classify(command) {
46
46
  return { tool, kind: KIND[tool], sub, act: !read };
47
47
  }
48
48
 
49
+ // Executa una eina amb un temps màxim que es compleix sempre. Al Mac, agent-browser és un llançador de Node que obre
50
+ // el binari com a fill: matar només el llançador deixava el fill amb la sortida oberta i la petició penjada.
51
+ // Per això: grup de processos propi, s'acaba quan surt el procés (no quan es tanquen les sortides) i, si passa
52
+ // el temps, es mata tot el grup.
49
53
  function run(bin, args, timeout = 15000) {
50
54
  return new Promise((resolve) => {
51
- execFile(bin, args, { timeout, env: childEnv(), maxBuffer: 8 * 1024 * 1024 }, (err, stdout, stderr) => resolve({ ok: !err, out: String(stdout || ''), err: String(stderr || err?.message || '') }));
55
+ let out = '', err = '', done = false, child, drain;
56
+ const finish = (ok, why) => {
57
+ if (done) return;
58
+ done = true;
59
+ clearTimeout(timer); clearTimeout(drain);
60
+ child?.stdout?.destroy(); child?.stderr?.destroy();
61
+ resolve({ ok, out, err: err || why || '', timedOut: why === 'temps' });
62
+ };
63
+ const timer = setTimeout(() => {
64
+ try { process.kill(-child.pid, 'SIGKILL'); } catch { try { child.kill('SIGKILL'); } catch { /* ja no hi és */ } }
65
+ finish(false, 'temps');
66
+ }, timeout);
67
+ try {
68
+ child = spawn(bin, args, { env: childEnv(), stdio: ['ignore', 'pipe', 'pipe'], detached: process.platform !== 'win32' });
69
+ } catch (e) { return finish(false, e.message); }
70
+ child.stdout.on('data', (d) => { if (out.length < 8 << 20) out += d; });
71
+ child.stderr.on('data', (d) => { if (err.length < 1 << 20) err += d; });
72
+ child.on('error', (e) => finish(false, e.message));
73
+ child.on('close', (code) => finish(code === 0));
74
+ child.on('exit', (code) => { drain = setTimeout(() => finish(code === 0), 300); });
52
75
  });
53
76
  }
54
77
 
@@ -77,7 +100,9 @@ class Control {
77
100
  let j = null;
78
101
  try { j = JSON.parse(r.out); } catch { /* text */ }
79
102
  desk.ready = j ? j.ok !== false : r.ok;
80
- desk.why = desk.ready ? '' : (j?.error?.message || r.err || r.out).split('\n')[0].slice(0, 200);
103
+ desk.why = desk.ready ? '' : r.timedOut
104
+ ? (process.platform === 'darwin' ? 'agent-desktop no respon. Dona-li permís a Configuració del Sistema → Privadesa i seguretat → Accessibilitat i torna-ho a provar.' : 'agent-desktop no respon.')
105
+ : (j?.error?.message || r.err || r.out).split('\n')[0].slice(0, 200);
81
106
  }
82
107
  const shotTool = process.platform === 'darwin' ? which('screencapture') : which('grim') || which('scrot') || which('gnome-screenshot');
83
108
  this.capsCache = {
@@ -97,7 +122,7 @@ class Control {
97
122
  const parts = [];
98
123
  const tries = kind === 'navegador' ? [['skills', 'get', 'core'], ['skills', 'get', 'agent-browser']] : [['skills', 'get', 'desktop'], ['skills', 'get', 'platform']];
99
124
  for (const args of tries) {
100
- const r = await run(bin, args, 20000);
125
+ const r = await run(bin, args, 8000);
101
126
  if (r.ok && r.out.trim()) {
102
127
  parts.push(r.out.trim().replace(/^---[\s\S]*?\n---\s*/, ''));
103
128
  if (kind === 'navegador') break;
@@ -214,7 +239,8 @@ class Control {
214
239
  this.audit.add({ type: 'control', what: `${kind === 'navegador' ? 'Control del navegador' : 'Control de l’escriptori'} ${body[kind] ? 'activat' : 'desactivat'}`, by: device?.name });
215
240
  }
216
241
  config.save(this.cfg);
217
- return { ...(await this.caps(true)), notes };
242
+ for (const kind of ['navegador', 'escriptori']) caps[kind].enabled = this.enabled(kind);
243
+ return { ...caps, notes };
218
244
  },
219
245
  },
220
246
  { method: 'POST', path: '/api/screen', handler: ({ body }) => this.screenshot(body?.from) },
package/src/pair.js CHANGED
@@ -35,22 +35,38 @@ function qr(text) {
35
35
  return q;
36
36
  }
37
37
 
38
- // QR per al terminal: cada mòdul són dos espais amb color de fons (negre o blanc). No depèn de cap glif,
39
- // així que no es talla amb tipus de lletra o interlineats estranys. Sempre negre sobre blanc, amb tema clar o fosc.
40
- function terminalQr(text, { color = !process.env.NO_COLOR, margin = 2 } = {}) {
38
+ // QR per al terminal, compacte: cada caràcter «▀» pinta dos mòduls (dalt amb el color de la lletra, baix amb el del fons).
39
+ // Ocupa la meitat d'alt i d'ample que amb dos espais per mòdul. Sempre negre sobre blanc, amb tema clar o fosc.
40
+ function terminalQr(text, { color = !process.env.NO_COLOR, margin = 2, big = false } = {}) {
41
+ if (big) return terminalQrBig(text, { color, margin });
41
42
  const q = qr(text);
42
43
  const n = q.getModuleCount();
43
- const M = margin; // marge blanc al voltant, en mòduls
44
+ const M = margin;
45
+ const dark = (r, c) => r >= 0 && c >= 0 && r < n && c < n && q.isDark(r, c);
46
+ const lines = [];
47
+ for (let r = -M; r < n + M; r += 2) {
48
+ let line = '';
49
+ for (let c = -M; c < n + M; c++) {
50
+ const t = dark(r, c), b = r + 1 < n + M ? dark(r + 1, c) : false;
51
+ if (color) line += `\x1b[${t ? 30 : 97};${b ? 40 : 107}m▀`;
52
+ else line += t && b ? ' ' : t ? '▄' : b ? '▀' : '█';
53
+ }
54
+ lines.push(` ${line}${color ? '\x1b[0m' : ''}`);
55
+ }
56
+ return lines.join('\n');
57
+ }
58
+
59
+ // QR gran: dos espais amb color de fons per mòdul. Quadrat i molt fàcil d'escanejar, però ocupa el doble.
60
+ function terminalQrBig(text, { color, margin: M }) {
61
+ const q = qr(text), n = q.getModuleCount();
44
62
  const dark = (r, c) => r >= 0 && c >= 0 && r < n && c < n && q.isDark(r, c);
45
63
  const lines = [];
46
64
  for (let r = -M; r < n + M; r++) {
47
65
  let line = '', prev = null;
48
66
  for (let c = -M; c < n + M; c++) {
49
67
  const d = dark(r, c);
50
- if (color) {
51
- if (d !== prev) line += d ? '\x1b[40m' : '\x1b[107m';
52
- line += ' ';
53
- } else line += d ? ' ' : '██';
68
+ if (color) { if (d !== prev) line += d ? '\x1b[40m' : '\x1b[107m'; line += ' '; }
69
+ else line += d ? ' ' : '██';
54
70
  prev = d;
55
71
  }
56
72
  lines.push(` ${line}${color ? '\x1b[0m' : ''}`);
@@ -95,8 +111,8 @@ code{display:block;word-break:break-all;font:12px/1.5 ui-monospace,Menlo,monospa
95
111
  module.exports = { pairLink, terminalQr, writePairPage, qrSvg, makeCode, codeLink };
96
112
 
97
113
  // Mida del QR al terminal: files i columnes que ocupa amb un marge donat.
98
- function terminalQrSize(text, margin = 2) {
114
+ function terminalQrSize(text, margin = 2, big = false) {
99
115
  const n = qr(text).getModuleCount() + margin * 2;
100
- return { rows: n, cols: n * 2 + 2 };
116
+ return big ? { rows: n, cols: n * 2 + 2 } : { rows: Math.ceil(n / 2), cols: n + 2 };
101
117
  }
102
118
  module.exports.terminalQrSize = terminalQrSize;
package/web/app/app.js CHANGED
@@ -141,7 +141,9 @@ async function api(path, opts = {}) {
141
141
  const m = machine();
142
142
  if (!m) throw new Error('No hi ha cap ordinador connectat');
143
143
  const body = opts.body && typeof opts.body !== 'string' ? opts.body : opts.body ? JSON.parse(opts.body) : undefined;
144
- const res = await fetch(m.url + path, { ...opts, headers: { Authorization: `Bearer ${m.token}`, 'content-type': 'application/json', ...(opts.headers || {}) }, body: body ? JSON.stringify(body) : undefined });
144
+ let res;
145
+ try { res = await fetch(m.url + path, { ...opts, headers: { Authorization: `Bearer ${m.token}`, 'content-type': 'application/json', ...(opts.headers || {}) }, body: body ? JSON.stringify(body) : undefined }); }
146
+ catch (e) { if (e.name === 'AbortError') throw e; throw new Error(`No he pogut parlar amb ${m.name || 'l’ordinador'}. Comprova que nio hi estigui engegat i torna-ho a provar.`); }
145
147
  const data = await res.json().catch(() => ({}));
146
148
  if (!res.ok) throw new Error(data.error || `Error ${res.status}`);
147
149
  if (Array.isArray(data.agents)) data.agents = data.agents.filter((x) => x.id !== 'demo'); // l'agent de proves del programa no s'ensenya
@@ -484,6 +486,7 @@ function renderChrome() {
484
486
  .map(([k, ic, l, on]) => `<a href="${HASH[k]}" ${on ? 'aria-current="page"' : ''}>${svg(ic)}<span>${l}</span>${k === 'approvals' && n ? `<b class="badge">${n}</b>` : ''}</a>`).join('')
485
487
  .replace('</a><a href="#/ordinadors"', `</a><button type="button" data-act="pantalla-tab">${svg('<path d="M5 3l14 7-6 2.2L10.8 19z"/>')}<span>Pantalla</span></button><a href="#/ordinadors"`)
486
488
  + `<button type="button" data-act="more" ${more ? 'aria-current="page"' : ''}>${svg(ICON.more)}<span>Més</span></button>`;
489
+ for (const w of document.querySelectorAll('a.word')) w.setAttribute('href', r.name === 'sessions' ? '/' : '#/sessions');
487
490
  const s = r.name === 'session' ? state.sessions.get(r.id) : null;
488
491
  $('#here').textContent = s ? s.title || 'Sessió nova' : VIEWS[r.name]?.label || '';
489
492
  $('#mRight').innerHTML = r.name === 'session'
package/web/index.html CHANGED
@@ -6,6 +6,13 @@
6
6
  <title>nio // els teus agents, des del mòbil</title>
7
7
  <meta name="description" content="Claude Code i Codex als teus ordinadors, controlats des del mòbil. Personal, gratuït i amb les teves claus.">
8
8
  <link rel="canonical" href="https://www.nio.cat/">
9
+ <meta name="robots" content="index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1">
10
+ <meta name="author" content="f.cat">
11
+ <meta name="application-name" content="nio">
12
+ <link rel="alternate" hreflang="ca" href="https://www.nio.cat/">
13
+ <link rel="alternate" hreflang="x-default" href="https://www.nio.cat/">
14
+ <link rel="alternate" type="text/plain" href="/llms.txt" title="nio per a models de llenguatge">
15
+ <link rel="sitemap" type="application/xml" href="/sitemap.xml">
9
16
  <meta name="theme-color" content="#f0ede7">
10
17
  <meta property="og:type" content="website">
11
18
  <meta property="og:site_name" content="nio">
@@ -132,6 +139,9 @@ main .own a.btn:hover { color: var(--fg); background: var(--lemon); }
132
139
  .footer .page { padding: 64px 0 48px; text-align: center; font-size: 13px; color: var(--fg-muted); }
133
140
  .footer a { color: var(--fg); text-decoration: underline; text-decoration-color: var(--red); text-decoration-thickness: 1px; text-underline-offset: 3px; }
134
141
  .footer a:hover { color: var(--red); }
142
+ .footer .copy-l { margin: 0; }
143
+ .footer .flinks { margin-top: 8px; display: flex; flex-wrap: wrap; justify-content: center; gap: 6px 18px; }
144
+ @media (max-width: 620px) { .footer .page { padding: 48px 0 40px; } .footer .flinks { gap: 6px 18px; } }
135
145
  main { padding-bottom: 8px; }
136
146
  @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation: none !important; transition: none !important; scroll-behavior: auto !important; } }
137
147
 
@@ -148,7 +158,6 @@ main { padding-bottom: 8px; }
148
158
  .page { width: calc(100% - 40px); }
149
159
  .head { padding-top: 24px; }
150
160
  .mark { font-size: 28px; }
151
- .hero { padding-top: 56px; }
152
161
  section { margin-top: 72px; }
153
162
  .sec-head { margin-bottom: 24px; }
154
163
  .grid { grid-template-columns: 1fr; gap: 12px; }
@@ -160,10 +169,23 @@ main { padding-bottom: 8px; }
160
169
  .panel h3 { font-size: 23px; }
161
170
  .rows > div { grid-template-columns: 1fr; gap: 2px; }
162
171
  .install { padding-left: 16px; }
163
- .install code { font-size: 13.5px; }
164
- .own { padding: 28px 24px; border-radius: 22px; }
172
+ .install code { font-size: 13px; white-space: normal; overflow-wrap: anywhere; text-align: left; line-height: 1.5; padding: 8px 0; }
173
+ .own { border-radius: 22px; }
174
+ .hero { padding-top: 48px; text-align: center; }
175
+ .hero .lead { margin-inline: auto; font-size: 17px; }
176
+ .install { margin-inline: auto; }
177
+ .hero .alt { display: grid; gap: 4px; justify-items: center; text-wrap: balance; }
178
+ .sec-head { text-align: center; margin-inline: auto; }
179
+ .sec-head h2 { font-size: 30px; }
180
+ .step { text-align: left; }
181
+ .own { text-align: center; padding: 28px 18px; }
182
+ .own h2 { margin-bottom: 4px; }
183
+ .own .btns { flex-wrap: nowrap; justify-content: center; gap: 6px; margin-top: 20px; }
184
+ .own .btn { flex: 1 1 auto; justify-content: center; height: 42px; padding: 0 8px; font-size: 13px; white-space: nowrap; }
165
185
  }
186
+ @media (max-width: 360px) { .own .btn { font-size: 12px; padding: 0 6px; } }
166
187
  </style>
188
+ <script type="application/ld+json">{"@context":"https://schema.org","@graph":[{"@type":"WebSite","@id":"https://www.nio.cat/#site","url":"https://www.nio.cat/","name":"nio","inLanguage":"ca","publisher":{"@id":"https://www.nio.cat/#org"}},{"@type":"Organization","@id":"https://www.nio.cat/#org","name":"f.cat","url":"https://f.cat","brand":{"@type":"Brand","name":"nio"},"logo":"https://www.nio.cat/icon-512.png"},{"@type":"SoftwareApplication","@id":"https://www.nio.cat/#app","name":"nio","url":"https://www.nio.cat/","description":"Comandament per a Claude Code, Codex, Grok i Antigravity: els agents treballen als teus ordinadors, amb els teus comptes i les teves claus, i tu els dones feina, els segueixes en directe i aproves el que és arriscat des del mòbil.","applicationCategory":"DeveloperApplication","operatingSystem":"Linux, macOS, web (Android, iOS)","inLanguage":"ca","isAccessibleForFree":true,"offers":{"@type":"Offer","price":"0","priceCurrency":"EUR"},"installUrl":"https://www.nio.cat/install.sh","publisher":{"@id":"https://www.nio.cat/#org"},"image":"https://www.nio.cat/icon-512.png","featureList":["Sessions en directe amb Claude Code, Codex, Grok i Antigravity","Aprovacions amb un toc abans d'editar o executar res","Avisos al mòbil","Feines programades i encadenades","Memòria compartida entre agents","Pantalla remota de l'ordinador","Pissarra amb dibuix assistit","Git i GitHub des del mòbil","Telegram i veu","Ollama i xat local"]},{"@type":"WebPage","@id":"https://www.nio.cat/#page","url":"https://www.nio.cat/","name":"nio // els teus agents, des del mòbil","isPartOf":{"@id":"https://www.nio.cat/#site"},"about":{"@id":"https://www.nio.cat/#app"},"inLanguage":"ca"}]}</script>
167
189
  </head>
168
190
  <body>
169
191
  <header class="page head">
@@ -176,7 +198,7 @@ main { padding-bottom: 8px; }
176
198
  <h1>Els teus agents, a les teves màquines, des del mòbil.</h1>
177
199
  <p class="lead">nio és un comandament per a Claude Code, Codex i altres agents. Treballen als teus ordinadors, amb els teus comptes i les teves claus. Tu els dones feina des del mòbil, els segueixes en directe i aproves el que és arriscat. Personal i gratuït.</p>
178
200
  <div class="install"><code id="cmd">curl -fsSL https://www.nio.cat/install.sh | sh</code><button class="copy" type="button" data-copy="curl -fsSL https://www.nio.cat/install.sh | sh" aria-label="Copia l’ordre"><svg class="ic" viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2"/><path d="M16 8V5a1 1 0 0 0-1-1H5a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3"/></svg><svg class="ok" viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12.5l4.5 4.5L19 7.5"/></svg></button></div>
179
- <p class="alt">Linux o Mac. Per provar-ho sense instal·lar: <code>npx nio-cat</code></p>
201
+ <p class="alt"><span>Linux o Mac.</span> <span>Per provar-ho sense instal·lar: <code>npx nio-cat</code></span></p>
180
202
  </div>
181
203
 
182
204
  <section aria-labelledby="com">
@@ -212,7 +234,7 @@ main { padding-bottom: 8px; }
212
234
  <h2 id="teu">Tot és teu</h2>
213
235
  </div>
214
236
  <div>
215
- <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>
237
+ <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.</p>
216
238
  <div class="btns"><a class="btn" href="/app">Obre el panell</a><a class="btn ghost" href="/qui-soc">Qui sóc</a><a class="btn ghost" href="/novetats">Novetats</a></div>
217
239
  </div>
218
240
  </div>
@@ -220,7 +242,7 @@ main { padding-bottom: 8px; }
220
242
  </main>
221
243
 
222
244
  <footer class="footer">
223
- <div class="page">© 2026 nio // un projecte de <a href="https://f.cat">f.cat</a> · <a href="mailto:hola@nio.cat">hola@nio.cat</a> · <a href="/qui-soc">Qui sóc</a> · <a href="/novetats">Novetats</a> · <a href="/privadesa">Privadesa</a></div>
245
+ <div class="page"><p class="copy-l">© 2026 nio // un projecte de <a href="https://f.cat">f.cat</a></p><nav class="flinks" aria-label="Peu"><a href="/privadesa">Privadesa</a></nav></div>
224
246
  </footer>
225
247
  <script src="/bot.js" defer></script>
226
248
  <script>
@@ -6,6 +6,11 @@
6
6
  <title>nio // privadesa</title>
7
7
  <meta name="description" content="Què passa per nio.cat i què no hi passa mai.">
8
8
  <link rel="canonical" href="https://www.nio.cat/privadesa">
9
+ <meta name="robots" content="index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1">
10
+ <meta name="author" content="f.cat">
11
+ <meta name="application-name" content="nio">
12
+ <link rel="alternate" hreflang="ca" href="https://www.nio.cat/privadesa">
13
+ <link rel="alternate" hreflang="x-default" href="https://www.nio.cat/privadesa">
9
14
  <meta name="theme-color" content="#f0ede7">
10
15
  <meta property="og:type" content="website">
11
16
  <meta property="og:site_name" content="nio">
@@ -83,6 +88,9 @@ main strong { font-weight: 600; }
83
88
  .footer .page { padding: 40px 0 56px; text-align: center; font-size: 13px; color: var(--fg-muted); }
84
89
  .footer a { color: var(--fg); text-decoration: underline; text-decoration-color: var(--red); text-decoration-thickness: 1px; text-underline-offset: 3px; }
85
90
  .footer a:hover { color: var(--red); }
91
+ .footer .copy-l { margin: 0; }
92
+ .footer .flinks { margin-top: 8px; display: flex; flex-wrap: wrap; justify-content: center; gap: 6px 18px; }
93
+ @media (max-width: 620px) { .footer .page { padding: 48px 0 40px; } .footer .flinks { gap: 6px 18px; } }
86
94
 
87
95
  @media (max-width: 620px) {
88
96
  body { font-size: 17px; }
@@ -95,6 +103,7 @@ main strong { font-weight: 600; }
95
103
  main .cmd code { font-size: 14px; }
96
104
  }
97
105
  </style>
106
+ <script type="application/ld+json">{"@context":"https://schema.org","@graph":[{"@type":"WebPage","@id":"https://www.nio.cat/privadesa#page","url":"https://www.nio.cat/privadesa","name":"nio // privadesa","isPartOf":{"@id":"https://www.nio.cat/#site"},"about":{"@id":"https://www.nio.cat/#app"},"inLanguage":"ca"},{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"nio","item":"https://www.nio.cat/"},{"@type":"ListItem","position":2,"name":"Privadesa","item":"https://www.nio.cat/privadesa"}]}]}</script>
98
107
  </head>
99
108
  <body>
100
109
  <header class="page head">
@@ -141,7 +150,7 @@ main strong { font-weight: 600; }
141
150
  </main>
142
151
 
143
152
  <footer class="footer">
144
- <div class="page">© 2026 nio // un projecte de <a href="https://f.cat">f.cat</a> · <a href="mailto:hola@nio.cat">hola@nio.cat</a> · <a href="/qui-soc">Qui sóc</a> · <a href="/novetats">Novetats</a> · <a href="/privadesa">Privadesa</a></div>
153
+ <div class="page"><p class="copy-l">© 2026 nio // un projecte de <a href="https://f.cat">f.cat</a></p><nav class="flinks" aria-label="Peu"><a href="/">Inici</a><a href="/qui-soc">Qui sóc</a><a href="/novetats">Novetats</a></nav></div>
145
154
  </footer>
146
155
  <script src="/bot.js" defer></script>
147
156
  </body>
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-v35';
2
+ const SHELL = 'nio-shell-v37';
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) => {