fix(home): card fills the slide; preview uses photo's natural aspect
CI / test (push) Has been cancelled
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:
@@ -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/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user