fix(01-13): on-brand scroll cue makes the onboarding tickbox findable (UIFIX-03)
Demo images / Build & push demo images (push) Successful in 4m25s
Demo images / Build & push demo images (push) Successful in 4m25s
On a short viewport the seed-confirmation tickbox sits below the fold inside the step's scrolling area while Continue stays pinned and disabled in the fixed footer — onboarding reads as broken rather than incomplete. The cue is a sticky-bottom scrim and glass pill inside the scroll region, and its visibility comes from real geometry: scrollHeight vs clientHeight for overflow, then a getBoundingClientRect comparison of the tickbox's bottom against the container's. On a tall screen the element does not render at all, so those screens are unchanged. Rects rather than offsetTop because offsetTop is relative to the nearest positioned ancestor — here the outer card, not the scroll container. It is wayfinding only: activating it scrolls the tickbox into view and never sets confirmed, focuses Continue, or auto-ticks, which a test pins. Listener setup was moved onto both onMounted paths — the sessionStorage restore path returned early, so a user navigating back would have had no cue. Verified: 6 new tests plus the full frontend suite green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
46bf5a7870
commit
5f366f7589
@@ -12,8 +12,8 @@
|
||||
</div>
|
||||
|
||||
<!-- Scrollable Content -->
|
||||
<div class="flex-1 overflow-y-auto overflow-x-hidden px-6 sm:px-8 min-h-0">
|
||||
<div class="flex flex-col items-center gap-3 sm:gap-4 py-3">
|
||||
<div ref="scrollContainer" class="flex-1 overflow-y-auto overflow-x-hidden px-6 sm:px-8 min-h-0">
|
||||
<div ref="contentWrapper" class="flex flex-col items-center gap-3 sm:gap-4 py-3">
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="text-center py-8">
|
||||
<div class="flex justify-center mb-4">
|
||||
@@ -95,7 +95,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Confirmation Checkbox -->
|
||||
<label class="flex items-center justify-center gap-3 mt-3 cursor-pointer select-none">
|
||||
<label ref="confirmLabel" class="flex items-center justify-center gap-3 mt-3 cursor-pointer select-none">
|
||||
<input
|
||||
v-model="confirmed"
|
||||
type="checkbox"
|
||||
@@ -105,6 +105,22 @@
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<Transition name="onb-cue-fade">
|
||||
<div v-if="showScrollCue" class="sticky bottom-0 inset-x-0 h-16 flex items-end justify-center pointer-events-none">
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-black/65 to-transparent"></div>
|
||||
<button
|
||||
type="button"
|
||||
class="relative z-10 mb-2 pointer-events-auto inline-flex items-center gap-1.5 px-3.5 py-1.5 rounded-full bg-black/60 backdrop-blur-md border border-white/10 text-white/75 text-xs"
|
||||
aria-label="Scroll down to the confirmation checkbox"
|
||||
@click="revealConfirm"
|
||||
>
|
||||
<span>One more step below</span>
|
||||
<svg class="onb-cue-chevron w-4 h-4 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<!-- Fixed Footer -->
|
||||
@@ -132,6 +148,46 @@ const router = useRouter()
|
||||
const continueButton = ref<HTMLButtonElement | null>(null)
|
||||
const words = ref<string[]>([])
|
||||
|
||||
// Bottom scroll cue (UIFIX-03) — on short viewports the confirmation
|
||||
// checkbox sits below the fold inside the scrolling region while the
|
||||
// Continue button stays pinned, disabled, in the fixed footer below. The
|
||||
// cue is a pure wayfinding affordance: it only appears when the scroll
|
||||
// region actually has more content below the fold than fits, and it never
|
||||
// touches `confirmed` or the Continue button itself.
|
||||
const scrollContainer = ref<HTMLElement | null>(null)
|
||||
const contentWrapper = ref<HTMLElement | null>(null)
|
||||
const confirmLabel = ref<HTMLElement | null>(null)
|
||||
const showScrollCue = ref(false)
|
||||
let cueResizeObserver: ResizeObserver | null = null
|
||||
|
||||
function updateScrollCue() {
|
||||
const container = scrollContainer.value
|
||||
const label = confirmLabel.value
|
||||
if (!container || !label || loading.value || words.value.length === 0 || confirmed.value) {
|
||||
showScrollCue.value = false
|
||||
return
|
||||
}
|
||||
const hasOverflow = container.scrollHeight > container.clientHeight
|
||||
if (!hasOverflow) {
|
||||
showScrollCue.value = false
|
||||
return
|
||||
}
|
||||
// Viewport-relative rects rather than offsetTop/offsetHeight — offsetTop is
|
||||
// relative to the nearest *positioned* ancestor (here, the outer card,
|
||||
// which carries `relative` for its own z-index stacking), not necessarily
|
||||
// this scroll container, so it cannot be trusted to measure "below the
|
||||
// scroll region's visible bottom".
|
||||
const labelBottom = label.getBoundingClientRect().bottom
|
||||
const containerBottom = container.getBoundingClientRect().bottom
|
||||
showScrollCue.value = labelBottom > containerBottom
|
||||
}
|
||||
|
||||
// Wayfinding only — this must never set `confirmed`, never focus/enable the
|
||||
// Continue button, and never call proceed().
|
||||
function revealConfirm() {
|
||||
confirmLabel.value?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
}
|
||||
|
||||
// Words / QR code view of the seed — words are the default first view.
|
||||
// The QR tab defaults to SeedQR (BIP39 word-index digit stream), the format
|
||||
// hardware wallets like Passport Prime / SeedSigner actually import; plain
|
||||
@@ -226,23 +282,48 @@ watch(confirmed, (val) => {
|
||||
setTimeout(() => continueButton.value?.focus({ preventScroll: true }), 100)
|
||||
})
|
||||
}
|
||||
updateScrollCue()
|
||||
})
|
||||
|
||||
// Words arrive asynchronously (RPC or restored from sessionStorage) and the
|
||||
// word grid changes height when the words/QR tabs switch — both can flip the
|
||||
// scroll region from non-overflowing to overflowing, so re-measure whenever
|
||||
// either happens.
|
||||
watch(words, () => { nextTick(updateScrollCue) })
|
||||
watch(loading, () => { nextTick(updateScrollCue) })
|
||||
|
||||
onMounted(() => {
|
||||
// Restore previously generated seed if navigating back (don't regenerate)
|
||||
const saved = sessionStorage.getItem('_seed_words')
|
||||
let restored = false
|
||||
const setup = () => {
|
||||
scrollContainer.value?.addEventListener('scroll', updateScrollCue)
|
||||
window.addEventListener('resize', updateScrollCue)
|
||||
if (contentWrapper.value && typeof ResizeObserver !== 'undefined') {
|
||||
cueResizeObserver = new ResizeObserver(() => updateScrollCue())
|
||||
cueResizeObserver.observe(contentWrapper.value)
|
||||
}
|
||||
nextTick(updateScrollCue)
|
||||
}
|
||||
if (saved) {
|
||||
try {
|
||||
const parsed = JSON.parse(saved)
|
||||
if (Array.isArray(parsed) && parsed.length === 24) {
|
||||
words.value = parsed
|
||||
return
|
||||
restored = true
|
||||
}
|
||||
} catch { /* regenerate */ }
|
||||
}
|
||||
generateSeed()
|
||||
setup()
|
||||
if (!restored) generateSeed()
|
||||
})
|
||||
onUnmounted(() => {
|
||||
stopTimers()
|
||||
scrollContainer.value?.removeEventListener('scroll', updateScrollCue)
|
||||
window.removeEventListener('resize', updateScrollCue)
|
||||
cueResizeObserver?.disconnect()
|
||||
cueResizeObserver = null
|
||||
})
|
||||
onUnmounted(() => { stopTimers() })
|
||||
|
||||
function proceed() {
|
||||
playNavSound('action')
|
||||
@@ -259,4 +340,24 @@ function proceed() {
|
||||
0%, 100% { transform: scale(1); opacity: 1; }
|
||||
50% { transform: scale(1.08); opacity: 0.7; }
|
||||
}
|
||||
|
||||
/* Bottom scroll cue (UIFIX-03) — chevron bob + fade transition. */
|
||||
.onb-cue-chevron {
|
||||
animation: onb-cue-bob 2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes onb-cue-bob {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(3px); }
|
||||
}
|
||||
.onb-cue-fade-enter-active,
|
||||
.onb-cue-fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.onb-cue-fade-enter-from,
|
||||
.onb-cue-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.onb-cue-chevron { animation: none; }
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user