diff --git a/client/src/assets/theme/cover-effects.css b/client/src/assets/theme/cover-effects.css index 6994731b6..f3b0a3345 100644 --- a/client/src/assets/theme/cover-effects.css +++ b/client/src/assets/theme/cover-effects.css @@ -55,6 +55,32 @@ box-shadow: none; } +/* Colour pulled from the book's own cover, strongest behind the artwork and + title and gone before the body copy. Alphas are tuned per theme: the same + hue needs more presence over a dark card than over a light one. + + The two tones glow from opposite top corners and overlap in the middle, which + reads as one soft field rather than two stripes. A cover with no distinct + second tone leaves the -2 variables inheriting the first, so the pair + collapses back into a single wash. */ +.book-detail-cover-tint { + --cover-tint-near: 0.24; + --cover-tint-mid: 0.16; + --cover-tint-far: 0.05; + --cover-tint-hue-2: var(--cover-tint-hue); + --cover-tint-saturation-2: var(--cover-tint-saturation); + background-image: + radial-gradient(78% 46% at 12% 0%, hsl(var(--cover-tint-hue) var(--cover-tint-saturation) 50% / var(--cover-tint-near)) 0%, transparent 72%), + radial-gradient(78% 46% at 88% 0%, hsl(var(--cover-tint-hue-2) var(--cover-tint-saturation-2) 50% / var(--cover-tint-mid)) 0%, transparent 72%), + linear-gradient(to bottom, hsl(var(--cover-tint-hue) var(--cover-tint-saturation) 50% / var(--cover-tint-far)) 0%, transparent 55%); +} + +.dark .book-detail-cover-tint { + --cover-tint-near: 0.34; + --cover-tint-mid: 0.24; + --cover-tint-far: 0.08; +} + .book-cover-artwork-frame { position: absolute; border-radius: inherit; diff --git a/client/src/composables/__tests__/useDisplaySettings.spec.ts b/client/src/composables/__tests__/useDisplaySettings.spec.ts index 143bdee21..5617dc3ec 100644 --- a/client/src/composables/__tests__/useDisplaySettings.spec.ts +++ b/client/src/composables/__tests__/useDisplaySettings.spec.ts @@ -22,6 +22,7 @@ function resetDisplaySettings() { settings.showSpineOnComics.value = false settings.bookShadowStrength.value = 'default' settings.bookCoverDisplayMode.value = 'blurred-fit' + settings.bookDetailCoverTint.value = 'single' settings.seriesCardCoverMode.value = 'stack' settings.gridCardPrimaryLabel.value = 'hidden' settings.gridCardSecondaryLabel.value = 'hidden' @@ -201,6 +202,26 @@ describe('useDisplaySettings preferences helpers', () => { expect(settings.showSpineOnComics.value).toBe(true) }) + it('includes bookDetailCoverTint in snapshot', () => { + settings.bookDetailCoverTint.value = 'duotone' + expect(getDisplayPreferencesSnapshot().bookDetailCoverTint).toBe('duotone') + }) + + it('defaults bookDetailCoverTint to single', () => { + resetDisplaySettings() + expect(settings.bookDetailCoverTint.value).toBe('single') + }) + + it('sanitizes bookDetailCoverTint and drops unknown values', () => { + expect(sanitizeDisplayPreferences({ bookDetailCoverTint: 'off' })).toEqual({ bookDetailCoverTint: 'off' }) + expect(sanitizeDisplayPreferences({ bookDetailCoverTint: 'rainbow' })).toEqual({}) + }) + + it('applies bookDetailCoverTint from preferences', () => { + applyDisplayPreferences({ bookDetailCoverTint: 'off' }) + expect(settings.bookDetailCoverTint.value).toBe('off') + }) + it('includes thumbnailClickAction in snapshot', () => { settings.thumbnailClickAction.value = 'details' const snap = getDisplayPreferencesSnapshot() diff --git a/client/src/composables/__tests__/useDisplaySettingsSync.spec.ts b/client/src/composables/__tests__/useDisplaySettingsSync.spec.ts index be03b5d00..e7e5915fe 100644 --- a/client/src/composables/__tests__/useDisplaySettingsSync.spec.ts +++ b/client/src/composables/__tests__/useDisplaySettingsSync.spec.ts @@ -22,6 +22,7 @@ function validDisplayPreferences(overrides: Partial = {}): D showSpineOnComics: false, bookShadowStrength: 'strong', bookCoverDisplayMode: 'blurred-fit', + bookDetailCoverTint: 'duotone', cardInfoMode: 'hover-overlay', seriesCardCoverMode: 'stack', gridCardPrimaryLabel: 'hidden', diff --git a/client/src/composables/useDisplaySettings.ts b/client/src/composables/useDisplaySettings.ts index 23037ac95..a3b925529 100644 --- a/client/src/composables/useDisplaySettings.ts +++ b/client/src/composables/useDisplaySettings.ts @@ -2,6 +2,7 @@ import { ref, watch } from 'vue' import { AUTHOR_COVER_SHAPES, BOOK_COVER_DISPLAY_MODES, + BOOK_DETAIL_COVER_TINTS, BOOK_SHADOW_STRENGTHS, BOOK_SPINE_OVERLAYS, BOOK_THUMBNAIL_CLICK_ACTION, @@ -14,6 +15,7 @@ import { TABLE_DENSITIES, type AuthorCoverShape, type BookCoverDisplayMode, + type BookDetailCoverTint, type BookShadowStrength, type BookSpineOverlay, type BookThumbnailClickAction, @@ -31,6 +33,7 @@ import { storage } from '@/services/storage' export type { AuthorCoverShape, BookCoverDisplayMode, + BookDetailCoverTint, BookShadowStrength, BookSpineOverlay, BookThumbnailClickAction, @@ -50,6 +53,7 @@ const DEFAULT_GRID_GAP = 28 const DEFAULT_BOOK_SPINE_OVERLAY: BookSpineOverlay = 'off' const DEFAULT_BOOK_SHADOW_STRENGTH: BookShadowStrength = 'default' const DEFAULT_BOOK_COVER_DISPLAY_MODE: BookCoverDisplayMode = 'blurred-fit' +const DEFAULT_BOOK_DETAIL_COVER_TINT: BookDetailCoverTint = 'single' const DEFAULT_SERIES_CARD_COVER_MODE: SeriesCardCoverMode = 'stack' const DEFAULT_BOOK_THUMBNAIL_CLICK_ACTION: BookThumbnailClickAction = 'reader' const DEFAULT_CARD_OVERLAYS: CardOverlayKey[] = ['progress-bar', 'format', 'rating', 'read-status', 'series-position'] @@ -72,6 +76,12 @@ function normalizeBookCoverDisplayMode(value: unknown): BookCoverDisplayMode { : DEFAULT_BOOK_COVER_DISPLAY_MODE } +function normalizeBookDetailCoverTint(value: unknown): BookDetailCoverTint { + return typeof value === 'string' && BOOK_DETAIL_COVER_TINTS.includes(value as BookDetailCoverTint) + ? (value as BookDetailCoverTint) + : DEFAULT_BOOK_DETAIL_COVER_TINT +} + function normalizeSeriesCardCoverMode(value: unknown): SeriesCardCoverMode { return typeof value === 'string' && SERIES_CARD_COVER_MODES.includes(value as SeriesCardCoverMode) ? (value as SeriesCardCoverMode) @@ -152,6 +162,7 @@ const bookShadowStrength = ref(normalizeBookShadowStrength(s const bookCoverDisplayMode = ref( normalizeBookCoverDisplayMode(storage.get('bookCoverDisplayMode', DEFAULT_BOOK_COVER_DISPLAY_MODE)), ) +const bookDetailCoverTint = ref(normalizeBookDetailCoverTint(storage.get('bookDetailCoverTint', DEFAULT_BOOK_DETAIL_COVER_TINT))) const seriesCardCoverMode = ref(normalizeSeriesCardCoverMode(storage.get('seriesCardCoverMode', DEFAULT_SERIES_CARD_COVER_MODE))) const gridCardPrimaryLabel = ref(normalizeGridCardLabelField(storage.get('gridCardPrimaryLabel', 'hidden'))) const gridCardSecondaryLabel = ref(normalizeGridCardLabelField(storage.get('gridCardSecondaryLabel', 'hidden'))) @@ -178,6 +189,7 @@ watch(bookSpineOverlay, (value) => storage.set('bookSpineOverlay', normalizeBook watch(showSpineOnComics, (v) => storage.set('showSpineOnComics', v)) watch(bookShadowStrength, (value) => storage.set('bookShadowStrength', normalizeBookShadowStrength(value))) watch(bookCoverDisplayMode, (value) => storage.set('bookCoverDisplayMode', normalizeBookCoverDisplayMode(value))) +watch(bookDetailCoverTint, (value) => storage.set('bookDetailCoverTint', normalizeBookDetailCoverTint(value))) watch(seriesCardCoverMode, (value) => storage.set('seriesCardCoverMode', normalizeSeriesCardCoverMode(value))) watch(gridCardPrimaryLabel, (value) => storage.set('gridCardPrimaryLabel', normalizeGridCardLabelField(value))) watch(gridCardSecondaryLabel, (value) => storage.set('gridCardSecondaryLabel', normalizeGridCardLabelField(value))) @@ -204,6 +216,7 @@ export function getDisplayPreferencesSnapshot(): DisplayPreferences { showSpineOnComics: showSpineOnComics.value === true, bookShadowStrength: normalizeBookShadowStrength(bookShadowStrength.value), bookCoverDisplayMode: normalizeBookCoverDisplayMode(bookCoverDisplayMode.value), + bookDetailCoverTint: normalizeBookDetailCoverTint(bookDetailCoverTint.value), seriesCardCoverMode: normalizeSeriesCardCoverMode(seriesCardCoverMode.value), gridCardPrimaryLabel: normalizeGridCardLabelField(gridCardPrimaryLabel.value), gridCardSecondaryLabel: normalizeGridCardLabelField(gridCardSecondaryLabel.value), @@ -240,6 +253,9 @@ export function sanitizeDisplayPreferences(raw: unknown): Partial ({ ok: false, json: async () => ({}), })), + setOnAuthFailure: vi.fn<(callback: () => void) => void>(), })) vi.mock('@/features/book/composables/useCoverVersions', () => ({ useCoverVersions: () => ({ coverUrl: () => '/cover.jpg', bumpVersion: vi.fn<(...args: unknown[]) => void>() }), diff --git a/client/src/features/book/components/detail/BookDetailLayout.vue b/client/src/features/book/components/detail/BookDetailLayout.vue index 8d21d4d55..e2a197a54 100644 --- a/client/src/features/book/components/detail/BookDetailLayout.vue +++ b/client/src/features/book/components/detail/BookDetailLayout.vue @@ -1,11 +1,21 @@