html,
body {
    margin: 0;
    padding: 0;
    font-family: var(--font-family-ui);
    font-size: var(--font-size-body);
    line-height: var(--line-height-body);
    background-color: var(--color-bg-paper);
    color: var(--color-text-primary);
}

* {
    box-sizing: border-box;
}

a {
    color: var(--color-primary);
    transition: color var(--motion-micro) var(--ease-standard);
}

a:hover {
    color: var(--color-primary-hover);
}

h1,
h2,
h3 {
    font-family: var(--font-family-display);
    font-weight: 600;
    line-height: var(--line-height-heading);
    margin: var(--space-4) 0 var(--space-2);
}

h1 {
    font-size: var(--font-size-h1);
    font-weight: 700;
    width: fit-content;
    margin-top: 0;
    /* Signature device: a chapter-heading-style rule under every page
       title, echoing the divider under a chapter number in an old book —
       not decoration, a consistent marker of "this is where a page/chapter
       begins" that recurs everywhere in the app. Sized to the title's own
       width (like a rule under a book's chapter heading), not the full
       page width. */
    padding-bottom: var(--space-2);
    border-bottom: 3px double var(--color-gold-accent);
}

h2 {
    font-size: var(--font-size-h2);
}

h3 {
    font-size: var(--font-size-h3);
}

.text-muted {
    color: var(--color-text-muted);
}

.text-secondary {
    color: var(--color-text-secondary);
}

/* Standard visually-hidden pattern -- content that should reach a screen
   reader (a loading announcement alongside an aria-hidden skeleton, a
   label with no room for visible text) without taking up layout space or
   being visible on screen. Global from its first use since it's a generic
   utility, not page-specific content (issue #341 a11y audit). */
.visually-hidden {
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip: rect(0, 0, 0, 0);
    white-space: nowrap;
    border: 0;
}

/* Shared line-icon set (Components/Icon/Icon.razor) -- base sizing only;
   context-specific sizes are set via an additional class where needed. */
.icon {
    width: 1.15em;
    height: 1.15em;
    flex-shrink: 0;
    vertical-align: -0.2em;
}

/* Shared "Bibliotheksregal" grid for CatalogCard collections (Discover,
   PublicProfile) -- responsive by construction instead of the fixed-width
   flex-wrap .library-grid uses, since CatalogCard itself has no fixed
   width. Global on purpose: this is exactly the kind of shared primitive
   issue #315 asks for instead of each page reinventing it. */
.catalog-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(9.5rem, 1fr));
    gap: var(--space-3);
}

/* Library page's "Raster" view: a grid of BookCards. Deliberately NOT
   .catalog-grid above -- that class relies on grid's default stretch
   sizing, which works for CatalogCard (no fixed width, so it fills
   whatever column width `1fr` computes) but not BookCard, whose sizes
   (.book-card-normal etc., BookCard.razor.css) are all fixed-width. A
   fixed-width item can't stretch, so reusing .catalog-grid would leave
   each BookCard flush-left in a wider-than-it column with inconsistent-
   looking gaps on wide viewports. Fixed 10rem columns (matching
   .book-card-normal's own width) avoid the mismatch entirely and give a
   uniform, evenly-gapped grid regardless of viewport width. */
.library-raster {
    display: grid;
    grid-template-columns: repeat(auto-fill, 10rem);
    gap: var(--space-3);
}

/* Shared skeleton-loading shimmer (PublicProfile's hero, Discover's book
   grid) -- a plain shape; a page's own scoped CSS sizes/positions it. */
.skeleton-shape {
    background: linear-gradient(90deg, var(--color-bg-card) 25%, var(--color-border) 50%, var(--color-bg-card) 75%);
    background-size: 200% 100%;
    animation: skeleton-shimmer var(--motion-skeleton) ease-in-out infinite;
    border-radius: var(--radius);
}

@keyframes skeleton-shimmer {
    0% {
        background-position: 200% 0;
    }
    100% {
        background-position: -200% 0;
    }
}

/* Shared "chapter" heading device (PublicProfile's Bibliothek/Projekte/
   Aktivität, Discover's Bibliotheksregal) -- the same double-gold-rule
   signature already used under every page's <h1> (see the h1 rule above),
   reapplied at h2 scale with an italic caption, since the global h1 rule
   only targets h1. */
.chapter-title {
    font-family: var(--font-family-display);
    font-weight: 600;
    font-size: var(--font-size-h2);
    text-align: center;
    width: fit-content;
    margin: 0 auto var(--space-3);
    padding-bottom: var(--space-1);
    border-bottom: 3px double var(--color-gold-accent);
}

.chapter-caption {
    font-weight: 400;
    font-style: italic;
    font-size: var(--font-size-small);
    color: var(--color-text-muted);
}

/* Scroll-reveal utility (issue #341) -- shared by Discover's search/toolbar/
   grid sections and PublicProfile's three chapters from the day it was
   introduced (both pages need it at once), so it starts global rather than
   page-scoped-then-promoted like the utilities above.

   .reveal-init/.reveal-visible are added by wwwroot/js/motion.js
   (initReveal), never by default markup -- a section with no [data-reveal]
   JS wiring, or a page rendered before the module loads, stays in its
   normal fully-visible state. See motion.js's own comment and
   Architecture.md. */
[data-reveal].reveal-init {
    opacity: 0;
    transform: translateY(10px);
    transition: opacity var(--motion-reveal) var(--ease-standard), transform var(--motion-reveal) var(--ease-standard);
    /* A page can offset its own top-level [data-reveal] groups (e.g.
       Discover's heading/search/books sections) via an inline
       `--reveal-delay` so they cascade in on initial load instead of all
       fading in at once -- see Discover.razor/PublicProfile.razor. */
    transition-delay: var(--reveal-delay, 0ms);
}

[data-reveal].reveal-init.reveal-visible {
    opacity: 1;
    transform: none;
}

/* Cards/results inside a just-revealed section get their own light
   stagger -- capped at the first 8 children so a long grid doesn't queue a
   slow cascade for items already visible at scroll-in. */
[data-reveal].reveal-visible .catalog-grid > *,
[data-reveal].reveal-visible .discover-author-results > * {
    animation: reveal-item var(--motion-reveal) var(--ease-standard) backwards;
}

@keyframes reveal-item {
    from {
        opacity: 0;
        transform: translateY(8px);
    }
    to {
        opacity: 1;
        transform: none;
    }
}

[data-reveal].reveal-visible .catalog-grid > :nth-child(1),
[data-reveal].reveal-visible .discover-author-results > :nth-child(1) { animation-delay: calc(var(--motion-micro) * 0); }
[data-reveal].reveal-visible .catalog-grid > :nth-child(2),
[data-reveal].reveal-visible .discover-author-results > :nth-child(2) { animation-delay: calc(var(--motion-micro) * 1); }
[data-reveal].reveal-visible .catalog-grid > :nth-child(3),
[data-reveal].reveal-visible .discover-author-results > :nth-child(3) { animation-delay: calc(var(--motion-micro) * 2); }
[data-reveal].reveal-visible .catalog-grid > :nth-child(4),
[data-reveal].reveal-visible .discover-author-results > :nth-child(4) { animation-delay: calc(var(--motion-micro) * 3); }
[data-reveal].reveal-visible .catalog-grid > :nth-child(5),
[data-reveal].reveal-visible .discover-author-results > :nth-child(5) { animation-delay: calc(var(--motion-micro) * 4); }
[data-reveal].reveal-visible .catalog-grid > :nth-child(6),
[data-reveal].reveal-visible .discover-author-results > :nth-child(6) { animation-delay: calc(var(--motion-micro) * 5); }
[data-reveal].reveal-visible .catalog-grid > :nth-child(7),
[data-reveal].reveal-visible .discover-author-results > :nth-child(7) { animation-delay: calc(var(--motion-micro) * 6); }
[data-reveal].reveal-visible .catalog-grid > :nth-child(n+8),
[data-reveal].reveal-visible .discover-author-results > :nth-child(n+8) { animation-delay: calc(var(--motion-micro) * 7); }

@media (prefers-reduced-motion: reduce) {
    /* Defense-in-depth alongside motion.js's own check -- this one also
       covers the OS motion setting changing live, without a reload, which
       matchMedia() in JS doesn't re-poll for on its own. */
    [data-reveal].reveal-init,
    [data-reveal].reveal-init.reveal-visible {
        opacity: 1;
        transform: none;
        transition: none;
    }

    [data-reveal] .catalog-grid > *,
    [data-reveal] .discover-author-results > * {
        animation: none;
    }
}

/* Shared literary empty-state (PublicProfile's Bibliothek/Projekte/
   Aktivität chapters, Discover's book grid) -- an ornament + flavor text
   instead of a bare "no items" line. Also used directly (not through the
   shared EmptyState component) by OfflineLibrary.razor's empty state,
   which needs an extra page-specific icon slot the component doesn't
   support. */
.empty-state-literary {
    text-align: center;
    padding: var(--space-5) var(--space-3);
    border: 1px dashed var(--color-border);
    border-radius: var(--radius-lg);
    background: var(--color-bg-card);
}

.empty-state-ornament {
    display: block;
    color: var(--color-gold-accent);
    font-size: 1.3rem;
    margin-bottom: var(--space-1);
}

.empty-state-literary p {
    font-family: var(--font-family-reader);
    font-style: italic;
    color: var(--color-text-secondary);
    max-width: 26rem;
    margin: 0 auto var(--space-2);
}

/* Was page-scoped in PublicProfile.razor.css, but the literary empty state
   is now always rendered via the shared EmptyState component (issue #349
   Phase B) -- a page-scoped rule can't reach into a child component's own
   markup (Blazor CSS isolation gives EmptyState's rendered elements its
   own scope attribute, not the calling page's), so this has to live here
   instead, alongside the other .empty-state-literary rules it was already
   documented as belonging with. */
.empty-state-literary .btn {
    margin-top: var(--space-1);
}

@media (prefers-reduced-motion: reduce) {
    .skeleton-shape {
        animation: none;
    }
}

button,
.btn {
    font-family: inherit;
    font-size: var(--font-size-body);
    font-weight: 500;
    border-radius: var(--radius);
    border: 1px solid var(--color-border);
    background-color: var(--color-bg-card);
    color: var(--color-text-primary);
    padding: var(--space-1) var(--space-2);
    cursor: pointer;
    transition: background-color var(--motion-micro) var(--ease-standard), border-color var(--motion-micro) var(--ease-standard), transform var(--motion-micro) var(--ease-standard);
}

.btn:disabled {
    cursor: not-allowed;
    opacity: 0.5;
}

button:hover,
.btn:hover {
    border-color: var(--color-primary);
}

/* Real press feedback (issue #349 Phase B, section 15) -- the transform
   transition was already declared above but nothing set a different
   transform on :active, so every plain button had none. scale(0.98),
   the same restrained intensity as the pre-existing Follow-button press
   effect (PublicProfile.razor.css's .btn-follow:active) -- not a
   :disabled button, since a disabled control shouldn't visually react to
   a click it isn't actually receiving. */
button:not(:disabled):active,
.btn:not(:disabled):active {
    transform: scale(0.98);
}

@media (prefers-reduced-motion: reduce) {
    button:not(:disabled):active,
    .btn:not(:disabled):active {
        transform: none;
    }
}

.btn-primary {
    background-color: var(--color-primary);
    border-color: var(--color-primary);
    color: var(--color-bg-light);
}

.btn-primary:hover {
    background-color: var(--color-primary-hover);
    border-color: var(--color-primary-hover);
}

.btn-primary:active {
    background-color: var(--color-primary-active);
}

/* App-wide base styling for text-like form fields. Before this rule,
   every input/select/textarea in the app rendered on unstyled browser
   defaults -- there was no project-wide rule for them (confirmed via
   grep), only the shared :focus-visible outline above. Scoped to
   exclude checkbox/radio, which get their own themed rule below instead
   of this text-field treatment. */
input:where(:not([type="checkbox"]):not([type="radio"])),
select,
textarea {
    font-family: inherit;
    font-size: var(--font-size-body);
    border-radius: var(--radius);
    border: 1px solid var(--color-border);
    background-color: var(--color-bg-card);
    color: var(--color-text-primary);
    padding: var(--space-1) var(--space-2);
}

/* App-wide themed checkbox, replacing the native white-box/blue-check
   control (which never matched any theme's palette -- raised directly by
   the user as looking "altmodisch und unprofessionell" next to the
   app's otherwise-styled UI). appearance: none strips native rendering
   so the box itself can be drawn in the current theme's own palette;
   the checkmark is a small rotated two-sided border (an unclipped ::after
   box, not an SVG/font glyph), so no extra asset is needed and it scales
   cleanly with font-size like a text-based check would, without a
   glyph's font-rendering/baseline quirks. --color-primary is reused
   deliberately, not a new token -- it's already this theme's own accent
   (brown-gold/blue/gold per theme, exactly what btn-primary uses), so a
   checked box always matches whatever "selected/active" already looks
   like elsewhere on the same page. */
