Skip to content

feat(settings): add adaptive cover display preferences#169

Merged
neonsolstice merged 1 commit into
bookorbit:mainfrom
chrismansell26:BO-152-adaptive-cover-ratios
May 31, 2026
Merged

feat(settings): add adaptive cover display preferences#169
neonsolstice merged 1 commit into
bookorbit:mainfrom
chrismansell26:BO-152-adaptive-cover-ratios

Conversation

@chrismansell26

@chrismansell26 chrismansell26 commented May 31, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

Adds adaptive cover display preferences to cleanly render book cover artwork of mixed aspect ratios across all views.

This PR includes:

  • Modular Appearance Settings: Split consolidated AppearanceSettings.vue into subtabs (AppearanceThemeSettings, AppearanceBookCoverSettings, AppearanceLayoutSettings, AppearanceBehaviorSettings, AppearancePreferenceStorage).
  • Reactive Composables: Added useDisplaySettings (local state persistence) and useDisplaySettingsSync (bi-directional preference syncing with server).
  • Aspect Ratio Render Component: Created a reusable BookCoverArtwork component that supports blurred-fit, fill-crop, and natural-bottom display modes, replacing ad-hoc cover display handling across cards, lists, tables, carousels, and widgets.
  • Preferences Sync Endpoint: Integrated schema validation and database-backed preference persistence on the server side under /api/v1/user-preferences/display.

Closes #152

How did you test this?

  • pnpm run verify:fast
    • Result: passed (lint:check + typecheck)
  • pnpm run test
    • Result: All 1948 tests passed successfully across 171 test files.

Screenshots

Screenshot 2026-05-30 at 6 42 38 PM

Anything non-obvious in the diff?

  • Appearance settings layout is modularized into sub-components to stay cleanly maintainable.
  • The blurred-fit mode draws a blurred background panel under the aspect-ratio-locked book cover to seamlessly avoid empty margins.

Checklist

  • I've read through my own diff
  • This PR was discussed in an issue and has maintainer approval (for new features)
  • Lint and tests pass locally
  • No unintended files included (build artifacts, .env, personal configs)

Summary by CodeRabbit

Release Notes

  • New Features

    • Added multiple book cover display modes (blurred-fit, natural-bottom, fill-crop) for customized viewing preferences
    • Implemented server synchronization of display settings for authenticated users across devices
    • Reorganized display settings into tabbed interface (Theme, Book Covers, Layout, Behavior)
    • Added enhanced cover customization controls including spine overlay, shadow strength, and overlay toggle options
  • Improvements

    • Refactored book cover rendering for improved visual consistency and layout control

Allow users to choose how cover artwork is fitted so mixed aspect ratios render cleanly in cards, lists, carousels, and widgets.

Persist display preferences through shared types and user-preferences endpoints so these settings stay consistent across sessions and devices.

Closes bookorbit#152
@coderabbitai

coderabbitai Bot commented May 31, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

This PR introduces a centralized display preferences system enabling multiple book cover display modes. It adds server synchronization for display settings, a new BookCoverArtwork component supporting natural-bottom and blurred-fit rendering, refactors cover rendering across all surfaces to use the new component, and provides comprehensive settings UI with tab-driven appearance configuration.

Changes

Display Settings & Cover Rendering Infrastructure

