feat(home): replace horizontal carousel with vertical scroll-snap stack
CI / test (push) Has been cancelled

For multi-frame setups, switch from side-swipe carousel + dot indicators
to a vertical scroll-snap stack of full-size cards. Each frame gets its
own page-height slide; flicking up/down moves between frames with the
same snap-stop feel as the horizontal version. Removes ~30 lines of
carousel scroll-tracking JS and the dot navigation.

Single-frame layout unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-06 18:45:02 -04:00
parent da0396788f
commit 396d4e941f
12 changed files with 59 additions and 148 deletions
+11 -42
View File
@@ -106,8 +106,8 @@ describe('HomeView', () => {
})
}
// 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 () => {
// HV-01: N devices renders a vertical stack of N large FrameCard stubs
it('renders one FrameCard per device in a vertical stack when multiple devices present', async () => {
const devicesStore = useDevicesStore()
devicesStore.devices = [
makeDevice({ id: 1, name: 'Frame A' }),
@@ -119,58 +119,27 @@ describe('HomeView', () => {
const wrapper = mountView()
await wrapper.vm.$nextTick()
expect(wrapper.find('.home-view__carousel').exists()).toBe(true)
expect(wrapper.find('.home-view__stack').exists()).toBe(true)
expect(wrapper.findAll('.home-view__slide')).toHaveLength(3)
expect(wrapper.findAll('.frame-card-stub')).toHaveLength(3)
// All cards should be the large variant (no more compact stack)
// All cards should be the large variant (no compact / no carousel)
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 () => {
it('labels each slide with the device name for accessibility', async () => {
const devicesStore = useDevicesStore()
devicesStore.devices = [
makeDevice({ id: 1, name: 'A' }),
makeDevice({ id: 2, name: 'B' }),
makeDevice({ id: 1, name: 'Living Room' }),
makeDevice({ id: 2, name: 'Bedroom' }),
]
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' }))
const slides = wrapper.findAll('.home-view__slide')
expect(slides[0].attributes('aria-label')).toBe('Living Room')
expect(slides[1].attributes('aria-label')).toBe('Bedroom')
})
// HV-01b: single device still renders one FrameCard (large variant branch)
+38 -96
View File
@@ -37,51 +37,34 @@
/>
</div>
<!-- Multiple devices swipeable carousel of large cards -->
<template v-else>
<!-- Multiple devices vertical scroll-snap stack of large cards -->
<div
v-else
class="home-view__stack"
role="list"
aria-label="Frames"
>
<div
ref="carouselEl"
class="home-view__carousel"
role="region"
aria-roledescription="carousel"
aria-label="Frames"
@scroll.passive="onCarouselScroll"
v-for="device in devicesStore.devices"
:key="device.id"
class="home-view__slide"
role="listitem"
:aria-label="device.name"
>
<div
v-for="device in devicesStore.devices"
:key="device.id"
class="home-view__slide"
role="group"
:aria-roledescription="`Slide`"
:aria-label="device.name"
>
<FrameCard
:deviceId="device.id"
:name="device.name"
size="large"
:status="deviceStatus(device)"
:orientation="device.orientation"
:thumbnailUrl="previewUrl(device)"
:lastSync="lastSyncLabel(device)"
:nextSync="nextSyncLabel(device)"
@add-photo="onAddPhoto"
@edit="onEdit"
/>
</div>
</div>
<div class="home-view__dots" role="tablist" aria-label="Frame selector">
<button
v-for="(d, i) in devicesStore.devices"
:key="d.id"
type="button"
role="tab"
:class="['home-view__dot', { 'home-view__dot--active': i === activeSlide }]"
:aria-label="`Go to ${d.name}`"
:aria-selected="i === activeSlide"
@click="goToSlide(i)"
<FrameCard
:deviceId="device.id"
:name="device.name"
size="large"
:status="deviceStatus(device)"
:orientation="device.orientation"
:thumbnailUrl="previewUrl(device)"
:lastSync="lastSyncLabel(device)"
:nextSync="nextSyncLabel(device)"
@add-photo="onAddPhoto"
@edit="onEdit"
/>
</div>
</template>
</div>
</main>
<!-- Frame settings sheet -->
@@ -131,7 +114,7 @@
</template>
<script setup lang="ts">
import { onMounted, ref, nextTick } from 'vue'
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useDevicesStore } from '@/stores/devices'
import { useUploadStore } from '@/stores/upload'
@@ -209,25 +192,6 @@ onMounted(() => {
devicesStore.fetchDevices()
})
// ── Multi-device carousel ─────────────────────────────────────────────────────
const carouselEl = ref<HTMLElement | null>(null)
const activeSlide = ref(0)
function onCarouselScroll() {
const el = carouselEl.value
if (!el) return
// Each slide is 100% of the carousel width — stride is clientWidth.
activeSlide.value = Math.round(el.scrollLeft / el.clientWidth)
}
async function goToSlide(i: number) {
await nextTick()
const el = carouselEl.value
if (!el) return
el.scrollTo({ left: i * el.clientWidth, behavior: 'smooth' })
}
function onAddPhoto(deviceId: number) {
// File picker must be triggered in the user-gesture context (the click handler)
// before navigating, otherwise browsers block it as a popup.
@@ -403,55 +367,33 @@ async function saveSettings() {
flex-direction: column;
}
// Carousel: full-bleed horizontal scroll-snap. The container ignores the
// page's side padding so cards reach edge-to-edge; each slide pads itself
// back in so the card visually aligns with the rest of the page.
&__carousel {
// Vertical scroll-snap stack of full-size cards. Each slide takes a full
// screen-port height and snaps as the user scrolls, so flipping between
// frames feels deliberate (like swiping pages) rather than continuous
// scrolling through a feed.
&__stack {
flex: 1;
min-height: 0;
display: flex;
overflow-x: auto;
overflow-y: hidden;
scroll-snap-type: x mandatory;
flex-direction: column;
gap: var(--space-3);
overflow-y: auto;
scroll-snap-type: y mandatory;
scroll-behavior: smooth;
scrollbar-width: none;
-webkit-overflow-scrolling: touch;
margin: 0 calc(-1 * var(--space-4));
&::-webkit-scrollbar { display: none; }
}
&__slide {
flex: 0 0 100%;
flex: 0 0 auto;
min-height: 100%;
box-sizing: border-box;
padding: 0 var(--space-4);
scroll-snap-align: start;
scroll-snap-stop: always;
display: flex;
flex-direction: column;
}
&__dots {
display: flex;
justify-content: center;
gap: var(--space-2);
padding-top: var(--space-3);
}
&__dot {
width: 8px;
height: 8px;
border-radius: 50%;
border: none;
padding: 0;
background: var(--color-border);
cursor: pointer;
transition: background var(--duration-fast), transform var(--duration-fast);
&--active {
background: var(--color-primary);
transform: scale(1.25);
}
scroll-snap-align: start;
scroll-snap-stop: always;
}
&__sheet-title {