input[type="checkbox"] {
    appearance: none;
    -webkit-appearance: none;
    width: 1.15rem;
    height: 1.15rem;
    flex-shrink: 0;
    margin: 0;
    border-radius: 0.3rem;
    border: 1.5px solid var(--color-border);
    background-color: var(--color-bg-card);
    cursor: pointer;
    position: relative;
    transition: background-color var(--motion-micro) var(--ease-standard), border-color var(--motion-micro) var(--ease-standard);
}

input[type="checkbox"]:hover {
    border-color: var(--color-primary);
}

input[type="checkbox"]:checked {
    background-color: var(--color-primary);
    border-color: var(--color-primary);
}

/* Deliberately no checkmark glyph -- the filled --color-primary box
   itself is the "checked" indicator (user feedback: a checkmark drawn in
   --color-text-on-dark read poorly/felt cluttered on the filled color;
   a plain solid-vs-outline box is cleaner and still unambiguous). */

/* No checkbox-specific :focus-visible rule here deliberately -- the
   shared input:focus-visible rule further down this file already covers
   it, using --color-accent-text rather than --color-primary specifically
   because --color-primary equals --color-gold-accent in Dark Library,
   which measures under WCAG's 3:1 focus-indicator floor (issue #341 a11y
   audit). Reusing --color-primary here for the outline would have quietly
   reintroduced that exact failure for every checkbox in that theme. */

#blazor-error-ui {
    background: var(--color-error);
    color: var(--color-bg-light);
    bottom: 0;
    box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
    display: none;
    left: 0;
    padding: var(--space-1) var(--space-6) var(--space-1) var(--space-2);
    position: fixed;
    width: 100%;
    z-index: 1000;
}

#blazor-error-ui .dismiss {
    cursor: pointer;
    position: absolute;
    right: var(--space-1);
    top: var(--space-1);
}

.loading-progress {
    position: relative;
    display: block;
    width: 8rem;
    height: 8rem;
    margin: 20vh auto 0;
    /* Shared by index.html's pre-boot loader and LoadingIndicator (issue
       #349 Phase B) -- previously a static 75%-filled ring with no
       @keyframes anywhere, i.e. the unmodified Blazor WASM template
       spinner reused as-is. Spinning the whole ring (rather than animating
       stroke-dasharray) is cheap -- one GPU-friendly transform, no layout
       recalculation -- and reads as "in progress" the instant it's visible,
       including during the pre-boot phase before any Blazor/JS has run. */
    animation: loading-progress-spin var(--motion-spinner) linear infinite;
}

.loading-progress circle {
    fill: none;
    stroke: var(--color-border);
    stroke-width: 0.6rem;
    transform-origin: 50% 50%;
    transform: rotate(-90deg);
}

.loading-progress circle:last-child {
    stroke: var(--color-primary);
    stroke-dasharray: calc(3.141 * 8rem * 0.75), calc(3.141 * 8rem * 0.25);
}

@keyframes loading-progress-spin {
    from {
        transform: rotate(0deg);
    }
    to {
        transform: rotate(360deg);
    }
}

@media (prefers-reduced-motion: reduce) {
    .loading-progress {
        animation: none;
    }
}

.loading-progress-text {
    text-align: center;
}

/* Visible keyboard focus everywhere -- inputs, buttons, links -- since
   nothing set one before. */
a:focus-visible,
button:focus-visible,
.btn:focus-visible,
input:focus-visible,
textarea:focus-visible,
select:focus-visible,
[tabindex]:focus-visible {
    /* --color-accent-text, not --color-gold-accent: the plain gold accent
       measures ~2.2-2.6:1 against light-theme paper/card backgrounds,
       under WCAG's 3:1 floor for a focus indicator (issue #341 a11y
       audit). --color-accent-text is a per-theme variant tuned for
       real contrast instead of pure decoration -- keep this color choice
       even when adjusting weight/offset below, it's load-bearing for
       WCAG 2.4.7 compliance, not just decoration.

       Width/offset toned down 2026-08-08 (user feedback: the original
       2px outline + 2px offset read as a "glowing" halo floating off the
       element, not a clean focus ring) -- a thinner outline hugging the
       element tighter is still clearly visible (WCAG 2.4.7 requires
       visible, not a minimum width) without the glow effect. */
    outline: 1.5px solid var(--color-accent-text);
    outline-offset: 1px;
}

.auth-form {
    display: flex;
    flex-direction: column;
    gap: var(--space-2);
    max-width: 24rem;
    margin: var(--space-4) auto;
    padding: var(--space-3);
    border: 1px solid var(--color-border);
    border-radius: var(--radius-lg);
    background-color: var(--color-bg-card);
    box-shadow: var(--shadow-card);
}

/* OAuth (issue #40) -- sits below .auth-form, same max-width so the "oder"
   divider and provider links line up visually with the form above them. */
.auth-divider {
    display: flex;
    align-items: center;
    gap: var(--space-2);
    max-width: 24rem;
    margin: var(--space-3) auto;
    color: var(--color-text-muted);
    font-size: var(--font-size-small);
}

.auth-divider::before,
.auth-divider::after {
    content: "";
    flex: 1;
    height: 1px;
    background-color: var(--color-border);
}

.auth-oauth-buttons {
    display: flex;
    flex-direction: column;
    gap: var(--space-2);
    max-width: 24rem;
    margin: 0 auto var(--space-4);
}

.btn-oauth {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: var(--space-2);
    text-decoration: none;
    padding: var(--space-2);
}

.btn-oauth-icon {
    width: 1.1em;
    height: 1.1em;
    flex-shrink: 0;
}

.form-group {
    display: flex;
    flex-direction: column;
    gap: var(--space-1);
}

.form-group label {
    font-size: var(--font-size-small);
    color: var(--color-text-secondary);
}

.form-group input,
.form-group textarea,
.form-group select {
    font-family: inherit;
    font-size: var(--font-size-body);
    padding: var(--space-1) calc(var(--space-1) * 1.25);
    border: 1px solid var(--color-border);
    border-radius: var(--radius);
    background-color: var(--color-bg-light);
    color: var(--color-text-primary);
}

.form-group input:focus-visible,
.form-group textarea:focus-visible,
.form-group select:focus-visible {
    border-color: var(--color-primary);
}

.checkbox-label {
    display: flex;
    align-items: center;
    gap: var(--space-1);
    font-size: var(--font-size-small);
    color: var(--color-text-secondary);
}

/* Book sharing (v3.2, issue #321) -- BookDetail.razor's "Geteilt mit"
   manager for a SHARED book's explicit share list. */
.book-share-manager {
    display: flex;
    flex-direction: column;
    gap: var(--space-1);
    margin-top: var(--space-2);
    padding: var(--space-2);
    border: 1px solid var(--color-border);
    border-radius: var(--radius);
    background-color: var(--color-bg-light);
}

.book-share-manager input {
    font-family: inherit;
    font-size: var(--font-size-body);
    padding: var(--space-1) calc(var(--space-1) * 1.25);
    border: 1px solid var(--color-border);
    border-radius: var(--radius);
    background-color: var(--color-bg-card);
    color: var(--color-text-primary);
}

.book-share-list,
.book-share-search-results {
    list-style: none;
    display: flex;
    flex-direction: column;
    gap: var(--space-1);
    padding: 0;
    margin: 0;
}

.book-share-list li,
.book-share-search-results li {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--space-1);
}

/* Comments (v3.3, issue #325) -- BookDetail.razor and the project detail
   page's comment list + submit form. */
.book-comments {
    margin-top: var(--space-3);
}

.book-comment-list {
    list-style: none;
    display: flex;
    flex-direction: column;
    gap: var(--space-2);
    padding: 0;
    margin: var(--space-1) 0;
}

.book-comment-item {
    padding: var(--space-2);
    border: 1px solid var(--color-border);
    border-radius: var(--radius);
}

.book-comment-header {
    display: flex;
    justify-content: space-between;
    align-items: baseline;
    gap: var(--space-2);
    margin-bottom: var(--space-1);
}

.comment-form {
    display: flex;
    flex-direction: column;
    gap: var(--space-1);
    margin-top: var(--space-2);
}

.comment-form textarea {
    font-family: inherit;
    font-size: var(--font-size-body);
    padding: var(--space-1) calc(var(--space-1) * 1.25);
    border: 1px solid var(--color-border);
    border-radius: var(--radius);
    background-color: var(--color-bg-card);
    color: var(--color-text-primary);
    min-height: 4rem;
}

/* File inputs sized to fill the dropzone (opacity 0, positioned over the
   label) so a real HTML5 file drag-and-drop lands directly on the native
   <input type="file"> -- no custom drop-target JS needed, OnChange fires
   exactly as it would for a click-to-browse selection. The highlight state
   is purely visual (@ondragenter/@ondragleave toggling a class). */
.dropzone {
    position: relative;
    display: flex;
    align-items: center;
    justify-content: center;
    min-height: 5rem;
    padding: var(--space-3);
    border: 2px dashed var(--color-border);
    border-radius: var(--radius-lg, var(--radius));
    text-align: center;
    transition: border-color 0.15s ease, background-color 0.15s ease;
}

.dropzone--active {
    border-color: var(--color-primary);
    background-color: var(--color-bg-card);
}

.dropzone--filled {
    border-style: solid;
    border-color: var(--color-success);
}

.dropzone--filled .dropzone-label {
    color: var(--color-text-primary);
}

.dropzone input[type="file"] {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    opacity: 0;
    cursor: pointer;
}

.dropzone-label {
    color: var(--color-text-secondary);
    font-size: var(--font-size-small);
    pointer-events: none;
}

.isbn-enrichment-row {
    display: flex;
    gap: var(--space-1);
}

.isbn-enrichment-row input {
    flex: 1;
}

.enrichment-search-row {
    margin-top: var(--space-1);
}

.enrichment-search-results {
    list-style: none;
    margin: var(--space-1) 0 0;
    padding: 0;
    max-height: 16rem;
    overflow-y: auto;
    border: 1px solid var(--color-border);
    border-radius: var(--radius, 4px);
}

.enrichment-search-result {
    display: flex;
    align-items: center;
    gap: var(--space-1);
    width: 100%;
    padding: var(--space-1);
    background: none;
    border: none;
    border-bottom: 1px solid var(--color-border);
    text-align: left;
    cursor: pointer;
    font: inherit;
    color: inherit;
    transition: background-color 0.15s ease;
}

.enrichment-search-results li:last-child .enrichment-search-result {
    border-bottom: none;
}

.enrichment-search-result:hover {
    background: var(--color-surface-hover, var(--color-surface));
}

.enrichment-search-cover {
    width: 2.5rem;
    aspect-ratio: 2 / 3;
    object-fit: cover;
    flex-shrink: 0;
}

.enrichment-preview {
    margin-top: var(--space-1);
    padding: var(--space-2);
    border: 1px solid var(--color-border);
    border-radius: var(--radius, 4px);
    background: var(--color-surface-alt, var(--color-surface));
}

.enrichment-preview ul {
    margin: var(--space-1) 0;
    padding-left: var(--space-2);
}

.form-error {
    color: var(--color-error);
}

/* Shared ErrorMessage component (issue #349 Phase B, section 24) --
   calm, not an alarming red banner: same centered/muted layout family as
   .empty-state, oxblood-family --color-error reserved for the small icon
   only, not a colored background/border. */
.error-message {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: var(--space-2);
    padding: var(--space-4) var(--space-2);
    text-align: center;
}

.error-message-icon {
    width: 1.75rem;
    height: 1.75rem;
    color: var(--color-error);
}

.error-message-text {
    color: var(--color-text-secondary);
    max-width: 32rem;
}

.form-success {
    color: var(--color-success);
}

.form-actions {
    display: flex;
    align-items: center;
    flex-wrap: wrap;
    gap: var(--space-2);
}

/* Upload form presented as a centered card rather than a full-width page,
   with a cover preview -- distinct from .auth-form since it carries far
   more fields and a file preview. */
.book-form-card {
    display: flex;
    flex-direction: column;
    gap: var(--space-2);
    max-width: 34rem;
    margin: var(--space-3) auto;
    padding: var(--space-3);
    border: 1px solid var(--color-border);
    border-radius: var(--radius-lg);
    background-color: var(--color-bg-card);
    box-shadow: var(--shadow-card);
}

.book-form-cover-preview {
    display: flex;
    align-items: center;
    gap: var(--space-2);
}

.book-form-cover-preview img {
    width: 6rem;
    aspect-ratio: 2 / 3;
    object-fit: cover;
    border-radius: var(--radius);
    border: 1px solid var(--color-border);
    background-color: var(--color-bg-paper);
}

