aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-02 14:35:25 -0700
committerAdam Soltys <[email protected]>2026-10-02 14:35:25 -0700
commitdb8b88b973635b7329cfd5f234c6f8dd6e9e2ccf (patch)
tree3e083c874efb716810ceb73b3f42ff242309cce2
parent12b129de5e579e0596c87f65524bd48cfbbe3e87 (diff)
downloadcoinosv3-db8b88b973635b7329cfd5f234c6f8dd6e9e2ccf.tar.gz
coinosv3-db8b88b973635b7329cfd5f234c6f8dd6e9e2ccf.zip
Send: the recipient field grows to show it all; history steps aside while sending
- The recipient is a one-line textarea that wraps and grows (rows from the text's length over the field's measured width), so a whole address is in view in the field itself — no second copy in small type below. Enter doesn't break it and pasted line breaks are dropped. Empty, it is as tall as its hint (measured in the hint's own face), so that's whole too. - Past the recipient — the amount, a review, a Lightning/Ark confirmation, a raw tx to broadcast — the history list below the send pane is left out. - tools/send-dest-test.js (390px and 1280px). Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Dmcrt9KyNDKVWFKnb8BREo
-rw-r--r--src/app.js81
-rw-r--r--src/style.css11
-rw-r--r--tools/profile-actions-test.js18
-rw-r--r--tools/send-dest-test.js63
4 files changed, 139 insertions, 34 deletions
diff --git a/src/app.js b/src/app.js
index a50808b..b797d9e 100644
--- a/src/app.js
+++ b/src/app.js
@@ -4625,7 +4625,11 @@ function tabContent() {
const detailOpen = ui.bump || ui.txDetail || ui.arkMoveDetail
|| ui.arkReconDetail || ui.arkExitDetail || ui.giftDetail;
if (detailOpen) return hist;
+ sendFocused = false;
const pane = ui.tab === 'receive' ? receiveTab() : sendTab();
+ // Past the recipient — an amount to enter, a payment to confirm — the
+ // history is one more thing on screen while you're about to send.
+ if (ui.tab === 'send' && sendFocused) return pane;
return h('div', { class: 'col', style: 'gap:16px' },
pane,
h('div', { class: 'small faint', style: 'text-transform:uppercase;letter-spacing:.06em;margin-top:4px' }, t('tabHistory')),
@@ -4766,6 +4770,7 @@ function loadSeedCard() {
);
}
+let sendFocused = false; // the send pane is past the recipient (set while it renders)
function sendTab() {
sendPageRendered = true;
// Watch-only wallet (e.g. restored after a session wipe without "Save to
@@ -4779,10 +4784,12 @@ function sendTab() {
);
}
if (ui.sendResult) return sendResultView();
- if (ui.broadcastTx) return broadcastConfirmView();
+ if (ui.broadcastTx) { sendFocused = true; return broadcastConfirmView(); }
const featView = featureHook('sendView');
- if (featView) return featView;
- if (ui.draft) return reviewView();
+ if (featView) { sendFocused = true; return featView; }
+ if (ui.draft) { sendFocused = true; return reviewView(); }
+ // the amount shows once a recipient is a real destination
+ if ((ui.send.recipients || []).some((r) => destReady(r.address))) sendFocused = true;
return sendForm();
}
@@ -5014,16 +5021,31 @@ async function broadcastScanned() {
// Full address as wrapping nodes, first/last 6 chars emphasized — readable
// without horizontally scrolling the input. Returns DOM nodes for in-place
// updates (the address input doesn't re-render on every keystroke).
-function addrVerifyNodes(a) {
- const n = 6;
- if (!a) return [];
- if (a.length <= n * 2) return [document.createTextNode(a)];
- return [
- h('span', { class: 'hl' }, a.slice(0, n)),
- document.createTextNode(a.slice(n, -n)),
- h('span', { class: 'hl' }, a.slice(-n)),
- ];
+// How many lines the recipient field needs to show all of what's in it.
+// Characters per line come from the field's own width and font, measured
+// the first time it's typed in (a monospace field: every character is as
+// wide as a 0); until then a phone-width guess. A rows attribute, not an
+// inline height, so a re-render reproduces it instead of resetting it.
+// An empty field is as tall as its hint, measured in the hint's own font:
+// the whole of it shows too, in any language.
+let destCpl = 30, destHintRows = 1;
+function measureDest(el) {
+ try {
+ const cs = getComputedStyle(el);
+ const c = measureDest.c || (measureDest.c = document.createElement('canvas').getContext('2d'));
+ c.font = cs.font;
+ const w = el.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight);
+ const cw = c.measureText('0').width;
+ if (w > 0 && cw > 0) destCpl = Math.max(8, Math.floor(w / cw));
+ const ph = el.getAttribute('placeholder') || '';
+ const pcs = getComputedStyle(el, '::placeholder');
+ c.font = `${pcs.fontSize} ${pcs.fontFamily}`;
+ if (w > 0 && ph) destHintRows = Math.min(3, Math.max(1, Math.ceil(c.measureText(ph).width / w)));
+ } catch {}
}
+const destRows = (v) => (String(v || '').length
+ ? Math.min(6, Math.max(1, Math.ceil(String(v).length / destCpl)))
+ : destHintRows);
// True once the destination is a real on-chain or silent-payment address — used to
// progressively reveal the amount/fee/coin controls. A Lightning invoice instead
@@ -5079,13 +5101,9 @@ function recipientRow(s, r, i) {
// render's nodes — the morph must install these exact nodes, not keep last
// render's (whose closures are stale). Both are fully filled before the
// morph runs, so the wholesale swap carries identical content.
- const check = h('div', { class: 'addr-check', 'data-fresh': '1' });
- const syncCheck = () => {
- const a = r.address.trim();
- const nodes = addrVerifyNodes(a);
- check.replaceChildren(...nodes);
- check.style.display = a && !viaOf() ? '' : 'none';
- };
+ // The field itself wraps and grows, so a whole address is always on
+ // screen (it used to be repeated below a one-line input for checking).
+ const syncCheck = () => {};
// The suggestions panel gets the same imperative treatment — see the
// sendSearcher note: a render between key repeats kills backspace-hold.
// One panel for the life of the page, rows rebuilt only when the results
@@ -5107,16 +5125,24 @@ function recipientRow(s, r, i) {
// A pasted/typed/scanned payload a feature recognizes (e.g. a bolt11) jumps
// straight to that feature's own confirmation flow.
const tryFeature = (v, typed) => featureMatchSend(v, typed);
- const addrInput = h('input', {
- type: 'text', class: 'mono-input grow', placeholder: i === 0 ? t('destPlaceholder') : 'bc1q…',
- autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', value: via ? via.name : r.address,
+ const shown = via ? via.name : r.address;
+ const addrInput = h('textarea', {
+ class: 'mono-input grow send-dest', placeholder: i === 0 ? t('destPlaceholder') : 'bc1q…',
+ rows: String(destRows(shown)), wrap: 'soft',
+ autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', value: shown,
+ // one line of text: Enter doesn't break it
+ onKeydown: (e) => { if (e.key === 'Enter') e.preventDefault(); },
// the delay lets the keyboard start opening (and any render() swap of
// this input) before parkSendField measures the live activeElement.
// warmSearch pre-opens the search transports so the first keystroke's
// results don't pay three cold handshakes.
onFocus: () => { warmSearch(); setTimeout(parkSendField, 300); },
onInput: (e) => {
+ // a pasted address with a line break in it is still one address
+ if (/[\r\n]/.test(e.target.value)) e.target.value = e.target.value.replace(/[\r\n]+/g, '');
const v = e.target.value;
+ measureDest(e.target);
+ e.target.rows = destRows(v);
r.address = v; syncCheck();
const hadError = !!ui.sendError;
ui.sendError = ''; // editing the destination starts over — drop stale errors
@@ -5156,7 +5182,6 @@ function recipientRow(s, r, i) {
}),
!single && h('button', { type: 'button', class: 'btn-sm', title: t('remove'), onClick: () => { s.recipients.splice(i, 1); render(); } }, '✕')
),
- check,
via ? h('div', { class: 'row gap6 send-via', style: 'align-items:center' },
via.pk && !single ? featureHook('avatarNode', via.pk) : null,
via.pk && single ? null : h('span', { class: 'small' }, via.name),
@@ -5196,6 +5221,16 @@ function recipientRow(s, r, i) {
) : null
);
syncCheck();
+ // the field's real width, once: a pasted or picked address gets its true
+ // line count rather than the guess
+ if (!measureDest.done) requestAnimationFrame(() => {
+ const el = document.querySelector('.send-dest');
+ if (!el || measureDest.done) return;
+ measureDest.done = true;
+ const before = destCpl + ':' + destHintRows;
+ measureDest(el);
+ if (destCpl + ':' + destHintRows !== before) render();
+ });
if (i === 0) sendSearch.pick = pickRecipient; // the persistent rows call this render's
syncSuggest();
return row;
diff --git a/src/style.css b/src/style.css
index e9e2186..a04df82 100644
--- a/src/style.css
+++ b/src/style.css
@@ -268,6 +268,15 @@ coinos-text[data-empty]:focus::before { content: none; }
.mono-input { font-family: var(--mono); }
.input-group { display: flex; gap: 8px; }
.input-group input { flex: 1; }
+/* the send recipient: one line that wraps and grows, so the whole address
+ is in view (rows set from its length; see destRows) */
+textarea.send-dest {
+ flex: 1; min-height: 0; resize: none; overflow-y: auto; line-height: 1.4;
+ font-size: inherit; word-break: break-all; white-space: pre-wrap;
+}
+/* the hint in the page's own face: narrower than the address font, so it
+ fits the one line beside Paste and Scan on a phone */
+textarea.send-dest::placeholder { font-family: var(--sans); }
/* unit toggle */
.seg { display: inline-flex; border: 1px solid var(--line-strong); border-radius: 10px; overflow: hidden; }
@@ -293,8 +302,6 @@ coinos-text[data-empty]:focus::before { content: none; }
.addr-actions button:hover { color: var(--ink); background: none; }
.addr-actions button:active { transform: none; }
.path { font-family: var(--mono); font-size: 12px; color: var(--faint); }
-.addr-check { font-family: var(--mono); font-size: 12px; line-height: 1.5; color: var(--muted); word-break: break-all; padding: 0 2px; }
-.addr-check .hl { color: var(--ink); font-weight: 700; }
/* ---- mnemonic grid ---- */
/* Three columns read ACROSS — 1 2 3 on the first line, 4 5 6 on the next —
diff --git a/tools/profile-actions-test.js b/tools/profile-actions-test.js
index 830d7a0..e44c037 100644
--- a/tools/profile-actions-test.js
+++ b/tools/profile-actions-test.js
@@ -59,7 +59,7 @@ try {
// the suggestion's metadata and fetch the large image before selection.
await page.evaluate(() => { profileActions.showSend({ fresh: true }); profileActions.render(); });
const portraitResponse = page.waitForResponse(r => r.url().endsWith('/recipient-portrait.svg'));
- await page.type('input.mono-input', 'carol');
+ await page.type('textarea.send-dest', 'carol');
await page.waitForSelector('.send-suggest .ava-img');
await portraitResponse;
await page.evaluate(() => {
@@ -73,7 +73,7 @@ try {
});
await page.click('.send-suggest .chat-thread-row');
await page.waitForSelector('.send-avatar');
- assert.equal(await page.$eval('input.mono-input', e => e.value), npubEncode(searchPeer));
+ assert.equal(await page.$eval('textarea.send-dest', e => e.value), npubEncode(searchPeer));
assert(await page.$eval('.send-avatar', e => e.classList.contains('ava-img') && e.style.backgroundImage.includes('/recipient-portrait.svg') && getComputedStyle(e).visibility !== 'hidden'), 'selection immediately uses the already-fetched suggestion portrait');
assert(await page.evaluate(() => recipientFrames.length > 0 && recipientFrames.every(f => !f.punk && !f.hidden && f.picture.includes('/recipient-portrait.svg'))), 'every selected-recipient frame has the real avatar, without fallback or placeholder');
await page.evaluate(() => recipientObserver.disconnect());
@@ -99,11 +99,11 @@ try {
Object.assign(ui, previous);
render();
}, previous);
- assert.equal(await page.$('input.mono-input'), null, 'previous payment displays its success view');
+ assert.equal(await page.$('textarea.send-dest'), null, 'previous payment displays its success view');
await openProfile(nextPeer);
await action('Pay');
- await page.waitForSelector('input.mono-input');
- assert.equal(await page.$eval('input.mono-input', (e) => e.value), npubEncode(nextPeer));
+ await page.waitForSelector('textarea.send-dest');
+ assert.equal(await page.$eval('textarea.send-dest', (e) => e.value), npubEncode(nextPeer));
assert(!(await page.$('.check-badge')), 'previous success must not win over the new payment form');
const state = await page.evaluate(() => ({
recipients: profileActions.ui.send.recipients.map(({ address, amount }) => ({ address, amount })),
@@ -125,8 +125,8 @@ try {
ui.zap = { stale: true }; ui.nameResolve = { text: '[email protected]' };
});
await action('Pay');
- await page.waitForSelector('input.mono-input');
- assert.equal(await page.$eval('input.mono-input', (e) => e.value), npubEncode(peer));
+ await page.waitForSelector('textarea.send-dest');
+ assert.equal(await page.$eval('textarea.send-dest', (e) => e.value), npubEncode(peer));
assert(await page.evaluate(() => {
const { ui } = profileActions;
return !ui.send.max && !ui.arkLnFromSavingsOk && ui.send.recipients.length === 1 && ui.send.recipients[0].amount === ''
@@ -183,7 +183,7 @@ try {
assert.equal(await page.evaluate(() => profileActions.ui.profilePk), peer);
await assertBlank();
await page.goBack();
- await page.waitForSelector('input.mono-input');
+ await page.waitForSelector('textarea.send-dest');
await assertBlank();
assert.equal(await page.$('.send-person'), null, 'returning to Send has no old recipient portrait');
@@ -194,7 +194,7 @@ try {
await page.waitForFunction(() => profileActions.ui.tab === 'receive');
await assertBlank();
await page.goForward();
- await page.waitForSelector('input.mono-input');
+ await page.waitForSelector('textarea.send-dest');
await assertBlank();
console.log('✓ Leaving Send for navigation tabs, a recipient profile or browser Back clears it; returning starts blank');
diff --git a/tools/send-dest-test.js b/tools/send-dest-test.js
new file mode 100644
index 0000000..6dc331c
--- /dev/null
+++ b/tools/send-dest-test.js
@@ -0,0 +1,63 @@
+// The Send form's recipient: one line that grows to show a whole address
+// (it used to be repeated in small type underneath), and the history list
+// steps aside once you're on to the amount.
+// Run: bun tools/send-dest-test.js
+import puppeteer from 'puppeteer-core';
+import { generateMnemonic } from '@scure/bip39';
+import { wordlist } from '@scure/bip39/wordlists/english';
+import { p2tr } from '@scure/btc-signer';
+import { schnorr } from '@noble/curves/secp256k1';
+import { buildHtml } from '../build.js';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+let ok = true;
+const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
+const REGTEST = { bech32: 'bcrt', pubKeyHash: 0x6f, scriptHash: 0xc4, wif: 0xef };
+const taproot = p2tr(schnorr.getPublicKey(schnorr.utils.randomPrivateKey()), undefined, REGTEST).address; // 64 characters
+const html = await buildHtml({ minify: true, pwa: false });
+const server = Bun.serve({ port: 0, fetch: () => new Response(html, { headers: { 'content-type': 'text/html' } }) });
+const browser = await puppeteer.launch({ executablePath: process.env.CHROME || '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
+const page = await browser.newPage();
+const errs = []; page.on('pageerror', (e) => errs.push(String(e)));
+const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase() === x || n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t);
+const field = () => page.$eval('textarea.send-dest', (e) => ({ rows: e.rows, h: e.clientHeight, sh: e.scrollHeight, v: e.value, lines: Math.round((e.clientHeight - parseFloat(getComputedStyle(e).paddingTop) - parseFloat(getComputedStyle(e).paddingBottom)) / parseFloat(getComputedStyle(e).lineHeight)) }));
+const history = () => page.evaluate(() => [...document.querySelectorAll('.tab-pane .small.faint')].some((e) => /^history$/i.test(e.textContent.trim())));
+try {
+ for (const width of [390, 1280]) {
+ await page.setViewport({ width, height: 844 });
+ await page.goto(server.url.origin, { waitUntil: 'domcontentloaded' });
+ if (width === 390) {
+ await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
+ await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400);
+ await click('create a new wallet'); await sleep(500); await click('to import'); await sleep(400);
+ await page.waitForSelector('textarea'); await page.type('textarea', generateMnemonic(wordlist)); await click('open wallet');
+ }
+ for (let i = 0; i < 60 && !(await page.evaluate(() => /receive/i.test(document.body.innerText))); i++) await sleep(300);
+ await page.evaluate(() => [...document.querySelectorAll('.tabs button, [role=tab], button')].find((b) => b.textContent.trim() === 'Send')?.click());
+ await page.waitForSelector('textarea.send-dest', { timeout: 10000 });
+ console.log(`[${width}px]`);
+ let f = await field();
+ if (process.env.SHOT) { const r = await page.$eval('textarea.send-dest', (e) => { const b = e.closest('.input-group').getBoundingClientRect(); return [b.x, b.y, b.width, b.height, e.clientWidth]; }); console.log('field width', r[4]); await page.screenshot({ path: process.env.SHOT + '-' + width + '.png', clip: { x: r[0] - 6, y: r[1] - 6, width: r[2] + 12, height: r[3] + 12 } }); }
+ check('an empty recipient shows its whole hint, no taller', f.sh <= f.h + 1 && f.rows <= (width > 600 ? 1 : 2), JSON.stringify(f));
+ check('...and the history is there below the form', await history());
+ await page.focus('textarea.send-dest');
+ await page.keyboard.type(taproot, { delay: 0 }); await sleep(600);
+ f = await field();
+ check('a 64-character address is all in view, the field grown to fit', f.v === taproot && f.sh <= f.h + 1 && (width > 600 || f.lines >= 2), JSON.stringify(f));
+ check('nothing repeats it underneath', !(await page.$('.addr-check')));
+ check('with the amount showing, the history steps aside', !!(await page.$('input[type=number]')) && !(await history()));
+ await page.keyboard.press('Enter'); await sleep(100);
+ check('Enter does not break the line', (await field()).v === taproot);
+ // clear it: back to the recipient step, history back
+ await page.$eval('textarea.send-dest', (e) => { e.value = ''; e.dispatchEvent(new Event('input', { bubbles: true })); }); await sleep(600);
+ f = await field();
+ check('cleared, it shrinks back to its hint', f.rows <= (width > 600 ? 1 : 2) && f.sh <= f.h + 1, JSON.stringify(f));
+ check('...and the history returns', await history());
+ }
+ check('no page errors', errs.length === 0, errs.join('; ').slice(0, 300));
+} catch (e) {
+ check('run completed', false, e.stack);
+} finally {
+ await browser.close(); server.stop(true);
+}
+console.log(ok ? '\n✅ the recipient grows to fit; the history steps aside while sending' : '\n❌ failures above');
+process.exit(ok ? 0 : 1);