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>
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import OnboardingSeedGenerate from '../OnboardingSeedGenerate.vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
const WORDS = Array.from({ length: 24 }, (_, i) => `word${i + 1}`)
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({ push: vi.fn(() => Promise.resolve()) }),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useNavSounds', () => ({
|
||||
playNavSound: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/rpc-client', () => ({
|
||||
rpcClient: {
|
||||
call: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
// Set the scroll region's and confirmation label's geometry directly — jsdom
|
||||
// has no layout engine, so scrollHeight/clientHeight/scrollTop are normally 0
|
||||
// and getBoundingClientRect() always returns an all-zero rect, so both must
|
||||
// be driven explicitly.
|
||||
function setGeometry(
|
||||
container: HTMLElement,
|
||||
label: HTMLElement,
|
||||
opts: { scrollHeight: number; clientHeight: number; scrollTop: number; containerBottom: number; labelBottom: number },
|
||||
) {
|
||||
Object.defineProperty(container, 'scrollHeight', { value: opts.scrollHeight, writable: true, configurable: true })
|
||||
Object.defineProperty(container, 'clientHeight', { value: opts.clientHeight, writable: true, configurable: true })
|
||||
Object.defineProperty(container, 'scrollTop', { value: opts.scrollTop, writable: true, configurable: true })
|
||||
container.getBoundingClientRect = () => ({ bottom: opts.containerBottom } as DOMRect)
|
||||
label.getBoundingClientRect = () => ({ bottom: opts.labelBottom } as DOMRect)
|
||||
}
|
||||
|
||||
describe('OnboardingSeedGenerate scroll cue (UIFIX-03)', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('ResizeObserver', vi.fn(() => ({ observe: vi.fn(), disconnect: vi.fn() })))
|
||||
vi.mocked(rpcClient.call).mockReset()
|
||||
Element.prototype.scrollIntoView = vi.fn()
|
||||
})
|
||||
|
||||
async function mountWithWords() {
|
||||
vi.mocked(rpcClient.call).mockResolvedValue({ words: WORDS })
|
||||
const wrapper = mount(OnboardingSeedGenerate)
|
||||
await flushPromises()
|
||||
await wrapper.vm.$nextTick()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
it('renders no cue when the scroll region reports no overflow', async () => {
|
||||
const wrapper = await mountWithWords()
|
||||
const container = wrapper.get('.overflow-y-auto').element as HTMLElement
|
||||
const label = wrapper.get('label').element as HTMLElement
|
||||
|
||||
setGeometry(container, label, {
|
||||
scrollHeight: 400,
|
||||
clientHeight: 400,
|
||||
scrollTop: 0,
|
||||
containerBottom: 400,
|
||||
labelBottom: 350,
|
||||
})
|
||||
await container.dispatchEvent(new Event('scroll'))
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).not.toContain('One more step below')
|
||||
})
|
||||
|
||||
it('renders the cue when there is overflow and the tickbox is below the fold', async () => {
|
||||
const wrapper = await mountWithWords()
|
||||
const container = wrapper.get('.overflow-y-auto').element as HTMLElement
|
||||
const label = wrapper.get('label').element as HTMLElement
|
||||
|
||||
setGeometry(container, label, {
|
||||
scrollHeight: 800,
|
||||
clientHeight: 400,
|
||||
scrollTop: 0,
|
||||
containerBottom: 400,
|
||||
labelBottom: 750,
|
||||
})
|
||||
await container.dispatchEvent(new Event('scroll'))
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('One more step below')
|
||||
})
|
||||
|
||||
it('removes the cue once scrolling brings the tickbox into view', async () => {
|
||||
const wrapper = await mountWithWords()
|
||||
const container = wrapper.get('.overflow-y-auto').element as HTMLElement
|
||||
const label = wrapper.get('label').element as HTMLElement
|
||||
|
||||
setGeometry(container, label, {
|
||||
scrollHeight: 800,
|
||||
clientHeight: 400,
|
||||
scrollTop: 0,
|
||||
containerBottom: 400,
|
||||
labelBottom: 750,
|
||||
})
|
||||
await container.dispatchEvent(new Event('scroll'))
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.text()).toContain('One more step below')
|
||||
|
||||
// Scroll down: the container's own viewport rect doesn't move, but its
|
||||
// scrolled content does — the label's viewport-relative bottom shifts up
|
||||
// by the scroll delta, bringing it inside the visible window.
|
||||
setGeometry(container, label, {
|
||||
scrollHeight: 800,
|
||||
clientHeight: 400,
|
||||
scrollTop: 400,
|
||||
containerBottom: 400,
|
||||
labelBottom: 350,
|
||||
})
|
||||
await container.dispatchEvent(new Event('scroll'))
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).not.toContain('One more step below')
|
||||
})
|
||||
|
||||
it('activating the cue scrolls the tickbox into view and never touches confirmed', async () => {
|
||||
const wrapper = await mountWithWords()
|
||||
const container = wrapper.get('.overflow-y-auto').element as HTMLElement
|
||||
const label = wrapper.get('label').element as HTMLElement
|
||||
|
||||
setGeometry(container, label, {
|
||||
scrollHeight: 800,
|
||||
clientHeight: 400,
|
||||
scrollTop: 0,
|
||||
containerBottom: 400,
|
||||
labelBottom: 750,
|
||||
})
|
||||
await container.dispatchEvent(new Event('scroll'))
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const cueButton = wrapper.findAll('button').find((b) => b.text().includes('One more step below'))
|
||||
expect(cueButton).toBeDefined()
|
||||
|
||||
await cueButton!.trigger('click')
|
||||
|
||||
expect(label.scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth', block: 'center' })
|
||||
const checkbox = wrapper.get('input[type="checkbox"]').element as HTMLInputElement
|
||||
expect(checkbox.checked).toBe(false)
|
||||
})
|
||||
|
||||
it('never shows the cue while loading, regardless of overflow', async () => {
|
||||
let resolveCall: (v: { words: string[] }) => void = () => {}
|
||||
vi.mocked(rpcClient.call).mockReturnValue(new Promise((resolve) => { resolveCall = resolve }))
|
||||
const wrapper = mount(OnboardingSeedGenerate)
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).not.toContain('One more step below')
|
||||
expect(wrapper.text()).toContain('Generating your seed phrase')
|
||||
|
||||
resolveCall({ words: WORDS })
|
||||
await flushPromises()
|
||||
})
|
||||
|
||||
it('removes the cue once the tickbox is ticked', async () => {
|
||||
const wrapper = await mountWithWords()
|
||||
const container = wrapper.get('.overflow-y-auto').element as HTMLElement
|
||||
const label = wrapper.get('label').element as HTMLElement
|
||||
|
||||
setGeometry(container, label, {
|
||||
scrollHeight: 800,
|
||||
clientHeight: 400,
|
||||
scrollTop: 0,
|
||||
containerBottom: 400,
|
||||
labelBottom: 750,
|
||||
})
|
||||
await container.dispatchEvent(new Event('scroll'))
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.text()).toContain('One more step below')
|
||||
|
||||
await wrapper.get('input[type="checkbox"]').setValue(true)
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).not.toContain('One more step below')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user