.book-form-cover-preview .cover-placeholder {
    width: 6rem;
    aspect-ratio: 2 / 3;
    border-radius: var(--radius);
    border: 1px dashed var(--color-border);
    background-color: var(--color-bg-paper);
}

/* Minimal Library layout -- same placeholder-styling stance as .auth-form
   above, real visual design lands with the dedicated frontend-design pass. */
.library-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    gap: var(--space-2);
}

/* Dark shelf-themed treatment for the Library page's own header only --
   applied alongside .library-header via a second class so the shared
   .library-header pattern used by ProjectDetail/CharacterDetail/Projects/
   Shelves section headers stays unaffected. */
.library-header-shelf {
    padding: var(--space-3) var(--space-4, 2rem);
    background: linear-gradient(160deg, color-mix(in srgb, var(--color-bg-dark) 92%, var(--color-primary) 8%), var(--color-bg-dark));
    border-radius: var(--radius-lg, var(--radius));
    box-shadow: 0 3px 8px rgba(0, 0, 0, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.06);
    color: var(--color-text-on-dark, #f2e9d8);
}

.library-header-shelf h1 {
    color: inherit;
}

.library-shelf {
    display: flex;
    flex-direction: column;
}

.library-toolbar {
    display: flex;
    flex-wrap: wrap;
    gap: var(--space-2);
    align-items: center;
    margin: var(--space-2) 0;
}

.library-toolbar input,
.library-toolbar select {
    font-family: inherit;
    font-size: var(--font-size-body);
    padding: var(--space-1);
    border: 1px solid var(--color-border);
    border-radius: var(--radius);
    background-color: var(--color-bg-light);
    color: var(--color-text-primary);
}

.library-search-wrapper {
    position: relative;
}

.library-search-suggestions {
    position: absolute;
    top: 100%;
    left: 0;
    z-index: 20;
    width: 100%;
    min-width: 18rem;
    max-height: 16rem;
    overflow-y: auto;
    margin: var(--space-1) 0 0;
    padding: 0;
    list-style: none;
    border: 1px solid var(--color-border);
    border-radius: var(--radius-lg, var(--radius));
    background-color: var(--color-bg-card);
    box-shadow: var(--shadow-card-hover, var(--shadow-card));
}

.library-search-suggestions li button {
    display: block;
    width: 100%;
    text-align: left;
    border: none;
    transition: background-color 0.15s ease;
    background: none;
    padding: var(--space-1);
    cursor: pointer;
}

.library-search-suggestions li button:hover {
    background-color: var(--color-bg-light);
}

.library-pager {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: var(--space-2);
    margin: var(--space-3) 0;
}

.library-favorite-filter {
    display: flex;
    align-items: center;
    gap: var(--space-1);
    font-size: var(--font-size-small);
    color: var(--color-text-secondary);
    white-space: nowrap;
}

.library-view-toggle {
    display: flex;
    gap: var(--space-2);
    margin-left: auto;
}

.library-grid {
    display: flex;
    flex-wrap: wrap;
    gap: var(--space-2);
}

.dashboard-overview {
    display: flex;
    flex-wrap: wrap;
    gap: var(--space-2);
    margin: var(--space-2) 0 var(--space-3);
}

.dashboard-stat {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: var(--space-1);
    min-width: 6rem;
    padding: var(--space-2) var(--space-3);
    border-radius: var(--radius-lg, var(--radius));
    background-color: var(--color-bg-card);
}

.dashboard-stat-value {
    font-family: var(--font-family-display);
    font-size: 1.75rem;
    font-weight: 600;
    line-height: 1;
    color: var(--color-primary);
}

.dashboard-stat-label {
    font-size: 0.85rem;
    color: var(--color-text-muted);
}

.statistics-genre-list {
    display: flex;
    flex-direction: column;
    gap: var(--space-1);
    margin: var(--space-2) 0 var(--space-3);
}

.statistics-genre-row {
    display: grid;
    grid-template-columns: 8rem auto;
    align-items: center;
    gap: var(--space-2);
}

.statistics-genre-label {
    font-size: 0.9rem;
    color: var(--color-text-primary);
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

/* Statistics page (v1.5 extended stats): stat-tile row reuses
   .dashboard-overview/.dashboard-stat as-is, then two side-by-side panels
   (goal ring + reading calendar), then a yearly-overview list reusing the
   same Mini Brass Dial visual as the genre breakdown below it. The
   page's header moved into the hero (Statistics.razor.css, Statistics
   Rework Phase 2) — .stats-header/.stats-subtitle's old plain-page
   styling no longer applies. */
.stats-overview .dashboard-stat {
    min-width: 8rem;
}

.stats-panels {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr));
    gap: var(--space-3);
    margin: var(--space-3) 0;
}

.stats-panel {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: var(--space-2);
    padding: var(--space-3);
    border: 1px solid var(--color-border);
    border-radius: var(--radius-lg, var(--radius));
    background-color: var(--color-bg-card);
}

/* "Illuminated Reading Calendar" (Statistics Rework Phase 1): the same
   low-opacity brass-texture technique as the Goal Ring (Task 3), applied
   once to the whole calendar panel rather than per-cell (364+ repeated
   background images would be wasteful). position: relative + overflow:
   hidden keep the texture inside the panel's own rounded corners.
   isolation: isolate makes this panel establish its own stacking context;
   combined with z-index: -1 on the ::before below, the texture paints
   after the panel's own background but before its non-positioned in-flow
   children (h2, .calendar-heatmap, .calendar-legend) -- per CSS painting
   order, a positioned box with negative z-index paints earlier than
   non-positioned in-flow descendants regardless of DOM order, so without
   isolation + z-index the texture would incorrectly paint on top of the
   content instead of behind it. */
.stats-panel.calendar-card {
    position: relative;
    overflow: hidden;
    isolation: isolate;
}

.stats-panel.calendar-card::before {
    content: "";
    position: absolute;
    inset: 0;
    z-index: -1;
    background-image: url("../images/statistics/brass-texture.webp");
    background-size: cover;
    opacity: 0.15;
    pointer-events: none;
}

.stats-panel h2 {
    align-self: flex-start;
    margin: 0;
}

/* "Brass Reading Dial" (Statistics Rework Phase 1): the conic-gradient
   mechanism above is untouched -- it still carries 100% of the actual
   data meaning. This adds a metallic bevel (box-shadow) and a low-opacity
   brass texture on a ::before pseudo-element, since CSS opacity applies to
   a whole element and can't fade just one layer of a multi-background
   stack -- the pseudo-element is the only way to fade only the texture
   while the conic-gradient stays at full strength. */
.goal-ring {
    --goal-pct: 0;
    position: relative;
    isolation: isolate;
    display: flex;
    align-items: center;
    justify-content: center;
    width: 8rem;
    height: 8rem;
    border-radius: 50%;
    background: conic-gradient(var(--color-primary) calc(var(--goal-pct) * 1%), var(--color-border) 0);
    border: 1px solid color-mix(in srgb, var(--color-primary) 60%, black);
    box-shadow:
        inset 2px 2px 4px color-mix(in srgb, white 25%, transparent),
        inset -2px -2px 4px color-mix(in srgb, black 25%, transparent);
    transition: background 0.3s ease;
}

.goal-ring::before {
    content: "";
    position: absolute;
    inset: 0;
    z-index: -1;
    border-radius: 50%;
    background-image: url("../images/statistics/brass-texture.webp");
    background-size: cover;
    opacity: 0.15;
    pointer-events: none;
}

.goal-ring-inner {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    width: 6.2rem;
    height: 6.2rem;
    border-radius: 50%;
    background-color: var(--color-bg-card);
}

.goal-ring-value {
    font-family: var(--font-family-display);
    font-size: 1.75rem;
    font-weight: 600;
    color: var(--color-primary);
}

.goal-ring-of {
    font-size: 0.8rem;
    color: var(--color-text-muted);
}

/* Mini Brass Dial: the Goal Ring's conic-gradient technique (see
   .goal-ring above) at a much smaller scale for Genre Breakdown and
   Yearly Overview rows. No brass-texture overlay and no dual inset/
   outset shadow -- at ~2.5rem those effects are imperceptible/muddy,
   so this carries only what still reads at that size. */
.mini-dial {
    --mini-dial-pct: 0;
    display: flex;
    align-items: center;
    justify-content: center;
    width: 2.75rem;
    height: 2.75rem;
    border-radius: 50%;
    background: conic-gradient(var(--color-primary) calc(var(--mini-dial-pct) * 1%), var(--color-border) 0);
}

.mini-dial-inner {
    display: flex;
    align-items: center;
    justify-content: center;
    width: 2.1rem;
    height: 2.1rem;
    border-radius: 50%;
    background-color: var(--color-bg-card);
    font-size: 0.8rem;
    font-weight: 600;
    color: var(--color-text-primary);
}

.goal-status {
    text-align: center;
    color: var(--color-text-secondary);
}

.goal-form {
    display: flex;
    flex-wrap: wrap;
    justify-content: center;
    gap: var(--space-1);
    width: 100%;
}

.goal-form input[type="number"] {
    flex: 1;
    min-width: 8rem;
}

/* GitHub-contribution-style heatmap: one column per week, 7 rows, rendered
   at a 0-4 intensity level scaled against the user's own busiest day
   (computed in Statistics.razor's BuildCalendarWeeks, not here).

   "Sternenkarte" (Statistics Polish Phase C): star-point reskin of the
   reading calendar. Each .calendar-cell keeps the same fixed footprint at
   every activity level -- the grid must stay pixel-aligned -- so "size"
   differences between levels come from an inner ::before dot sized as a
   percentage of that fixed box, never from resizing the cell itself.
   Every cell (including the legend swatches, which reuse these same
   classes outside .calendar-heatmap) always paints its own small dark
   circular base, so the star-point stays legible whether it's sitting on
   the heatmap's night-sky background or directly on the panel's normal
   background in the legend row.

   The night-sky background on .calendar-heatmap intentionally paints over
   the Phase 1 brass-texture panel background (.stats-panel.calendar-card
   ::before) within the heatmap's own bounds -- this is a brass-framed
   window onto a starfield, not the texture being disabled. The texture
   remains visible in the surrounding header/padding/legend area.

   Compact density (Statistics Visuals Round 2, Task 3): the cell footprint
   was tightened from 0.7rem/3px gap (14.2px pitch) to 0.58rem/1px gap
   (10.28px pitch) so all 53 week columns fit inside the panel's column
   width without horizontal scrolling, at both ~1366px and ~1920px
   viewport widths -- see task-3-report.md for the live-measured
   clientWidth/scrollWidth numbers behind this value. The level
   percentages and glow box-shadows below were kept as-is; live-browser
   verification across three themes at normal viewing size confirmed
   they still read as a distinguishable 0-4 progression. */
.calendar-heatmap {
    display: flex;
    gap: 1px;
    overflow-x: auto;
    padding: var(--space-2);
    max-width: 100%;
    background: radial-gradient(
        ellipse at center,
        color-mix(in srgb, var(--color-bg-dark) 85%, black 15%) 0%,
        var(--color-bg-dark) 100%
    );
    border-radius: var(--radius);
    box-shadow: inset 0 0 10px rgba(0, 0, 0, 0.45);
}

.calendar-week {
    display: flex;
    flex-direction: column;
    gap: 1px;
}

.calendar-cell {
    position: relative;
    width: 0.58rem;
    height: 0.58rem;
    border-radius: 50%;
    background-color: color-mix(in srgb, var(--color-bg-dark) 65%, transparent);
}

.calendar-cell::before {
    content: "";
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    border-radius: 50%;
    width: 15%;
    height: 15%;
    opacity: 0.3;
    background-color: color-mix(in srgb, var(--color-primary) 80%, white);
}

.calendar-cell--level-1::before {
    width: 30%;
    height: 30%;
    opacity: 0.55;
}

/* Glow ("earned illumination") is deliberately reserved for levels 2-4, where it
   intensifies with activity; levels 0-1 stay flat so the effect reads as a reward
   for real reading activity, not decoration applied uniformly regardless of the data. */
.calendar-cell--level-2::before {
    width: 45%;
    height: 45%;
    opacity: 0.8;
    box-shadow: 0 0 3px color-mix(in srgb, var(--color-primary) 60%, transparent);
}

.calendar-cell--level-3::before {
    width: 62%;
    height: 62%;
    opacity: 1;
    background-color: color-mix(in srgb, var(--color-primary) 84%, white);
    box-shadow: 0 0 5px color-mix(in srgb, var(--color-primary) 75%, transparent);
}

.calendar-cell--level-4::before {
    width: 80%;
    height: 80%;
    opacity: 1;
    background-color: var(--color-primary);
    box-shadow:
        0 0 6px color-mix(in srgb, var(--color-primary) 90%, transparent),
        0 0 14px color-mix(in srgb, var(--color-primary) 55%, transparent);
}

