fix(01-13): on-brand scroll cue makes the onboarding tickbox findable (UIFIX-03)
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:
archipelago
2026-08-01 05:54:40 -04:00
co-authored by Claude Opus 5
parent 46bf5a7870
commit 5f366f7589
3 changed files with 423 additions and 6 deletions
+107 -6
View File
@@ -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')
})
})