nio-cat 0.3.0 → 0.3.2

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
@@ -11,7 +11,7 @@ const { Hub } = require('../src/hub');
11
11
  const { createServer } = require('../src/server');
12
12
  const { detectAll } = require('../src/agents');
13
13
  const { startTunnel } = require('../src/tunnel');
14
- const { pairLink, terminalQr, writePairPage } = require('../src/pair');
14
+ const { pairLink, terminalQr, writePairPage, makeCode, codeLink, terminalQrSize } = require('../src/pair');
15
15
  const { which } = require('../src/proc');
16
16
 
17
17
  const argv = process.argv.slice(2);
@@ -81,13 +81,24 @@ function openDashboard() {
81
81
  else console.log(` ${c.dim('Panell:')} ${link}`);
82
82
  }
83
83
 
84
- function showPair(publicUrl) {
85
- const link = pairLink(flags.app || cfg.app, publicUrl, cfg.token);
86
- const file = writePairPage(link);
87
- console.log(`\n ${c.b('Escaneja aquest codi amb el mòbil')} ${c.dim('(o obre ' + file + ')')}\n`);
88
- console.log(terminalQr(link));
89
- console.log(`\n ${c.dim('Enllaç:')} ${link}\n`);
90
- console.log(c.dim(' Aquest enllaç dona accés als teus agents. Guarda\'l com una contrasenya.\n'));
84
+ async function showPair(publicUrl) {
85
+ const app = flags.app || cfg.app;
86
+ const link = pairLink(app, publicUrl, cfg.token), code = makeCode(publicUrl, cfg.token), qrLink = codeLink(app, code);
87
+ const file = writePairPage(link, code, qrLink);
88
+ // El QR ha de cabre al terminal: marge normal, marge d'1 mòdul i, si no hi cap, la pàgina del navegador.
89
+ const rows = process.stdout.rows || 999, cols = process.stdout.columns || 999;
90
+ const fits = (m) => { const z = terminalQrSize(qrLink, m); return z.rows + 9 <= rows && z.cols + 2 <= cols; };
91
+ const margin = fits(2) ? 2 : fits(1) ? 1 : 0;
92
+ if (margin) {
93
+ console.log(`\n ${c.b('Escaneja aquest codi amb el mòbil')}\n`);
94
+ console.log(terminalQr(qrLink, { margin }));
95
+ } else {
96
+ console.log(`\n ${c.warn('El terminal és massa petit per al QR.')} Fes-lo més gran o obre ${file}`);
97
+ if (canOpenBrowser()) openUrl(file);
98
+ }
99
+ console.log(`\n ${c.b('Codi:')} ${code}`);
100
+ console.log(c.dim(' Copia\'l i enganxa\'l a Sessions → Tria un ordinador.'));
101
+ console.log(c.dim(' Aquest codi dona accés als teus agents. Guarda\'l com una contrasenya.\n'));
91
102
  if (flags.open) {
92
103
  const opener = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
93
104
  try {
@@ -98,7 +109,6 @@ function showPair(publicUrl) {
98
109
  }
99
110
  }
100
111
 
101
-
102
112
  // nio ssh usuari@servidor: engega nio al servidor (si no hi és), obre un túnel SSH i obre el panell ja connectat.
103
113
  const shq = (v) => `'${String(v).replace(/'/g, `'\\''`)}'`;
104
114
  function remoteScript(port, dir) {
@@ -260,7 +270,7 @@ async function start() {
260
270
  console.log(' Encara no hi ha cap adreça desada. Engega nio primer.');
261
271
  break;
262
272
  }
263
- showPair(flags.url || cfg.lastUrl);
273
+ await showPair(flags.url || cfg.lastUrl);
264
274
  break;
265
275
  case 'reset-token':
266
276
  cfg.token = crypto.randomBytes(24).toString('base64url');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nio-cat",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "nio: fes servir Claude Code, Codex, Grok, Antigravity i Ollama del teu ordinador des del mòbil.",
5
5
  "license": "MIT",
6
6
  "author": "f.cat",
package/src/pair.js CHANGED
@@ -12,6 +12,22 @@ function pairLink(app, url, token) {
12
12
  return `${app.replace(/\/$/, '')}/app#u=${u}&t=${encodeURIComponent(token)}`;
13
13
  }
14
14
 
15
+ // Codi de connexió: l'adreça i la clau dins d'una sola cadena («nio-…»). No cal cap servidor.
16
+ // La web ja sap posar-hi l'enllaç de davant. Les adreces de Cloudflare es comprimeixen (≈ 60 caràcters).
17
+ const CF = '.trycloudflare.com';
18
+ function makeCode(url, token) {
19
+ const raw = Buffer.from(token, 'base64url'), tok24 = raw.length === 24 && raw.toString('base64url') === token;
20
+ const sub = url.startsWith('https://') && url.endsWith(CF) ? url.slice(8, -CF.length) : null;
21
+ const lan = url.match(/^http:\/\/(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3}):(\d{1,5})$/);
22
+ let bytes;
23
+ if (tok24 && sub && /^[\w-]+$/.test(sub)) bytes = Buffer.concat([Buffer.from([1]), raw, Buffer.from(sub)]);
24
+ else if (tok24 && lan && lan.slice(1, 5).every((n) => +n < 256) && +lan[5] < 65536) bytes = Buffer.concat([Buffer.from([2]), raw, Buffer.from(lan.slice(1, 5).map(Number)), Buffer.from([+lan[5] >> 8, +lan[5] & 255])]);
25
+ else bytes = Buffer.concat([Buffer.from([0]), Buffer.from(`${url}\n${token}`)]);
26
+ return `nio-${bytes.toString('base64url')}`;
27
+ }
28
+ // Enllaç curt per al QR: una adreça de l'app amb el codi al fragment. Un QR més petit cap millor al terminal.
29
+ const codeLink = (app, code) => `${app.replace(/\/$/, '')}/app#${code}`;
30
+
15
31
  function qr(text) {
16
32
  const q = qrcode(0, 'L');
17
33
  q.addData(text);
@@ -19,34 +35,49 @@ function qr(text) {
19
35
  return q;
20
36
  }
21
37
 
22
- // QR per al terminal: dues files de mòduls per línia amb mitjos blocs, i colors fixos
23
- // (negre sobre blanc) perquè la càmera el llegeixi igual amb tema clar o fosc.
24
- function terminalQr(text, { color = !process.env.NO_COLOR } = {}) {
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 } = {}) {
25
41
  const q = qr(text);
26
42
  const n = q.getModuleCount();
27
- const M = 2; // marge blanc al voltant, en mòduls
43
+ const M = margin; // marge blanc al voltant, en mòduls
28
44
  const dark = (r, c) => r >= 0 && c >= 0 && r < n && c < n && q.isDark(r, c);
29
45
  const lines = [];
30
- for (let r = -M; r < n + M; r += 2) {
31
- let line = '';
46
+ for (let r = -M; r < n + M; r++) {
47
+ let line = '', prev = null;
32
48
  for (let c = -M; c < n + M; c++) {
33
- const top = dark(r, c);
34
- const bot = r + 1 < n + M && dark(r + 1, c);
35
- if (color) line += top && bot ? '█' : top ? '▀' : bot ? '▄' : ' ';
36
- else line += top && bot ? ' ' : top ? '▄' : bot ? '▀' : '█';
49
+ const d = dark(r, c);
50
+ if (color) {
51
+ if (d !== prev) line += d ? '\x1b[40m' : '\x1b[107m';
52
+ line += ' ';
53
+ } else line += d ? ' ' : '██';
54
+ prev = d;
37
55
  }
38
- lines.push(color ? ` \x1b[30;107m${line}\x1b[0m` : ` ${line}`);
56
+ lines.push(` ${line}${color ? '\x1b[0m' : ''}`);
39
57
  }
40
58
  return lines.join('\n');
41
59
  }
42
60
 
61
+ // SVG propi: una sola forma amb tires horitzontals de mòduls, vora de 4 mòduls i vores nítides.
62
+ // (El de la llibreria dibuixa cada mòdul per separat i, en escalar-lo, deixa línies fines entre mòduls.)
43
63
  function qrSvg(text) {
44
- return qr(text).createSvgTag({ cellSize: 6, margin: 4, scalable: true });
64
+ const q = qr(text), n = q.getModuleCount(), M = 4;
65
+ let d = '';
66
+ for (let r = 0; r < n; r++) {
67
+ for (let c = 0; c < n; c++) {
68
+ if (!q.isDark(r, c)) continue;
69
+ let w = 1;
70
+ while (c + w < n && q.isDark(r, c + w)) w++;
71
+ d += `M${c + M} ${r + M}h${w}v1h-${w}z`;
72
+ c += w - 1;
73
+ }
74
+ }
75
+ const size = n + M * 2;
76
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${size} ${size}" shape-rendering="crispEdges" role="img" aria-label="Codi QR"><rect width="${size}" height="${size}" fill="#fff"/><path d="${d}" fill="#000"/></svg>`;
45
77
  }
46
78
 
47
- function writePairPage(link) {
48
- const q = qr(link);
49
- const svg = q.createSvgTag({ cellSize: 6, margin: 4, scalable: true });
79
+ function writePairPage(link, code, qrLink) {
80
+ const svg = qrSvg(qrLink || link);
50
81
  const html = `<!doctype html><html lang="ca"><head><meta charset="utf-8">
51
82
  <meta name="viewport" content="width=device-width,initial-scale=1"><title>nio // connecta el mòbil</title>
52
83
  <link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600&display=swap" rel="stylesheet">
@@ -55,11 +86,17 @@ main{width:min(420px,100% - 40px);padding:40px 0}h1{font-size:30px;font-weight:5
55
86
  .qr{background:#fffefd;border:1px solid #cec7bd;padding:18px;border-radius:16px;margin:24px 0}.qr svg{width:100%;height:auto;display:block}
56
87
  code{display:block;word-break:break-all;font:12px/1.5 ui-monospace,Menlo,monospace;background:#e6e1d8;padding:12px 14px;border-radius:12px;color:#272224}small{display:block;margin-top:20px;font-size:13px;color:#69615c}</style></head>
57
88
  <body><main><h1>Connecta el mòbil</h1><p>Escaneja el codi amb la càmera. S’obrirà nio ja connectat a aquest ordinador.</p><div class="qr">${svg}</div>
58
- <p>Si fas servir nio instal·lat a la pantalla d’inici, copia aquest enllaç i enganxa’l a <b>Connecta un ordinador</b>:</p><code>${link.replace(/&/g, '&amp;')}</code>
59
- <small>Aquest enllaç dona accés als teus agents. Guarda’l com una contrasenya.</small></main></body></html>`;
89
+ <p>O copia aquest codi i enganxa'l a <b>Sessions → Tria un ordinador</b>:</p><code>${code}</code><small>Aquest codi dona accés als teus agents. Guarda'l com una contrasenya.</small></main></body></html>`;
60
90
  const file = path.join(HOME, 'pair.html');
61
91
  fs.writeFileSync(file, html, { mode: 0o600 });
62
92
  return file;
63
93
  }
64
94
 
65
- module.exports = { pairLink, terminalQr, writePairPage, qrSvg };
95
+ module.exports = { pairLink, terminalQr, writePairPage, qrSvg, makeCode, codeLink };
96
+
97
+ // Mida del QR al terminal: files i columnes que ocupa amb un marge donat.
98
+ function terminalQrSize(text, margin = 2) {
99
+ const n = qr(text).getModuleCount() + margin * 2;
100
+ return { rows: n, cols: n * 2 + 2 };
101
+ }
102
+ module.exports.terminalQrSize = terminalQrSize;
package/src/server.js CHANGED
@@ -10,7 +10,7 @@ const ollama = require('./agents/ollama');
10
10
  const { save } = require('./config');
11
11
  const { httpErr } = require('./hub');
12
12
  const github = require('./github');
13
- const { pairLink, qrSvg } = require('./pair');
13
+ const { pairLink, qrSvg, makeCode, codeLink } = require('./pair');
14
14
 
15
15
  // L'app web: copiada dins del paquet en publicar-lo, o l'arrel del repositori en desenvolupament.
16
16
  const WEB_DIRS = [path.join(__dirname, '..', 'web'), path.join(__dirname, '..', '..')];
@@ -232,7 +232,8 @@ function createServer({ cfg, hub, getPublicUrl, getApp }) {
232
232
  const pub = getPublicUrl();
233
233
  if (!pub || /^https?:\/\/(127\.0\.0\.1|localhost|\[::1\])(:|\/|$)/i.test(pub)) return { link: null };
234
234
  const link = pairLink(getApp ? getApp() : cfg.app, pub, cfg.token);
235
- return { link, svg: qrSvg(link), url: pub, lan: /^http:\/\//.test(pub) };
235
+ const code = makeCode(pub, cfg.token);
236
+ return { link, code, svg: qrSvg(codeLink(getApp ? getApp() : cfg.app, code)), url: pub, lan: /^http:\/\//.test(pub) };
236
237
  }
237
238
  if (p === '/api/github' && method === 'GET') return github.status();
238
239
  if (p === '/api/github/repos' && method === 'GET') return github.repos(url.searchParams.get('owner') || '');
package/web/app/app.js CHANGED
@@ -191,6 +191,21 @@ function parsePair(text) {
191
191
  if (!url || !token) return null;
192
192
  return { url: url.replace(/\/$/, ''), token };
193
193
  }
194
+ // Codi «nio-…»: porta l'adreça i la clau dins (vegeu makeCode al programa). Es descodifica aquí, sense servidor.
195
+ const CF = '.trycloudflare.com';
196
+ function parseCode(text) {
197
+ const m = String(text || '').replace(/\s+/g, '').match(/(?:^|#)nio-([\w-]+)$/i); if (!m) return null;
198
+ try {
199
+ const bin = atob(m[1].replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - (m[1].length % 4)) % 4)), u = Uint8Array.from(bin, (c) => c.charCodeAt(0));
200
+ const tok = () => btoa(String.fromCharCode(...u.slice(1, 25))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
201
+ if (u[0] === 1) return { url: `https://${new TextDecoder().decode(u.slice(25))}${CF}`, token: tok() };
202
+ if (u[0] === 2) return { url: `http://${[...u.slice(25, 29)].join('.')}:${(u[29] << 8) | u[30]}`, token: tok() };
203
+ if (u[0] === 0) { const [url, token] = new TextDecoder().decode(u.slice(1)).split('\n'); if (url && token) return { url: url.replace(/\/$/, ''), token }; }
204
+ } catch { /* codi malmès */ }
205
+ return null;
206
+ }
207
+ // Un codi o un enllaç de connexió (#u=…&t=…).
208
+ const pairFrom = (text) => parsePair(text) || parseCode(text);
194
209
  function addMachine({ url, token, name }) {
195
210
  const ms = store.machines; let i = ms.findIndex((m) => m.url === url);
196
211
  if (i >= 0) ms[i].token = token; else { ms.push({ url, token, name: name || new URL(url).hostname }); i = ms.length - 1; }
@@ -199,7 +214,7 @@ function addMachine({ url, token, name }) {
199
214
  connect();
200
215
  }
201
216
  function checkPairHash() {
202
- const pair = parsePair(location.hash); if (!pair) return false;
217
+ const pair = parsePair(location.hash) || parseCode(location.hash); if (!pair) return false;
203
218
  history.replaceState(null, '', location.pathname + '#/sessions');
204
219
  state.route = { name: 'sessions' };
205
220
  addMachine(pair); render(); toast('Ordinador connectat'); return true;
@@ -285,9 +300,9 @@ function viewNoMachine(what) {
285
300
  <div class="panel empty">${bot('sleep')}<h2>Sembla que no hi ha cap sessió iniciada</h2><p>Crea’n una de nova a la pàgina Sessions. Allà connectes l’ordinador i hi dones feina.</p><a class="btn ink" href="#/sessions">Ves a Sessions</a></div>`;
286
301
  }
287
302
  function pasteForm(open) {
288
- return `<details class="paste" ${open ? 'open' : ''}><summary>Ja tens l’enllaç de connexió?</summary>
289
- <form class="pair-form" id="pairForm"><input type="url" id="pairInput" placeholder="https://www.nio.cat/app#u=…&t=…" autocomplete="off" autocapitalize="off" spellcheck="false" required aria-label="Enllaç de connexió"><button class="btn ink" type="submit">Connecta</button></form>
290
- <p class="muted small">Surt al terminal, sota el codi QR.</p></details>`;
303
+ return `<details class="paste" ${open ? 'open' : ''}><summary>Tens un codi?</summary>
304
+ <form class="pair-form" id="pairForm"><input type="text" id="pairInput" placeholder="nio-…" autocomplete="off" autocapitalize="off" spellcheck="false" required aria-label="Codi de connexió"><button class="btn ink" type="submit">Connecta</button></form>
305
+ <p class="muted small">Surt al terminal, sota el codi QR. Copia’l sencer.</p></details>`;
291
306
  }
292
307
  function viewConnect() {
293
308
  const CMD = 'npx nio-cat', cmd = (c) => `<div class="cmd big"><code>${esc(c)}</code><button class="btn sm" data-copy="${esc(c)}" type="button">${svg(ICON.copy)}Copia</button></div>`;
@@ -312,7 +327,7 @@ function viewConnect() {
312
327
  <p class="small">Al terminal, des de la carpeta dels teus projectes:</p>${cmd(CMD)}
313
328
  <p class="muted small">S’obre aquest panell ja connectat. Cal Node.js 18 o més nou.</p></div></section>
314
329
  <section class="panel choice"><header><h2>Un altre ordinador</h2></header><div class="body stack">
315
- <p class="small">Engega nio a l’altre ordinador o a un servidor. Copia l’enllaç que mostra i enganxa’l aquí.</p>
330
+ <p class="small">Engega nio a l’altre ordinador o a un servidor. Copia aquí el codi que mostra.</p>
316
331
  ${pasteForm(true)}
317
332
  <p class="small"><b>Un servidor (VPS) per SSH.</b> Des d'aquest ordinador, sense obrir cap port:</p>${cmd('npx nio-cat ssh usuari@servidor')}
318
333
  <p class="muted small">Engega nio al servidor, obre el túnel i el panell queda connectat.</p></div></section>
@@ -674,8 +689,8 @@ function addMachineSheet() {
674
689
  <div class="stack-lg">
675
690
  <div class="stack"><b>Un ordinador</b><p class="muted small">Al terminal de l’altre ordinador, des de la carpeta dels projectes:</p>${cmd('npx nio-cat')}<p class="muted small">Escaneja el QR que surt, o enganxa aquí sota l’enllaç.</p></div>
676
691
  <div class="stack"><b>Un servidor (VPS) per SSH</b><p class="muted small">Des de l’ordinador on tens aquest panell. No cal obrir cap port al servidor, només Node.js 18 o més nou:</p>${cmd('npx nio-cat ssh usuari@servidor')}<p class="muted small">Engega nio al servidor, obre un túnel SSH i et mostra el panell connectat.</p></div>
677
- <div class="stack"><b>Un servidor amb adreça pública</b><p class="muted small">Si el servidor té una adreça amb HTTPS (Tailscale, Cloudflare o Caddy):</p>${cmd('npx nio-cat --url https://la-teva-adreça --no-open')}<p class="muted small">Deixa’l sota <span class="mono">tmux</span>, <span class="mono">pm2</span> o <span class="mono">systemd</span>. Copia l’enllaç que mostra i enganxa’l aquí.</p></div>
678
- <form class="pair-form" id="pairForm"><input type="url" id="pairInput" placeholder="https://…/app#u=…&t=…" autocomplete="off" autocapitalize="off" spellcheck="false" required aria-label="Enllaç de connexió"><button class="btn ink" type="submit">Connecta</button></form>
692
+ <div class="stack"><b>Un servidor amb adreça pública</b><p class="muted small">Si el servidor té una adreça amb HTTPS (Tailscale, Cloudflare o Caddy):</p>${cmd('npx nio-cat --url https://la-teva-adreça --no-open')}<p class="muted small">Deixa’l sota <span class="mono">tmux</span>, <span class="mono">pm2</span> o <span class="mono">systemd</span>. Copia aquí el codi que mostra.</p></div>
693
+ <form class="pair-form" id="pairForm"><input type="text" id="pairInput" placeholder="nio-… o enllaç" autocomplete="off" autocapitalize="off" spellcheck="false" required aria-label="Codi o enllaç de connexió"><button class="btn ink" type="submit">Connecta</button></form>
679
694
  </div>`);
680
695
  }
681
696
  let qrTimer = 0;
@@ -690,7 +705,7 @@ function mobileQrSheet() {
690
705
  if (n < 8) qrTimer = setTimeout(() => paint(n + 1), 2000);
691
706
  return;
692
707
  }
693
- box.innerHTML = `<div class="qr-box">${r.svg}</div><p class="muted small">Escaneja-ho amb la càmera del mòbil.${r.lan ? ' El mòbil ha d’estar a la mateixa wifi.' : ''}</p><button class="btn" type="button" data-copy="${esc(r.link)}">${svg(ICON.copy)}Copia l’enllaç</button>`;
708
+ box.innerHTML = `<div class="qr-box">${r.svg}</div><p class="muted small">Escaneja-ho amb la càmera del mòbil.${r.lan ? ' El mòbil ha d’estar a la mateixa wifi.' : ''}</p><div class="row"><button class="btn" type="button" data-copy="${esc(r.code)}">${svg(ICON.copy)}Copia el codi</button></div><p class="muted small">Si no pots escanejar, enganxa el codi a Sessions → Tria un ordinador.</p>`;
694
709
  };
695
710
  openSheet(`<h2>Continua al mòbil</h2><div class="stack" id="qrBox"><p class="muted small">Preparant el codi…</p></div><div class="row sheet-foot"><button class="btn ghost" type="button" data-close>Tanca</button></div>`, (sh) => { $('[data-close]', sh).onclick = closeSheet; });
696
711
  paint();
@@ -1017,8 +1032,8 @@ document.addEventListener('submit', (e) => {
1017
1032
  if (e.target.id === 'pullForm') { e.preventDefault(); return pullModel($('#pullIn').value); }
1018
1033
  if (e.target.id !== 'pairForm') return;
1019
1034
  e.preventDefault();
1020
- const pair = parsePair($('#pairInput', e.target).value);
1021
- if (!pair) return toast('Aquest enllaç no és de connexió');
1035
+ const pair = pairFrom($('#pairInput', e.target).value);
1036
+ if (!pair) return toast('Aquest codi no és de connexió. Copia’l sencer del terminal.');
1022
1037
  closeSheet(); addMachine(pair); toast('Connectant…'); go('#/sessions');
1023
1038
  });
1024
1039
 
@@ -1211,6 +1226,11 @@ const qs = new URLSearchParams(location.search).get('s');
1211
1226
  if (qs) history.replaceState(null, '', location.pathname + `#/s/${qs}`);
1212
1227
  // Les dades del panell antic (simulació d'agents d'operacions) ja no es fan servir.
1213
1228
  try { localStorage.removeItem('nio.panell.v1'); localStorage.removeItem('nio.tour.v1'); } catch { /* res */ }
1229
+ // A la còpia local (127.0.0.1, localhost) la icona té el fons groc suau, per distingir-la de www.nio.cat.
1230
+ if (['127.0.0.1', 'localhost', '[::1]'].includes(location.hostname)) {
1231
+ const ico = document.querySelector('link[rel="icon"]');
1232
+ if (ico) ico.href = 'data:image/svg+xml,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="8" fill="#f6e9a0"/><circle cx="16" cy="16" r="10.2" fill="none" stroke="#272224" stroke-width="3.6"/><rect x="11.9" y="12.6" width="2.6" height="6" rx="1.3" fill="#272224"/><rect x="17.5" y="12.6" width="2.6" height="6" rx="1.3" fill="#272224"/></svg>');
1233
+ }
1214
1234
  setNav(false);
1215
1235
  if (!checkPairHash()) state.route = parseRoute();
1216
1236
  if (machine()) connect();
package/web/app/style.css CHANGED
@@ -417,3 +417,5 @@ dialog.sheet.wiz .sheet-in { padding: 0; gap: 0; flex: 1; min-height: 0; display
417
417
  }
418
418
  #fsForm { display: flex; gap: 8px; }
419
419
  #fsForm input { flex: 1; min-width: 0; }
420
+ .pair-code { display: grid; gap: 4px; text-align: center; }
421
+ .pair-code b { font-size: 30px; letter-spacing: .14em; font-weight: 600; cursor: copy; }