.calendar-legend {
    display: flex;
    align-items: center;
    gap: 4px;
}

.calendar-legend-label {
    font-size: 0.75rem;
    color: var(--color-text-muted);
}

.yearly-overview-list {
    display: flex;
    flex-direction: column;
    gap: var(--space-2);
    margin: var(--space-2) 0 var(--space-3);
}

.yearly-overview-row {
    display: grid;
    grid-template-columns: 4rem 1fr;
    align-items: center;
    gap: var(--space-2);
}

.yearly-overview-year {
    font-family: var(--font-family-display);
    font-weight: 600;
    color: var(--color-text-primary);
}

.yearly-overview-detail {
    grid-column: 1 / -1;
    font-size: 0.85rem;
    color: var(--color-text-muted);
}

.shelf-book-entry {
    display: flex;
    flex-direction: column;
    gap: var(--space-1);
    width: 10rem;
}

/* ShelfBook: books stand spine-out at rest, rotate ~90 deg on hover/
   focus to reveal the cover. Load-bearing rule: every element between
   the perspective-establishing ancestor (.shelf-book-slot, see below) and
   this element needs transform-style: preserve-3d, or the rotation
   flattens into a 2D distortion.
   Rotation lives on the nested .shelf-book-rotator child (see its own
   rule below), not on .shelf-book itself -- this element only ever
   carries translate/scale. This split does NOT change which direction
   translateZ points (a translation written to the left of a rotation in
   a CSS transform list is applied in the parent/world frame either way,
   regardless of which element carries the rotation), so it does not by
   itself fix any translateZ-direction issue. Its actual purpose is
   isolating rotation as its own animatable channel so shelf-physics.js
   can drive it independently of translate/scale (see that file's
   applyTransform). The actual sideways-shift bug this rework set out to
   fix was a SEPARATE issue, since fixed: .shelf-books' perspective was
   shared across the whole row, so its default perspective-origin (50% of
   the WHOLE ROW's width) meant translateZ on a revealed book radially
   magnified its position away from the row's center rather than purely
   toward the viewer -- see .shelf-book-slot's own `perspective` rule
   (below, in this file) for the actual fix (perspective moved per-book). */
.shelf-book {
    display: block;
    width: 3.25rem;
    height: 9.5rem;
    position: relative;
    text-decoration: none;
    cursor: pointer;
    transform-style: preserve-3d;
    transform-origin: bottom center;
    transition: transform var(--motion-reveal) var(--ease-standard);
}

/* Carries ONLY the rotation (rest angle, or the reveal rotation on hover/
   focus/touch) -- .shelf-book itself carries only translate/scale (see
   its own rule above and the hover-fallback rule below). This split
   isolates rotation as its own animatable channel so shelf-physics.js
   can drive it independently every spring frame (see that file's
   applyTransform/getRotator). It does NOT change which direction
   translateZ points on .shelf-book -- see the corrected comment on
   .shelf-book above for why the originally-assumed transform-order bug
   was never real, and where the actual sideways-shift bug lived instead
   (perspective-origin, not transform order -- since fixed, see
   .shelf-book-slot's `perspective` rule below). Needs its own
   transform-style: preserve-3d (same "every ancestor in the chain" rule
   noted on .shelf-book above) and its own copy of the bottom-center
   transform-origin, since it's now the element that actually rotates --
   .shelf-book keeps its own copy too (transform-origin is not an
   inherited CSS property), since .shelf-book still receives `scale(...)`
   on reveal (see the hover-fallback rule below) and must keep anchoring
   that scale at the book's base, not its center, to look identical to
   today. position: absolute; inset: 0 makes it fill .shelf-book's box
   exactly as .shelf-book-spine/.shelf-book-cover did when they were
   direct children of .shelf-book (their own `position: absolute; inset:
   0` rule, unchanged, now resolves against this element instead). */
.shelf-book-rotator {
    display: block;
    position: absolute;
    inset: 0;
    transform-style: preserve-3d;
    transform-origin: bottom center;
    transform: rotateY(var(--shelf-book-rest));
    transition: transform var(--motion-reveal) var(--ease-standard);
}

.shelf-book-spine,
.shelf-book-cover,
.shelf-book-back,
.shelf-book-pages,
.shelf-book-top,
.shelf-book-bottom {
    position: absolute;
    border-radius: 0.22rem;
    backface-visibility: hidden;
    box-shadow: 0 2px 5px rgba(0, 0, 0, 0.35);
    overflow: hidden;
    transition: box-shadow var(--motion-spring-sync) var(--ease-standard);
}

/* Explicit per-face sizing (controller fix, 2026-09-18): the shared rule
   above deliberately no longer sets `inset: 0` -- that pinned every face
   to .shelf-book's own 3.25rem-wide box regardless of which dimension the
   face is actually meant to span, so .shelf-book-cover/-back never
   reached their intended W=6.3rem width and .shelf-book-top/-bottom never
   got their W x T (6.3rem x 3.25rem) footprint. Each pair below sizes to
   the same T=3.25rem/W=6.3rem/H=9.5rem box the translateZ formula (see
   the comment on .shelf-book-cover below) assumes. */
.shelf-book-cover,
.shelf-book-back {
    left: 0;
    top: 0;
    width: 6.3rem;
    height: 9.5rem;
}

.shelf-book-spine,
.shelf-book-pages {
    left: 1.525rem;
    top: 0;
    width: 3.25rem;
    height: 9.5rem;
}

.shelf-book-top,
.shelf-book-bottom {
    left: 0;
    width: 6.3rem;
    height: 3.25rem;
}

.shelf-book-top { top: 0; }
.shelf-book-bottom { bottom: 0; }

.shelf-book:hover .shelf-book-spine,
.shelf-book:hover .shelf-book-cover,
.shelf-book:hover .shelf-book-back,
.shelf-book:hover .shelf-book-pages,
.shelf-book:hover .shelf-book-top,
.shelf-book:hover .shelf-book-bottom,
.shelf-book:focus-visible .shelf-book-spine,
.shelf-book:focus-visible .shelf-book-cover,
.shelf-book:focus-visible .shelf-book-back,
.shelf-book:focus-visible .shelf-book-pages,
.shelf-book:focus-visible .shelf-book-top,
.shelf-book:focus-visible .shelf-book-bottom,
.shelf-book:has(:focus-visible) .shelf-book-spine,
.shelf-book:has(:focus-visible) .shelf-book-cover,
.shelf-book:has(:focus-visible) .shelf-book-back,
.shelf-book:has(:focus-visible) .shelf-book-pages,
.shelf-book:has(:focus-visible) .shelf-book-top,
.shelf-book:has(:focus-visible) .shelf-book-bottom,
.shelf-book.is-revealed .shelf-book-spine,
.shelf-book.is-revealed .shelf-book-cover,
.shelf-book.is-revealed .shelf-book-back,
.shelf-book.is-revealed .shelf-book-pages,
.shelf-book.is-revealed .shelf-book-top,
.shelf-book.is-revealed .shelf-book-bottom {
    box-shadow: 2px 14px 28px -4px rgba(0, 0, 0, 0.5);
}

/* Subtle leather grain overlay (Library Rework Phase 3), same established
   technique as the wood texture below (.shelf-compartment::before). Painted at z-index: -1, so it sits
   above each face's own gradient background (which now carries either a
   cover-derived tint or the Phase 1 procedural palette color -- see the
   Canvas color extraction task) but below the face's own children. On
   .shelf-book-cover specifically, this means the leather grain is only
   visible when there's no real cover image on top (the placeholder/no-cover
   case) -- once a real photographic cover image paints (an absolutely-
   positioned child, painting above z-index: -1), it naturally covers the
   leather texture, which is correct: a real cover photo doesn't need a
   fake leather-grain overlay pretending to be its material. On
   .shelf-book-spine, which never has an image, the leather texture is
   always visible. If book-leather-texture.webp is ever missing, this
   simply fails to paint -- each face's existing gradient still renders
   correctly with no layout break. */
.shelf-book-spine::before,
.shelf-book-cover::before,
.shelf-book-back::before {
    content: "";
    position: absolute;
    inset: 0;
    z-index: -1;
    background-image: url("../images/library/book-leather-texture.webp");
    background-size: cover;
    opacity: 0.18;
    pointer-events: none;
}

.shelf-book-spine {
    transform: rotateY(-90deg) translateZ(3.15rem);
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    padding: 0.55rem 0;
    writing-mode: vertical-rl;
    /* Establishes its own stacking context so the ::before leather-texture
       overlay's z-index: -1 (above) scopes to this element and paints
       behind its own children, instead of escaping to the nearest
       positioned ancestor and painting in the wrong place. */
    isolation: isolate;
    /* Hinge-groove shading: the spine borders a leather cover face on
       both its own left and right in this local coordinate frame, so
       both edges get a similar, symmetric inset shadow (unlike cover/
       back's asymmetric left-strong/right-weak below). Third layer
       repeats the shared rule's rest-state outer shadow: this rule and
       the shared 6-face rule above both match via a single class
       selector here (equal (0,1,0) specificity), so this rule's
       box-shadow wins only because it comes later in the file --
       and since a more-specific-or-tied box-shadow fully replaces
       (does not layer with) the shared rule's, the outer shadow must
       be repeated here or it would vanish at rest. */
    box-shadow:
        inset 0.2rem 0 0.3rem -0.12rem rgba(0, 0, 0, 0.5),
        inset -0.2rem 0 0.3rem -0.12rem rgba(0, 0, 0, 0.5),
        0 2px 5px rgba(0, 0, 0, 0.35);
}

