feat(home): full-size frame card; horizontal carousel for multi-frame setups
CI / test (push) Has been cancelled

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-06 18:28:49 -04:00
parent 78ff21fb98
commit 089e317691
14 changed files with 174 additions and 42 deletions
+2 -7
View File
@@ -168,23 +168,18 @@ const statusText = computed(() => {
opacity: 0.6; opacity: 0.6;
} }
// ── Large (single device) ──────────────────────────────────────────────── // ── Large (single device or carousel slide) ──────────────────────────────
// 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 &__preview { &--large &__preview {
background: var(--color-surface-2); background: var(--color-surface-2);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
max-height: min(240px, 30dvh);
overflow: hidden;
} }
&--large &__img { &--large &__img {
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: contain; object-fit: cover;
} }
&--large &__empty-preview { &--large &__empty-preview {
+54 -5
View File
@@ -106,22 +106,71 @@ describe('HomeView', () => {
}) })
} }
// HV-01: N devices renders N FrameCard stubs // HV-01: N devices renders a carousel of N large FrameCard stubs + N dots
it('renders one FrameCard per device when devices are present', async () => { it('renders one FrameCard per device in a carousel 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' }),
makeDevice({ id: 2, name: 'Frame B' }), makeDevice({ id: 2, name: 'Frame B' }),
makeDevice({ id: 3, name: 'Frame C' }), makeDevice({ id: 3, name: 'Frame C' }),
] ]
// Mock fetchDevices so onMounted doesn't overwrite devices
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue() vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView() const wrapper = mountView()
await wrapper.vm.$nextTick() await wrapper.vm.$nextTick()
const cards = wrapper.findAll('.frame-card-stub') expect(wrapper.find('.home-view__carousel').exists()).toBe(true)
expect(cards).toHaveLength(3) 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) // HV-01b: single device still renders one FrameCard (large variant branch)
+95 -7
View File
@@ -37,14 +37,28 @@
/> />
</div> </div>
<!-- Multiple devices compact stack --> <!-- Multiple devices swipeable carousel of large cards -->
<div v-else class="home-view__list"> <template v-else>
<FrameCard <div
ref="carouselEl"
class="home-view__carousel"
role="region"
aria-roledescription="carousel"
aria-label="Frames"
@scroll.passive="onCarouselScroll"
>
<div
v-for="device in devicesStore.devices" v-for="device in devicesStore.devices"
:key="device.id" :key="device.id"
class="home-view__slide"
role="group"
:aria-roledescription="`Slide`"
:aria-label="device.name"
>
<FrameCard
:deviceId="device.id" :deviceId="device.id"
:name="device.name" :name="device.name"
size="compact" size="large"
:status="deviceStatus(device)" :status="deviceStatus(device)"
:orientation="device.orientation" :orientation="device.orientation"
:thumbnailUrl="previewUrl(device)" :thumbnailUrl="previewUrl(device)"
@@ -54,6 +68,20 @@
@edit="onEdit" @edit="onEdit"
/> />
</div> </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>
</template>
</main> </main>
<!-- Frame settings sheet --> <!-- Frame settings sheet -->
@@ -103,7 +131,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from 'vue' import { onMounted, ref, nextTick } 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'
@@ -181,6 +209,25 @@ 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.
@@ -354,10 +401,51 @@ async function saveSettings() {
flex-direction: column; flex-direction: column;
} }
&__list { // 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 {
display: flex; display: flex;
flex-direction: column; overflow-x: auto;
overflow-y: hidden;
scroll-snap-type: x 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%;
box-sizing: border-box;
padding: 0 var(--space-4);
scroll-snap-align: start;
scroll-snap-stop: always;
}
&__dots {
display: flex;
justify-content: center;
gap: var(--space-2); 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-CaSppT-T.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-C3K0Qa0V.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
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-Dtb3F_Km.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-CtkfMKf5.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-Dtb3F_Km.js"></script> <script type="module" crossorigin src="/build/assets/index-CtkfMKf5.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>