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] }>() defineEmits<{ 'add-photo': [deviceId: number]; edit: [deviceId: number] }>()
// On large cards we let the <img> drive the preview height (its intrinsic // Large cards lock the preview's aspect to the panel's actual dimensions,
// aspect ratio is the device's actual aspect). Until the image loads — or // independent of whether the image has loaded. That way the layout never
// when no thumbnail exists yet — we want the empty placeholder to reserve // jumps when the thumbnail arrives, the <img> can use object-fit: contain
// roughly the same shape so the layout doesn't jump. // to scale down inside the container without overflow, and both portrait
const previewStyle = computed(() => ({})) // and landscape devices fit cleanly inside whatever their parent column
// allows.
const emptyAspectStyle = computed(() => { const previewStyle = computed(() => {
if (props.size !== 'large') return {} if (props.size !== 'large') return {}
const { width, height } = panelDims(props.model ?? 'v1', props.orientation) const { width, height } = panelDims(props.model ?? 'v1', props.orientation)
return { aspectRatio: `${width} / ${height}` } 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(() => { const statusText = computed(() => {
switch (props.status) { switch (props.status) {
case 'ok': return 'Online' case 'ok': return 'Online'
@@ -190,26 +194,27 @@ const statusText = computed(() => {
height: 100%; height: 100%;
} }
// Cap the preview height so portrait frames (3:5) don't blow past half // Preview locks to the device's actual aspect via inline aspect-ratio set
// the viewport. Landscape frames (5:3) at full card width are short enough // by previewStyle. Caps height at 40dvh so a portrait frame's tall aspect
// that the cap never engages. The photo is centered within the preview; // doesn't push everything else below the fold; `min-width: 0` lets flex
// portrait shots get a thin grey bar on each side instead of stretching. // shrink it on narrow containers.
&--large &__preview { &--large &__preview {
flex: 0 0 auto; flex: 0 0 auto;
width: 100%;
min-width: 0;
max-height: 40dvh;
background: var(--color-surface-2); background: var(--color-surface-2);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
overflow: hidden; overflow: hidden;
max-height: 40dvh;
} }
&--large &__img { &--large &__img {
display: block; display: block;
max-width: 100%; width: 100%;
max-height: 100%; height: 100%;
width: auto; object-fit: contain;
height: auto;
} }
&--large &__empty-preview { &--large &__empty-preview {
@@ -219,6 +224,7 @@ const statusText = computed(() => {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 100%; width: 100%;
height: 100%;
} }
&--large &__body { &--large &__body {
+11 -12
View File
@@ -146,35 +146,34 @@ describe('FrameCard', () => {
expect(wrapper.emitted('edit')![0]).toEqual([1]) 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, { const wrapper = mount(FrameCard, {
props: { ...defaultProps, size: 'large', orientation: 'landscape', model: 'v1' }, props: { ...defaultProps, size: 'large', orientation: 'landscape', model: 'v1' },
}) })
const empty = wrapper.find('.frame-card__empty-preview') expect(wrapper.find('.frame-card__preview').attributes('style')).toMatch(/aspect-ratio:\s*1600\s*\/\s*960/)
expect(empty.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, { const wrapper = mount(FrameCard, {
props: { ...defaultProps, size: 'large', orientation: 'portrait', model: 'v1' }, props: { ...defaultProps, size: 'large', orientation: 'portrait', model: 'v1' },
}) })
const empty = wrapper.find('.frame-card__empty-preview') expect(wrapper.find('.frame-card__preview').attributes('style')).toMatch(/aspect-ratio:\s*960\s*\/\s*1600/)
expect(empty.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, { const wrapper = mount(FrameCard, {
props: { ...defaultProps, size: 'large', orientation: 'portrait', model: 'v2' }, props: { ...defaultProps, size: 'large', orientation: 'portrait', model: 'v2' },
}) })
const empty = wrapper.find('.frame-card__empty-preview') expect(wrapper.find('.frame-card__preview').attributes('style')).toMatch(/aspect-ratio:\s*1200\s*\/\s*1600/)
expect(empty.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, { const wrapper = mount(FrameCard, {
props: { ...defaultProps, size: 'large', orientation: 'landscape', model: 'v2' }, props: { ...defaultProps, size: 'large', orientation: 'landscape', model: 'v2' },
}) })
const empty = wrapper.find('.frame-card__empty-preview') expect(wrapper.find('.frame-card__preview').attributes('style')).toMatch(/aspect-ratio:\s*1600\s*\/\s*1200/)
expect(empty.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="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" /> <meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="pictureFrame" /> <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="modulepreload" crossorigin href="/build/assets/_plugin-vue_export-helper-BNDVmFr7.js">
<link rel="stylesheet" crossorigin href="/build/assets/index-BlLBHR1q.css"> <link rel="stylesheet" crossorigin href="/build/assets/index-BlLBHR1q.css">
</head> </head>