diff options
| author | Adam Soltys <[email protected]> | 2026-09-10 22:08:26 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-09-10 22:08:26 -0700 |
| commit | f0a5e0de3e68eee184b25ced2393329aec169f6d (patch) | |
| tree | b0d0450d93715a728f56f8fea3f6e533fc61f5d0 | |
| parent | db5218ec83c1d84da5a2062e514b65132bcfe240 (diff) | |
| download | coinosv3-f0a5e0de3e68eee184b25ced2393329aec169f6d.tar.gz coinosv3-f0a5e0de3e68eee184b25ced2393329aec169f6d.zip | |
The padlock answers where you tap it, including on your profile page.
Feature screens (a profile, a chat thread, a search) are drawn ahead of the
wallet screen — and the padlock's "Protect this device?" card was drawn BY
the wallet screen. So tapping the padlock from a profile did nothing you
could see: the card was waiting behind the page, and only appeared once you
navigated back to the wallet. The ask now outranks feature screens (with
onboarding still ahead of it), and declining leaves you exactly where you
tapped, because nothing else moves.
A lock that goes through does move: it drops the profile/chat/search the
padlock was tapped from, so you land on the wallet wearing a closed padlock
instead of reading a profile page with only a toast to say what happened.
Logging out drops it too — a page belonging to the locked session had no
business outliving it, or greeting whoever unlocks next.
tools/padlock-ask-test.js covers the tap and the decline.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_019mrt228Upn9nNCwGixzSLx
| -rw-r--r-- | src/app.js | 32 | ||||
| -rw-r--r-- | tools/padlock-ask-test.js | 63 |
2 files changed, 88 insertions, 7 deletions
@@ -508,8 +508,17 @@ function renderInner() { for (const el of root.querySelectorAll('[data-keep-scroll]')) if (el.scrollLeft || el.scrollTop) scrolls[el.getAttribute('data-keep-scroll')] = { l: el.scrollLeft, t: el.scrollTop }; + // The padlock's "protect this device?" ask is modal: it belongs over + // whatever page the tap happened on. A feature screen (your profile, a chat + // thread) used to swallow it — walletScreen paints the card, and + // screenView outranks walletScreen, so the padlock did nothing visible + // until you navigated back to the wallet and the card finally appeared + // there. Declining still lands back exactly where the tap happened, + // because nothing else moves. Onboarding keeps its precedence. + const lockAsk = ui.pw && ui.pw.purpose === 'lock' && !(ui.onb || onbInProgress()); const screen = - featureHook('screenView') + (lockAsk && h('div', { class: 'col', style: 'gap:16px' }, brandHeader(false), pwPromptCard())) + || featureHook('screenView') || (ui.screen === 'wallet' ? walletScreen() : ui.screen === 'accounts' @@ -1374,13 +1383,22 @@ function identities() { return out; } +// Page-level navigation owned by the features (a profile, a chat thread, a +// search, the zap setup) — dropped whenever the session underneath it +// changes, so the next paint is the app's own screen and not the page the +// previous identity (or the unlocked wallet) was reading. +function clearFeatureNav() { + ui.profilePk = null; ui.profEdit = null; ui.profEditFilled = false; + ui.chatOpen = false; ui.userSearch = null; ui.noteThread = null; ui.zapSetup = null; +} + // Switch to another identity already signed in on this device: the wallet // it opens lands on its own history, out of any profile or chat the previous // identity had open. function switchIdentity(id) { const acc = accounts.find((a) => a.id === id); if (!acc) return; - ui.profilePk = null; ui.profEdit = null; ui.profEditFilled = false; ui.chatOpen = false; + clearFeatureNav(); switchToView(acc.id, viewsOf(acc)[0]); } // The working set of wallets you can switch between. Full (seed-bearing) @@ -1979,6 +1997,7 @@ function lock({ offerPassword = false } = {}) { ui.watchXpub = ''; ui.watchLabel = ''; ui.pw = null; + clearFeatureNav(); // a profile/chat from the locked session must not outlive it ui.vaultPw = ''; ui.vaultError = ''; ui.confirmClear = false; @@ -2088,6 +2107,10 @@ function softLock() { } } const keepId = activeId; + // The lock is going through: leave whatever page the padlock was tapped + // from, so what you land on is the wallet wearing a closed padlock — from + // a profile page the toast was the only sign anything had happened. + clearFeatureNav(); _featuresInited = false; for (const f of FEATURES) { try { f.stop && f.stop(); } catch {} } vaultPassword = null; @@ -3224,11 +3247,6 @@ function walletScreen() { const s = onboardScreen(); if (s) return s; // null = the wizard just handed over; fall through } - // The padlock's set-a-password ask renders here, over the open wallet — - // declining it must land back exactly where the tap happened. - if (ui.pw && ui.pw.purpose === 'lock') { - return h('div', { class: 'col', style: 'gap:16px' }, brandHeader(false), pwPromptCard()); - } // A feature can hold the wallet behind a required onboarding step (picking // a username). Imported wallets skip it once their name is recovered. const ob = featureHook('onboardingView'); diff --git a/tools/padlock-ask-test.js b/tools/padlock-ask-test.js new file mode 100644 index 0000000..8007aad --- /dev/null +++ b/tools/padlock-ask-test.js @@ -0,0 +1,63 @@ +// The padlock is answerable from wherever it's tapped. Feature screens (your +// profile, a chat thread) are drawn ahead of the wallet screen, and the +// "Protect this device?" card is drawn BY the wallet screen — so the tap used +// to do nothing visible until you navigated home and the card finally +// appeared there. The ask outranks feature screens now, and declining still +// leaves you exactly where you tapped. +// +// Run: bun tools/padlock-ask-test.js +import puppeteer from 'puppeteer-core'; +import { generateMnemonic } from '@scure/bip39'; +import { wordlist } from '@scure/bip39/wordlists/english'; +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 html = await buildHtml({ minify: true, pwa: false }); +const server = Bun.serve({ port: 5231, fetch: () => new Response(html, { headers: { 'content-type': 'text/html' } }) }); +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] }); +const page = await browser.newPage(); +const click = (sel, t) => page.evaluate((s, x) => { const e = [...document.querySelectorAll(s)].find((n) => n.textContent.trim().toLowerCase().includes(x.toLowerCase())); if (e) { e.click(); return true; } return false; }, sel, t); +const bodyText = () => page.evaluate(() => document.body.innerText); +const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await bodyText()).toLowerCase().includes(x.toLowerCase())) return true; await sleep(250); } return false; }; +try { + await page.setViewport({ width: 390, height: 844 }); + await page.goto('http://localhost:5231/', { waitUntil: 'domcontentloaded' }); + await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); + await page.reload({ waitUntil: 'domcontentloaded' }); + await sleep(400); + await click('button', 'get started'); + await sleep(500); + await click('button', 'Import existing'); + await sleep(400); + await page.waitForSelector('textarea'); + await page.type('textarea', generateMnemonic(wordlist)); + await click('button', 'Open wallet'); + await waitText('receive', 20000); + + // open my own profile (the header avatar) + await page.evaluate(() => document.querySelector('.header-avatar')?.click()); + await sleep(900); + const onProfile = await page.evaluate(() => !!document.querySelector('.profile-avatar, .chat-avatar.profile-avatar')); + check('profile page is open', onProfile, (await bodyText()).slice(0, 60).replace(/\n/g, ' | ')); + + // tap the padlock — the ask must appear without navigating anywhere + const tapped = await page.evaluate(() => { + const b = [...document.querySelectorAll('button')].find((e) => /lock/i.test(e.getAttribute('aria-label') || '')); + if (!b) return false; b.click(); return true; + }); + check('padlock is reachable from the profile page', tapped); + await sleep(500); + const text = await bodyText(); + check('the ask appears on the tap', /protect this device/i.test(text), text.split('\n').slice(0, 3).join(' | ')); + + // declining lands back on the profile, not somewhere else + await click('button', 'not now'); + await sleep(600); + const back = await page.evaluate(() => !!document.querySelector('.profile-avatar')); + check('declining lands back on the profile page', back, (await bodyText()).slice(0, 60).replace(/\n/g, ' | ')); +} finally { + await browser.close(); server.stop(true); +} +console.log(ok ? '\n✅ padlock ask is immediate' : '\n❌ failed'); +process.exit(ok ? 0 : 1); |
