aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--build.js3
-rw-r--r--src/app.js61
-rw-r--r--src/i18n.js7
-rw-r--r--src/style.css12
-rw-r--r--static/covers-sm/1.webpbin0 -> 17790 bytes
-rw-r--r--static/covers-sm/10.webpbin0 -> 20882 bytes
-rw-r--r--static/covers-sm/11.webpbin0 -> 13044 bytes
-rw-r--r--static/covers-sm/12.webpbin0 -> 6314 bytes
-rw-r--r--static/covers-sm/2.webpbin0 -> 21276 bytes
-rw-r--r--static/covers-sm/3.webpbin0 -> 15830 bytes
-rw-r--r--static/covers-sm/4.webpbin0 -> 46144 bytes
-rw-r--r--static/covers-sm/5.webpbin0 -> 11644 bytes
-rw-r--r--static/covers-sm/6.webpbin0 -> 4846 bytes
-rw-r--r--static/covers-sm/7.webpbin0 -> 4840 bytes
-rw-r--r--static/covers-sm/8.webpbin0 -> 11302 bytes
-rw-r--r--static/covers-sm/9.webpbin0 -> 23250 bytes
-rw-r--r--static/covers/1.webpbin0 -> 54880 bytes
-rw-r--r--static/covers/10.webpbin0 -> 67270 bytes
-rw-r--r--static/covers/11.webpbin0 -> 37620 bytes
-rw-r--r--static/covers/12.webpbin0 -> 24492 bytes
-rw-r--r--static/covers/2.webpbin0 -> 76144 bytes
-rw-r--r--static/covers/3.webpbin0 -> 58962 bytes
-rw-r--r--static/covers/4.webpbin0 -> 175664 bytes
-rw-r--r--static/covers/5.webpbin0 -> 37376 bytes
-rw-r--r--static/covers/6.webpbin0 -> 18224 bytes
-rw-r--r--static/covers/7.webpbin0 -> 13696 bytes
-rw-r--r--static/covers/8.webpbin0 -> 34188 bytes
-rw-r--r--static/covers/9.webpbin0 -> 80772 bytes
-rw-r--r--static/covers/CREDITS.txt14
-rw-r--r--tools/onboard-cover-test.js64
30 files changed, 152 insertions, 9 deletions
diff --git a/build.js b/build.js
index da51245..b09e47d 100644
--- a/build.js
+++ b/build.js
@@ -519,7 +519,8 @@ if (import.meta.main) {
// what a punk published as someone's nostr picture should be, but the
// circles the app draws are 18-64px and shouldn't pay for the rest.
const { readdirSync } = await import('node:fs');
- for (const d of ['punks', 'punks-sm'])
+ // ...and the default profile covers, full size and the wizard's preview copies
+ for (const d of ['punks', 'punks-sm', 'covers', 'covers-sm'])
for (const f of readdirSync('static/' + d)) await Bun.write(`dist/${d}/${f}`, Bun.file(`static/${d}/${f}`));
const kb = (Buffer.byteLength(html) / 1024).toFixed(0);
diff --git a/src/app.js b/src/app.js
index 0138de2..0ff5ccb 100644
--- a/src/app.js
+++ b/src/app.js
@@ -3563,6 +3563,13 @@ const PUNK_PICKS = [7, 14, 21, 3, 33, 40, 47, 36, 61, 26, 12, 50];
// picker are 56px, so they load the small copies.
const punkImg = (n) => `punks/${n}.webp`;
const punkTile = (n) => assetUrl(`punks-sm/${n}.webp`);
+// Default covers (static/covers, Unsplash nature shots — see CREDITS.txt):
+// published full size (1500x500), previewed from the 750px copies. Like the
+// punk, a pubkey that never picks one still gets its own by default.
+const COVER_COUNT = 12;
+const coverImg = (n) => `covers/${n}.webp`;
+const coverPreview = (n) => assetUrl(`covers-sm/${n}.webp`);
+const defaultCover = (pk) => (pk ? parseInt(pk.slice(-6, -2), 16) % COVER_COUNT : 0) + 1;
async function onbUpload(file) {
const fd = new FormData();
@@ -3851,14 +3858,51 @@ function onboardScreen() {
// The big avatar IS the selection: it shows exactly what Continue will
// publish — a picked punk, an upload, or the pubkey-derived default.
const preview = sel || (me ? punkUrl(me) : null);
+ // ...and the cover behind it the same: a default (number) or an upload
+ // (URL). Untouched, it is this pubkey's default cover.
+ const cover = o.cover ?? defaultCover(me);
+ const coverN = typeof cover === 'number' ? cover : null;
+ const step = (d) => {
+ const from = coverN ?? (o.coverLast || defaultCover(me));
+ o.cover = ((from - 1 + d + COVER_COUNT) % COVER_COUNT) + 1;
+ o.coverLast = o.cover;
+ render();
+ };
+ // the neighbours load ahead, so cycling never shows a blank cover
+ if (coverN) for (const d of [1, -1]) { const im = new Image(); im.src = coverPreview(((coverN - 1 + d + COVER_COUNT) % COVER_COUNT) + 1); }
return page([
title(t('onbAvatarTitle')),
h('p', { class: 'muted', style: 'margin:0' }, t('onbAvatarBody')),
- preview ? h('img', {
- class: 'onb-avatar', src: /^(https?|data|blob):/i.test(preview) ? preview : assetUrl(preview), alt: '',
- style: ui.onbBusy ? 'opacity:.5' : '',
- onError: (e) => { e.target.style.visibility = 'hidden'; },
- }) : null,
+ h('div', { class: 'onb-look' },
+ h('div', {
+ class: 'onb-cover', role: 'group', 'aria-label': t('onbCover'),
+ style: `background-image:url(${JSON.stringify(coverN ? coverPreview(coverN) : cover)})` + (ui.onbBusy ? ';opacity:.5' : ''),
+ // on the wizard state, not this render's closure: a render mid-swipe
+ // swaps the handlers, and the release must still see the press
+ onPointerdown: (e) => { o.swipeX = e.clientX; },
+ onPointerup: (e) => {
+ if (o.swipeX == null) return;
+ const dx = e.clientX - o.swipeX; o.swipeX = null;
+ if (Math.abs(dx) > 40) step(dx < 0 ? 1 : -1);
+ },
+ },
+ h('button', { type: 'button', class: 'onb-cover-nav prev', 'aria-label': t('onbCoverPrev'), onClick: () => step(-1) }, '‹'),
+ h('button', { type: 'button', class: 'onb-cover-nav next', 'aria-label': t('onbCoverNext'), onClick: () => step(1) }, '›'),
+ h('span', { class: 'onb-cover-count' }, coverN ? `${coverN} / ${COVER_COUNT}` : t('onbCoverYours')),
+ h('button', { type: 'button', class: 'onb-cover-upload', disabled: ui.onbBusy, 'aria-label': t('onbCoverUpload'), title: t('onbCoverUpload'),
+ onClick: () => document.getElementById('onb-cover-file')?.click() }, '📷'),
+ h('input', { type: 'file', id: 'onb-cover-file', accept: 'image/*', style: 'display:none', onChange: async (e) => {
+ const f = e.target.files && e.target.files[0];
+ if (!f) return;
+ ui.onbBusy = true; ui.onbError = ''; render();
+ try { o.cover = await onbUpload(f); } catch (err) { ui.onbError = err.message; }
+ ui.onbBusy = false; render();
+ } })),
+ preview ? h('img', {
+ class: 'onb-avatar', src: /^(https?|data|blob):/i.test(preview) ? preview : assetUrl(preview), alt: '',
+ style: ui.onbBusy ? 'opacity:.5' : '',
+ onError: (e) => { e.target.style.visibility = 'hidden'; },
+ }) : null),
h('div', { class: 'onb-punks', 'data-keep-scroll': 'punks' },
...PUNK_PICKS.map((n) =>
h('img', {
@@ -3887,14 +3931,17 @@ function onboardScreen() {
// once replaced a real profile picture with a broken string).
const picked = o.avatar && !/^https?:/i.test(o.avatar) ? 'https://v3.coinos.io/' + o.avatar : o.avatar;
const picture = picked || (me ? new URL(punkUrl(me), 'https://v3.coinos.io/').href : undefined);
- const fields = { name: addr ? addr.split('@')[0] : undefined, picture };
+ const banner = typeof cover === 'number' ? 'https://v3.coinos.io/' + coverImg(cover) : cover;
+ const fields = { name: addr ? addr.split('@')[0] : undefined, picture, banner };
// Publishing the profile is a nicety — a signer that can't sign
// right now (a sleeping phone bunker) must not trap the wizard.
// And it's an OFFER: the address-derived name (and, unless they
// actually picked one, the default punk) only fills gaps — it
// never renames an identity that already has a profile.
if (fields.name || fields.picture) {
- try { await featureHook('publishProfile', fields, { fillOnly: o.avatar ? ['name'] : ['name', 'picture'] }); }
+ // a cover they actually chose replaces one they had; the default only fills a gap
+ const fillOnly = ['name', ...(o.avatar ? [] : ['picture']), ...(o.cover != null ? [] : ['banner'])];
+ try { await featureHook('publishProfile', fields, { fillOnly }); }
catch (e) { toast(t('onbProfileSkipped')); console.warn('onboarding: profile publish failed —', e.message); }
}
o.step = nextAfterAvatar();
diff --git a/src/i18n.js b/src/i18n.js
index b82a2ac..f08ea11 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -1133,7 +1133,12 @@ const STR = {
onbNameWait: 'Getting things ready…',
onbNotNow: 'Not now',
onbAvatarTitle: 'Choose your look',
- onbAvatarBody: 'Choose an avatar or upload your own picture. You can change it anytime.',
+ onbAvatarBody: 'Pick a cover and an avatar, or upload your own. You can change them anytime.',
+ onbCover: 'Cover image',
+ onbCoverPrev: 'Previous cover',
+ onbCoverNext: 'Next cover',
+ onbCoverUpload: 'Upload a cover',
+ onbCoverYours: 'Your photo',
onbUpload: 'Upload a photo',
onbUploadFailed: 'Upload failed. Try another picture',
onbProfileSkipped: 'Couldn’t publish your profile. Set it later from Edit profile',
diff --git a/src/style.css b/src/style.css
index c0edfac..5acd9ea 100644
--- a/src/style.css
+++ b/src/style.css
@@ -1250,6 +1250,18 @@ body::after {
.onb-punk:hover { transform: scale(1.08); }
.onb-punk.sel { border-color: var(--accent); transform: scale(1.08); }
.onb-avatar { width: 160px; height: 160px; border-radius: 50%; object-fit: cover; align-self: center; box-shadow: var(--shadow); transition: opacity .15s; }
+/* the cover sits behind the avatar, which overlaps its bottom edge — the
+ profile page's own layout, so what they pick is what they'll see */
+.onb-look { display: flex; flex-direction: column; align-items: center; }
+.onb-look .onb-avatar { width: 120px; height: 120px; margin-top: -60px; border: 4px solid var(--bg); position: relative; }
+.onb-cover { position: relative; width: 100%; aspect-ratio: 3 / 1; border-radius: 14px; background: var(--surface2) center / cover no-repeat; touch-action: pan-y; user-select: none; transition: background-image .2s, opacity .15s; }
+.onb-cover-nav { position: absolute; bottom: 8px; width: 34px; height: 34px; border-radius: 50%; border: none; padding: 0; background: rgba(0,0,0,.45); color: #fff; font-size: 22px; line-height: 1; cursor: pointer; display: flex; align-items: center; justify-content: center; }
+.onb-cover-nav:hover { background: rgba(0,0,0,.65); }
+.onb-cover-nav.prev { left: 8px; }
+.onb-cover-nav.next { right: 8px; }
+.onb-cover-count { position: absolute; left: 10px; top: 8px; font-size: 11px; color: #fff; background: rgba(0,0,0,.45); padding: 2px 8px; border-radius: 10px; }
+.onb-cover-upload { position: absolute; right: 8px; top: 8px; width: 32px; height: 32px; border-radius: 50%; border: none; padding: 0; background: rgba(0,0,0,.45); color: #fff; font-size: 15px; cursor: pointer; display: flex; align-items: center; justify-content: center; }
+.onb-cover-upload:hover { background: rgba(0,0,0,.65); }
.onb-confetti { position: fixed; inset: 0; pointer-events: none; overflow: hidden; }
.onb-confetti i { position: absolute; top: -12px; width: 9px; height: 14px; opacity: .9; animation: confetti-fall 2.8s ease-in forwards; }
.onb-confetti i:nth-child(4n) { background: #f7931a; }
diff --git a/static/covers-sm/1.webp b/static/covers-sm/1.webp
new file mode 100644
index 0000000..f6b876a
--- /dev/null
+++ b/static/covers-sm/1.webp
Binary files differ
diff --git a/static/covers-sm/10.webp b/static/covers-sm/10.webp
new file mode 100644
index 0000000..e08f4ba
--- /dev/null
+++ b/static/covers-sm/10.webp
Binary files differ
diff --git a/static/covers-sm/11.webp b/static/covers-sm/11.webp
new file mode 100644
index 0000000..b6dfb0a
--- /dev/null
+++ b/static/covers-sm/11.webp
Binary files differ
diff --git a/static/covers-sm/12.webp b/static/covers-sm/12.webp
new file mode 100644
index 0000000..2b6c75d
--- /dev/null
+++ b/static/covers-sm/12.webp
Binary files differ
diff --git a/static/covers-sm/2.webp b/static/covers-sm/2.webp
new file mode 100644
index 0000000..58b1e9a
--- /dev/null
+++ b/static/covers-sm/2.webp
Binary files differ
diff --git a/static/covers-sm/3.webp b/static/covers-sm/3.webp
new file mode 100644
index 0000000..ca3a6a3
--- /dev/null
+++ b/static/covers-sm/3.webp
Binary files differ
diff --git a/static/covers-sm/4.webp b/static/covers-sm/4.webp
new file mode 100644
index 0000000..bbd8bea
--- /dev/null
+++ b/static/covers-sm/4.webp
Binary files differ
diff --git a/static/covers-sm/5.webp b/static/covers-sm/5.webp
new file mode 100644
index 0000000..42be0cf
--- /dev/null
+++ b/static/covers-sm/5.webp
Binary files differ
diff --git a/static/covers-sm/6.webp b/static/covers-sm/6.webp
new file mode 100644
index 0000000..cf83873
--- /dev/null
+++ b/static/covers-sm/6.webp
Binary files differ
diff --git a/static/covers-sm/7.webp b/static/covers-sm/7.webp
new file mode 100644
index 0000000..d2347c3
--- /dev/null
+++ b/static/covers-sm/7.webp
Binary files differ
diff --git a/static/covers-sm/8.webp b/static/covers-sm/8.webp
new file mode 100644
index 0000000..812243f
--- /dev/null
+++ b/static/covers-sm/8.webp
Binary files differ
diff --git a/static/covers-sm/9.webp b/static/covers-sm/9.webp
new file mode 100644
index 0000000..4c85ddf
--- /dev/null
+++ b/static/covers-sm/9.webp
Binary files differ
diff --git a/static/covers/1.webp b/static/covers/1.webp
new file mode 100644
index 0000000..cd78e12
--- /dev/null
+++ b/static/covers/1.webp
Binary files differ
diff --git a/static/covers/10.webp b/static/covers/10.webp
new file mode 100644
index 0000000..e256531
--- /dev/null
+++ b/static/covers/10.webp
Binary files differ
diff --git a/static/covers/11.webp b/static/covers/11.webp
new file mode 100644
index 0000000..3d6badb
--- /dev/null
+++ b/static/covers/11.webp
Binary files differ
diff --git a/static/covers/12.webp b/static/covers/12.webp
new file mode 100644
index 0000000..45b7c36
--- /dev/null
+++ b/static/covers/12.webp
Binary files differ
diff --git a/static/covers/2.webp b/static/covers/2.webp
new file mode 100644
index 0000000..3daffc2
--- /dev/null
+++ b/static/covers/2.webp
Binary files differ
diff --git a/static/covers/3.webp b/static/covers/3.webp
new file mode 100644
index 0000000..803119b
--- /dev/null
+++ b/static/covers/3.webp
Binary files differ
diff --git a/static/covers/4.webp b/static/covers/4.webp
new file mode 100644
index 0000000..d486e9b
--- /dev/null
+++ b/static/covers/4.webp
Binary files differ
diff --git a/static/covers/5.webp b/static/covers/5.webp
new file mode 100644
index 0000000..b73d145
--- /dev/null
+++ b/static/covers/5.webp
Binary files differ
diff --git a/static/covers/6.webp b/static/covers/6.webp
new file mode 100644
index 0000000..8bfa8b0
--- /dev/null
+++ b/static/covers/6.webp
Binary files differ
diff --git a/static/covers/7.webp b/static/covers/7.webp
new file mode 100644
index 0000000..b09b6e9
--- /dev/null
+++ b/static/covers/7.webp
Binary files differ
diff --git a/static/covers/8.webp b/static/covers/8.webp
new file mode 100644
index 0000000..0d5e25e
--- /dev/null
+++ b/static/covers/8.webp
Binary files differ
diff --git a/static/covers/9.webp b/static/covers/9.webp
new file mode 100644
index 0000000..09330c6
--- /dev/null
+++ b/static/covers/9.webp
Binary files differ
diff --git a/static/covers/CREDITS.txt b/static/covers/CREDITS.txt
new file mode 100644
index 0000000..2df95f3
--- /dev/null
+++ b/static/covers/CREDITS.txt
@@ -0,0 +1,14 @@
+Default profile covers — photos from Unsplash (https://unsplash.com/license), served via picsum.photos, resized to 1500x500 (covers/) and 750x250 (covers-sm/).
+
+1.webp Alberto Restifo https://unsplash.com/photos/Ni4NgA64TFQ
+2.webp Jeff Sheldon https://unsplash.com/photos/oOFrXiz8-0A
+3.webp Jeff Sheldon https://unsplash.com/photos/SdSc4sWVMRU
+4.webp Juskteez Vu https://unsplash.com/photos/TIrXot28Znc
+5.webp Sebastian Boguszewicz https://unsplash.com/photos/YG_Fxyqz9xg
+6.webp Tim de Groot https://unsplash.com/photos/yNGQ830uFB4
+7.webp Elaine Li https://unsplash.com/photos/9jYj32TN9Ts
+8.webp Jasper van der Meij https://unsplash.com/photos/Xo3uIN_Q1Y0
+9.webp Danny Froese https://unsplash.com/photos/JXkCLZhhRJg
+10.webp Tomasz Paciorek https://unsplash.com/photos/rMwCJs4Pcw0
+11.webp Dustin Scarpitti https://unsplash.com/photos/26-lAP0XprM
+12.webp Liane Metzler https://unsplash.com/photos/Y1ByvAGQ5iE
diff --git a/tools/onboard-cover-test.js b/tools/onboard-cover-test.js
new file mode 100644
index 0000000..4262bc1
--- /dev/null
+++ b/tools/onboard-cover-test.js
@@ -0,0 +1,64 @@
+// The wizard's "Choose your look" step: a cover sits behind the avatar,
+// defaulting to this pubkey's own, and its arrows (or a swipe) cycle the
+// twelve nature covers with a counter; the preview copies are what load.
+// Run: bun run build && bun tools/onboard-cover-test.js [screenshot.png]
+import assert from 'node:assert/strict';
+import puppeteer from './puppeteer.js';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+const server = Bun.serve({ port: 0, async fetch(req) {
+ const p = new URL(req.url).pathname;
+ const f = Bun.file('dist' + (p === '/' ? '/index.html' : p));
+ return (await f.exists()) ? new Response(f) : new Response(Bun.file('dist/index.html'), { headers: { 'content-type': 'text/html' } });
+} });
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox'] });
+try {
+ const page = await browser.newPage();
+ const errors = [];
+ page.on('pageerror', (e) => errors.push(String(e)));
+ await page.setViewport({ width: 390, height: 844, deviceScaleFactor: 2 });
+ await page.goto(server.url.href);
+ await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
+ await page.reload(); await sleep(1200);
+ const click = (x) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); e?.click(); return !!e; }, x);
+ await click('create a new wallet'); await sleep(1500);
+ await click('skip verification'); await sleep(1500);
+ await page.evaluate(() => localStorage.setItem('btc-wallet-onb-step', 'avatar'));
+ await page.reload();
+ await page.waitForSelector('.onb-cover', { timeout: 10000 });
+ const state = () => page.evaluate(() => {
+ const c = document.querySelector('.onb-cover');
+ return { count: document.querySelector('.onb-cover-count').textContent, bg: c.style.backgroundImage };
+ });
+ const first = await state();
+ assert.match(first.count, /^\d+ \/ 12$/, 'a default cover is chosen: ' + first.count);
+ assert.match(first.bg, /covers-sm\/\d+\.webp/);
+ const n = Number(first.count.split(' ')[0]);
+ await page.click('.onb-cover-nav.next'); await sleep(100);
+ assert.equal((await state()).count, `${n % 12 + 1} / 12`, 'next moves on, wrapping at twelve');
+ await page.click('.onb-cover-nav.prev'); await page.click('.onb-cover-nav.prev'); await sleep(100);
+ assert.equal((await state()).count, `${((n - 2 + 12) % 12) + 1} / 12`, 'previous goes back, wrapping');
+ // a swipe left is "next"
+ const box = await (await page.$('.onb-cover')).boundingBox();
+ const before = Number((await state()).count.split(' ')[0]);
+ // across the cover's open upper part (the avatar covers the lower middle)
+ const y = box.y + box.height * 0.3;
+ await page.mouse.move(box.x + box.width * 0.6, y);
+ await page.mouse.down(); await page.mouse.move(box.x + box.width * 0.3, y, { steps: 5 }); await page.mouse.up();
+ await sleep(100);
+ assert.equal((await state()).count, `${before % 12 + 1} / 12`, 'a swipe left shows the next cover');
+ // the cover image itself really loads, and the avatar sits over its bottom edge
+ const loaded = await page.evaluate(async () => {
+ const url = document.querySelector('.onb-cover').style.backgroundImage.match(/url\("?(.*?)"?\)/)[1];
+ const im = new Image(); im.src = url; await im.decode(); return im.naturalWidth;
+ });
+ assert.equal(loaded, 750);
+ const overlap = await page.evaluate(() => {
+ const c = document.querySelector('.onb-cover').getBoundingClientRect(), a = document.querySelector('.onb-avatar').getBoundingClientRect();
+ return a.top < c.bottom && a.bottom > c.bottom;
+ });
+ assert(overlap, 'the avatar overlaps the cover\'s bottom edge');
+ if (process.argv[2]) await page.screenshot({ path: process.argv[2] });
+ assert.deepEqual(errors, []);
+ console.log('✓ the look step: default cover, arrows and swipe cycle twelve covers, avatar over the cover');
+} finally { await browser.close(); server.stop(true); }
+process.exit(0);