fix(home): card fills the slide; preview uses photo's natural aspect
CI / test (push) Has been cancelled

Two complaints, one root cause: the FrameCard was floating in the slide with
a min-height-padded preview, so (1) photos got top/bottom gray bars instead
of fitting their container, and (2) there was a fat empty gap between the
card body and the bottom nav.

Restructured the large card to flex-fill its slide:
- preview hugs the photo's intrinsic aspect ratio (img with width:100%
  height:auto); no min-height, no aspect-ratio override → no letterbox
- card body has flex:1, info pinned at top, Add Photo button pinned at
  bottom via margin-top:auto and width:100%
- HomeView main / single-card / carousel all flex:1 down through the
  layout so the slide gets the full available height
- empty-state placeholder still reserves the device's aspect so the
  card doesn't jump while images load

Result: the photo fills its container left/right with no bars; the body
absorbs all remaining space below, with the action button always sitting
just above the bottom nav.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-06 18:39:58 -04:00
parent d266770170
commit 78405b644d
15 changed files with 61 additions and 33 deletions
+37 -15
View File
@@ -28,7 +28,7 @@
:alt="`Current photo on ${name}`"
class="frame-card__img"
/>
<div v-else class="frame-card__empty-preview" aria-hidden="true">
<div v-else class="frame-card__empty-preview" :style="emptyAspectStyle" aria-hidden="true">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<circle cx="8.5" cy="8.5" r="1.5"/>
@@ -85,7 +85,13 @@ const props = defineProps<{
defineEmits<{ 'add-photo': [deviceId: number]; edit: [deviceId: number] }>()
const previewStyle = computed(() =>
// On large cards we let the <img> drive the preview height (its intrinsic
// aspect ratio is the device's actual aspect). Until the image loads — or
// when no thumbnail exists yet — we want the empty placeholder to reserve
// roughly the same shape so the layout doesn't jump.
const previewStyle = computed(() => ({}))
const emptyAspectStyle = computed(() =>
props.size === 'large'
? { aspectRatio: props.orientation === 'portrait' ? '3 / 5' : '5 / 3' }
: {}
@@ -169,38 +175,49 @@ const statusText = computed(() => {
}
// ── Large (single device or carousel slide) ──────────────────────────────
// Floor the preview to a healthy chunk of the viewport so landscape frames
// (5:3) don't render as a thin strip. Portrait frames (3:5) keep their
// natural aspect — taller still wins.
&--large &__preview {
background: var(--color-surface-2);
// The card stretches to fill its parent (slide or single-card container).
// The preview hugs the photo's natural aspect — no letterbox bars, no min-
// height forcing extra blank space. Whatever vertical room is left after
// the photo gets absorbed by the body, with the Add button pinned to the
// bottom so the card never has a "huge gap" floating above the nav.
&--large {
display: flex;
align-items: center;
justify-content: center;
min-height: 50dvh;
flex-direction: column;
height: 100%;
}
&--large &__preview {
flex: 0 0 auto;
background: var(--color-surface-2);
display: block;
overflow: hidden;
}
&--large &__img {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
height: auto;
}
&--large &__empty-preview {
color: var(--color-text-muted);
opacity: 0.5;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
}
&--large &__body {
flex: 1;
min-height: 0;
padding: var(--space-4);
display: flex;
align-items: flex-start;
justify-content: space-between;
flex-direction: column;
gap: var(--space-3);
}
&--large &__info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
@@ -212,6 +229,11 @@ const statusText = computed(() => {
font-weight: 700;
}
&--large &__add-btn {
margin-top: auto;
width: 100%;
}
// ── Compact (multi device) ───────────────────────────────────────────────
&--compact {
display: flex;
@@ -123,20 +123,19 @@ describe('FrameCard', () => {
expect(wrapper.emitted('edit')![0]).toEqual([1])
})
it('sets landscape aspect ratio style in large mode', () => {
it('reserves a 5:3 aspect placeholder when no thumbnail is present (landscape)', () => {
const wrapper = mount(FrameCard, {
props: { ...defaultProps, size: 'large', orientation: 'landscape' },
})
const preview = wrapper.find('.frame-card__preview')
// Vue serializes { aspectRatio: '5/3' } as 'aspect-ratio: 5 / 3;'
expect(preview.attributes('style')).toMatch(/aspect-ratio:\s*5\s*\/\s*3/)
const empty = wrapper.find('.frame-card__empty-preview')
expect(empty.attributes('style')).toMatch(/aspect-ratio:\s*5\s*\/\s*3/)
})
it('sets portrait aspect ratio style in large mode', () => {
it('reserves a 3:5 aspect placeholder when no thumbnail is present (portrait)', () => {
const wrapper = mount(FrameCard, {
props: { ...defaultProps, size: 'large', orientation: 'portrait' },
})
const preview = wrapper.find('.frame-card__preview')
expect(preview.attributes('style')).toMatch(/aspect-ratio:\s*3\s*\/\s*5/)
const empty = wrapper.find('.frame-card__empty-preview')
expect(empty.attributes('style')).toMatch(/aspect-ratio:\s*3\s*\/\s*5/)
})
})
+6
View File
@@ -348,6 +348,7 @@ async function saveSettings() {
<style scoped lang="scss">
.home-view {
flex: 1;
padding: var(--space-4) var(--space-4) calc(var(--bottom-nav-height) + var(--space-4));
display: flex;
flex-direction: column;
@@ -397,6 +398,7 @@ async function saveSettings() {
}
&__single {
flex: 1;
display: flex;
flex-direction: column;
}
@@ -405,6 +407,8 @@ async function saveSettings() {
// 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 {
flex: 1;
min-height: 0;
display: flex;
overflow-x: auto;
overflow-y: hidden;
@@ -423,6 +427,8 @@ async function saveSettings() {
padding: 0 var(--space-4);
scroll-snap-align: start;
scroll-snap-stop: always;
display: flex;
flex-direction: column;
}
&__dots {
@@ -0,0 +1 @@
{"version":"4.1.5","results":[[":frontend/src/test/views/LibraryView.test.ts",{"duration":0,"failed":true}],[":frontend/src/test/views/HomeView.test.ts",{"duration":0,"failed":true}],[":frontend/src/test/views/UploadView.test.ts",{"duration":0,"failed":true}],[":frontend/src/test/stores/images.test.ts",{"duration":0,"failed":true}],[":frontend/src/test/stores/upload.test.ts",{"duration":0,"failed":true}],[":frontend/src/test/stores/devices.test.ts",{"duration":0,"failed":true}],[":frontend/src/test/components/ApproveCard.test.ts",{"duration":0,"failed":true}],[":frontend/src/test/components/BaseBottomSheet.test.ts",{"duration":0,"failed":true}],[":frontend/src/test/components/DevicePicker.test.ts",{"duration":0,"failed":true}],[":frontend/src/test/components/FrameCard.test.ts",{"duration":0,"failed":true}],[":frontend/src/test/components/ShareSheet.test.ts",{"duration":0,"failed":true}],[":frontend/src/test/composables/useTheme.test.ts",{"duration":0,"failed":true}],[":frontend/src/test/components/BottomNav.test.ts",{"duration":0,"failed":true}],[":frontend/src/test/components/BaseButton.test.ts",{"duration":0,"failed":true}],[":frontend/src/test/App.test.ts",{"duration":0,"failed":true}],[":frontend/src/test/components/BaseInput.test.ts",{"duration":0,"failed":true}],[":frontend/src/test/views/SettingsView.test.ts",{"duration":0,"failed":true}],[":frontend/src/test/components/StickerTray.test.ts",{"duration":0,"failed":true}],[":frontend/src/test/stores/toast.test.ts",{"duration":0,"failed":true}],[":frontend/src/test/stores/auth.test.ts",{"duration":53.48111400000005,"failed":true}],[":frontend/src/test/components/OrientationPicker.test.ts",{"duration":0,"failed":true}],[":frontend/src/test/components/BaseToast.test.ts",{"duration":0,"failed":true}],[":frontend/src/test/components/BaseChip.test.ts",{"duration":0,"failed":true}],[":frontend/src/test/components/BaseCard.test.ts",{"duration":0,"failed":true}]]}
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-DCuRF4Y8.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-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};
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-BxH1sIci.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-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};
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="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="pictureFrame" />
<script type="module" crossorigin src="/build/assets/index-BxH1sIci.js"></script>
<script type="module" crossorigin src="/build/assets/index-aP_uBWCi.js"></script>
<link rel="modulepreload" crossorigin href="/build/assets/_plugin-vue_export-helper-DVo1OUMD.js">
<link rel="stylesheet" crossorigin href="/build/assets/index-BlLBHR1q.css">
</head>