.shelf-book-spine-title {
    position: relative;
    z-index: 2;
    font-size: 0.6rem;
    font-weight: 600;
    letter-spacing: 0.05em;
    color: #f0e0b8;
    text-shadow: 0 1px 3px rgba(0, 0, 0, 0.8), 0 0 6px rgba(0, 0, 0, 0.4);
    max-height: calc(100% - 1.1rem);
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

/* Kapitalband: the small woven two-tone band at the top and bottom of a
   hardcover spine, where the page block meets the binding. Fixed red/
   gold regardless of theme -- this is the book's own physical
   decoration, not app UI chrome, so it doesn't participate in the
   palette/theme system the way the leather color does. */
.shelf-book-headband {
    position: absolute;
    left: 0;
    right: 0;
    height: 0.26rem;
    background:
        repeating-linear-gradient(60deg, transparent 0, transparent 0.06rem, rgba(0, 0, 0, 0.35) 0.06rem, rgba(0, 0, 0, 0.35) 0.09rem, transparent 0.09rem, transparent 0.18rem),
        repeating-linear-gradient(-60deg, transparent 0, transparent 0.06rem, rgba(255, 255, 255, 0.18) 0.06rem, rgba(255, 255, 255, 0.18) 0.09rem, transparent 0.09rem, transparent 0.18rem),
        linear-gradient(90deg, #7a1f1f, #8f2a28 50%, #7a1f1f);
    box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.5), inset 0 -1px 1px rgba(255, 255, 255, 0.1);
    z-index: 2;
}
.shelf-book-headband-top { top: 0; border-radius: 0.22rem 0.22rem 0 0; }
.shelf-book-headband-bottom { bottom: 0; border-radius: 0 0 0.22rem 0.22rem; }

/* Gold double-rule frame, inset from the board edge -- classic
   fine-binding decoration. Two concentric borders with a hairline gap,
   drawn via one element's border + outline so it needs no extra DOM
   nodes beyond itself. */
.shelf-book-gilt-frame {
    position: absolute;
    inset: 0.32rem;
    border: 1px solid color-mix(in srgb, var(--color-gold-accent) 55%, transparent);
    outline: 1px solid color-mix(in srgb, var(--color-gold-accent) 30%, transparent);
    outline-offset: 2px;
    pointer-events: none;
    /* z-index 2, not 1: .shelf-book-cover img is also z-index 1 and comes
       later in the DOM, so an equal z-index let the img win ties and paint
       over this frame on any book with a real cover. */
    z-index: 2;
}

/* True box geometry: each face's translateZ is half of the dimension it
   does NOT span -- the standard CSS-cube formula. T (thickness) =
   3.25rem = .shelf-book's own width (unchanged, the spine's on-screen
   size at rest); W (cover width) = 6.3rem (unchanged, the app's
   established 2:3-ish cover shape); H = 9.5rem (unchanged). Verified
   live in a browser (color-coded debug faces swept -180deg..180deg,
   then the real textured faces) before being written here -- a prior
   attempt at a 3rd face (PR #447) hit apparent z-fighting because the
   old geometry hinged the cover off the spine's edge instead of
   sharing a true box center, so faces slightly interpenetrated at the
   seam; this formula shares edges exactly, with zero overlap. */
.shelf-book-cover {
    transform: translateZ(1.625rem);
    display: flex;
    flex-direction: column;
    justify-content: flex-end;
    padding: 0.5rem;
    gap: 0.15rem;
    color: var(--color-text-on-dark);
    /* Same reasoning as the pre-existing pointer-events guard this
       replaces: modern browsers generally hit-test a 3D-transformed
       element at its rendered (projected) position, but this project
       found live that an edge-on preserve-3d face can still register
       clicks against its untransformed layout box -- keep the same
       guard rather than re-litigating that quirk. */
    pointer-events: none;
    /* Hinge-groove shading: left edge borders the spine (the book's real
       hinge) so it gets the stronger inset shadow; right edge borders
       the fore-edge/pages face so it gets a weaker one. Third layer
       repeats the shared rule's rest-state outer shadow: this rule and
       the shared 6-face rule above both match via a single class
       selector here (equal (0,1,0) specificity), so this rule's
       box-shadow wins only because it comes later in the file --
       and since a more-specific-or-tied box-shadow fully replaces
       (does not layer with) the shared rule's, the outer shadow must
       be repeated here or it would vanish at rest. */
    box-shadow:
        inset 0.28rem 0 0.35rem -0.15rem rgba(0, 0, 0, 0.5),
        inset -0.12rem 0 0.2rem -0.08rem rgba(0, 0, 0, 0.3),
        0 2px 5px rgba(0, 0, 0, 0.35);
}

.shelf-book:hover .shelf-book-cover,
.shelf-book:focus-visible .shelf-book-cover,
.shelf-book:has(:focus-visible) .shelf-book-cover,
.shelf-book.is-revealed .shelf-book-cover {
    pointer-events: auto;
}

.shelf-book-back {
    transform: rotateY(180deg) translateZ(1.625rem);
    /* Same spine-hinge-on-left, fore-edge-on-right relationship as
       .shelf-book-cover above -- identical hinge-groove shading. */
    box-shadow:
        inset 0.28rem 0 0.35rem -0.15rem rgba(0, 0, 0, 0.5),
        inset -0.12rem 0 0.2rem -0.08rem rgba(0, 0, 0, 0.3),
        0 2px 5px rgba(0, 0, 0, 0.35);
}

/* Hover-state overrides for the three leather faces' hinge-groove
   box-shadow above. The per-element rest-state rules above (a single
   class selector, (0,1,0) specificity) are NOT what wins on hover --
   Step 1's shared hover/focus/revealed rule (`.shelf-book:hover
   .shelf-book-cover, ...`, three chained class-level selectors =
   (0,3,0)) beats them outright on specificity alone, no tie, no source
   order involved. So without this block, hovering would fall straight
   through to that shared hover rule's box-shadow -- which is just the
   plain outer glow, no inset layers -- deleting the hinge-groove seam
   shadow entirely on hover rather than merely freezing it at its
   rest-state size. This block's selectors have the exact same form as
   Step 1's shared hover rule, so they're ALSO (0,3,0) -- a genuine
   specificity tie with that rule. That tie is what source order
   resolves: this block keeps the inset seam layers and swaps only the
   third (outer-shadow) layer to the hover value, and must stay AFTER
   Step 1's shared hover rule in the file to win that tie. */
.shelf-book:hover .shelf-book-cover,
.shelf-book:focus-visible .shelf-book-cover,
.shelf-book:has(:focus-visible) .shelf-book-cover,
.shelf-book.is-revealed .shelf-book-cover,
.shelf-book:hover .shelf-book-back,
.shelf-book:focus-visible .shelf-book-back,
.shelf-book:has(:focus-visible) .shelf-book-back,
.shelf-book.is-revealed .shelf-book-back {
    box-shadow:
        inset 0.28rem 0 0.35rem -0.15rem rgba(0, 0, 0, 0.5),
        inset -0.12rem 0 0.2rem -0.08rem rgba(0, 0, 0, 0.3),
        2px 14px 28px -4px rgba(0, 0, 0, 0.5);
}

.shelf-book:hover .shelf-book-spine,
.shelf-book:focus-visible .shelf-book-spine,
.shelf-book:has(:focus-visible) .shelf-book-spine,
.shelf-book.is-revealed .shelf-book-spine {
    box-shadow:
        inset 0.2rem 0 0.3rem -0.12rem rgba(0, 0, 0, 0.5),
        inset -0.2rem 0 0.3rem -0.12rem rgba(0, 0, 0, 0.5),
        2px 14px 28px -4px rgba(0, 0, 0, 0.5);
}

.shelf-book-pages,
.shelf-book-top,
.shelf-book-bottom {
    background:
        radial-gradient(ellipse 70% 55% at 50% 50%, rgba(255, 255, 255, 0.3), transparent 70%),
        repeating-linear-gradient(to bottom, #f2e9d6 0, #f2e9d6 1.5px, #e3d5b3 1.5px, #e3d5b3 2.3px, #f0e6d0 2.3px, #f0e6d0 3.6px, #ddcda3 3.6px, #ddcda3 4px),
        linear-gradient(160deg, #eadfc4, #d8c69a);
    box-shadow:
        inset 0 0.6rem 0.6rem -0.4rem rgba(0, 0, 0, 0.35),
        inset 0 -0.6rem 0.6rem -0.4rem rgba(0, 0, 0, 0.35),
        0 2px 5px rgba(0, 0, 0, 0.35);
    pointer-events: none;
}

.shelf-book-pages {
    transform: rotateY(90deg) translateZ(3.15rem);
}

.shelf-book-top {
    transform-origin: top center;
    transform: translateZ(-1.625rem) rotateX(90deg);
}

.shelf-book-bottom {
    transform-origin: bottom center;
    transform: translateZ(-1.625rem) rotateX(-90deg);
}

.shelf-book-cover img {
    position: absolute;
    inset: 0;
    z-index: 1;
    width: 100%;
    height: 100%;
    object-fit: cover;
}

.shelf-book-cover-placeholder {
    position: absolute;
    inset: 0;
}

/* Hidden by default (Einstellungen > "Titel/Autor auf Regal-Cover
   anzeigen", off by default) -- most real cover images already carry their
   own printed title/author, so this overlay is opt-in clutter rather than
   a default. Gated purely via a data-attribute on <html> (set by
   shelfCoverText.js at app boot, same technique as theme.js) so no Razor
   parameter or JS interop is needed per rendered book. */
.shelf-book-cover-title,
.shelf-book-cover-author {
    display: none;
    position: relative;
    z-index: 2;
    font-size: 0.65rem;
    line-height: 1.2;
    text-shadow: 0 1px 3px rgba(0, 0, 0, 0.6);
}
html[data-shelf-cover-text="show"] .shelf-book-cover-title,
html[data-shelf-cover-text="show"] .shelf-book-cover-author {
    display: inline;
}
.shelf-book-cover-title { font-weight: 600; color: var(--color-accent-text); }
.shelf-book-cover-author { font-size: 0.6rem; opacity: 0.85; }

.shelf-book-favorite {
    position: relative;
    z-index: 2;
    align-self: flex-end;
    background: none;
    border: none;
    color: var(--color-text-on-dark);
    font-size: 0.85rem;
    cursor: pointer;
    padding: 0.15rem;
    text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6);
}
.shelf-book-favorite.is-favorite { color: var(--color-primary); }

.shelf-book-borrowed-badge {
    position: relative;
    z-index: 2;
    font-size: 0.55rem;
    background: rgba(0, 0, 0, 0.5);
    padding: 0.1rem 0.3rem;
    border-radius: 0.15rem;
    align-self: flex-start;
}

.shelf-book-progress {
    position: relative;
    z-index: 2;
    height: 0.2rem;
    background: rgba(255, 255, 255, 0.25);
    border-radius: 0.1rem;
    overflow: hidden;
}
.shelf-book-progress-bar {
    height: 100%;
    background: var(--color-primary);
}

/* Procedural placeholder palette -- the fallback for books with no cover
   image, or where cover-color extraction failed (Library Rework Phase 3,
   see .has-cover-tint below). 5 procedurally-varied swatches derived from
   --color-primary/--color-secondary/--color-border/--color-bg-dark, each
   as a gradient plus a matching cover-face tint. Every color-mix() below
   intentionally specifies only the first color's percentage so the second
   defaults to the exact remainder (percentages summing to less than 100%
   makes the browser apply an unwanted alpha multiplier to the result). */
.shelf-book-palette-0 .shelf-book-spine,
.shelf-book-palette-0 .shelf-book-cover,
.shelf-book-palette-0 .shelf-book-back { background: linear-gradient(160deg, color-mix(in srgb, var(--color-secondary) 85%, white), color-mix(in srgb, var(--color-secondary) 70%, black)); }
.shelf-book-palette-1 .shelf-book-spine,
.shelf-book-palette-1 .shelf-book-cover,
.shelf-book-palette-1 .shelf-book-back { background: linear-gradient(160deg, color-mix(in srgb, var(--color-border) 60%, var(--color-secondary)), color-mix(in srgb, var(--color-border) 50%, black)); }
.shelf-book-palette-2 .shelf-book-spine,
.shelf-book-palette-2 .shelf-book-cover,
.shelf-book-palette-2 .shelf-book-back { background: linear-gradient(160deg, color-mix(in srgb, var(--color-primary) 85%, white), color-mix(in srgb, var(--color-primary) 70%, black)); }
.shelf-book-palette-3 .shelf-book-spine,
.shelf-book-palette-3 .shelf-book-cover,
.shelf-book-palette-3 .shelf-book-back { background: linear-gradient(160deg, color-mix(in srgb, var(--color-bg-dark) 40%, var(--color-primary)), color-mix(in srgb, var(--color-bg-dark) 70%, black)); }
.shelf-book-palette-4 .shelf-book-spine,
.shelf-book-palette-4 .shelf-book-cover,
.shelf-book-palette-4 .shelf-book-back { background: linear-gradient(160deg, color-mix(in srgb, var(--color-secondary) 78%, var(--color-primary)), color-mix(in srgb, var(--color-secondary) 65%, black)); }

/* Cover-derived spine tint (Library Rework Phase 3): when ShelfBook.razor.cs
   successfully extracted a color from the book's actual cover image, it
   sets --shelf-book-tint and adds this class directly on each face span
   (.shelf-book-spine/.shelf-book-cover/.shelf-book-back), NOT on the .shelf-book anchor --
   shelf-physics.js (Phase 2) writes the anchor's style.transform every rAF
   frame during the spring animation and its classList (is-revealed) on the
   touch-reveal path; putting this tint's class/style on the anchor too
   would mean Blazor's async re-render (once cover load + extraction
   complete) replaces the whole class/style attribute via setAttribute,
   wiping out shelf-physics.js's JS-owned state. The face spans are never
   touched by shelf-physics.js, so it's safe there. This selector
   (.shelf-book .shelf-book-spine.has-cover-tint, specificity 0,3,0) still
   needs to unconditionally beat .shelf-book-palette-N .shelf-book-spine
   (0,2,0) regardless of source order, since a book always carries both a
   palette-N class on its ancestor and (when a tint applies) this class on
   the face span itself. Uses the same 85%/70% white/black mix pattern as
   the simpler palette rules above, applied uniformly to the extracted
   color rather than trying to retrofit --shelf-book-tint into each
   palette's own distinct two-token mix recipe. */
.shelf-book .shelf-book-spine.has-cover-tint,
.shelf-book .shelf-book-cover.has-cover-tint,
.shelf-book .shelf-book-back.has-cover-tint {
    background: linear-gradient(160deg,
        color-mix(in srgb, var(--shelf-book-tint) 85%, white),
        color-mix(in srgb, var(--shelf-book-tint) 70%, black));
}

.shelf-book:hover,
.shelf-book:focus-visible,
.shelf-book:has(:focus-visible) {
    transform: translateY(-1.4rem) translateZ(3.6rem) scale(1.05);
}

.shelf-book:hover .shelf-book-rotator,
.shelf-book:focus-visible .shelf-book-rotator,
.shelf-book:has(:focus-visible) .shelf-book-rotator {
    transform: rotateY(4deg);
}

/* Added by shelf-physics.js (Phase 2) once it takes over the reveal
   animation -- disables the Phase 1 CSS transition so it doesn't try to
   further ease between this module's own already-eased per-frame writes
   to .shelf-book's inline transform. Only applied when the module
   actually initializes (skipped entirely under prefers-reduced-motion,
   in which case Phase 1's plain CSS transition -- itself already
   reduced-motion-aware -- remains the only mechanism). */
.shelf-physics-active .shelf-book,
.shelf-physics-active .shelf-book-rotator,
.shelf-physics-active .shelf-book-slot {
    transition: none;
}

@media (prefers-reduced-motion: reduce) {
    .shelf-book,
    .shelf-book-rotator,
    /* The lift-shadow (box-shadow) transition on all 6 box faces
       (.shelf-book-spine/-cover/-back/-pages/-top/-bottom, see their
       shared rule above) isn't driven by shelf-physics.js at all, so
       it isn't covered by that module's own reduced-motion no-op -- it
       needs its own carve-out here, same as .shelf-book/.shelf-book-
       rotator, so a reduced-motion reveal snaps its shadow instantly
       instead of still visibly easing while the rotation/scale next to
       it correctly snaps. */
    .shelf-book-spine,
    .shelf-book-cover,
    .shelf-book-back,
    .shelf-book-pages,
    .shelf-book-top,
    .shelf-book-bottom {
        transition: none;
    }
}

.shelf-row-group {
    margin-top: 0;
}

.shelf-plaque {
    position: relative;
    /* Establishes its own stacking context so the ::before brass-texture
       overlay's z-index: -1 (below) scopes to this element, matching the
       same technique already used by .goal-ring (Statistics) and
       .shelf-compartment (Library Rework Phase 3) elsewhere in this file. */
    isolation: isolate;
    display: inline-block;
    background: linear-gradient(160deg, color-mix(in srgb, var(--color-primary) 88%, white), color-mix(in srgb, var(--color-primary) 70%, black));
    color: var(--color-bg-dark);
    font-family: var(--font-family-display);
    font-size: 0.7rem;
    font-weight: 600;
    padding: 0.3rem 0.9rem;
    border-radius: 0.1rem;
    border: 1px solid color-mix(in srgb, var(--color-primary) 40%, black);
    box-shadow: 0 2px 4px rgba(0, 0, 0, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.35), inset 0 -1px 0 rgba(0, 0, 0, 0.25);
    margin-top: var(--space-2);
    margin-bottom: var(--space-2);
    letter-spacing: 0.05em;
}

/* Reuses the exact established brass-texture.webp compositing technique
   from .goal-ring::before (Statistics) -- cross-feature reuse of an
   existing generic texture asset, not Statistics-specific content. If
   the asset is ever missing, this simply fails to paint and the plain
   gradient background above still renders correctly with no layout
   break, same fallback behavior as every other texture overlay on this
   page. */
.shelf-plaque::before {
    content: "";
    position: absolute;
    inset: 0;
    z-index: -1;
    background-image: url("../images/statistics/brass-texture.webp");
    background-size: cover;
    opacity: 0.2;
    pointer-events: none;
}

/* Invisible trigger for the Regal view's scroll-driven lazy rendering
   (Library Rework -- unpaginated shelf). Needs a non-zero size to be a
   reliable IntersectionObserver target across browsers; kept visually
   inert otherwise. Lives as a sibling AFTER .library-shelf, never inside
   .shelf-cabinet -- see that class's own :last-child rule below. */
.shelf-load-more-sentinel {
  height: 1px;
}

/* Wraps all of a Grid-view page's ShelfRows in one continuous cabinet
   silhouette (Library Shelf Cabinet phase) -- individual .shelf-compartment
   rows (below) lose their own external rounding/shadow and become flat
   internal "shelf levels" inside this one frame instead of independent
   floating boxes. isolation: isolate scopes the ::before/::after posts'
   z-index: 1 so they paint above this element's own background but stay
   correctly ordered relative to this element's own stacking context, not
   some ancestor's. */
.shelf-cabinet {
    position: relative;
    isolation: isolate;
    border-radius: var(--radius-lg, var(--radius));
    padding: 0.6rem 1.6rem;
    background: linear-gradient(180deg, color-mix(in srgb, var(--color-bg-dark) 88%, black), color-mix(in srgb, var(--color-bg-dark) 96%, black));
    /* The two inset shadows (no blur, 6px offset) read as solid trim
       bands across the full width -- a plain-CSS stand-in for a crown
       moulding at the top and a base moulding at the bottom, without any
       new image asset. */
    box-shadow:
        0 10px 24px rgba(0, 0, 0, 0.45),
        inset 0 6px 0 color-mix(in srgb, var(--color-primary) 30%, var(--color-bg-dark)),
        inset 0 -6px 0 color-mix(in srgb, var(--color-primary) 30%, var(--color-bg-dark));
}

/* Left/right posts. Two background layers on the same pseudo-element
   (a gradient bevel + the existing wood-grain texture asset, blended
   together via background-blend-mode) rather than a third element --
   :before/:after are the only two pseudo-elements available per real
   element, and this cabinet needs exactly two posts. z-index: 1 (not the
   more common -1) since these must paint ABOVE .shelf-cabinet's own
   background/box-shadow trim, not behind it -- they're a foreground
   architectural feature, unlike the low-opacity texture overlays used
   elsewhere on this page (.shelf-compartment::before,
   .shelf-book-spine::before) which sit behind their host's content.
   Clearance from the post's inner edge to the first book: cabinet
   padding (1.6rem) minus post width (1.4rem) plus compartment padding
   (var(--space-3), 1.5rem currently) = 1.7rem / ~27px today. The posts
   unconditionally occlude book content at this z-index (no descendant
   z-index, including .shelf-book-slot's own z-index: 5 on reveal, can
   escape past them) -- this clearance must stay positive, so a future
   change to .shelf-cabinet's padding or these posts' width needs a
   sanity check against a book's own reveal/neighbor-parting excursion
   (currently a few px at most) before shrinking it. */
.shelf-cabinet::before,
.shelf-cabinet::after {
    content: "";
    position: absolute;
    top: 0;
    bottom: 0;
    width: 1.4rem;
    background-image:
        linear-gradient(90deg,
            color-mix(in srgb, var(--color-bg-dark) 55%, black) 0%,
            color-mix(in srgb, var(--color-primary) 25%, var(--color-bg-dark)) 45%,
            color-mix(in srgb, var(--color-bg-dark) 55%, black) 100%),
        url("../images/library/shelf-wood-texture.webp");
    background-size: cover, cover;
    background-blend-mode: overlay;
    box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.6);
    z-index: 1;
    pointer-events: none;
}
.shelf-cabinet::before {
    left: 0;
    border-radius: var(--radius-lg, var(--radius)) 0 0 var(--radius-lg, var(--radius));
}
.shelf-cabinet::after {
    right: 0;
    border-radius: 0 var(--radius-lg, var(--radius)) var(--radius-lg, var(--radius)) 0;
}

.shelf-compartment {
    background: linear-gradient(180deg, color-mix(in srgb, var(--color-bg-dark) 90%, black), var(--color-bg-dark));
    box-shadow: inset 0 10px 18px rgba(0, 0, 0, 0.5), inset 0 -2px 0 rgba(0, 0, 0, 0.3);
    padding: var(--space-3) var(--space-3) 0;
    position: relative;
    /* Establishes its own stacking context so the ::before wood-texture
       overlay's z-index: -1 (below) scopes to this element and paints
       behind its own children, instead of escaping to the nearest
       positioned ancestor and painting in the wrong place. */
    isolation: isolate;
}

/* Aged wood grain overlay (Library Rework Phase 3), following the exact
   established brass-texture.webp technique (.goal-ring::before, app.css) --
   a low-opacity ::before painted at z-index: -1, above the compartment's
   own gradient but below its children. Uses its OWN matching border-radius
   rather than the parent's overflow: hidden (unlike the calendar-card
   precedent), since a revealed .shelf-book (Phase 2's spring reveal) can
   lift slightly above the compartment's resting bounds and must never be
   clipped. If shelf-wood-texture.webp is ever missing, this simply fails
   to paint -- the compartment's existing gradient background still renders
   correctly underneath with no layout break. */
.shelf-compartment::before {
    content: "";
    position: absolute;
    inset: 0;
    z-index: -1;
    background-image: url("../images/library/shelf-wood-texture.webp");
    background-size: cover;
    opacity: 0.15;
    pointer-events: none;
}

.shelf-books {
    display: flex;
    align-items: flex-end;
    gap: var(--space-2);
    flex-wrap: wrap;
    padding-bottom: var(--space-2);
}

/* perspective lives here, per book, NOT on .shelf-books (the whole row) --
   see the corrected comment on .shelf-book (above, near the top of this
   file's ShelfBook rules) for the full story. In short: perspective's
   vanishing point (perspective-origin, default 50% 50%) is the center of
   whichever element carries the perspective property. Sharing one
   perspective across the whole row means every book's translateZ push
   toward the viewer gets radially projected away from the ROW's center,
   not its own -- a book far from the row's center visibly shifts
   sideways on reveal, proportional to that distance. Giving each slot
   its own perspective makes each book's own center its vanishing point,
   so translateZ always means "toward the viewer" with zero position-
   dependent skew, regardless of where the book sits in the row. */
/* zoom (not transform: scale()) so the user-chosen book size also affects
   layout -- .shelf-books is a flex row and .shelf-compartment is content-
   height, so a bigger zoom naturally means fewer books per row and a
   taller compartment, with no extra CSS needed for either. transform:
   scale() would only repaint bigger without changing the box's
   contribution to that layout. Verified live (2026-09-20) that zoom
   scales every rem-based measurement in this box's translateZ/perspective
   chain by the exact same factor as width/height, so the closed 6-face
   geometry (see .shelf-book-cover etc. below) stays intact at every
   level -- set on the slot specifically (not .shelf-cabinet), so only the
   books scale, not the cabinet trim/posts/lip/plaques around them. */
.shelf-book-slot {
    transform-style: preserve-3d;
    perspective: 60rem;
    zoom: var(--shelf-book-zoom, 1);
}

/* .shelf-book-slot establishes its own stacking context via
   transform-style: preserve-3d, so a z-index set directly on the
   .shelf-book inside it can never lift the slot above a sibling slot --
   the z-index has to live on the slot itself instead. Note: :has()
   nested inside :has() is deliberately avoided here -- support is
   inconsistent and an invalid selector in a comma-separated list drops
   the *entire* rule, silently reverting this fix. :has(:focus-visible)
   alone already covers focus landing on the favorite button, since it's
   a descendant of the slot just like the anchor is. */
.shelf-book-slot:has(.shelf-book:hover),
.shelf-book-slot:has(:focus-visible),
.shelf-book-slot:has(.shelf-book.is-revealed) {
    position: relative;
    z-index: 5;
}

.shelf-lip {
    height: 1rem;
    background: linear-gradient(180deg, color-mix(in srgb, var(--color-primary) 85%, white) 0%, var(--color-primary) 45%, color-mix(in srgb, var(--color-primary) 70%, black) 100%);
    box-shadow: 0 4px 8px rgba(0, 0, 0, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.3);
}

/* Only the last shelf level's lip sits at .shelf-cabinet's own external
   bottom edge and needs matching rounding -- every other level's lip is
   now an internal divider between two levels (see .shelf-row-group's
   margin-top: 0 above) and must stay flat/square, not rounded.
   Depends on .shelf-cabinet containing ONLY .shelf-row-group children --
   if a future change ever appends anything else inside .shelf-cabinet
   (e.g. decorative set-dressing, deferred per the design spec), this
   selector's `:last-child` would stop matching the last actual shelf
   level, silently un-rounding the cabinet's bottom edge with no test
   failure to catch it (see LibraryPageTests.cs's cabinet test, which
   asserts .shelf-cabinet's last element child specifically -- extend
   that assertion if this container's contents ever change). */
.shelf-row-group:last-child .shelf-lip {
    border-radius: 0 0 var(--radius-lg, var(--radius)) var(--radius-lg, var(--radius));
}

.book-detail {
    display: flex;
    align-items: flex-start;
    gap: var(--space-3);
    margin-top: var(--space-2);
}

.book-detail-cover {
    width: 12rem;
    flex-shrink: 0;
    aspect-ratio: 2 / 3;
    border-radius: var(--radius);
    overflow: hidden;
    background-color: var(--color-bg-card);
    border: 1px solid var(--color-border);
    box-shadow: var(--shadow-card);
}

.book-detail-cover img {
    width: 100%;
    height: 100%;
    object-fit: cover;
}

.book-detail-info {
    flex: 1;
    min-width: 0;
}

.book-detail-title-row {
    display: flex;
    align-items: baseline;
    gap: var(--space-2);
}

.book-detail-favorite {
    border: none;
    background: none;
    cursor: pointer;
    font-size: var(--font-size-h2);
    line-height: 1;
    color: var(--color-text-disabled);
    padding: 0;
}

.book-detail-favorite.is-favorite {
    /* --color-accent-text, not --color-gold-accent -- see the
       focus-visible rule's comment (issue #341 a11y audit). This glyph is
       large (font-size-h2), so it only needs 3:1, but the plain gold
       accent still falls short of that on light themes. */
    color: var(--color-accent-text);
}

.book-detail-borrowed-badge {
    color: var(--color-text-secondary);
    font-size: var(--font-size-small);
}

.book-detail-rating {
    display: flex;
    align-items: center;
    gap: var(--space-2);
    margin: var(--space-1) 0;
}

.book-detail-rate-stars {
    display: flex;
    gap: 2px;
}

.book-detail-shelves {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--space-2);
    margin-top: var(--space-2);
}