Layer / File(s) Summary
Display Preferences Type System
packages/types/src/display-preferences.ts, packages/types/src/index.ts
Centralized type definitions for display preferences including cover modes, sizes, overlay selections, and layout options.
Display Settings State Management
client/src/composables/useDisplaySettings.ts, client/src/composables/__tests__/useDisplaySettings.spec.ts
Core composable managing display preferences in local storage with normalization, snapshot generation, sanitization, and reactive state application.
Server Display Settings Sync
client/src/composables/useDisplaySettingsSync.ts, client/src/composables/__tests__/useDisplaySettingsSync.spec.ts
Composable for loading, seeding, and persisting display preferences with debouncing, error handling, and pagehide-triggered flushing.
Auth Integration with Display Sync
client/src/features/auth/composables/useAuth.ts
Wire up display settings synchronization initialization and cleanup in auth flows.
Cover Frame & Aspect Ratio Utilities
client/src/features/book/lib/cover-aspect-ratio.ts, client/src/features/book/lib/cover-aspect-ratio.test.ts
Support for aligned overlay frame positioning with bottom/center anchoring for display-mode-aware layout.
Cover Display Mode Styling
client/src/assets/theme/cover-effects.css
CSS rules for natural-bottom and blurred-fit modes, frame positioning, shadow behavior, and spine pseudo-element handling.
BookCoverArtwork Component
client/src/features/book/components/BookCoverArtwork.vue, client/src/features/book/components/__tests__/BookCoverArtwork.spec.ts
New component rendering covers in multiple display modes with blurred backdrop, spine overlay, placeholder fallback, and load/error event emission.
BookCoverSurface Display Mode Integration
client/src/features/book/components/BookCoverSurface.vue, client/src/features/book/components/__tests__/BookCoverSurface.spec.ts
Add display mode prop for mode-aware rendering and DOM attribute binding.
Component Migrations: Card & List Views
client/src/features/book/components/BookCoverCard.vue, client/src/features/book/components/BookListRow.vue, client/src/features/book/components/BookQuickView.vue, related tests
Refactor cover rendering to use BookCoverArtwork with mode-aware aspect ratio computation and lifecycle handlers.
Component Migrations: Detail & Carousel
client/src/features/book/components/detail/BookCarousel.vue, client/src/features/book/components/detail/tabs/DetailsTab.vue, related tests
Refactor cover rendering with reset-based approach and display-mode-aware aspect ratio selection.
Component Migrations: Widgets & Table
client/src/features/dashboard/components/widgets/*, client/src/features/book/components/table/BookTableCoverCell.vue
Refactor dashboard widget and table cover rendering to use BookCoverArtwork.
Settings Tab Infrastructure
client/src/features/settings/lib/appearance-tabs.ts, client/src/features/settings/lib/__tests__/appearance-tabs.spec.ts, client/src/router/index.ts, client/src/features/settings/SettingsHeader.vue
Tab system for appearance settings with normalization, label mapping, and dynamic router title resolution.
Settings Pages Container & Header
client/src/features/settings/AppearanceSettings.vue, client/src/features/settings/__tests__/AppearanceSettings.spec.ts, client/src/features/settings/__tests__/SettingsHeader.spec.ts
Refactor appearance settings into tab-driven container with conditional sub-page rendering.
Display Settings Sub-Pages
client/src/features/settings/AppearanceThemeSettings.vue, client/src/features/settings/AppearanceBookCoverSettings.vue, client/src/features/settings/AppearanceLayoutSettings.vue, client/src/features/settings/AppearanceBehaviorSettings.vue, client/src/features/settings/AppearancePreferenceStorage.vue
Individual settings pages for theme customization, cover display modes, layout options, library behavior, and preference storage location.
Server Display Preferences API
server/src/modules/user-preferences/user-preferences.service.ts, server/src/modules/user-preferences/user-preferences.controller.ts, related tests
Server-side GET/PUT endpoints, service methods, Zod validation schema, and comprehensive tests for display preferences persistence.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes


Poem

🐰 The covers now stretch and adapt so fine,
Natural-bottom mode makes books align,
Display settings sync to your account with care,
Settings tabs organize preferences fair,
BookCoverArtwork renders each book with flair!

@github-actions github-actions Bot added server Changes affecting server-side code, APIs, or backend behavior. client Changes affecting the client application or frontend behavior. packages labels May 31, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (1)
client/src/features/settings/AppearanceLayoutSettings.vue (1)

52-67: 💤 Low value

Consider aria-pressed on the segmented toggle buttons.

The scope (synced/per-view) and shape (circle/square) selectors are styled toggle buttons but don't expose their selected state to assistive tech. Adding :aria-pressed="coverSizeScope === 'synced'" (and equivalents) conveys the active option.

Also applies to: 206-221

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@client/src/features/settings/AppearanceLayoutSettings.vue` around lines 52 -
67, The segmented toggle buttons (the cover size scope buttons using
coverSizeScope and setCoverSizeScope) do not expose their selected state to
assistive tech; update each option button to include an aria-pressed binding
(e.g., :aria-pressed="coverSizeScope === 'synced'" for the synced button and
analogous for 'per-view') so the active state is conveyed; apply the same
pattern to the shape selector buttons referenced by the shape state (the
circle/square controls) so each button binds aria-pressed to its corresponding
comparison of the shape state.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@client/src/features/settings/__tests__/AppearanceSettings.spec.ts`:
- Around line 20-53: The mock objects are inconsistent: displaySnapshot includes
gridGap, viewMode, and tableDensity but displayRefs omits them, which can break
tests vs production for the useDisplaySettings composable; update displayRefs to
include refs for gridGap (ref(28)), viewMode (ref('grid')), and tableDensity
(ref('comfortable')) so the mock return shape matches displaySnapshot and the
real composable (check the displaySnapshot and displayRefs constants and the
useDisplaySettings mock).

In `@client/src/features/settings/AppearanceLayoutSettings.vue`:
- Around line 83-92: The range sliders lack programmatic labels; for each slider
(e.g., the portrait cover size slider bound to portraitCoverSize with
`@input`="handlePortraitCoverSizeInput" and :disabled="!syncModeEnabled") add an
accessible name by either giving the input an id and pairing it with the
existing visible text via a <label for="..."> or by adding an appropriate
aria-label/aria-labelledby value that references the visible heading. Repeat the
same pattern for the other four sliders (square cover size, portrait grid gap,
square grid gap, author cover size) so every <input type="range"> has a
descriptive accessible label tied to its visible text.

In `@client/src/features/settings/AppearancePreferenceStorage.vue`:
- Around line 91-122: The two storage-mode card DIVs (the one using syncEnabled
and the one using isDemoRestrictedAccount) are not keyboard accessible; replace
each outer <div> with a semantic <button type="button"> that preserves the
existing :class and content and keeps the `@click` handlers (handleDeviceMode and
handleAccountMode) so keyboard/Enter/Space work; for the account card bind
:disabled="isDemoRestrictedAccount" so it becomes non-interactive when
restricted and adjust the class logic to account for the disabled state (keep
visual styles like cursor-not-allowed and opacity via classes when
isDemoRestrictedAccount is true), ensuring focus styles remain available and no
additional click handling fires when disabled.

---

Nitpick comments:
In `@client/src/features/settings/AppearanceLayoutSettings.vue`:
- Around line 52-67: The segmented toggle buttons (the cover size scope buttons
using coverSizeScope and setCoverSizeScope) do not expose their selected state
to assistive tech; update each option button to include an aria-pressed binding
(e.g., :aria-pressed="coverSizeScope === 'synced'" for the synced button and
analogous for 'per-view') so the active state is conveyed; apply the same
pattern to the shape selector buttons referenced by the shape state (the
circle/square controls) so each button binds aria-pressed to its corresponding
comparison of the shape state.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: cc81b24f-7ef8-4a63-a39d-2589e921005a

📥 Commits

Reviewing files that changed from the base of the PR and between 174d76d and f516453.

📒 Files selected for processing (44)
  • client/src/assets/theme/cover-effects.css
  • client/src/composables/__tests__/useDisplaySettings.spec.ts
  • client/src/composables/__tests__/useDisplaySettingsSync.spec.ts
  • client/src/composables/useDisplaySettings.ts
  • client/src/composables/useDisplaySettingsSync.ts
  • client/src/features/auth/composables/useAuth.ts
  • client/src/features/book/components/BookCoverArtwork.vue
  • client/src/features/book/components/BookCoverCard.test.ts
  • client/src/features/book/components/BookCoverCard.vue
  • client/src/features/book/components/BookCoverSurface.vue
  • client/src/features/book/components/BookListRow.vue
  • client/src/features/book/components/BookQuickView.vue
  • client/src/features/book/components/__tests__/BookCoverArtwork.spec.ts
  • client/src/features/book/components/__tests__/BookCoverSurface.spec.ts
  • client/src/features/book/components/__tests__/BookQuickView.spec.ts
  • client/src/features/book/components/detail/BookCarousel.test.ts
  • client/src/features/book/components/detail/BookCarousel.vue
  • client/src/features/book/components/detail/tabs/DetailsTab.vue
  • client/src/features/book/components/detail/tabs/__tests__/DetailsTab.spec.ts
  • client/src/features/book/components/table/BookTableCoverCell.vue
  • client/src/features/book/lib/cover-aspect-ratio.test.ts
  • client/src/features/book/lib/cover-aspect-ratio.ts
  • client/src/features/dashboard/components/widgets/CurrentlyReadingWidget.vue
  • client/src/features/dashboard/components/widgets/HighlightOfTheDayWidget.vue
  • client/src/features/dashboard/components/widgets/LongWaitWidget.vue
  • client/src/features/dashboard/components/widgets/NeglectedGemsWidget.vue
  • client/src/features/settings/AppearanceBehaviorSettings.vue
  • client/src/features/settings/AppearanceBookCoverSettings.vue
  • client/src/features/settings/AppearanceLayoutSettings.vue
  • client/src/features/settings/AppearancePreferenceStorage.vue
  • client/src/features/settings/AppearanceSettings.vue
  • client/src/features/settings/AppearanceThemeSettings.vue
  • client/src/features/settings/SettingsHeader.vue
  • client/src/features/settings/__tests__/AppearanceSettings.spec.ts
  • client/src/features/settings/__tests__/SettingsHeader.spec.ts
  • client/src/features/settings/lib/__tests__/appearance-tabs.spec.ts
  • client/src/features/settings/lib/appearance-tabs.ts
  • client/src/router/index.ts
  • packages/types/src/display-preferences.ts
  • packages/types/src/index.ts
  • server/src/modules/user-preferences/user-preferences.controller.test.ts
  • server/src/modules/user-preferences/user-preferences.controller.ts
  • server/src/modules/user-preferences/user-preferences.service.test.ts
  • server/src/modules/user-preferences/user-preferences.service.ts

Comment on lines +20 to +53
const displaySnapshot = {
portraitCoverSize: 180,
squareCoverSize: 180,
coverSizeScope: 'per-view',
gridGap: 28,
portraitGridGap: 12,
squareGridGap: 12,
viewMode: 'grid',
cardOverlays: [],
smartScopeFilterExpanded: false,
authorCoverSize: 120,
authorCoverShape: 'circle',
tableZebraStriping: true,
tableDensity: 'comfortable',
bookSpineOverlay: 'off',
bookShadowStrength: 'default',
bookCoverDisplayMode: 'blurred-fit',
}

const displayRefs = {
portraitCoverSize: ref(180),
squareCoverSize: ref(180),
coverSizeScope: ref('per-view'),
portraitGridGap: ref(12),
squareGridGap: ref(12),
cardOverlays: ref([]),
smartScopeFilterExpanded: ref(false),
authorCoverSize: ref(120),
authorCoverShape: ref('circle'),
tableZebraStriping: ref(true),
bookSpineOverlay: ref('off'),
bookShadowStrength: ref('default'),
bookCoverDisplayMode: ref('blurred-fit'),
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Verify consistency between displaySnapshot and displayRefs.

The displaySnapshot object (lines 20-37) includes gridGap, viewMode, and tableDensity, but the displayRefs object (lines 39-53) does not include refs for these three fields. If the production useDisplaySettings composable exposes these fields, tests may pass while production code fails when accessing displayRefs.gridGap, displayRefs.viewMode, or displayRefs.tableDensity.

Ensure the mock's return value matches the actual composable's interface.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@client/src/features/settings/__tests__/AppearanceSettings.spec.ts` around
lines 20 - 53, The mock objects are inconsistent: displaySnapshot includes
gridGap, viewMode, and tableDensity but displayRefs omits them, which can break
tests vs production for the useDisplaySettings composable; update displayRefs to
include refs for gridGap (ref(28)), viewMode (ref('grid')), and tableDensity
(ref('comfortable')) so the mock return shape matches displaySnapshot and the
real composable (check the displaySnapshot and displayRefs constants and the
useDisplaySettings mock).

Comment on lines +83 to +92
<input
:value="portraitCoverSize"
type="range"
min="100"
max="280"
step="10"
class="w-full accent-primary cursor-pointer"
:disabled="!syncModeEnabled"
@input="handlePortraitCoverSizeInput"
/>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Range sliders lack an accessible name.

The <input type="range"> controls have no programmatically associated label (no aria-label, and the visible "Cover size"/heading text lives in sibling <div>s without for/id linkage). Screen-reader users will hear an unnamed "slider". This applies to all five sliders in this file (portrait/square cover size, portrait/square grid gap, author cover size).

♿ Proposed fix (apply the same pattern to each slider)
             <input
               :value="portraitCoverSize"
               type="range"
+              aria-label="Portrait cover size"
               min="100"
               max="280"
               step="10"
               class="w-full accent-primary cursor-pointer"
               :disabled="!syncModeEnabled"
               `@input`="handlePortraitCoverSizeInput"
             />
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<input
:value="portraitCoverSize"
type="range"
min="100"
max="280"
step="10"
class="w-full accent-primary cursor-pointer"
:disabled="!syncModeEnabled"
@input="handlePortraitCoverSizeInput"
/>
<input
:value="portraitCoverSize"
type="range"
aria-label="Portrait cover size"
min="100"
max="280"
step="10"
class="w-full accent-primary cursor-pointer"
:disabled="!syncModeEnabled"
`@input`="handlePortraitCoverSizeInput"
/>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@client/src/features/settings/AppearanceLayoutSettings.vue` around lines 83 -
92, The range sliders lack programmatic labels; for each slider (e.g., the
portrait cover size slider bound to portraitCoverSize with
`@input`="handlePortraitCoverSizeInput" and :disabled="!syncModeEnabled") add an
accessible name by either giving the input an id and pairing it with the
existing visible text via a <label for="..."> or by adding an appropriate
aria-label/aria-labelledby value that references the visible heading. Repeat the
same pattern for the other four sliders (square cover size, portrait grid gap,
square grid gap, author cover size) so every <input type="range"> has a
descriptive accessible label tied to its visible text.

Comment on lines +91 to +122
<div
class="flex items-start gap-4 px-4 py-3.5 md:px-5 md:py-4 rounded-lg border-2 cursor-pointer transition-colors"
:class="!syncEnabled ? 'border-primary bg-primary/5' : 'border-border bg-card hover:border-muted-foreground/30'"
@click="handleDeviceMode"
>
<div
class="mt-0.5 flex items-center justify-center w-8 h-8 rounded-lg shrink-0 transition-colors"
:class="!syncEnabled ? 'bg-primary/15 text-primary' : 'bg-muted text-muted-foreground'"
>
<Monitor :size="16" />
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<span class="settings-label">This device only</span>
<span v-if="!syncEnabled" class="text-xs font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded bg-primary/15 text-primary">
Active
</span>
</div>
<span class="block text-xs text-muted-foreground leading-relaxed">
Preferences stay in your browser. Best if you want a different look on different devices.
</span>
</div>
</div>

<div
class="flex items-start gap-4 px-4 py-3.5 md:px-5 md:py-4 rounded-lg border-2 transition-colors"
:class="[
isDemoRestrictedAccount ? 'border-border bg-card opacity-50 cursor-not-allowed' : 'cursor-pointer',
!isDemoRestrictedAccount && (syncEnabled ? 'border-primary bg-primary/5' : 'border-border bg-card hover:border-muted-foreground/30'),
]"
@click="handleAccountMode"
>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Mode selection cards are not keyboard operable.

Both storage-mode options are plain <div>s with @click; they have no role, tabindex, or keydown handler, so keyboard-only and screen-reader users cannot select a storage mode. This blocks task completion for those users. Also, the "My account" card still fires handleAccountMode on click when isDemoRestrictedAccount (only styled cursor-not-allowed).

Prefer native <button type="button"> (with :disabled for the demo-restricted case), which gives focus, Enter/Space activation, and disabled semantics for free.

♿ Sketch: device-mode card as a button
-      <div
+      <button
+        type="button"
         class="flex items-start gap-4 px-4 py-3.5 md:px-5 md:py-4 rounded-lg border-2 cursor-pointer transition-colors"
         :class="!syncEnabled ? 'border-primary bg-primary/5' : 'border-border bg-card hover:border-muted-foreground/30'"
         `@click`="handleDeviceMode"
       >
         ...
-      </div>
+      </button>

Apply the same for the account card, adding :disabled="isDemoRestrictedAccount" so it is non-interactive when restricted.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@client/src/features/settings/AppearancePreferenceStorage.vue` around lines 91
- 122, The two storage-mode card DIVs (the one using syncEnabled and the one
using isDemoRestrictedAccount) are not keyboard accessible; replace each outer
<div> with a semantic <button type="button"> that preserves the existing :class
and content and keeps the `@click` handlers (handleDeviceMode and
handleAccountMode) so keyboard/Enter/Space work; for the account card bind
:disabled="isDemoRestrictedAccount" so it becomes non-interactive when
restricted and adjust the class logic to account for the disabled state (keep
visual styles like cursor-not-allowed and opacity via classes when
isDemoRestrictedAccount is true), ensuring focus styles remain available and no
additional click handling fires when disabled.

@neonsolstice
neonsolstice merged commit 589ccd6 into bookorbit:main May 31, 2026
22 checks passed
@github-actions

github-actions Bot commented Jun 1, 2026

Copy link
Copy Markdown

🎉 This PR is included in version 1.8.0 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

@github-actions github-actions Bot added the released Issue or PR is included in a released version. label Jun 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

client Changes affecting the client application or frontend behavior. packages released Issue or PR is included in a released version. server Changes affecting server-side code, APIs, or backend behavior.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature] media browser, different ratio covers or options to stretch

2 participants