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
+38 -16
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,9 +85,15 @@ 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' }
? { 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 {