.book-detail-shelf-option {
    display: flex;
    align-items: center;
    gap: var(--space-1);
    font-size: var(--font-size-small);
}

.book-detail-meta {
    display: grid;
    grid-template-columns: auto 1fr;
    gap: var(--space-1) var(--space-2);
    margin: var(--space-2) 0;
}

.book-detail-meta dt {
    color: var(--color-text-secondary);
}

.reader-controls {
    display: flex;
    justify-content: center;
    margin: var(--space-2) auto;
}

/* Same toggle-button + absolute-positioned-popover pattern as
   MultiSelectDropdown (Library's Genre/Tag filters) -- reused here rather
   than inventing a second dropdown convention. */
.reader-settings-menu {
    position: relative;
    display: inline-block;
}

.reader-settings-popover {
    position: absolute;
    top: calc(100% + var(--space-1));
    left: 50%;
    transform: translateX(-50%);
    z-index: 20;
    display: flex;
    flex-direction: column;
    gap: var(--space-2);
    min-width: 20rem;
    padding: var(--space-3);
    border: 1px solid var(--color-border);
    border-radius: var(--radius-lg, var(--radius));
    background-color: var(--color-bg-card);
    box-shadow: var(--shadow-card-hover, var(--shadow-card));
}

.reader-setting-row {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--space-2);
}

