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/)
})
})
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
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-DHJU4XGZ.js"></script>
<script type="module" crossorigin src="/build/assets/index-RZqoO867.js"></script>
<link rel="modulepreload" crossorigin href="/build/assets/_plugin-vue_export-helper-BNDVmFr7.js">
<link rel="stylesheet" crossorigin href="/build/assets/index-BlLBHR1q.css">
</head>