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 // HV-01: N devices renders a vertical stack of N large FrameCard stubs
it('renders one FrameCard per device in a carousel when multiple devices present', async () => { it('renders one FrameCard per device in a vertical stack when multiple devices present', async () => {
const devicesStore = useDevicesStore() const devicesStore = useDevicesStore()
devicesStore.devices = [ devicesStore.devices = [
makeDevice({ id: 1, name: 'Frame A' }), makeDevice({ id: 1, name: 'Frame A' }),
@@ -119,58 +119,27 @@ describe('HomeView', () => {
const wrapper = mountView() const wrapper = mountView()
await wrapper.vm.$nextTick() 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) 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' }) const cards = wrapper.findAllComponents({ name: 'FrameCard' })
for (const c of cards) expect(c.props('size')).toBe('large') 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() const devicesStore = useDevicesStore()
devicesStore.devices = [ devicesStore.devices = [
makeDevice({ id: 1, name: 'A' }), makeDevice({ id: 1, name: 'Living Room' }),
makeDevice({ id: 2, name: 'B' }), makeDevice({ id: 2, name: 'Bedroom' }),
] ]
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue() vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView() const wrapper = mountView()
await wrapper.vm.$nextTick() await wrapper.vm.$nextTick()
let dots = wrapper.findAll('.home-view__dot') const slides = wrapper.findAll('.home-view__slide')
expect(dots[0].classes()).toContain('home-view__dot--active') expect(slides[0].attributes('aria-label')).toBe('Living Room')
expect(dots[1].classes()).not.toContain('home-view__dot--active') expect(slides[1].attributes('aria-label')).toBe('Bedroom')
// 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) // HV-01b: single device still renders one FrameCard (large variant branch)
+38 -96
View File
@@ -37,51 +37,34 @@
/> />
</div> </div>
<!-- Multiple devices swipeable carousel of large cards --> <!-- Multiple devices vertical scroll-snap stack of large cards -->
<template v-else> <div
v-else
class="home-view__stack"
role="list"
aria-label="Frames"
>
<div <div
ref="carouselEl" v-for="device in devicesStore.devices"
class="home-view__carousel" :key="device.id"
role="region" class="home-view__slide"
aria-roledescription="carousel" role="listitem"
aria-label="Frames" :aria-label="device.name"
@scroll.passive="onCarouselScroll"
> >
<div <FrameCard
v-for="device in devicesStore.devices" :deviceId="device.id"
:key="device.id" :name="device.name"
class="home-view__slide" size="large"
role="group" :status="deviceStatus(device)"
:aria-roledescription="`Slide`" :orientation="device.orientation"
:aria-label="device.name" :thumbnailUrl="previewUrl(device)"
> :lastSync="lastSyncLabel(device)"
<FrameCard :nextSync="nextSyncLabel(device)"
:deviceId="device.id" @add-photo="onAddPhoto"
:name="device.name" @edit="onEdit"
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)"
/> />
</div> </div>
</template> </div>
</main> </main>
<!-- Frame settings sheet --> <!-- Frame settings sheet -->
@@ -131,7 +114,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref, nextTick } from 'vue' import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useDevicesStore } from '@/stores/devices' import { useDevicesStore } from '@/stores/devices'
import { useUploadStore } from '@/stores/upload' import { useUploadStore } from '@/stores/upload'
@@ -209,25 +192,6 @@ onMounted(() => {
devicesStore.fetchDevices() 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) { function onAddPhoto(deviceId: number) {
// File picker must be triggered in the user-gesture context (the click handler) // File picker must be triggered in the user-gesture context (the click handler)
// before navigating, otherwise browsers block it as a popup. // before navigating, otherwise browsers block it as a popup.
@@ -403,55 +367,33 @@ async function saveSettings() {
flex-direction: column; flex-direction: column;
} }
// Carousel: full-bleed horizontal scroll-snap. The container ignores the // Vertical scroll-snap stack of full-size cards. Each slide takes a full
// page's side padding so cards reach edge-to-edge; each slide pads itself // screen-port height and snaps as the user scrolls, so flipping between
// back in so the card visually aligns with the rest of the page. // frames feels deliberate (like swiping pages) rather than continuous
&__carousel { // scrolling through a feed.
&__stack {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
display: flex; display: flex;
overflow-x: auto; flex-direction: column;
overflow-y: hidden; gap: var(--space-3);
scroll-snap-type: x mandatory; overflow-y: auto;
scroll-snap-type: y mandatory;
scroll-behavior: smooth; scroll-behavior: smooth;
scrollbar-width: none; scrollbar-width: none;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
margin: 0 calc(-1 * var(--space-4));
&::-webkit-scrollbar { display: none; } &::-webkit-scrollbar { display: none; }
} }
&__slide { &__slide {
flex: 0 0 100%; flex: 0 0 auto;
min-height: 100%;
box-sizing: border-box; box-sizing: border-box;
padding: 0 var(--space-4);
scroll-snap-align: start;
scroll-snap-stop: always;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} scroll-snap-align: start;
scroll-snap-stop: always;
&__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);
}
} }
&__sheet-title { &__sheet-title {
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
import{_ as e,d as t,g as n,j as r,k as i,l as a,o,p as s,pt as c,t as l,u,v as d,z as f}from"./_plugin-vue_export-helper-DVo1OUMD.js";import{n as p,t as m}from"./BaseBottomSheet-CXcLrct9.js";var h={class:`device-picker__list`},g=[`checked`,`onChange`],_={class:`device-picker__name`},v={class:`device-picker__orientation`},y=l(d({__name:`DevicePicker`,props:{modelValue:{type:Boolean},devices:{},selected:{},uploading:{type:Boolean}},emits:[`update:modelValue`,`update:selected`,`confirm`],setup(l,{emit:d}){let y=l,b=d;function x(e){y.selected.includes(e)?b(`update:selected`,y.selected.filter(t=>t!==e)):b(`update:selected`,[...y.selected,e])}let S=a(()=>{let e=y.selected.length;return e===0?`Add to frame`:`Add to ${e} frame${e>1?`s`:``}`});return(a,d)=>(i(),t(m,{"model-value":l.modelValue,label:`Choose frames`,"onUpdate:modelValue":d[1]||=e=>a.$emit(`update:modelValue`,e)},{default:f(()=>[d[2]||=u(`h2`,{class:`device-picker__title`},`Add to frames`,-1),d[3]||=u(`p`,{class:`device-picker__sub`},`Choose which frames will show this photo.`,-1),u(`div`,h,[(i(!0),s(o,null,r(l.devices,e=>(i(),s(`label`,{key:e.id,class:`device-picker__row`},[u(`input`,{type:`checkbox`,class:`device-picker__check`,checked:l.selected.includes(e.id),onChange:t=>x(e.id)},null,40,g),u(`span`,_,c(e.name),1),u(`span`,v,c(e.orientation),1)]))),128))]),e(p,{variant:`primary`,class:`device-picker__confirm`,disabled:l.selected.length===0||l.uploading,onClick:d[0]||=e=>a.$emit(`confirm`)},{default:f(()=>[n(c(l.uploading?`Uploading…`:S.value),1)]),_:1},8,[`disabled`])]),_:1},8,[`model-value`]))}}),[[`__scopeId`,`data-v-a6466fa5`]]);export{y as t}; import{_ as e,d as t,g as n,j as r,k as i,l as a,o,p as s,pt as c,t as l,u,v as d,z as f}from"./_plugin-vue_export-helper-DVo1OUMD.js";import{n as p,t as m}from"./BaseBottomSheet-DYbXyS7e.js";var h={class:`device-picker__list`},g=[`checked`,`onChange`],_={class:`device-picker__name`},v={class:`device-picker__orientation`},y=l(d({__name:`DevicePicker`,props:{modelValue:{type:Boolean},devices:{},selected:{},uploading:{type:Boolean}},emits:[`update:modelValue`,`update:selected`,`confirm`],setup(l,{emit:d}){let y=l,b=d;function x(e){y.selected.includes(e)?b(`update:selected`,y.selected.filter(t=>t!==e)):b(`update:selected`,[...y.selected,e])}let S=a(()=>{let e=y.selected.length;return e===0?`Add to frame`:`Add to ${e} frame${e>1?`s`:``}`});return(a,d)=>(i(),t(m,{"model-value":l.modelValue,label:`Choose frames`,"onUpdate:modelValue":d[1]||=e=>a.$emit(`update:modelValue`,e)},{default:f(()=>[d[2]||=u(`h2`,{class:`device-picker__title`},`Add to frames`,-1),d[3]||=u(`p`,{class:`device-picker__sub`},`Choose which frames will show this photo.`,-1),u(`div`,h,[(i(!0),s(o,null,r(l.devices,e=>(i(),s(`label`,{key:e.id,class:`device-picker__row`},[u(`input`,{type:`checkbox`,class:`device-picker__check`,checked:l.selected.includes(e.id),onChange:t=>x(e.id)},null,40,g),u(`span`,_,c(e.name),1),u(`span`,v,c(e.orientation),1)]))),128))]),e(p,{variant:`primary`,class:`device-picker__confirm`,disabled:l.selected.length===0||l.uploading,onClick:d[0]||=e=>a.$emit(`confirm`)},{default:f(()=>[n(c(l.uploading?`Uploading…`:S.value),1)]),_:1},8,[`disabled`])]),_:1},8,[`model-value`]))}}),[[`__scopeId`,`data-v-a6466fa5`]]);export{y as t};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
import{K as e,dt as t,f as n,ft as r,j as i,k as a,l as o,o as s,p as c,pt as l,t as u,u as d,v as f}from"./_plugin-vue_export-helper-DVo1OUMD.js";import{n as p,r as m,t as h}from"./index-aP_uBWCi.js";var g={class:`settings`},_={class:`settings__section`},v={class:`theme-grid`,role:`radiogroup`,"aria-label":`Choose theme`},y=[`aria-checked`,`aria-label`,`onClick`],b={class:`theme-swatch__label`},x={key:0,class:`theme-swatch__check`,"aria-hidden":`true`},S={class:`settings__section`},C={class:`settings__row`},w={class:`settings__row-value`},T=u(f({__name:`SettingsView`,setup(u){let f=m(),{saveTheme:T}=p(),E=o(()=>f.user?.theme??`warm-craft`);function D(e){T(e)}return(o,u)=>(a(),c(`main`,g,[u[5]||=d(`h1`,{class:`settings__title`},`Settings`,-1),d(`section`,_,[u[1]||=d(`h2`,{class:`settings__section-title`},`Theme`,-1),d(`div`,v,[(a(!0),c(s,null,i(e(h),e=>(a(),c(`button`,{key:e.id,type:`button`,role:`radio`,"aria-checked":E.value===e.id,"aria-label":e.label,class:t([`theme-swatch`,{"theme-swatch--active":E.value===e.id}]),style:r({"--swatch-bg":e.bg,"--swatch-primary":e.primary,"--swatch-text":e.text}),onClick:t=>D(e.id)},[u[0]||=d(`span`,{class:`theme-swatch__preview`,"aria-hidden":`true`},[d(`span`,{class:`theme-swatch__bar`}),d(`span`,{class:`theme-swatch__dot`})],-1),d(`span`,b,l(e.label),1),E.value===e.id?(a(),c(`span`,x,``)):n(``,!0)],14,y))),128))])]),d(`section`,S,[u[3]||=d(`h2`,{class:`settings__section-title`},`Account`,-1),d(`div`,C,[u[2]||=d(`span`,{class:`settings__row-label`},`Signed in as`,-1),d(`span`,w,l(e(f).user?.email),1)]),u[4]||=d(`a`,{href:`/logout`,class:`settings__logout`},`Sign out`,-1)])]))}}),[[`__scopeId`,`data-v-76ec3881`]]);export{T as default}; import{K as e,dt as t,f as n,ft as r,j as i,k as a,l as o,o as s,p as c,pt as l,t as u,u as d,v as f}from"./_plugin-vue_export-helper-DVo1OUMD.js";import{n as p,r as m,t as h}from"./index-Dt4UyE7n.js";var g={class:`settings`},_={class:`settings__section`},v={class:`theme-grid`,role:`radiogroup`,"aria-label":`Choose theme`},y=[`aria-checked`,`aria-label`,`onClick`],b={class:`theme-swatch__label`},x={key:0,class:`theme-swatch__check`,"aria-hidden":`true`},S={class:`settings__section`},C={class:`settings__row`},w={class:`settings__row-value`},T=u(f({__name:`SettingsView`,setup(u){let f=m(),{saveTheme:T}=p(),E=o(()=>f.user?.theme??`warm-craft`);function D(e){T(e)}return(o,u)=>(a(),c(`main`,g,[u[5]||=d(`h1`,{class:`settings__title`},`Settings`,-1),d(`section`,_,[u[1]||=d(`h2`,{class:`settings__section-title`},`Theme`,-1),d(`div`,v,[(a(!0),c(s,null,i(e(h),e=>(a(),c(`button`,{key:e.id,type:`button`,role:`radio`,"aria-checked":E.value===e.id,"aria-label":e.label,class:t([`theme-swatch`,{"theme-swatch--active":E.value===e.id}]),style:r({"--swatch-bg":e.bg,"--swatch-primary":e.primary,"--swatch-text":e.text}),onClick:t=>D(e.id)},[u[0]||=d(`span`,{class:`theme-swatch__preview`,"aria-hidden":`true`},[d(`span`,{class:`theme-swatch__bar`}),d(`span`,{class:`theme-swatch__dot`})],-1),d(`span`,b,l(e.label),1),E.value===e.id?(a(),c(`span`,x,``)):n(``,!0)],14,y))),128))])]),d(`section`,S,[u[3]||=d(`h2`,{class:`settings__section-title`},`Account`,-1),d(`div`,C,[u[2]||=d(`span`,{class:`settings__row-label`},`Signed in as`,-1),d(`span`,w,l(e(f).user?.email),1)]),u[4]||=d(`a`,{href:`/logout`,class:`settings__logout`},`Sign out`,-1)])]))}}),[[`__scopeId`,`data-v-76ec3881`]]);export{T as default};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -14,7 +14,7 @@
<meta name="mobile-web-app-capable" content="yes" /> <meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" /> <meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="pictureFrame" /> <meta name="apple-mobile-web-app-title" content="pictureFrame" />
<script type="module" crossorigin src="/build/assets/index-aP_uBWCi.js"></script> <script type="module" crossorigin src="/build/assets/index-Dt4UyE7n.js"></script>
<link rel="modulepreload" crossorigin href="/build/assets/_plugin-vue_export-helper-DVo1OUMD.js"> <link rel="modulepreload" crossorigin href="/build/assets/_plugin-vue_export-helper-DVo1OUMD.js">
<link rel="stylesheet" crossorigin href="/build/assets/index-BlLBHR1q.css"> <link rel="stylesheet" crossorigin href="/build/assets/index-BlLBHR1q.css">
</head> </head>