.reader-setting-label {
    color: var(--color-text-secondary);
    font-size: var(--font-size-small);
    white-space: nowrap;
}

.reader-setting-group {
    display: flex;
    align-items: center;
    gap: var(--space-1);
}

.reader-setting-group .btn.is-active {
    background-color: var(--color-primary);
    border-color: var(--color-primary);
    color: var(--color-bg-card);
}

.reader-settings-done {
    width: 100%;
}

/* Bookmarks popover (v1.5) -- same toggle-button + popover shell as
   .reader-settings-popover, with a scrollable list borrowed from
   .epub-toc-popover's pattern since the item count is unbounded. */
.reader-bookmarks-popover {
    min-width: 18rem;
}

.reader-bookmark-note-input {
    width: 100%;
}

.reader-bookmarks-list {
    display: flex;
    flex-direction: column;
    gap: var(--space-1);
    max-height: 16rem;
    overflow-y: auto;
    margin: 0;
    padding: 0;
    list-style: none;
}

.reader-bookmark-item {
    display: flex;
    align-items: center;
    gap: var(--space-1);
}

.reader-bookmark-jump {
    display: flex;
    flex: 1;
    align-items: center;
    gap: var(--space-2);
    min-width: 0;
    text-align: left;
    background: none;
    border: none;
    color: var(--color-text-primary);
    font-size: var(--font-size-small);
    padding: var(--space-1) var(--space-2);
    cursor: pointer;
}

.reader-bookmark-jump:hover,
.reader-bookmark-jump:focus-visible {
    color: var(--color-primary);
}

.reader-bookmark-percentage {
    flex-shrink: 0;
    color: var(--color-text-secondary);
    font-variant-numeric: tabular-nums;
}

