fix(authors): keep circular portrait metadata readable

Inset and center hover metadata within the circular portrait so the book count and last-added date remain readable. Add regression coverage for both shapes and metadata states.

Fixes #1413
This commit is contained in:
neon
2026-10-12 18:51:25 +00:00
parent 9e2cbfd3a5
commit df7ef5daf0
2 changed files with 74 additions and 5 deletions
@@ -101,12 +101,11 @@ function handleDelete() {
> >
<AuthorPortrait :author="author" :shape="shape" :cover-fallback="coverFallback" /> <AuthorPortrait :author="author" :shape="shape" :cover-fallback="coverFallback" />
<!-- Resting state stays quiet; the numbers are one hover away rather than <!-- A 16% inset keeps the text box inside the circle's inscribed square. -->
printed over every tile in the grid. -->
<span <span
v-if="!selectionMode" v-if="!selectionMode"
class="pointer-events-none absolute inset-0 z-20 hidden flex-col justify-end gap-0.5 bg-linear-to-t from-black/75 via-black/30 to-transparent p-2 opacity-0 transition-opacity duration-150 group-focus-within/tile:opacity-100 group-hover/tile:opacity-100 @[4.5rem]:flex" class="pointer-events-none absolute inset-0 z-20 hidden flex-col justify-end gap-0.5 bg-linear-to-t from-black/75 via-black/30 to-transparent opacity-0 transition-opacity duration-150 group-focus-within/tile:opacity-100 group-hover/tile:opacity-100 @[4.5rem]:flex"
:class="shape === 'circle' ? 'rounded-full' : 'rounded-[10px]'" :class="shape === 'circle' ? 'rounded-full p-[16%] text-center' : 'rounded-[10px] p-2'"
> >
<span class="truncate text-[11px] font-bold leading-tight text-white">{{ countLine }}</span> <span class="truncate text-[11px] font-bold leading-tight text-white">{{ countLine }}</span>
<span v-if="lastAdded" class="hidden truncate text-[10px] leading-tight text-white/75 @[5.5rem]:block">{{ lastAdded }}</span> <span v-if="lastAdded" class="hidden truncate text-[10px] leading-tight text-white/75 @[5.5rem]:block">{{ lastAdded }}</span>
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils' import { mount } from '@vue/test-utils'
import type { AuthorSummary } from '@bookorbit/types' import type { AuthorSummary } from '@bookorbit/types'
import { i18n } from '@/i18n'
import AuthorTile from '../AuthorTile.vue' import AuthorTile from '../AuthorTile.vue'
function author(overrides: Partial<AuthorSummary> = {}): AuthorSummary { function author(overrides: Partial<AuthorSummary> = {}): AuthorSummary {
@@ -24,6 +25,75 @@ function mountTile(props: Record<string, unknown> = {}) {
} }
describe('AuthorTile', () => { describe('AuthorTile', () => {
beforeEach(() => {
i18n.global.locale.value = 'en'
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-10-12T12:00:00Z'))
})
afterEach(() => {
vi.useRealTimers()
})
it('insets and centers circular hover metadata inside the unclipped area', () => {
const wrapper = mountTile({ shape: 'circle', author: author({ lastAddedAt: '2026-09-28T12:00:00Z' }) })
const metadata = wrapper.get('span.bg-linear-to-t')
// The inscribed square needs at least 14.65% padding on each edge to clear the circle.
expect(metadata.classes()).toContain('p-[16%]')
expect(metadata.classes()).toContain('text-center')
expect(metadata.classes()).not.toContain('p-2')
expect(metadata.element.children).toHaveLength(2)
})
it('preserves the square hover metadata layout', () => {
const wrapper = mountTile({ shape: 'square' })
const metadata = wrapper.get('span.bg-linear-to-t')
expect(metadata.classes()).toContain('p-2')
expect(metadata.classes()).not.toContain('p-[16%]')
expect(metadata.classes()).not.toContain('text-center')
})
it('updates the metadata inset when the cover shape changes', async () => {
const wrapper = mountTile({ shape: 'square' })
const metadata = wrapper.get('span.bg-linear-to-t')
await wrapper.setProps({ shape: 'circle' })
expect(metadata.classes()).toContain('p-[16%]')
expect(metadata.classes()).not.toContain('p-2')
await wrapper.setProps({ shape: 'square' })
expect(metadata.classes()).toContain('p-2')
expect(metadata.classes()).not.toContain('p-[16%]')
})
it.each(['circle', 'square'])('reveals both metadata lines on hover or keyboard focus for %s covers', (shape) => {
const wrapper = mountTile({ shape, author: author({ lastAddedAt: '2026-09-28T12:00:00Z' }) })
const metadata = wrapper.get('span.bg-linear-to-t')
expect(metadata.text()).toContain('3 books')
expect(metadata.text()).toContain('2 weeks ago')
expect(metadata.classes()).toEqual(
expect.arrayContaining(['pointer-events-none', 'group-hover/tile:opacity-100', 'group-focus-within/tile:opacity-100', '@[4.5rem]:flex']),
)
expect(metadata.get('span.hidden').classes()).toContain('@[5.5rem]:block')
})
it('omits the date line when no book addition date is available', () => {
const wrapper = mountTile({ shape: 'circle', author: author({ bookCount: 1, lastAddedAt: null }) })
const metadata = wrapper.get('span.bg-linear-to-t')
expect(metadata.text()).toBe('1 book')
expect(metadata.element.children).toHaveLength(1)
})
it('hides hover metadata in selection mode', () => {
const wrapper = mountTile({ shape: 'circle', selectionMode: true })
expect(wrapper.find('span.bg-linear-to-t').exists()).toBe(false)
})
it('stretches the activate button over the whole tile, not just the artwork', () => { it('stretches the activate button over the whole tile, not just the artwork', () => {
const wrapper = mountTile() const wrapper = mountTile()
const root = wrapper.element as HTMLElement const root = wrapper.element as HTMLElement