diff --git a/client/src/features/book/components/detail/tabs/DetailsTab.vue b/client/src/features/book/components/detail/tabs/DetailsTab.vue index 757a62917..bf7bf011f 100644 --- a/client/src/features/book/components/detail/tabs/DetailsTab.vue +++ b/client/src/features/book/components/detail/tabs/DetailsTab.vue @@ -278,23 +278,32 @@ watch(coverSrc, () => { /** * The cover frame follows the library's aspect ratio, so a 1/1 library makes it much shorter than - * a 2/3 one. The column height is fixed by the grid, so the width is derived from the height that - * is actually available: that keeps the frame's ratio exact instead of letting it stretch. + * a 2/3 one. The column height is fixed by the grid, so the width is derived from the space left + * after the actions: that keeps the group top-packed without overflowing short viewports. */ -const coverSlotEl = ref(null) -const coverSlotHeight = ref(0) -let coverSlotObserver: ResizeObserver | null = null +const coverColumnEl = ref(null) +const coverActionsEl = ref(null) +const coverColumnHeight = ref(0) +const coverActionsHeight = ref(0) +let coverLayoutObserver: ResizeObserver | null = null +const COVER_GROUP_GAP_PX = 16 const coverMaxWidth = computed(() => { - if (coverSlotHeight.value <= 0) return undefined + const availableHeight = coverColumnHeight.value - coverActionsHeight.value - COVER_GROUP_GAP_PX + if (availableHeight <= 0) return undefined const parts = detailCoverAspectRatio.value.split('/').map((part) => Number(part.trim())) const width = parts[0] const height = parts[1] if (width === undefined || height === undefined) return undefined if (!Number.isFinite(width) || !Number.isFinite(height) || height <= 0) return undefined - return `${Math.floor(coverSlotHeight.value * (width / height))}px` + return `${Math.floor(availableHeight * (width / height))}px` }) +function measureCoverLayout() { + coverColumnHeight.value = coverColumnEl.value?.clientHeight ?? 0 + coverActionsHeight.value = coverActionsEl.value?.getBoundingClientRect().height ?? 0 +} + function toggleDescription() { descriptionExpanded.value = !descriptionExpanded.value } @@ -304,20 +313,22 @@ function handleMobileScoreOpen(open: boolean) { } onMounted(() => { - coverSlotObserver = new ResizeObserver((entries) => { - coverSlotHeight.value = entries[0]?.contentRect.height ?? 0 - }) - if (coverSlotEl.value) coverSlotObserver.observe(coverSlotEl.value) + coverLayoutObserver = new ResizeObserver(measureCoverLayout) + if (coverColumnEl.value) coverLayoutObserver.observe(coverColumnEl.value) + if (coverActionsEl.value) coverLayoutObserver.observe(coverActionsEl.value) + measureCoverLayout() }) onBeforeUnmount(() => { - coverSlotObserver?.disconnect() - coverSlotObserver = null + coverLayoutObserver?.disconnect() + coverLayoutObserver = null }) -watch(coverSlotEl, (element) => { - coverSlotObserver?.disconnect() - if (element) coverSlotObserver?.observe(element) +watch([coverColumnEl, coverActionsEl], ([column, actions]) => { + coverLayoutObserver?.disconnect() + if (column) coverLayoutObserver?.observe(column) + if (actions) coverLayoutObserver?.observe(actions) + measureCoverLayout() }) const coverAspectRatio = inject(COVER_ASPECT_RATIO_KEY, ref(DEFAULT_COVER_ASPECT_RATIO)) @@ -1185,22 +1196,21 @@ watch( - +
-
+
-
-
- -
+
+
+
+ +
+ +
+ + + + + + + + +
-
- + + + + + + {{ t('book.detail.details.peek') }} + +
+ +
+
+ +
+ + + - + + +
- - - - - - - {{ t('book.detail.details.peek') }} -
-
-
- +
+
+ + +
+ +

{{ koboAnomaly.label }}

+
+
+ {{ koboAnomaly.tooltip }} +
- -
-
- - -
- -

{{ koboAnomaly.label }}

-
-
- {{ koboAnomaly.tooltip }} -
@@ -1847,8 +1859,9 @@ watch(

{{ t('book.detail.details.noDescription') }}

@@ -2123,7 +2136,7 @@ watch(
-
+
@@ -2182,3 +2195,24 @@ watch( + + diff --git a/client/src/features/book/components/detail/tabs/__tests__/DetailsTab.spec.ts b/client/src/features/book/components/detail/tabs/__tests__/DetailsTab.spec.ts index f404ee0e8..117099b69 100644 --- a/client/src/features/book/components/detail/tabs/__tests__/DetailsTab.spec.ts +++ b/client/src/features/book/components/detail/tabs/__tests__/DetailsTab.spec.ts @@ -230,14 +230,19 @@ describe('DetailsTab cover surface', () => { expect(surfaces.every((surface) => surface.attributes('style')?.includes('aspect-ratio: 2 / 1'))).toBe(true) }) - it('only constrains cover width from the observed slot height in the wide layout', async () => { + it('constrains cover width to the column height remaining above its actions', async () => { const wrapper = mountDetails(makeBook()) await flushPromises() const surface = wrapper.get('.book-cover-surface') const frame = surface.element.parentElement as HTMLElement + const column = wrapper.get('[data-test="cover-column"]') + const actions = wrapper.get('[data-test="cover-actions"]') - resizeObserverCallbacks.at(-1)?.([{ contentRect: { height: 220 } } as ResizeObserverEntry], {} as ResizeObserver) + Object.defineProperty(column.element, 'clientHeight', { configurable: true, value: 320 }) + vi.spyOn(actions.element, 'getBoundingClientRect').mockReturnValue({ height: 84 } as DOMRect) + + resizeObserverCallbacks.at(-1)?.([], {} as ResizeObserver) await flushPromises() expect(frame.style.maxWidth).toBe('') @@ -245,6 +250,55 @@ describe('DetailsTab cover surface', () => { expect(frame.classList).toContain('@min-[46rem]/book-detail:max-w-[var(--detail-cover-max-width)]') }) + it('anchors wide-layout covers beside the top of the metadata column', async () => { + const wrapper = mountDetails(makeBook()) + await flushPromises() + + const surface = wrapper.get('.book-cover-surface') + const slot = surface.element.parentElement?.parentElement as HTMLElement + + expect(slot.classList).toContain('@min-[46rem]/book-detail:items-start') + expect(slot.classList).toContain('@min-[46rem]/book-detail:justify-end') + expect(slot.classList).not.toContain('@min-[46rem]/book-detail:items-center') + }) + + it('keeps wide-layout actions immediately after the cover instead of at the bottom of the column', async () => { + const wrapper = mountDetails(makeBook()) + await flushPromises() + + const column = wrapper.get('[data-test="cover-column"]') + const actions = wrapper.get('[data-test="cover-actions"]') + const coverRow = column.element.firstElementChild as HTMLElement + + expect(actions.element.parentElement).toBe(column.element) + expect(coverRow.nextElementSibling).toBe(actions.element) + expect(coverRow.classList).not.toContain('@min-[46rem]/book-detail:flex-1') + }) + + it('lets the discovery shelf follow the natural height of the detail content', async () => { + const wrapper = mountDetails(makeBook()) + await flushPromises() + + const layout = wrapper.get('[data-test="details-layout"]') + const shelf = wrapper.get('[data-test="discovery-shelf"]') + + expect(shelf.element.parentElement).toBe(layout.element) + expect(layout.element.classList).toContain('@min-[46rem]/book-detail:content-start') + expect(layout.element.classList).not.toContain('@min-[46rem]/book-detail:h-full') + expect(layout.element.classList).not.toContain('@min-[46rem]/book-detail:grid-rows-[minmax(0,1fr)_clamp(11.25rem,29%,17.5rem)]') + }) + + it('uses the responsive synopsis clamp until the full description is opened', async () => { + const wrapper = mountDetails(makeBook({ description: '

A synopsis

' })) + await flushPromises() + + const synopsis = wrapper.get('[data-test="synopsis-copy"]') + + expect(synopsis.element.classList).toContain('synopsis-copy--clamped') + await wrapper.get(`[aria-controls="book-${wrapper.props('book').id}-synopsis"]`).trigger('click') + expect(synopsis.element.classList).not.toContain('synopsis-copy--clamped') + }) + it('forces spine overlay off for audiobook details covers', async () => { bookSpineOverlay.value = 'strong' @@ -335,7 +389,7 @@ describe('DetailsTab cover surface', () => { ) await flushPromises() - const synopsis = wrapper.get('.line-clamp-4') + const synopsis = wrapper.get('[data-test="synopsis-copy"]') expect(synopsis.html()).toContain('Second paragraph') const toggle = wrapper.findAll('button').find((button) => button.text() === 'Show more') @@ -345,7 +399,7 @@ describe('DetailsTab cover surface', () => { expect(synopsis.attributes('id')).toBe('book-12-synopsis') await toggle!.trigger('click') - expect(synopsis.classes()).not.toContain('line-clamp-4') + expect(synopsis.classes()).not.toContain('synopsis-copy--clamped') expect(synopsis.classes()).not.toContain('max-h-44') expect(synopsis.classes()).not.toContain('overflow-y-auto') expect(toggle!.text()).toBe('Show less') @@ -353,7 +407,7 @@ describe('DetailsTab cover surface', () => { await toggle!.trigger('click') - expect(synopsis.classes()).toContain('line-clamp-4') + expect(synopsis.classes()).toContain('synopsis-copy--clamped') expect(toggle!.text()).toBe('Show more') expect(toggle!.attributes('aria-expanded')).toBe('false') })