.reader-bookmark-note {
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.reader-bookmark-delete {
    flex-shrink: 0;
    padding: var(--space-1);
    line-height: 1;
}

/* Scrollbar-hide preference (source request) for Scroll View -- purely
   cosmetic, the container stays scrollable via mouse wheel/touch/keyboard
   either way. A single on/off checkbox rather than a per-axis choice, so
   this works identically in every browser (scrollbar-width has no
   per-axis variant in Firefox, so an earlier per-axis version of this
   setting behaved inconsistently there). */
.scrollbar-hide-both {
    scrollbar-width: none;
    -ms-overflow-style: none;
}

.scrollbar-hide-both::-webkit-scrollbar {
    display: none;
}

/* Same toggle-button + absolute-positioned-popover pattern as
   .reader-settings-menu, but a scrollable list instead of setting rows. */
.epub-toc-menu {
    position: relative;
    display: inline-block;
}

.epub-toc-popover {
    position: absolute;
    top: calc(100% + var(--space-1));
    left: 0;
    z-index: 20;
    display: flex;
    flex-direction: column;
    gap: var(--space-1);
    min-width: 16rem;
    max-width: 24rem;
    max-height: 20rem;
    overflow-y: auto;
    padding: var(--space-2);
    border: 1px solid var(--color-border);
    border-radius: var(--radius-lg, var(--radius));
    background-color: var(--color-bg-card);
    box-shadow: var(--shadow-card-hover, var(--shadow-card));
}

.epub-toc-item {
    display: block;
    width: 100%;
    text-align: left;
    background: none;
    border: none;
    color: var(--color-text-primary);
    font-size: var(--font-size-small);
    padding-top: var(--space-1);
    padding-bottom: var(--space-1);
    padding-right: var(--space-2);
    cursor: pointer;
}

.epub-toc-item:hover,
.epub-toc-item:focus-visible {
    color: var(--color-primary);
}

.reader-content {
    max-width: 42rem;
    max-height: calc(100vh - 14rem);
    margin: 0 auto;
    overflow-y: auto;
    padding: var(--space-4) var(--space-5);
    border: 1px solid var(--color-border);
    border-radius: var(--radius-lg);
    background-color: var(--color-bg-reader);
    box-shadow: var(--shadow-card);
    font-family: var(--font-family-reader);
    line-height: 1.75;
}

/* Reader typography/layout settings (TXT/MD -- EPUB gets the equivalent via
   epubReader.js's own content-rule builder, since it renders into an
   isolated iframe these classes can't reach). "Normal" is the base rule
   above; these are the two non-default presets per setting, matching the
   "serif"/"sans", "tight"/"normal"/"loose", "narrow"/"normal"/"wide" keys
   readerSettings.js persists. */
.reader-content--font-sans {
    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}

.reader-content--line-tight {
    line-height: 1.4;
}

.reader-content--line-loose {
    line-height: 2.2;
}

.reader-content--width-narrow {
    max-width: 34rem;
}

.reader-content--width-wide {
    max-width: 54rem;
}

/* TXT/MD Book View (issue #155): #reader-content becomes the clipping
   viewport (fixed height, hidden overflow) and #reader-content-inner
   (textPaginator.js) becomes the CSS multi-column box that overflows it
   horizontally -- one column per page, same technique epub.js uses
   internally for its own paginated flow. Scroll View is unaffected: this
   class is only applied when Ansicht is set to "Buch". */
.reader-content--paginated {
    height: calc(100vh - 14rem);
    max-height: none;
    overflow: hidden;
}

/* User-requested: MD's "---" (renders as a real <hr>) and TXT's literal
   ---Seitenumbruch--- marker (split into separate .reader-content-txt-segment
   <pre> blocks by Reader.razor, since plain text has no element to hang a
   break rule off) should force a new page in Book View rather than letting
   the column layout pack straight through a chapter boundary. break-after
   is the real CSS Fragmentation property for this -- verified against real
   multi-column layout before shipping, not assumed to work from the spec
   text alone. No-op in Scroll View, since #reader-content-inner has no
   column layout there. */
.reader-content--paginated hr,
.reader-content--paginated .reader-content-txt-segment {
    break-after: column;
}

/* "Seite pro Kapitel" mode (source follow-up to issue #155): deliberately
   does NOT add .reader-content--paginated -- there's no CSS-column layout
   here at all, just the base .reader-content's own overflow-y:auto/
   max-height (the same scrollable-page look Scroll View already uses),
   showing one chapter/segment at a time. scrollbar-hide-both (existing
   Scroll View class) applies here too when the setting is on. */

.reader-content-nav {
    display: flex;
    justify-content: space-between;
    align-items: center;
    max-width: 42rem;
    margin: var(--space-2) auto 0;
}

.reader-content-nav--width-narrow {
    max-width: 34rem;
}

.reader-content-nav--width-wide {
    max-width: 54rem;
}

.reader-content-nav span {
    font-variant-numeric: tabular-nums;
    color: var(--color-text-secondary);
}

/* min(100%, 28em) rather than a flat 100% -- 100% alone only stops an
   image overflowing its container, it doesn't scale with the font-size
   change .reader-content's inline font-size drives; em is relative to the
   inherited (computed) font-size on this element, so the image's cap
   shrinks/grows right along with A-/A+ instead of looking static. The
   100% side of min() still guards against a wide image overflowing a
   narrow reading column at a large font size. */
.reader-content img {
    max-width: min(100%, 28em);
    height: auto;
}

.reader-content pre {
    white-space: pre-wrap;
    font-family: inherit;
}

/* EPUB/PDF frames: a "viewport" gives pdf.js/epub.js the available space to
   fit into (a real CSS-determined size, independent of content), scaling
   with the window (`min(vw, rem)`) instead of a flat rem cap regardless of
   screen size. The frame itself hugs whatever shape the content turns out
   to be -- for PDF specifically, `width/height: fit-content` is what stops
   a portrait page from floating in a wide box with dead space on both
   sides (see wwwroot/js/pdfReader.js's renderPage). Nav moved below the
   frame, matching the source spec's layout (§64.4: Zurück/Kapitel/
   Einstellungen above the content, Seite/Fortschritt/Navigation below). */

/* Realistic View (issue #189) needs a StPageFlip overlay positioned
   exactly on top of the real epub.js rendition (kept alive underneath,
   used purely as a content/pagination engine to capture pages from -- see
   epubReader.js) -- this wrapper is the shared `position: relative`
   ancestor both the rendition frame and the overlay anchor to, carrying
   the sizing/centering rules the frame used to own directly so neither
   Book/Scroll View's appearance nor the width-narrow/wide modifiers had
   to change. */
.epub-reader-viewport {
    position: relative;
    width: 100%;
    max-width: min(90vw, 90rem);
    height: calc(100vh - 11rem);
    margin: 0 auto;
}

.epub-reader-viewport--width-narrow {
    max-width: min(90vw, 54rem);
}

.epub-reader-viewport--width-wide {
    max-width: min(94vw, 140rem);
}

.epub-reader-frame {
    width: 100%;
    height: 100%;
    border: 1px solid var(--color-border);
    border-radius: var(--radius-lg);
    overflow: hidden;
    background-color: var(--color-bg-reader);
    box-shadow: var(--shadow-card);
    transition: opacity 0.18s ease;
}

/* Subtle page-turn fade (§66: calm, not showy) -- toggled by
   epubReader.js's withPageTransition() around next()/prev(). */
.epub-reader-frame--turning {
    opacity: 0;
}

/* Scroll View (issue #174, §14.1) -- the base rule's overflow: hidden is
   correct for Book View (epub.js's own pagination fills the frame exactly),
   but Scroll View needs the frame itself to be the scrollable element for
   epub.js's "scrolled" flow to have anywhere to scroll. */
.epub-reader-frame--scroll {
    overflow-y: auto;
    /* Windows' classic (non-overlay) scrollbar reserves a gutter that isn't
       accounted for until the vertical scrollbar actually appears -- content
       sized to the full pre-scrollbar width then overflows horizontally by
       that same gutter width the instant the vertical one shows up,
       producing a horizontal scrollbar that serves no real content.
       scrollbar-gutter reserves the space unconditionally so width
       calculations are correct from the very first render. */
    scrollbar-gutter: stable;
    /* Confirmed live: setting only overflow-y forces the UA to compute
       overflow-x as auto too (per the CSS Overflow spec, "visible" on one
       axis paired with a non-visible value on the other is not allowed --
       it silently becomes auto), which combined with scrollbar-gutter above
       reserves and paints an inert horizontal scrollbar track even when
       there is zero horizontal overflow to scroll. Explicit overflow-x:
       hidden opts back out of that pairing rule. */
    overflow-x: hidden;
}

/* Realistic View overlay (issue #189) -- StPageFlip draws its own "open
   book" look onto an internal canvas, same reasoning as PDF's
   pdf-reader-frame--realistic variant. Sits exactly on top of the real
   (kept-alive, capture-source) epub.js rendition underneath; only shown
   once entry.realistic actually has a page ready to display, so the raw
   rendition briefly stays visible during the very first capture rather
   than showing an empty overlay. */
.epub-reader-frame--realistic {
    position: absolute;
    inset: 0;
    display: none;
    align-items: center;
    justify-content: center;
    border: 1px solid var(--color-border);
    border-radius: var(--radius-lg);
    overflow: hidden;
    background-color: transparent;
}

.epub-reader-frame--realistic.is-active {
    display: flex;
}

.epub-reader-nav {
    display: flex;
    justify-content: space-between;
    align-items: center;
    max-width: min(90vw, 90rem);
    margin: var(--space-2) auto 0;
}

.epub-reader-nav--width-narrow {
    max-width: min(90vw, 54rem);
}

.epub-reader-nav--width-wide {
    max-width: min(94vw, 140rem);
}

.epub-reader-progress {
    font-variant-numeric: tabular-nums;
    color: var(--color-text-secondary);
}

/* Seitenbreite (PageWidth) applies here the same way it does to
   epub-reader-viewport above -- PDF pages are fixed-layout (no reflow to
   fill extra width the way EPUB text does), but the *viewport* still
   benefits from more room: less horizontal scrolling once the user zooms
   in past 100% (see pdf-reader-viewport--zoomed) and, on wide monitors,
   avoids the reader looking pinned to a narrow middle column regardless
   of window size. */
.pdf-reader-viewport {
    max-width: min(90vw, 90rem);
    height: calc(100vh - 11rem);
    margin: 0 auto;
    /* display:flex + align/justify-content:center used to live here, but
       flex's *content* alignment only ever exposes scrollable overflow on
       one side once a child grows past the container (the "safe centering"
       fallback the spec mandates for flex) -- confirmed live via a
       synthetic 400px-in-200px test: flex center and even grid's own
       place-content:center both reported only half (100px of 200px) of the
       needed scrollable range. Grid *item* alignment (align-items/
       justify-items, not -content) doesn't get that fallback -- the same
       test measured the full 200px range and a correct scrollWidth. So
       zoomed/wide content stays genuinely centered *and* fully reachable in
       both directions, no separate flex-start fallback class needed. */
    display: grid;
    align-items: center;
    justify-items: center;
    overflow: auto;
    /* Same reasoning as epub-reader-frame--scroll's scrollbar-gutter above --
       applies here too since this element is also the scrolling container
       in both Scroll View and a zoomed/Realistic View past its own bounds. */
    scrollbar-gutter: stable;
}

/* PdfReader.razor.cs calls FocusAsync() on this element right after every
   load so arrow-key/spacebar page navigation works immediately (issue
   #144 point 2) -- without an explicit override, that programmatic focus
   still matches the global [tabindex]:focus-visible rule above in most
   browsers' heuristics (they can't distinguish "focused so a feature
   works" from "focused because the user tabbed here"), drawing a gold
   ring around the whole reader on every single page load. Reported live
   as an unexplained orange border appearing "beim Laden der Seite".
   Keyboard nav itself doesn't depend on the ring being visible, so this
   just turns it off for this one programmatically-focused element. */
.pdf-reader-viewport:focus-visible {
    outline: none;
}

.pdf-reader-viewport--width-narrow {
    max-width: min(90vw, 54rem);
}

.pdf-reader-viewport--width-wide {
    max-width: min(94vw, 140rem);
}

/* Scroll View (issue #174) -- a top-anchored, vertically stacked document
   shouldn't be vertically centered (it's taller than the viewport by
   design), but should stay horizontally centered like a single page. */
.pdf-reader-viewport--scroll {
    align-items: start;
}

.pdf-reader-frame {
    width: fit-content;
    height: fit-content;
    overflow: hidden;
    border: 1px solid var(--color-border);
    border-radius: var(--radius-lg);
    background-color: var(--color-bg-reader);
    box-shadow: var(--shadow-card);
    display: flex;
    align-items: center;
    justify-content: center;
    /* Confirmed live: as a grid item with overflow:hidden, this frame's
       automatic min-width resolves to 0 instead of its content size (the
       CSS Sizing spec only uses a content-based automatic minimum when the
       item's own overflow is visible), so .pdf-reader-viewport's grid
       layout was free to compress the frame's *scrollable overflow region*
       down to the viewport's own width whenever a page (zoomed, or a
       narrower Seitenbreite than the rendered canvas) was actually wider --
       silently clipping the excess instead of leaving it reachable via the
       viewport's scrollbar. A synthetic test (200px grid container,
       overflow:hidden child wanting 400px) measured scrollWidth stuck at
       300 with no min-width set, and the full 400 once min-width:
       fit-content was added -- flex-shrink:0 measured no different from
       unset, since the parent is grid, not flex. */
    min-width: fit-content;
}

.pdf-reader-frame canvas {
    display: block;
}

/* Scroll View (issue #174, §14.1) -- swaps the single centered fit-content
   frame for a top-anchored vertical stack of per-page wrappers, each sized
   in advance from its own pdf.js viewport so the scrollbar reflects the
   real total document length even before every page has actually rendered
   a canvas (pdfReader.js's IntersectionObserver-driven lazy render). */
.pdf-reader-frame--scroll {
    width: auto;
    height: auto;
    flex-direction: column;
    align-items: center;
    gap: var(--space-2);
    background-color: transparent;
    border: none;
    box-shadow: none;
}

.pdf-reader-page {
    border: 1px solid var(--color-border);
    border-radius: var(--radius-lg);
    background-color: var(--color-bg-reader);
    box-shadow: var(--shadow-card);
    overflow: hidden;
}

.pdf-reader-page canvas {
    display: block;
}

/* Issue #213 -- pdfReader.js's showRenderError, shown in place of a page's
   canvas when pdf.js's worker hangs past RENDER_TIMEOUT_MS. Self-centering
   (not relying on the parent's own layout) so it looks right both inside
   Book View's flex-centered .pdf-reader-frame and inside a Scroll View
   .pdf-reader-page wrapper, which has no centering of its own. */
.pdf-reader-render-error {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: var(--space-2);
    padding: var(--space-4);
    text-align: center;
    color: var(--color-text-secondary);
}

/* Realistic View (issue #182) -- StPageFlip draws the whole "open book"
   look itself (facing pages, spine shadow, page-edge stack) directly onto
   its own internal canvas; pdfReader.js's initRealisticView sets this
   element's width/height inline in px from the book's own reported bounds
   (see the comment there for why -- the library's own percentage-based
   auto-sizing collapses to 0 as a flex item), so this variant only needs to
   stop the base rule's fit-content sizing/flex-centering/card chrome
   (border, background, shadow) from fighting it. */
.pdf-reader-frame--realistic {
    display: block;
    background-color: transparent;
    border: none;
    box-shadow: none;
}

/* Hides the frame while Realistic View's pages are still rendering (the
   "Buch wird vorbereitet" pause) -- a CSS class rather than an inline
   style="display:none" binding deliberately: pdfReader.js's
   initRealisticView sets this same element's style.width/height directly
   via JS once ready, and a Blazor-owned `style="..."` attribute binding
   would get re-diffed (and clobber that JS-set inline style) the moment
   _realisticPreparing flips back to false and StateHasChanged() re-renders
   -- confirmed live: the width/height set by JS was wiped seemingly at
   random right after appearing, traced to exactly this re-render. class
   bindings don't have this problem since JS never touches this element's
   class list. */
/* visibility:hidden, not display:none -- confirmed live to be the actual
   reason Realistic View only ever showed a single page on the very first
   load (fixed itself the moment any later action, e.g. a Seitenbreite
   change, rebuilt the flip UI while the frame was visible again).
   buildPageFlip() runs *during* this hidden window (initRealisticView is
   awaited before _realisticPreparing flips back to false), and a
   display:none subtree gives every descendant a zero-size layout box --
   StPageFlip's own portrait-vs-landscape check reads exactly that (a
   descendant canvas's offsetWidth), so it always measured 0 and always
   concluded "not enough room for a spread" regardless of the real
   available width. visibility:hidden keeps the box (and everything
   inside it) genuinely laid out and measurable while still invisible to
   the user; position:absolute takes it out of this grid container's
   normal flow so its real (non-zero, often full-viewport-width) footprint
   doesn't reserve visible space next to the "wird vorbereitet" spinner. */
.pdf-reader-frame--hidden {
    visibility: hidden;
    position: absolute;
    top: 0;
    left: 0;
}

.pdf-reader-nav {
    display: flex;
    justify-content: space-between;
    align-items: center;
    max-width: min(90vw, 90rem);
    margin: var(--space-2) auto 0;
}

.pdf-reader-nav--width-narrow {
    max-width: min(90vw, 54rem);
}

.pdf-reader-nav--width-wide {
    max-width: min(94vw, 140rem);
}

.pdf-reader-zoom {
    display: flex;
    align-items: center;
    gap: var(--space-1);
}

.pdf-reader-zoom-reset {
    min-width: 3.5rem;
    text-align: center;
    font-variant-numeric: tabular-nums;
}

.pdf-reader-nav input[type="number"] {
    width: 4rem;
    text-align: center;
    font-family: inherit;
    font-size: var(--font-size-body);
    padding: var(--space-1);
    border: 1px solid var(--color-border);
    border-radius: var(--radius);
    background-color: var(--color-bg-light);
    color: var(--color-text-primary);
    /* Hide the native up/down spinner -- it eats into the already-narrow
       input and isn't needed since Enter/blur triggers the jump. */
    -moz-appearance: textfield;
}

.pdf-reader-nav input[type="number"]::-webkit-outer-spin-button,
.pdf-reader-nav input[type="number"]::-webkit-inner-spin-button {
    -webkit-appearance: none;
    margin: 0;
}

/* Impressum's responsible-person name doubles as a secret entry point --
   deliberately styled as plain text (no underline/link color) so it isn't
   visually flagged as clickable; a subtle hover shift is the only cue. */
.impressum-name {
    color: inherit;
    text-decoration: none;
    transition: color 0.15s ease;
}

.impressum-name:hover {
    color: var(--color-primary);
}

/* Shared cover-thumbnail structure, used by BookCard.razor (private
   library) and by several detail pages' own single-cover markup
   (BookDetail/CharacterDetail/LocationDetail/ProjectDetail/ShelfDetail).
   PublicProfile.razor and Discover.razor used to hand-roll a duplicate of
   this for their public book/project cards -- that duplicate is what went
   missing entirely in PR #313; as of issue #339 those two pages use the
   real CatalogCard component instead (Components/CatalogCard), so this
   block no longer needs to cover them. */
.book-card {
    display: flex;
    flex-direction: column;
    gap: var(--space-1);
    text-decoration: none;
    color: var(--color-text-primary);
}

.book-card-cover {
    position: relative;
    aspect-ratio: 2 / 3;
    border-radius: var(--radius, 4px);
    overflow: hidden;
    background-color: var(--color-bg-card);
    border: 1px solid var(--color-border);
    box-shadow: var(--shadow-card);
    transition: box-shadow 0.15s ease, transform 0.15s ease;
}

.book-card:hover .book-card-cover {
    box-shadow: var(--shadow-card-hover);
    transform: translateY(-2px);
}

.book-card-cover img {
    width: 100%;
    height: 100%;
    object-fit: cover;
}

.book-card-cover-placeholder {
    width: 100%;
    height: 100%;
    display: flex;
    align-items: center;
    justify-content: center;
    color: var(--color-text-disabled);
}

.book-card-cover-placeholder svg {
    width: 40%;
    height: 40%;
}

.book-card-title {
    font-family: var(--font-family-display);
    font-weight: 600;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.book-card-author {
    color: var(--color-text-secondary);
    font-size: var(--font-size-small);
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.star-button {
    background: none;
    border: none;
    padding: 0;
    cursor: pointer;
    font-size: 1rem;
    line-height: 1;
    color: var(--color-border);
}

.star-button.is-filled {
    /* --color-accent-text, not --color-gold-accent -- this text-size glyph
       needs real contrast to convey rating state, not just decoration
       (issue #341 a11y audit; see the focus-visible rule's comment). */
    color: var(--color-accent-text);
}

.star-button:disabled {
    cursor: default;
    opacity: 0.6;
}

.project-card {
    display: flex;
    flex-direction: column;
    gap: var(--space-1);
    text-decoration: none;
    color: var(--color-text-primary);
    width: 10rem;
}

.project-card-cover {
    aspect-ratio: 4 / 3;
    border-radius: var(--radius, 4px);
    overflow: hidden;
    background-color: var(--color-bg-card);
    border: 1px solid var(--color-border);
    box-shadow: var(--shadow-card);
    transition: box-shadow 0.15s ease, transform 0.15s ease;
}

.project-card:hover .project-card-cover {
    box-shadow: var(--shadow-card-hover);
    transform: translateY(-2px);
}

.project-card-cover img {
    width: 100%;
    height: 100%;
    object-fit: cover;
}

.project-card-cover-placeholder {
    width: 100%;
    height: 100%;
    display: flex;
    align-items: center;
    justify-content: center;
    color: var(--color-text-disabled);
}

.project-card-cover-placeholder svg {
    width: 40%;
    height: 40%;
}

.project-card-title {
    font-family: var(--font-family-display);
    font-weight: 600;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.project-card-type {
    color: var(--color-text-secondary);
    font-size: var(--font-size-small);
}

/* Notification preferences (v3.3 Phase 3, issue #326) -- Settings.razor's
   per-type enable/disable toggles. */
.notification-preferences {
    display: flex;
    flex-direction: column;
    gap: var(--space-1);
    margin: var(--space-2) 0;
}

.notification-preference-row,
.library-preference-row {
    display: flex;
    align-items: baseline;
    gap: var(--space-1);
    padding: var(--space-1) 0;
    cursor: pointer;
}

