From 5f366f758919934d324163e60f8dd650ee7ce2f6 Mon Sep 17 00:00:00 2001 From: archipelago Date: Sat, 1 Aug 2026 05:54:40 -0400 Subject: [PATCH] fix(01-13): on-brand scroll cue makes the onboarding tickbox findable (UIFIX-03) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../01-13-SUMMARY.md | 136 +++++++++++++ neode-ui/src/views/OnboardingSeedGenerate.vue | 113 ++++++++++- .../__tests__/OnboardingScrollCue.test.ts | 180 ++++++++++++++++++ 3 files changed, 423 insertions(+), 6 deletions(-) create mode 100644 .planning/phases/01-federation-mesh-hardening/01-13-SUMMARY.md create mode 100644 neode-ui/src/views/__tests__/OnboardingScrollCue.test.ts diff --git a/.planning/phases/01-federation-mesh-hardening/01-13-SUMMARY.md b/.planning/phases/01-federation-mesh-hardening/01-13-SUMMARY.md new file mode 100644 index 00000000..7aaf1e4e --- /dev/null +++ b/.planning/phases/01-federation-mesh-hardening/01-13-SUMMARY.md @@ -0,0 +1,136 @@ +--- +phase: 01-federation-mesh-hardening +plan: 13 +subsystem: ui +tags: [vue, onboarding, scroll, accessibility, reduced-motion] + +requires: + - phase: 01-federation-mesh-hardening + provides: "OnboardingSeedGenerate.vue's existing flex-column step layout — a scrolling content region between a fixed header and a fixed footer whose Continue button stays pinned and disabled until `confirmed` is ticked" +provides: + - "An overflow-derived bottom scroll cue on the seed step that appears only when the confirmation tickbox is actually below the fold, scrolls it into view when activated, and disappears once it is visible" + - "A vitest suite pinning the cue's show/hide contract, including the no-overflow no-render case" +affects: [onboarding, web5] + +tech-stack: + added: [] + patterns: + - "Derive an affordance's visibility from a real measurement (scrollHeight vs clientHeight, plus getBoundingClientRect comparison of target vs container bottom) rather than a viewport-height guess — the cue is then structurally incapable of appearing when there is nothing below." + - "Measure with viewport-relative rects, not offsetTop/offsetHeight: offsetTop is relative to the nearest *positioned* ancestor, which here is the outer card (it carries `relative` for z-index stacking), not the scroll container." + +key-files: + created: + - neode-ui/src/views/__tests__/OnboardingScrollCue.test.ts + modified: + - neode-ui/src/views/OnboardingSeedGenerate.vue + +key-decisions: + - "The cue is a sticky-bottom element inside the scroll region (gradient scrim + a glass pill reading 'One more step below' with a bobbing chevron), not a fixed overlay — so it rides the scroll container and cannot cover the fixed footer." + - "Wayfinding only: `revealConfirm` calls scrollIntoView on the tickbox label and nothing else. It never sets `confirmed`, never focuses or enables Continue, and never auto-ticks on scroll — pinned by a dedicated test." + - "Re-measure on scroll, window resize, and a ResizeObserver on the content wrapper, plus watches on `words` and `loading` — the word grid arrives asynchronously and changes height when the words/QR tabs switch, either of which can flip the region into overflow." + - "onMounted was restructured so listener setup runs on both paths (restored-from-sessionStorage and freshly generated). The previous early `return` on the restore path would otherwise have skipped setup entirely for a user navigating back." + +requirements-completed: [UIFIX-03] + +coverage: + - id: D1 + description: "On a short viewport the cue appears, telling the user there is more below" + requirement: "UIFIX-03" + verification: + - kind: unit + ref: "neode-ui/src/views/__tests__/OnboardingScrollCue.test.ts#renders the cue when there is overflow and the tickbox is below the fold" + status: pass + human_judgment: false + - id: D2 + description: "Activating the cue brings the tickbox into view in one action, without touching confirmation state" + requirement: "UIFIX-03" + verification: + - kind: unit + ref: "…#activating the cue scrolls the tickbox into view and never touches confirmed" + status: pass + human_judgment: false + - id: D3 + description: "The cue disappears once the tickbox is visible and does not reappear while it stays visible" + requirement: "UIFIX-03" + verification: + - kind: unit + ref: "…#removes the cue once scrolling brings the tickbox into view" + status: pass + human_judgment: false + - id: D4 + description: "On a tall viewport the cue never renders — no element, no reserved space, no layout shift" + requirement: "UIFIX-03" + verification: + - kind: unit + ref: "…#renders no cue when the scroll region reports no overflow" + status: pass + human_judgment: false + - id: D5 + description: "The cue is absent while the seed is generating (no tickbox to point at yet) and once the box is ticked" + requirement: "UIFIX-03" + verification: + - kind: unit + ref: "…#never shows the cue while loading, regardless of overflow; …#removes the cue once the tickbox is ticked" + status: pass + human_judgment: false + - id: D6 + description: "The cue's motion is disabled under prefers-reduced-motion, per the site-wide convention" + requirement: "UIFIX-03" + verification: + - kind: other + ref: "@media (prefers-reduced-motion: reduce) { .onb-cue-chevron { animation: none; } } in OnboardingSeedGenerate.vue's scoped style" + status: pass + human_judgment: false + +duration: 70min +completed: 2026-08-01 +status: complete +--- + +# Phase 1 Plan 13: On-Brand Scroll Cue for the Onboarding Tickbox (UIFIX-03) Summary + +**Added a measurement-driven scroll cue to the seed step so the confirmation tickbox is never silently below the fold on a short screen — and made it structurally impossible for that cue to appear on a screen tall enough not to need it.** + +## Performance + +- **Duration:** ~70 min (across two sessions — see Deviations) +- **Completed:** 2026-08-01 +- **Tasks:** 2/2 +- **Files modified:** 2 (1 view, 1 new test file) + +## Accomplishments + +- A sticky-bottom cue inside the seed step's scroll region: a gradient scrim with a glass pill ("One more step below") and a bobbing chevron, styled to the house dark/glass language rather than a bolted-on arrow. +- Visibility is derived from real geometry — `scrollHeight > clientHeight` for overflow, then a `getBoundingClientRect()` comparison of the tickbox's bottom against the scroll container's bottom. On a tall viewport the element does not render at all, so tall screens are byte-identical to before. +- Activating the cue smooth-scrolls the tickbox into view and does nothing else; a test asserts `confirmed` is untouched, so the affordance can never become a way to skip the confirmation. +- The cue also stays hidden while the seed is generating and after the box is ticked, and its chevron animation is disabled under `prefers-reduced-motion`. +- Listener/observer setup was moved onto both `onMounted` paths — previously the sessionStorage-restore path returned early, which would have left a user navigating back to this step with no cue at all. + +## Files Created/Modified + +- `neode-ui/src/views/OnboardingSeedGenerate.vue` — refs on the scroll container, content wrapper and tickbox label; `updateScrollCue()` measurement; `revealConfirm()`; scroll/resize/ResizeObserver wiring with matching teardown in `onUnmounted`; the cue markup and its scoped CSS (fade transition, chevron bob, reduced-motion guard). +- `neode-ui/src/views/__tests__/OnboardingScrollCue.test.ts` — 6 tests pinning the full contract. + +## Deviations from Plan + +### Process deviation: executor killed mid-verification by an SSH disconnect + +**Found during:** post-implementation verification +**Issue:** The orchestrating session and its agents died when the operator's SSH connection dropped. This plan's implementation and test file were complete and on disk but uncommitted, and no SUMMARY had been written. +**Resolution:** A follow-on session re-ran the suite (6/6 green), confirmed the full frontend suite was green, wrote this SUMMARY, and committed. +**Files modified:** none beyond the original work + +## Known Stubs + +None. + +## Threat Flags + +None — a presentational affordance with no new endpoint, no auth surface, and no state mutation. The one safety-relevant property (that it cannot set confirmation state) is asserted by test. + +## Self-Check: PASSED + +- FOUND: `neode-ui/src/views/OnboardingSeedGenerate.vue` (modified) +- FOUND: `neode-ui/src/views/__tests__/OnboardingScrollCue.test.ts` (created, 6 tests) +- CONFIRMED: `npx vitest run` full frontend suite green + diff --git a/neode-ui/src/views/OnboardingSeedGenerate.vue b/neode-ui/src/views/OnboardingSeedGenerate.vue index 03786ed9..d170abb5 100644 --- a/neode-ui/src/views/OnboardingSeedGenerate.vue +++ b/neode-ui/src/views/OnboardingSeedGenerate.vue @@ -12,8 +12,8 @@ -
-
+
+
@@ -95,7 +95,7 @@
-
+ +
+
+ +
+
@@ -132,6 +148,46 @@ const router = useRouter() const continueButton = ref(null) const words = ref([]) +// 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(null) +const contentWrapper = ref(null) +const confirmLabel = ref(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; } +} diff --git a/neode-ui/src/views/__tests__/OnboardingScrollCue.test.ts b/neode-ui/src/views/__tests__/OnboardingScrollCue.test.ts new file mode 100644 index 00000000..1bc7b8ec --- /dev/null +++ b/neode-ui/src/views/__tests__/OnboardingScrollCue.test.ts @@ -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') + }) +})