From 089e3176912cfcccbb15820c41e45c3398429c11 Mon Sep 17 00:00:00 2001 From: Matt Edholm Date: Wed, 6 May 2026 18:28:49 -0400 Subject: [PATCH] feat(home): full-size frame card; horizontal carousel for multi-frame setups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts the 240px preview cap — frames render at their natural device aspect again. Single-frame layout unchanged. For multi-frame setups, replaces the compact stack with a horizontal scroll-snap carousel: one large card per slide, full-bleed to the viewport edges, with dot navigation below that tracks the active slide and supports tap-to-jump. Native CSS scroll-snap drives the swipe gesture; no extra JS gesture library. Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/src/components/FrameCard.vue | 9 +- frontend/src/test/views/HomeView.test.ts | 59 +++++++- frontend/src/views/HomeView.vue | 128 +++++++++++++++--- ...aSppT-T.js => BaseBottomSheet-C3K0Qa0V.js} | 2 +- ...r-Ds3dUuHV.js => DevicePicker-BDJP__rJ.js} | 2 +- public/build/assets/HomeView--2ilxFCK.css | 1 - public/build/assets/HomeView-AUY60Aym.css | 1 + public/build/assets/HomeView-CmHDPCOU.js | 1 - public/build/assets/HomeView-DElvKIxl.js | 1 + ...ew-BeFjTKGe.js => LibraryView-D8H7sTCC.js} | 2 +- ...w-CpFtvVz9.js => SettingsView-D9LhXb3F.js} | 2 +- ...iew-Cy3DCB70.js => UploadView-Clcz1bZ1.js} | 2 +- .../{index-Dtb3F_Km.js => index-CtkfMKf5.js} | 4 +- public/build/index.html | 2 +- 14 files changed, 174 insertions(+), 42 deletions(-) rename public/build/assets/{BaseBottomSheet-CaSppT-T.js => BaseBottomSheet-C3K0Qa0V.js} (98%) rename public/build/assets/{DevicePicker-Ds3dUuHV.js => DevicePicker-BDJP__rJ.js} (96%) delete mode 100644 public/build/assets/HomeView--2ilxFCK.css create mode 100644 public/build/assets/HomeView-AUY60Aym.css delete mode 100644 public/build/assets/HomeView-CmHDPCOU.js create mode 100644 public/build/assets/HomeView-DElvKIxl.js rename public/build/assets/{LibraryView-BeFjTKGe.js => LibraryView-D8H7sTCC.js} (98%) rename public/build/assets/{SettingsView-CpFtvVz9.js => SettingsView-D9LhXb3F.js} (92%) rename public/build/assets/{UploadView-Cy3DCB70.js => UploadView-Clcz1bZ1.js} (98%) rename public/build/assets/{index-Dtb3F_Km.js => index-CtkfMKf5.js} (99%) diff --git a/frontend/src/components/FrameCard.vue b/frontend/src/components/FrameCard.vue index 38ea644..a801acb 100644 --- a/frontend/src/components/FrameCard.vue +++ b/frontend/src/components/FrameCard.vue @@ -168,23 +168,18 @@ const statusText = computed(() => { opacity: 0.6; } - // ── Large (single device) ──────────────────────────────────────────────── - // Portrait frames have aspect 3:5 — at full mobile width (~360px) that would - // be 600px tall and totally dominate the screen. Cap so the card stays - // phone-friendly while still showing the photo at the frame's real shape. + // ── Large (single device or carousel slide) ────────────────────────────── &--large &__preview { background: var(--color-surface-2); display: flex; align-items: center; justify-content: center; - max-height: min(240px, 30dvh); - overflow: hidden; } &--large &__img { width: 100%; height: 100%; - object-fit: contain; + object-fit: cover; } &--large &__empty-preview { diff --git a/frontend/src/test/views/HomeView.test.ts b/frontend/src/test/views/HomeView.test.ts index 47e92dc..7033f29 100644 --- a/frontend/src/test/views/HomeView.test.ts +++ b/frontend/src/test/views/HomeView.test.ts @@ -106,22 +106,71 @@ describe('HomeView', () => { }) } - // HV-01: N devices renders N FrameCard stubs - it('renders one FrameCard per device when devices are present', async () => { + // HV-01: N devices renders a carousel of N large FrameCard stubs + N dots + it('renders one FrameCard per device in a carousel when multiple devices present', async () => { const devicesStore = useDevicesStore() devicesStore.devices = [ makeDevice({ id: 1, name: 'Frame A' }), makeDevice({ id: 2, name: 'Frame B' }), makeDevice({ id: 3, name: 'Frame C' }), ] - // Mock fetchDevices so onMounted doesn't overwrite devices vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue() const wrapper = mountView() await wrapper.vm.$nextTick() - const cards = wrapper.findAll('.frame-card-stub') - expect(cards).toHaveLength(3) + expect(wrapper.find('.home-view__carousel').exists()).toBe(true) + expect(wrapper.findAll('.frame-card-stub')).toHaveLength(3) + // All cards should be the large variant (no more compact stack) + const cards = wrapper.findAllComponents({ name: 'FrameCard' }) + for (const c of cards) expect(c.props('size')).toBe('large') + // One navigation dot per device + expect(wrapper.findAll('.home-view__dot')).toHaveLength(3) + }) + + it('marks the first dot active by default and updates active dot on scroll', async () => { + const devicesStore = useDevicesStore() + devicesStore.devices = [ + makeDevice({ id: 1, name: 'A' }), + makeDevice({ id: 2, name: 'B' }), + ] + vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue() + + const wrapper = mountView() + await wrapper.vm.$nextTick() + let dots = wrapper.findAll('.home-view__dot') + expect(dots[0].classes()).toContain('home-view__dot--active') + expect(dots[1].classes()).not.toContain('home-view__dot--active') + + // Simulate the carousel having scrolled to the second slide + const carousel = wrapper.find('.home-view__carousel').element as HTMLElement + Object.defineProperty(carousel, 'clientWidth', { configurable: true, value: 360 }) + Object.defineProperty(carousel, 'scrollLeft', { configurable: true, value: 360 }) + await wrapper.find('.home-view__carousel').trigger('scroll') + await wrapper.vm.$nextTick() + + dots = wrapper.findAll('.home-view__dot') + expect(dots[1].classes()).toContain('home-view__dot--active') + expect(dots[0].classes()).not.toContain('home-view__dot--active') + }) + + it('clicking a dot scrolls the carousel to that slide', async () => { + const devicesStore = useDevicesStore() + devicesStore.devices = [makeDevice({ id: 1 }), makeDevice({ id: 2 }), makeDevice({ id: 3 })] + vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue() + + const wrapper = mountView() + await wrapper.vm.$nextTick() + + const carousel = wrapper.find('.home-view__carousel').element as HTMLElement + Object.defineProperty(carousel, 'clientWidth', { configurable: true, value: 360 }) + const scrollToSpy = vi.fn() + ;(carousel as any).scrollTo = scrollToSpy + + await wrapper.findAll('.home-view__dot')[2].trigger('click') + await wrapper.vm.$nextTick() + + expect(scrollToSpy).toHaveBeenCalledWith(expect.objectContaining({ left: 720, behavior: 'smooth' })) }) // HV-01b: single device still renders one FrameCard (large variant branch) diff --git a/frontend/src/views/HomeView.vue b/frontend/src/views/HomeView.vue index dd93061..8d4a8c5 100644 --- a/frontend/src/views/HomeView.vue +++ b/frontend/src/views/HomeView.vue @@ -37,23 +37,51 @@ /> - -
- -
+ + @@ -103,7 +131,7 @@ +