nio-cat 0.3.1 → 0.3.3

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, makeCode } = 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);
@@ -82,10 +82,20 @@ function openDashboard() {
82
82
  }
83
83
 
84
84
  async function showPair(publicUrl) {
85
- const link = pairLink(flags.app || cfg.app, publicUrl, cfg.token), code = makeCode(publicUrl, cfg.token);
86
- const file = writePairPage(link, code);
87
- console.log(`\n ${c.b('Escaneja aquest codi amb el mòbil')} ${c.dim('(o obre ' + file + ')')}\n`);
88
- console.log(terminalQr(link));
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
+ }
89
99
  console.log(`\n ${c.b('Codi:')} ${code}`);
90
100
  console.log(c.dim(' Copia\'l i enganxa\'l a Sessions → Tria un ordinador.'));
91
101
  console.log(c.dim(' Aquest codi dona accés als teus agents. Guarda\'l com una contrasenya.\n'));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nio-cat",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
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
@@ -16,13 +16,17 @@ function pairLink(app, url, token) {
16
16
  // La web ja sap posar-hi l'enllaç de davant. Les adreces de Cloudflare es comprimeixen (≈ 60 caràcters).
17
17
  const CF = '.trycloudflare.com';
18
18
  function makeCode(url, token) {
19
- const raw = Buffer.from(token, 'base64url');
19
+ const raw = Buffer.from(token, 'base64url'), tok24 = raw.length === 24 && raw.toString('base64url') === token;
20
20
  const sub = url.startsWith('https://') && url.endsWith(CF) ? url.slice(8, -CF.length) : null;
21
- const bytes = raw.length === 24 && raw.toString('base64url') === token && sub && /^[\w-]+$/.test(sub)
22
- ? Buffer.concat([Buffer.from([1]), raw, Buffer.from(sub)])
23
- : Buffer.concat([Buffer.from([0]), Buffer.from(`${url}\n${token}`)]);
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}`)]);
24
26
  return `nio-${bytes.toString('base64url')}`;
25
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}`;
26
30
 
27
31
  function qr(text) {
28
32
  const q = qrcode(0, 'L');
@@ -33,10 +37,10 @@ function qr(text) {
33
37
 
34
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,
35
39
  // així que no es talla amb tipus de lletra o interlineats estranys. Sempre negre sobre blanc, amb tema clar o fosc.
36
- function terminalQr(text, { color = !process.env.NO_COLOR } = {}) {
40
+ function terminalQr(text, { color = !process.env.NO_COLOR, margin = 2 } = {}) {
37
41
  const q = qr(text);
38
42
  const n = q.getModuleCount();
39
- const M = 2; // marge blanc al voltant, en mòduls
43
+ const M = margin; // marge blanc al voltant, en mòduls
40
44
  const dark = (r, c) => r >= 0 && c >= 0 && r < n && c < n && q.isDark(r, c);
41
45
  const lines = [];
42
46
  for (let r = -M; r < n + M; r++) {
@@ -72,8 +76,8 @@ function qrSvg(text) {
72
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>`;
73
77
  }
74
78
 
75
- function writePairPage(link, code) {
76
- const svg = qrSvg(link);
79
+ function writePairPage(link, code, qrLink) {
80
+ const svg = qrSvg(qrLink || link);
77
81
  const html = `<!doctype html><html lang="ca"><head><meta charset="utf-8">
78
82
  <meta name="viewport" content="width=device-width,initial-scale=1"><title>nio // connecta el mòbil</title>
79
83
  <link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600&display=swap" rel="stylesheet">
@@ -88,4 +92,11 @@ code{display:block;word-break:break-all;font:12px/1.5 ui-monospace,Menlo,monospa
88
92
  return file;
89
93
  }
90
94
 
91
- module.exports = { pairLink, terminalQr, writePairPage, qrSvg, makeCode };
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, makeCode } = 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, code: makeCode(pub, cfg.token), 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
@@ -28,6 +28,7 @@ const ICON = {
28
28
  more: '<circle cx="5" cy="12" r="1.3" fill="currentColor"/><circle cx="12" cy="12" r="1.3" fill="currentColor"/><circle cx="19" cy="12" r="1.3" fill="currentColor"/>',
29
29
  refresh: '<path d="M20 11a8 8 0 1 0-2.3 5.7M20 4v7h-7"/>', back: '<path d="M15 5l-7 7 7 7"/>',
30
30
  bell: '<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/>',
31
+ check: '<path class="tick" d="M5 12.5l4.5 4.5L19 7.5"/>',
31
32
  copy: '<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"/>'
32
33
  };
33
34
  const AGENT_NAMES = { claude: 'Claude Code', codex: 'Codex', grok: 'Grok', antigravity: 'Antigravity', ollama: 'Ollama', demo: 'Demo', browser: 'Xat local' };
@@ -194,10 +195,12 @@ function parsePair(text) {
194
195
  // Codi «nio-…»: porta l'adreça i la clau dins (vegeu makeCode al programa). Es descodifica aquí, sense servidor.
195
196
  const CF = '.trycloudflare.com';
196
197
  function parseCode(text) {
197
- const m = String(text || '').replace(/\s+/g, '').match(/^nio-([\w-]+)$/i); if (!m) return null;
198
+ const m = String(text || '').replace(/\s+/g, '').match(/(?:^|#)nio-([\w-]+)$/i); if (!m) return null;
198
199
  try {
199
200
  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
- if (u[0] === 1) return { url: `https://${new TextDecoder().decode(u.slice(25))}${CF}`, token: btoa(String.fromCharCode(...u.slice(1, 25))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') };
201
+ const tok = () => btoa(String.fromCharCode(...u.slice(1, 25))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
202
+ if (u[0] === 1) return { url: `https://${new TextDecoder().decode(u.slice(25))}${CF}`, token: tok() };
203
+ if (u[0] === 2) return { url: `http://${[...u.slice(25, 29)].join('.')}:${(u[29] << 8) | u[30]}`, token: tok() };
201
204
  if (u[0] === 0) { const [url, token] = new TextDecoder().decode(u.slice(1)).split('\n'); if (url && token) return { url: url.replace(/\/$/, ''), token }; }
202
205
  } catch { /* codi malmès */ }
203
206
  return null;
@@ -212,7 +215,7 @@ function addMachine({ url, token, name }) {
212
215
  connect();
213
216
  }
214
217
  function checkPairHash() {
215
- const pair = parsePair(location.hash); if (!pair) return false;
218
+ const pair = parsePair(location.hash) || parseCode(location.hash); if (!pair) return false;
216
219
  history.replaceState(null, '', location.pathname + '#/sessions');
217
220
  state.route = { name: 'sessions' };
218
221
  addMachine(pair); render(); toast('Ordinador connectat'); return true;
@@ -445,10 +448,17 @@ function renderSessionHead() {
445
448
  bar.innerHTML = `<span class="chip"><span class="agent-tag" data-agent="${esc(s.agent)}">${esc(AGENT_NAMES[s.agent] || s.agent)}</span></span>
446
449
  ${ag?.noModes ? '' : `<button class="chip" data-act="mode" type="button">${esc(MODES[s.mode]?.label || s.mode)}</button>`}
447
450
  <span class="chip mono">${esc(basename(s.cwd))}</span>
448
- ${s.model ? `<span class="chip mono">${esc(s.model)}</span>` : ''}
451
+ <button class="chip mono" data-act="model" type="button" title="Canvia de model">${esc(s.model || 'Model per defecte')}</button>
449
452
  ${s.cost ? `<span class="chip mono">${s.cost.toFixed(2)} $</span>` : ''}
450
453
  ${stateChip(s)}`;
451
454
  }
455
+ // Alçada del camp de text segons el contingut, sense barra de desplaçament fins al límit.
456
+ function autogrow(ta, max = 180) {
457
+ ta.style.height = 'auto';
458
+ const h = ta.scrollHeight + (ta.offsetHeight - ta.clientHeight);
459
+ ta.style.height = `${Math.min(h, max)}px`;
460
+ ta.style.overflowY = h > max ? 'auto' : 'hidden';
461
+ }
452
462
  const nearBottom = (f) => f.scrollHeight - f.scrollTop - f.clientHeight < 140;
453
463
  function putEvent(e, scroll = true) {
454
464
  const feed = $('#feed'); if (!feed) return;
@@ -516,7 +526,7 @@ function renderComposer() {
516
526
  box.dataset.busy = String(busy);
517
527
  box.innerHTML = `<div class="inner"><textarea rows="1" id="msg" placeholder="${busy ? 'L’agent està treballant…' : 'Escriu a l’agent…'}" enterkeyhint="send" aria-label="Missatge"></textarea><button class="send ${busy ? 'stop' : ''}" id="sendBtn" type="button" aria-label="${busy ? 'Atura l’agent' : 'Envia'}">${svg(busy ? ICON.stop : ICON.send)}</button></div>`;
518
528
  const ta = $('#msg'); ta.value = prev;
519
- const grow = () => { ta.style.height = 'auto'; ta.style.height = `${Math.min(ta.scrollHeight, 180)}px`; };
529
+ const grow = () => autogrow(ta);
520
530
  grow(); ta.addEventListener('input', grow);
521
531
  const send = async () => {
522
532
  const text = ta.value.trim(); if (!text || !s) return;
@@ -662,6 +672,28 @@ async function newSessionSheet(preset = {}) {
662
672
  draw();
663
673
  }, 'wiz');
664
674
  }
675
+ // Canvia el model d'una sessió. Val a partir del pròxim missatge.
676
+ const COST_FROM = {
677
+ claude: 'El teu compte de Claude Code a l’ordinador (subscripció o clau d’API que hi tinguis)',
678
+ codex: 'El teu compte de Codex (ChatGPT o clau d’OpenAI) a l’ordinador',
679
+ grok: 'El teu compte de Grok a l’ordinador',
680
+ antigravity: 'El teu compte de Google a l’ordinador',
681
+ ollama: 'Res: el model funciona al teu ordinador'
682
+ };
683
+ const MODEL_PICKS = { claude: [['opus', 'Opus', 'El més capaç'], ['sonnet', 'Sonnet', 'Equilibrat'], ['haiku', 'Haiku', 'El més ràpid']] };
684
+ async function modelSheet(s) {
685
+ const picks = s.agent === 'ollama'
686
+ ? (await api('/api/ollama/models').catch(() => [])).map((m) => [m.name, m.name, [m.params, m.quant, bytes(m.size)].filter(Boolean).join(' · ')])
687
+ : MODEL_PICKS[s.agent] || [];
688
+ const row = (v, name, note) => `<button class="card" type="button" data-model="${esc(v)}" aria-pressed="${(s.model || '') === v}"><b>${esc(name)}</b>${note ? `<div class="muted small" style="margin-top:4px">${esc(note)}</div>` : ''}</button>`;
689
+ openSheet(`<h2>Model</h2><p class="muted small">${esc(AGENT_NAMES[s.agent] || s.agent)}. El canvi s’aplica a partir del pròxim missatge.</p>
690
+ <div class="list">${row('', 'Per defecte', 'El que l’agent faci servir sol')}${picks.map((x) => row(...x)).join('')}</div>
691
+ <form class="pair-form" id="modelForm"><input type="text" id="modelFree" placeholder="Un altre model (nom exacte)" value="${picks.some((x) => x[0] === s.model) ? '' : esc(s.model || '')}" autocapitalize="off" spellcheck="false" aria-label="Nom del model"><button class="btn ink" type="submit">Desa</button></form>`, (sh) => {
692
+ const save = async (model) => { try { const sum = await api(`/api/sessions/${s.id}`, { method: 'PATCH', body: { model } }); state.sessions.set(s.id, sum); closeSheet(); renderSessionHead(); } catch (e) { toast(e.message); } };
693
+ $$('[data-model]', sh).forEach((b) => (b.onclick = () => save(b.dataset.model)));
694
+ $('#modelForm', sh).onsubmit = (e) => { e.preventDefault(); e.stopPropagation(); save($('#modelFree', sh).value.trim()); };
695
+ });
696
+ }
665
697
  function modeSheet(s) {
666
698
  openSheet(`<h2>Permisos</h2><div class="list">${Object.entries(MODES).map(([k, m]) => `<button class="card" type="button" data-m="${k}" style="${k === s.mode ? 'border-color:var(--fg);box-shadow:inset 0 0 0 1px var(--fg)' : ''}"><b>${m.label}</b><div class="muted small" style="margin-top:4px">${esc(modeHelp(s.agent, k))}</div></button>`).join('')}</div><p class="muted small">El canvi s’aplica a partir del pròxim missatge.</p>`, (sh) => {
667
699
  $$('[data-m]', sh).forEach((b) => (b.onclick = async () => {
@@ -742,7 +774,7 @@ async function gitSheet(id) {
742
774
  function sessionOptions(id) {
743
775
  const s = state.sessions.get(id); if (!s) return;
744
776
  openSheet(`<h2>${esc(s.title || 'Sessió')}</h2>
745
- <dl class="kv"><dt>Agent</dt><dd>${esc(AGENT_NAMES[s.agent] || s.agent)}</dd><dt>Carpeta</dt><dd class="mono">${esc(s.cwd)}</dd><dt>Permisos</dt><dd>${esc(MODES[s.mode]?.label || '')}</dd><dt>Creada</dt><dd>${new Date(s.created).toLocaleString('ca')}</dd></dl>
777
+ <dl class="kv"><dt>Agent</dt><dd>${esc(AGENT_NAMES[s.agent] || s.agent)}</dd><dt>Carpeta</dt><dd class="mono">${esc(s.cwd)}</dd><dt>Permisos</dt><dd>${esc(MODES[s.mode]?.label || '')}</dd><dt>Model</dt><dd class="mono">${esc(s.model || 'per defecte')}</dd><dt>Es gasta de</dt><dd>${esc(COST_FROM[s.agent] || 'El compte de l’agent a l’ordinador')}. nio no fa cap crida pel seu compte.</dd><dt>Creada</dt><dd>${new Date(s.created).toLocaleString('ca')}</dd></dl>
746
778
  <label class="f"><span>Nom de la sessió</span><input type="text" id="renameIn" value="${esc(s.title)}" maxlength="120"></label>
747
779
  <button class="btn" data-act="git" type="button">Git i GitHub…</button>
748
780
  <div class="row between"><button class="btn danger" id="delBtn" type="button">Esborra la sessió</button><button class="btn ink" id="renameBtn" type="button">Desa</button></div>`, (sh) => {
@@ -930,7 +962,7 @@ function bindLocal() {
930
962
  for (const m of local.messages) feed.appendChild(m.role === 'user' ? el(`<div class="msg-user">${esc(m.content)}</div>`) : el(`<div class="msg-ai">${md(stripThink(m.content))}</div>`));
931
963
  feed.scrollTop = feed.scrollHeight;
932
964
  const ta = $('#lmsg');
933
- ta.addEventListener('input', () => { ta.style.height = 'auto'; ta.style.height = `${Math.min(ta.scrollHeight, 180)}px`; });
965
+ ta.addEventListener('input', () => autogrow(ta));
934
966
  ta.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey && !isTouch) { e.preventDefault(); sendLocal(); } });
935
967
  $('#lsend').onclick = () => (local.busy ? stopLocal() : sendLocal());
936
968
  setLocalBusy(local.busy);
@@ -964,7 +996,7 @@ async function loadLocal(id) {
964
996
  async function sendLocal() {
965
997
  const ta = $('#lmsg'), text = ta.value.trim();
966
998
  if (!text || local.busy) return;
967
- ta.value = ''; ta.style.height = 'auto';
999
+ ta.value = ''; autogrow(ta);
968
1000
  const feed = $('#lfeed'); if (!local.messages.length) feed.innerHTML = '';
969
1001
  local.messages.push({ role: 'user', content: text });
970
1002
  feed.appendChild(el(`<div class="msg-user">${esc(text)}</div>`));
@@ -990,7 +1022,15 @@ async function sendLocal() {
990
1022
  document.addEventListener('click', async (e) => {
991
1023
  const a = e.target.closest('[data-act], [data-copy], [data-decide], [data-machine], [data-lm]');
992
1024
  if (!a) return;
993
- if (a.dataset.copy) { try { await navigator.clipboard.writeText(a.dataset.copy); toast('Copiat'); } catch { toast(a.dataset.copy); } return; }
1025
+ if (a.dataset.copy) {
1026
+ try { await navigator.clipboard.writeText(a.dataset.copy); } catch { return toast(a.dataset.copy); }
1027
+ if (a.dataset.copied) return;
1028
+ // El botó mostra una marca animada i «Copiat» un moment.
1029
+ const html = a.innerHTML; a.dataset.copied = '1'; a.classList.add('copied');
1030
+ a.innerHTML = `${svg(ICON.check)}${/\S/.test(a.textContent) ? 'Copiat' : ''}`;
1031
+ setTimeout(() => { a.innerHTML = html; a.classList.remove('copied'); delete a.dataset.copied; }, 1700);
1032
+ return;
1033
+ }
994
1034
  if (a.dataset.decide) return decide(a.closest('[data-approval]'), a.dataset.decide);
995
1035
  if (a.dataset.machine != null) { closeSheet(); store.active = Number(a.dataset.machine); state.ollama = null; state.sessions.clear(); state.approvals.clear(); state.events.clear(); state.loaded.clear(); state.connected = false; state.info = null; connect(); return render(); }
996
1036
  if (a.dataset.lm) { if (local.loading) return; LS.set('nio.localModel', a.dataset.lm); return render(); }
@@ -1002,6 +1042,7 @@ document.addEventListener('click', async (e) => {
1002
1042
  case 'ollama-del': return deleteModel(a.dataset.name);
1003
1043
  case 'ollama-chat': try { state.info = await api('/api/info?refresh=1'); } catch { /* res */ } return newSessionSheet({ agent: 'ollama', model: a.dataset.name });
1004
1044
  case 'retry': return connect();
1045
+ case 'model': { const s = state.sessions.get(state.route.id); if (s) modelSheet(s); return; }
1005
1046
  case 'mode': { const s = state.sessions.get(state.route.id); if (s) modeSheet(s); return; }
1006
1047
  case 'session-more': return sessionOptions(state.route.id);
1007
1048
  case 'refresh-agents': try { state.info = await api('/api/info?refresh=1'); render(); toast('Agents actualitzats'); } catch (err) { toast(err.message); } return;
package/web/app/style.css CHANGED
@@ -419,3 +419,11 @@ dialog.sheet.wiz .sheet-in { padding: 0; gap: 0; flex: 1; min-height: 0; display
419
419
  #fsForm input { flex: 1; min-width: 0; }
420
420
  .pair-code { display: grid; gap: 4px; text-align: center; }
421
421
  .pair-code b { font-size: 30px; letter-spacing: .14em; font-weight: 600; cursor: copy; }
422
+
423
+ /* Enllaços-targeta sense subratllat, camps de text sense barra mínima, animació de «Copiat» */
424
+ a.card, a.card:hover { text-decoration: none; color: inherit; }
425
+ .composer textarea { overflow-y: hidden; }
426
+ .copied { color: var(--moss); }
427
+ .copied .tick { stroke-dasharray: 24; stroke-dashoffset: 24; animation: tick .38s var(--ease) forwards; }
428
+ @keyframes tick { to { stroke-dashoffset: 0; } }
429
+ @media (prefers-reduced-motion: reduce) { .copied .tick { animation: none; stroke-dashoffset: 0; } }