fix(cover): resolve spine sizing and consistency issues

Delay spine layer rendering for blurred-fit covers until the image ratio is known so it does not overflow the artwork. Enable spine overlays on series stack cards, series detail headers, and carousels where they were previously disabled. Apply data attributes to spine layers so standalone artwork elements render properly without surface ancestors.

Closes #294
This commit is contained in:
neon
2026-06-12 05:43:12 +00:00
parent b81ba163ce
commit 5d0b5143fc
7 changed files with 60 additions and 10 deletions
+12
View File
@@ -76,11 +76,23 @@
}
.book-cover-spine-layer {
--book-cover-spine-opacity: 0;
--book-cover-spine-width: 6%;
pointer-events: none;
border-radius: inherit;
overflow: hidden;
}
.book-cover-spine-layer[data-cover-spine='subtle'] {
--book-cover-spine-opacity: 0.7;
--book-cover-spine-width: 6%;
}
.book-cover-spine-layer[data-cover-spine='strong'] {
--book-cover-spine-opacity: 1;
--book-cover-spine-width: 8%;
}
.book-cover-surface::before,
.book-cover-surface::after,
.book-cover-spine-layer::before,
@@ -44,7 +44,7 @@ const emit = defineEmits<{
error: []
}>()
const { bookCoverDisplayMode } = useDisplaySettings()
const { bookCoverDisplayMode, bookSpineOverlay } = useDisplaySettings()
const injectedAspectRatio = inject(COVER_ASPECT_RATIO_KEY, ref(DEFAULT_COVER_ASPECT_RATIO))
const loaded = ref(false)
@@ -63,7 +63,13 @@ const slotRatio = computed(() => coverAspectRatioValue(String(frameAspectRatio.v
const canRenderImage = computed(() => props.hasCover && props.src !== null && props.src !== '' && !failed.value)
const showBlurredBackdrop = computed(() => effectiveMode.value === 'blurred-fit' && canRenderImage.value && loaded.value)
const useNaturalFrame = computed(() => effectiveMode.value === 'natural-bottom' && canRenderImage.value && loaded.value)
const showSpineLayer = computed(() => props.spine && canRenderImage.value && loaded.value)
const spineMode = computed(() => (props.spine ? (bookSpineOverlay?.value ?? 'off') : 'off'))
// In blurred-fit the image is letterboxed inside the full slot, so the spine
// layer must be fitted to the image rectangle. Until the ratio is known that
// fit can't be computed, so hold the layer back rather than let it span the
// whole slot and overflow the artwork.
const spineRatioReady = computed(() => effectiveMode.value !== 'blurred-fit' || (imageRatio.value !== null && imageRatio.value > 0))
const showSpineLayer = computed(() => spineMode.value !== 'off' && canRenderImage.value && loaded.value && spineRatioReady.value)
const frameStyle = computed(() => {
if (useNaturalFrame.value) return fittedCoverFrameStyle(imageRatio.value, slotRatio.value, 'bottom')
@@ -164,7 +170,7 @@ function handleImageError() {
@load="handleImageLoad"
@error="handleImageError"
/>
<span v-if="showSpineLayer" class="book-cover-spine-layer absolute inset-0 z-[3]" :style="spineStyle" />
<span v-if="showSpineLayer" class="book-cover-spine-layer absolute inset-0 z-[3]" :data-cover-spine="spineMode" :style="spineStyle" />
</span>
</template>
<span
@@ -191,6 +191,7 @@ const secondaryLabelText = computed(() => resolveSeriesLabel(gridCardSecondaryLa
<div class="group">
<BookCoverSurface
class="relative w-full rounded-sm overflow-hidden transition-[box-shadow,transform] duration-150 will-change-transform group-hover:scale-[1.02]"
:class="isStack ? 'book-cover-surface--spine-fitted' : ''"
interactive
:disable-spine="isAudiobook"
:style="{ aspectRatio: coverAspectRatio, ...(isStack ? { backgroundColor: 'transparent', boxShadow: 'none' } : {}) }"
@@ -213,7 +214,7 @@ const secondaryLabelText = computed(() => resolveSeriesLabel(gridCardSecondaryLa
alt=""
loading="lazy"
decoding="async"
:spine="false"
:spine="!isAudiobook"
@error="() => handleCoverError(bookId)"
/>
@@ -7,7 +7,7 @@ import { clearCoverLoadCache } from '@/features/book/lib/cover-load-cache'
import { useDisplaySettings } from '@/composables/useDisplaySettings'
import BookCoverArtwork from '../BookCoverArtwork.vue'
const { bookCoverDisplayMode } = useDisplaySettings()
const { bookCoverDisplayMode, bookSpineOverlay } = useDisplaySettings()
function mountArtwork(
options: {
@@ -58,10 +58,12 @@ async function triggerMainImageLoad(wrapper: ReturnType<typeof mountArtwork>, na
beforeEach(() => {
clearCoverLoadCache()
bookSpineOverlay.value = 'off'
})
afterEach(() => {
bookCoverDisplayMode.value = 'blurred-fit'
bookSpineOverlay.value = 'off'
})
describe('BookCoverArtwork', () => {
@@ -73,6 +75,7 @@ describe('BookCoverArtwork', () => {
})
it('renders blurred-fit covers with an object-contain image and backdrop after load', async () => {
bookSpineOverlay.value = 'strong'
const wrapper = mountArtwork({ mode: 'blurred-fit' })
const image = await triggerMainImageLoad(wrapper, 600, 900)
@@ -169,6 +172,33 @@ describe('BookCoverArtwork', () => {
expect(wrapper.find('.book-cover-spine-layer').exists()).toBe(false)
})
it('tags the spine layer with the active overlay mode so it works without a surface ancestor', async () => {
bookSpineOverlay.value = 'subtle'
const wrapper = mountArtwork({ mode: 'blurred-fit' })
await triggerMainImageLoad(wrapper, 600, 900)
expect(wrapper.find('.book-cover-spine-layer').attributes('data-cover-spine')).toBe('subtle')
})
it('withholds the blurred-fit spine layer until the image ratio is known so it cannot overflow the cover', async () => {
bookSpineOverlay.value = 'strong'
const wrapper = mountArtwork({ mode: 'blurred-fit' })
await triggerMainImageLoad(wrapper, 0, 0)
expect(wrapper.find('.book-cover-spine-layer').exists()).toBe(false)
})
it('still renders the spine layer for full-bleed modes when the ratio is unknown', async () => {
bookSpineOverlay.value = 'strong'
const wrapper = mountArtwork({ mode: 'fill-crop' })
await triggerMainImageLoad(wrapper, 0, 0)
expect(wrapper.find('.book-cover-spine-layer').exists()).toBe(true)
})
describe('cover load cache (anti-flicker)', () => {
it('shows the skeleton and fade-in for a cover that has never loaded', () => {
const wrapper = mountArtwork({ src: '/fresh-cover.jpg' })
@@ -188,7 +218,7 @@ describe('BookCoverArtwork', () => {
expect(image.classes()).toContain('opacity-100')
expect(image.classes()).not.toContain('transition-opacity')
expect(second.find('.animate-pulse').exists()).toBe(false)
expect(second.find('.animate-pulse').classes()).toContain('opacity-0')
})
it('restores instantly when src changes to an already-loaded cover', async () => {
@@ -201,7 +231,7 @@ describe('BookCoverArtwork', () => {
await wrapper.setProps({ src: '/warm.jpg' })
const image = wrapper.find('img[alt="Dune cover"]')
expect(image.classes()).toContain('opacity-100')
expect(wrapper.find('.animate-pulse').exists()).toBe(false)
expect(wrapper.find('.animate-pulse').classes()).toContain('opacity-0')
})
it('still shows the skeleton when src changes to an unseen cover', async () => {
@@ -243,6 +243,7 @@ describe('BookCarousel', () => {
})
it('renders fitted spine layer only for non-audiobook covers with loaded artwork', async () => {
bookSpineOverlay.value = 'strong'
const wrapper = mountCarousel([
makeBook({ id: 1, hasCover: true, isAudiobook: false }),
makeBook({ id: 2, hasCover: true, isAudiobook: true }),
@@ -276,7 +277,7 @@ describe('BookCarousel', () => {
const wrapper = mountCarousel(books)
expect(wrapper.findAll('[data-book-id]').length).toBe(3)
expect(wrapper.findAll('img').length).toBe(2)
expect(wrapper.findAll('[data-testid="placeholder"]').length).toBe(3)
expect(wrapper.findAll('[data-testid="placeholder"]').length).toBe(1)
})
it('uses book id as seed when title is null', () => {
@@ -157,7 +157,7 @@ const coverStyles = computed(() => {
:frame-aspect-ratio="coverFrameAspectRatios[i] ?? PORTRAIT_STACK_FRAME_ASPECT_RATIO"
loading="lazy"
decoding="async"
:spine="false"
:spine="true"
@load="handleCoverLoad(bookId, $event)"
@error="handleCoverError(bookId)"
/>
@@ -492,7 +492,7 @@ watch(
:frame-aspect-ratio="leadCoverFrameAspectRatios[i] ?? PORTRAIT_STACK_FRAME_ASPECT_RATIO"
loading="lazy"
decoding="async"
:spine="false"
:spine="true"
@load="handleLeadCoverLoad(bookId, $event)"
@error="handleLeadCoverError(bookId)"
/>