From 39ce1ef4c04bf010c75b3431be69174d6ef2c80a Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Wed, 7 Oct 2026 13:25:49 -0700 Subject: Setup wizard: a cover behind the avatar — twelve nature covers (self-hosted, Unsplash) cycled with arrows or a swipe, or upload your own; untouched, each pubkey gets its own default, published as the kind-0 banner beside the punk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/app.js | 61 ++++++++++++++++++++++++++++++++++++++++++++++++++++------- src/i18n.js | 7 ++++++- src/style.css | 12 ++++++++++++ 3 files changed, 72 insertions(+), 8 deletions(-) (limited to 'src') 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; } -- cgit v1.2.3