fix(home): preview locks aspect to panel dims + object-fit so it never overflows
CI / test (push) Has been cancelled

The large FrameCard preview let the <img> drive height (`flex: 0 0
auto`, `width: auto`, `max-*: 100%`). On wide-container layouts and on
the new V2 1200×1600 dimensions the image's intrinsic size leaked
past the card, and the max-width/max-height combo can drop aspect
ratio in some browsers.

Now: the preview container locks its `aspect-ratio` to
`panelDims(model, orientation)` — same source of truth that drives the
empty-placeholder shape — and the <img> fills the container with
`object-fit: contain`. Container shape is stable whether or not the
thumbnail has loaded; image always scales to fit, portrait or
landscape device, narrow or wide phone column.

emptyAspectStyle no longer needs to carry aspect (parent already has
it); empty-preview placeholder fills 100% of the parent now.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-14 14:30:16 -04:00
parent bea25098a0
commit ad0d6c572c
10 changed files with 41 additions and 36 deletions
+22 -16
View File
@@ -89,18 +89,22 @@ const props = defineProps<{
defineEmits<{ 'add-photo': [deviceId: number]; edit: [deviceId: number] }>()
// 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(() => {
// Large cards lock the preview's aspect to the panel's actual dimensions,
// independent of whether the image has loaded. That way the layout never
// jumps when the thumbnail arrives, the <img> can use object-fit: contain
// to scale down inside the container without overflow, and both portrait
// and landscape devices fit cleanly inside whatever their parent column
// allows.
const previewStyle = computed(() => {
if (props.size !== 'large') return {}
const { width, height } = panelDims(props.model ?? 'v1', props.orientation)
return { aspectRatio: `${width} / ${height}` }
})
// Empty-preview placeholder fills its parent (which already has aspect set
// by previewStyle), so nothing extra needed here.
const emptyAspectStyle = computed(() => ({}))
const statusText = computed(() => {
switch (props.status) {
case 'ok': return 'Online'
@@ -190,26 +194,27 @@ const statusText = computed(() => {
height: 100%;
}
// Cap the preview height so portrait frames (3:5) don't blow past half
// the viewport. Landscape frames (5:3) at full card width are short enough
// that the cap never engages. The photo is centered within the preview;
// portrait shots get a thin grey bar on each side instead of stretching.
// Preview locks to the device's actual aspect via inline aspect-ratio set
// by previewStyle. Caps height at 40dvh so a portrait frame's tall aspect
// doesn't push everything else below the fold; `min-width: 0` lets flex
// shrink it on narrow containers.
&--large &__preview {
flex: 0 0 auto;
width: 100%;
min-width: 0;
max-height: 40dvh;
background: var(--color-surface-2);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
max-height: 40dvh;
}
&--large &__img {
display: block;
max-width: 100%;
max-height: 100%;
width: auto;
height: auto;
width: 100%;
height: 100%;
object-fit: contain;
}
&--large &__empty-preview {
@@ -219,6 +224,7 @@ const statusText = computed(() => {
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
&--large &__body {
+11 -12
View File
@@ -146,35 +146,34 @@ describe('FrameCard', () => {
expect(wrapper.emitted('edit')![0]).toEqual([1])
})
it('reserves a V1 landscape (1600 × 960) placeholder when no thumbnail is present', () => {
// Aspect ratio is set on the preview container itself (not the placeholder)
// so the box shape is stable whether or not the thumbnail has loaded —
// image inside scales with object-fit: contain.
it('locks preview aspect to V1 landscape (1600 × 960)', () => {
const wrapper = mount(FrameCard, {
props: { ...defaultProps, size: 'large', orientation: 'landscape', model: 'v1' },
})
const empty = wrapper.find('.frame-card__empty-preview')
expect(empty.attributes('style')).toMatch(/aspect-ratio:\s*1600\s*\/\s*960/)
expect(wrapper.find('.frame-card__preview').attributes('style')).toMatch(/aspect-ratio:\s*1600\s*\/\s*960/)
})
it('reserves a V1 portrait (960 × 1600) placeholder when no thumbnail is present', () => {
it('locks preview aspect to V1 portrait (960 × 1600)', () => {
const wrapper = mount(FrameCard, {
props: { ...defaultProps, size: 'large', orientation: 'portrait', model: 'v1' },
})
const empty = wrapper.find('.frame-card__empty-preview')
expect(empty.attributes('style')).toMatch(/aspect-ratio:\s*960\s*\/\s*1600/)
expect(wrapper.find('.frame-card__preview').attributes('style')).toMatch(/aspect-ratio:\s*960\s*\/\s*1600/)
})
it('reserves a V2 portrait (1200 × 1600) placeholder when no thumbnail is present', () => {
it('locks preview aspect to V2 portrait (1200 × 1600)', () => {
const wrapper = mount(FrameCard, {
props: { ...defaultProps, size: 'large', orientation: 'portrait', model: 'v2' },
})
const empty = wrapper.find('.frame-card__empty-preview')
expect(empty.attributes('style')).toMatch(/aspect-ratio:\s*1200\s*\/\s*1600/)
expect(wrapper.find('.frame-card__preview').attributes('style')).toMatch(/aspect-ratio:\s*1200\s*\/\s*1600/)
})
it('reserves a V2 landscape (1600 × 1200) placeholder when no thumbnail is present', () => {
it('locks preview aspect to V2 landscape (1600 × 1200)', () => {
const wrapper = mount(FrameCard, {
props: { ...defaultProps, size: 'large', orientation: 'landscape', model: 'v2' },
})
const empty = wrapper.find('.frame-card__empty-preview')
expect(empty.attributes('style')).toMatch(/aspect-ratio:\s*1600\s*\/\s*1200/)
expect(wrapper.find('.frame-card__preview').attributes('style')).toMatch(/aspect-ratio:\s*1600\s*\/\s*1200/)
})
})