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>