:root {
    /* Colors */
    --primary-accent: #458b60;
    /* Hover is the one brand green darkened — derived via color-mix so
       #458b60 stays the single green literal in the codebase. */
    --primary-accent-hover: color-mix(in srgb, var(--primary-accent), #000 12%);

    --border-subtle: rgba(0, 0, 0, 0.08);
    --border-light: rgba(0, 0, 0, 0.05);

    --bg-white: #ffffff;
    --bg-subtle: #f0f0f0;
    --bg-hover: #e5e5e5;
    --bg-active: #f3f3f3;

    --text-primary: #000000;
    --text-muted: #9a9a9a;
    --text-disabled: #999999;

    /* Layout */
    --header-height: 60px;
    --gap: 1rem;
    --radius-standard: 12px;
    --radius-full: 40px;

    /* Legacy/Specific */
    --theme-border-color: #ebebeb;
    --theme-border-color-hover: #d4d4d4;
    --button-text-color-alt: #3d3d3d;
    --cell-max: 240px;

    /* Shadows */
    --shadow-inset-highlight: 0 1px 0 0 rgba(255, 255, 255, 0.1) inset;
}

*,
*::before,
*::after {
    box-sizing: border-box;
}

button,
input,
select,
textarea {
    font-family: inherit;
    font-size: inherit;
    line-height: inherit;
    color: inherit;
    outline: none;
}

button {
    cursor: pointer;
    border: none;
    background: none;
    padding: 0;
    /* Specific properties only — `transition: all` would animate
       layout-triggering props (width / height / margin / padding) on
       every state change, costing a full relayout + repaint for each
       hover. Listing the props the UI actually changes (bg/color/
       opacity/transform) keeps the work to the compositor. */
    transition: background-color 0.2s ease, color 0.2s ease, opacity 0.2s ease, transform 0.2s ease;
}

.album-grid {
    display: grid;
    gap: var(--gap);
    grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
    justify-content: center;
    max-width: none;
    margin: 0;
    /* Folder-page grid only (album / photo grid is overridden by
       .album-view .album-grid further down). The wrapper supplies
       the page margin on large viewports (≥1200px), so the grid
       only adds vertical breathing room here. Under 1200px the
       wrapper drops to 0 padding and the grid takes over the page
       margin — see the 1200px rule below. Column-count breakpoints
       (900 / 500) are independent of the page-margin breakpoint. */
    padding: 1rem 0;
}

@media (max-width: 1200px) {
    .album-grid {
        padding: 1rem;
    }
}

@media (max-width: 900px) {
    .album-grid {
        grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
    }
}

@media (max-width: 500px) {
    .album-grid {
        grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
    }
}

.icon-svg {
    width: 1em;
    height: 1em;
    display: inline-block;
    vertical-align: middle;
    flex-shrink: 0;
    fill: currentColor;
}

/* Standard sr-only / visually-hidden utility. Strips the element
   from sighted view (clipped to 1×1 with no overflow) while keeping
   it in the accessibility tree, so screen readers still announce
   it. Used by the home page's <h1>PhotoJungle</h1> — the branding
   is an SVG wordmark with no machine-readable text, so the h1 sits
   in source for assistive tech / SEO without altering the design. */
.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;
}

html {
    height: 100%;
}

body {
    min-height: 100%;
    margin: 0;
    font-family: system-ui, -apple-system, sans-serif;
    min-width: 370px;
    position: relative;
    letter-spacing: normal;
    color: var(--text-primary);
    /* Disable iOS Safari's double-tap-to-zoom site-wide. The viewport
       meta already sets user-scalable=no, but iOS ignores that for
       accessibility — touch-action: manipulation is the supported
       opt-out and inherits to every interactive descendant. */
    touch-action: manipulation;
}

.hidden {
    display: none !important;
}

/* Topbar dropdown panels — Explore sidebar (anchored under the Explore
   button) and the Sort menu (anchored under the Sort button on album
   pages). They share the same visual treatment (avg-colour fill,
   downward slide-in, white-on-tint rows, 8px inner padding) so the
   identical-design rules live in one merged ruleset. Per-panel
   specifics (left vs right anchor, width) are split out below.

   Background follows --page-color (set on <html> by the bootstrap in
   index.html or by app.js after info hydration). The .open class is
   the sole visibility driver — no backdrop, no responsive collapse. */
/* Both dropdowns live inside the topbar (Sidebar is statically in
   index.html for the worker to SSR into; Sort menu is built by
   setupTopBarOnce). `position: absolute` against the sticky topbar
   means they track whichever shape the topbar is currently in —
   pinned, morphing, or floating pill — without any JS coupling.
   The 44px button is centred in the 62px topbar (9px gap top +
   bottom), so the button's bottom edge sits 9px above the topbar
   bottom. `top: calc(100% + 9px)` lands the dropdown exactly 18px
   below the button (= 9px below the topbar's bottom edge). */
.sidebar,
.sort-menu {
    position: absolute;
    top: calc(100% + 9px);
    /* Slightly-darker tone of --topbar-bg so the dropdown reads as
       a layered surface above the bar it hangs off. color-mix with
       12% black yields a perceptibly different shade for any tint
       (avg colour, accent, past-header neutral) and gives dark
       mode a darker grey than the topbar's #464646. */
    background: color-mix(in srgb, var(--topbar-bg), black 30%);
    color: #fff;
    border-radius: 16px;
    box-shadow: 0 6px 18px rgba(0, 0, 0, 0.18);
    z-index: 1150;
    opacity: 0;
    visibility: hidden;
    transform: translateY(-12px);
    pointer-events: none;
    overflow: hidden;
    padding: 8px;
    box-sizing: border-box;
    transition: opacity 0.22s ease, transform 0.22s ease, background-color 0.2s ease, visibility 0s linear 0.22s;
}

.sidebar.open,
.sort-menu.open {
    opacity: 1;
    visibility: visible;
    transform: translateY(0);
    pointer-events: auto;
    transition: opacity 0.22s ease, transform 0.22s ease;
}

/* Sidebar — left-anchored under Explore, fixed width, scrollable.
   Flex column so .sidebar-content can fill the panel's inner box.
   `left: 9px` lands the sidebar against the Explore button (the
   topbar has `padding: 0 9px`, so 9px into the topbar's padding
   box is the content edge — where #tbb starts). */
.sidebar {
    display: flex;
    flex-direction: column;
    left: 9px;
    width: min(260px, calc(100vw - 18px));
    max-height: calc(100vh - 90px);
}

.sidebar-content {
    display: flex;
    flex-direction: column;
    flex: 1;
    min-height: 0;
    overflow-y: auto;
    scrollbar-width: thin;
    scrollbar-color: rgba(255, 255, 255, 0.3) transparent;
}

/* Sort menu — its trigger button now lives in the album hero meta
   row, not the topbar, so the menu can no longer be `position:
   absolute` against the topbar. Switch to `position: fixed`; JS sets
   `top` and `right` from the trigger button's getBoundingClientRect
   each time the menu opens. The `.sidebar` rule above still anchors
   the Explore dropdown against the topbar's left edge.
   z-index 2200 sits above the topbar (2100) — the sticky topbar
   creates its own stacking context so a lower z-index would tuck the
   menu behind the bar where the bubble re-enters its rectangle. */
.sort-menu {
    position: fixed;
    top: auto;
    right: auto;
    width: max-content;
    max-width: calc(100vw - 18px);
    z-index: 2200;
}

.sort-menu ul {
    list-style: none;
    padding: 0;
    margin: 0;
}

/* Match the sidebar's category-nav icon sizing so the sort dropdown
   feels visually identical — items, icon size, gap. */
.sort-menu .sort-menu-item .icon-svg {
    width: 1.3rem;
    height: 1.3rem;
    font-size: 1.3rem;
    flex-shrink: 0;
}

.sort-menu .sort-menu-item span {
    flex-shrink: 0;
    padding-left: 5px;
}

.main-content {
    display: flex;
    flex-direction: column;
    align-items: center;
    min-height: 100vh;
    /* No padding — the album-grid-wrapper fills the viewport up to its
       max-width, and any inner breathing room comes from the grids
       themselves (see .album-grid + media query below). */
    padding: 0;
}

.main-content>* {
    width: 100%;
    max-width: 1400px;
}

.top-bar {
    position: sticky;
    top: 0;
    height: 62px;
    width: 100%;
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding: 0 9px;
    flex-shrink: 0;
    user-select: none;
    background: transparent;
    z-index: 1000;
}

/* Hide the album grid on the home page until JS would do it anyway —
   prevents a brief paint before the home-landing layout takes over.
   The topbar stays visible on home now (just Explore + logo), so it's
   no longer part of this pre-paint hide. */
html:not(.nh) .album-grid,
html:not(.nh) #scroll-sentinel {
    display: none !important;
}

.top-bar-left,
.top-bar-right {
    display: flex;
    align-items: center;
    flex: 1;
}

.top-bar-left {
    justify-content: flex-start;
    gap: 9px;
}

.top-bar-right {
    justify-content: flex-end;
    gap: 9px;
}

.top-bar-center {
    position: relative;
    flex: 0 1 350px;
    width: 100%;
    height: 100%;
}

/* The centered logo and the search input share the same slot inside
   .top-bar-center and crossfade based on [data-mode]. */
.top-bar-center .top-bar-logo-link,
.top-bar-center .header-search {
    position: absolute;
    inset: 0;
    display: flex;
    align-items: center;
    transition: opacity 0.22s ease, transform 0.22s cubic-bezier(0.4, 0, 0.2, 1);
}

.top-bar-center .header-search {
    inset: 0 8px;
}

.top-bar-logo-link {
    justify-content: center;
    gap: 8px;
    cursor: pointer;
    padding: 0 16px;
}

.top-bar-center[data-mode="search"] .top-bar-logo-link {
    opacity: 0;
    transform: translateY(-4px);
    pointer-events: none;
}

.top-bar-center[data-mode="logo"] .header-search {
    opacity: 0;
    transform: translateY(4px);
    pointer-events: none;
}

.top-bar svg.top-bar-logo {
    display: block;
    width: 40px;
    height: 26px;
    object-fit: contain;
    flex-shrink: 0;
    fill: var(--primary-accent);
}

.logo-text {
    display: flex;
    align-items: center;
    font-size: 1.35rem;
}



.photo-word,
.jungle-word {
    display: block;
    width: auto;
    overflow: visible;
}

.photo-word {
    height: 0.85em;
    margin-top: -0.055em;
}

.jungle-word {
    height: 1.1em;
    margin-top: 0.15em;
}



.home-category {
    width: 100% !important;
}

.category-nav {
    flex-grow: 1;
    overflow-y: auto;
    padding: 0;
    scrollbar-width: none;
}

.category-nav ul {
    list-style: none;
    padding: 0;
    margin: 0;
}

.category-nav li button,
.category-nav li a,
.sort-menu-item {
    display: flex;
    gap: 0.3rem;
    align-items: center;
    height: 44px;
    width: 100%;
    padding: 0 0.8rem;
    background: none;
    border: 0;
    border-radius: 12px;
    text-align: left;
    font-size: 0.9rem;
    color: rgba(255, 255, 255, 0.85);
    cursor: pointer;
    overflow: hidden;
    white-space: nowrap;
    box-sizing: border-box;
    user-select: none;
    text-decoration: none;
    transition: background-color 0.2s ease, color 0.2s ease;
}

/* Hover on non-active items: very subtle white overlay + fully white
   text. Excluded from .active so the active row keeps its solid
   topbar-style look even when the cursor is over it. */
.category-nav li button:not(.active):hover,
.category-nav li a:not(.active):hover,
.sort-menu-item:not(.active):hover {
    background: rgba(255, 255, 255, 0.05);
    color: #fff;
}

/* Active row uses the same translucent-white fill + inset highlight as
   the topbar buttons so the highlight feels consistent across the bar
   and the dropdown. Height + font stay sidebar-sized. Hover is scoped
   to :not(.active), so hovering the active row leaves it unchanged. */
.category-nav li button.active,
.category-nav li a.active,
.sort-menu-item.active {
    color: #fff;
    font-weight: 600;
    background: rgba(255, 255, 255, 0.15);
    box-shadow: var(--shadow-inset-highlight);
}

.category-nav .icon {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    text-align: center;
    flex-shrink: 0;
    width: 1.3rem;
    height: 1.3rem;
    font-size: 1.3rem;
}

/* Folder rows whose menu entry has an `image` use this <img> in place
   of the Material icon — round avatar at icon size. Background fills
   the circle while bytes are in flight; opacity transitions to 1 once
   the load fires (delegate listener in common.js). */
.sidebar-folder-avatar {
    width: 23px;
    height: 23px;
    border-radius: 50%;
    object-fit: cover;
    flex-shrink: 0;
    background: #eee;
    opacity: 0;
    transition: opacity 0.25s ease;
}

.sidebar-folder-avatar.loaded {
    opacity: 1;
}

.category-name {
    padding-left: 5px;
    flex-grow: 1;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    opacity: 1;
}

/* .category-toggle-btn + .collapsible-content rules below are NOT
   currently used — the sidebar was flattened so folders sit directly
   under Home with no collapsible "Collections" / "Creators" headers.
   The styles are kept for easy re-enable: restoring the wrapper
   markup in common.js renderSidebar() + worker.js _buildSidebarHtml()
   brings the sectioned look back without touching CSS. */
.category-toggle-btn {
    color: rgba(255, 255, 255, 0.9) !important;
    font-size: .85rem !important;
    text-transform: none;
    padding: 10px 0.8rem !important;
    border-top: 1px solid rgba(255, 255, 255, 0.1) !important;
    border-radius: 0 !important;
    display: flex;
    align-items: center;
    justify-content: space-between;
    font-weight: 500;
}

.category-toggle-btn .category-name {
    font-weight: 600;
}

.category-toggle-btn .icon-svg {
    font-size: 1.4rem;
}

.collapsible-content {
    overflow: hidden;
    max-height: 0;
}

/* Edge-prerendered sidebar arrives with aria-hidden="false" on every open
   category. Make those default to a visible height so categories aren't
   collapsed before JS hydrates. JS then writes inline max-height for the
   actual expand/collapse animation, which wins over this rule. */
.collapsible-content[aria-hidden="false"] {
    max-height: none;
}

ul.category-items {
    margin: 0;
    padding: 0;
    list-style: none;
}


.album-grid-wrapper {
    position: relative;
    width: 100%;
    margin: 0;
    /* Page outer margin (large format). Bottom 0 so the infinite-
       scroll sentinel sits flush with the wrapper's bottom edge.
       Under 1200px the wrapper drops to 0 padding all around and
       the inner .album-grid picks up the page margin instead — see
       the 1200px override below. */
    padding: 1rem 1rem 0;
    box-sizing: border-box;
    /* `overflow: clip` clips like `overflow: hidden` but DOES NOT
       create a scroll container — critical because the sticky topbar
       below relies on the window for its scrolling context. Using
       `overflow-x: hidden` here (the previous value) made this
       wrapper the sticky container, and since the wrapper itself
       never scrolls (the window does), sticky elements inside
       silently degraded to non-sticky. */
    overflow: clip;
    flex: 1;
    display: flex;
    flex-direction: column;
}

@media (max-width: 1200px) {
    .album-grid-wrapper {
        /* Wrapper is flush with the viewport on small screens; the
           grid's own padding handles the breathing room (see the
           matching .album-grid rule below). The topbar squares its
           corners at this breakpoint so the at-edge alignment reads
           as one flush band. */
        padding: 0;
    }
}

.empty-state {
    grid-column: 1 / -1;
    display: flex;
    align-items: center;
    justify-content: center;
    flex: 1;
    color: #999;
    font-size: 1rem;
    user-select: none;
    padding: 3rem 1rem;
}

.not-found-page {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    flex: 1;
    width: 100%;
    user-select: none;
    gap: 18px;
}

.not-found-logo {
    width: 80px;
    height: auto;
    display: block;
}

.not-found-title {
    color: #999;
    font-size: 1.4rem;
    font-weight: 500;
    margin: 0;
}

/* Standalone pill button reused on 404 (Go Back) — same 44px
   height + pill shape as the folder-hero Website link, with a
   gap for the leading arrow icon. Wears the brand accent in
   light mode; dark mode keeps it black (see the html.dark
   override further down) so it doesn't clash with the dark
   page background. */
.go-back-btn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: 8px;
    height: 44px;
    margin: 1rem 0 0 0;
    padding: 0 20px 0 14px;
    background: var(--primary-accent);
    color: #fff;
    border-radius: var(--radius-full);
    text-decoration: none;
    font-size: 0.9rem;
    font-weight: 500;
    border: none;
    cursor: pointer;
    transition: background-color 0.15s ease;
}

.go-back-btn:hover {
    background: var(--primary-accent-hover);
}

html.dark .go-back-btn {
    background: #000;
}

html.dark .go-back-btn:hover {
    background: #222;
}

.go-back-btn .icon-svg {
    font-size: 1.25rem;
}

.nh .home-landing {
    display: none;
}

.home-landing {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    flex: 1;
    width: 100%;
    user-select: none;
    gap: 12px;
    padding: 5rem 2rem;
    box-sizing: border-box;
}

.home-branding-wrapper {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 16px;
}

.home-description {
    max-width: 360px;
    text-align: center;
    font-size: 1.1rem;
    line-height: 1.6;
    color: #555;
    margin: 12px 0;
    opacity: 0;
    animation: homeFadeIn 0.6s ease 0.15s forwards;
}

.home-branding {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 3px;
    opacity: 0;
    animation: homeFadeIn 0.6s ease forwards;
}

.home-logo {
    width: 80px;
    height: auto;
    object-fit: contain;
}

svg.home-logo {
    display: block;
}

.home-logo-text {
    display: flex;
    align-items: center;
    font-size: 1.9rem;
}



.features-grid {
    display: grid;
    grid-template-columns: repeat(2, 1fr);
    gap: 20px;
    width: 100%;
    max-width: 680px;
    margin-top: 40px;
}

.feature-tile {
    border: 1px solid rgba(0, 0, 0, 0.08);
    border-radius: 16px;
    padding: 16px;
    display: flex;
    flex-direction: row;
    align-items: flex-start;
    text-align: left;
    background: transparent;
    gap: 12px;
    box-sizing: border-box;
    opacity: 0;
}

.feature-tile:nth-child(1) {
    animation: homeFadeIn 0.6s ease 0.45s forwards;
}

.feature-tile:nth-child(2) {
    animation: homeFadeIn 0.6s ease 0.6s forwards;
}

.feature-tile:nth-child(3) {
    animation: homeFadeIn 0.6s ease 0.75s forwards;
}

.feature-tile:nth-child(4) {
    animation: homeFadeIn 0.6s ease 0.9s forwards;
}

@media (max-width: 600px) {
    .features-grid {
        grid-template-columns: 1fr;
    }
}

.feature-icon-wrapper {
    display: flex;
    align-items: center;
    justify-content: flex-start;
    flex-shrink: 0;
}

.feature-icon-wrapper .icon-svg {
    font-size: 2rem;
    color: #333;
}

.feature-content {
    display: flex;
    flex-direction: column;
}

.feature-tile h3 {
    margin: 0 0 6px 0;
    font-size: 1rem;
    font-weight: 400;
    color: #000;
}

.feature-tile p {
    margin: 0;
    font-size: 0.9rem;
    color: #6a6a6a;
    line-height: 1.6;
}

@media (max-width: 450px) {
    .feature-tile h3 {
        font-size: 0.95rem;
    }

    .feature-tile p {
        font-size: 0.85rem;
    }
}

.home-explore-btn {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 6px;
    padding: 12px 14px 12px 20px;
    background: var(--primary-accent);
    color: #fff;
    border: none;
    border-radius: 40px;
    font-size: 0.95rem;
    font-weight: 500;
    cursor: pointer;
    opacity: 0;
    animation: homeFadeIn 0.6s ease 0.3s forwards;
    transition: background-color 0.2s ease, transform 0.2s ease;
}

.home-explore-btn .icon-svg {
    font-size: 1.4rem;
}

@keyframes homeFadeIn {
    from {
        opacity: 0;
        transform: translateY(8px);
    }

    to {
        opacity: 1;
        transform: translateY(0);
    }
}

.tile {
    position: relative;
    display: block;
    text-decoration: none;
    color: inherit;
    width: 100%;
    aspect-ratio: 1 / 1.3;
    transition: transform 0.2s ease-in-out;
    z-index: 955;
    cursor: pointer;
}

.tile-inner {
    position: relative;
    width: 100%;
    height: 100%;
    overflow: hidden;
    border-radius: 15px;
    background-color: rgba(var(--avg-color), 1);
    transition: box-shadow 0.2s ease-in-out;
}

.album-cover {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    object-fit: cover;
    z-index: 1;
    opacity: 0;
    transition: opacity 0.3s ease-in-out;
    -webkit-user-drag: none;
    user-drag: none;
}

/* Avg-color fade rising from the bottom of the cover. Same gradient
   shape as the album-hero overlay so the tile reads as a miniature of
   the hero. Solid at the very bottom, transparent by ~55% from the
   bottom — keeps the upper half of the cover clean. */
.tile-fade {
    position: absolute;
    inset: 0;
    z-index: 2;
    pointer-events: none;
    background: linear-gradient(to top,
            rgba(var(--avg-color), 1) 0%,
            rgba(var(--avg-color), 0.85) 18%,
            rgba(var(--avg-color), 0) 55%);
}

.album-cover.loaded {
    opacity: 1;
}

/* A cover that 404s / fails to decode never gets .loaded, so it stays at
   opacity:0 — but the broken-image glyph can still leak through. Hiding it
   lets the tile's avg-color background (on the parent) fill the slot
   cleanly. Mirrors the .album-hero-cover.broken treatment.
   visibility:hidden, NOT display:none — the cover is absolutely positioned so
   the two are layout-identical, but display:none gives the img an empty box
   that the lazy loader's IntersectionObserver treats as never-intersecting,
   which silently stops retries: a transiently-failed tile would be abandoned
   instead of recovering. (Heroes keep display:none — they're not
   loader-managed.) markImgLoaded removes .broken when a retry succeeds. */
.album-cover.broken {
    visibility: hidden;
}

.info {
    position: absolute;
    bottom: 0;
    left: 0;
    right: 0;
    z-index: 3;
    padding: 12px 14px 14px;
    text-align: center;
    color: #fff;
    display: flex;
    flex-direction: column;
    align-items: center;
    line-height: 20px;
    gap: 5px;
    pointer-events: none;
}

.info h3 {
    margin: 0;
    font-size: 0.9rem;
    color: #fff;
    line-height: 20px;
    display: -webkit-box;
    -webkit-line-clamp: 3;
    -webkit-box-orient: vertical;
    overflow: hidden;
    /* Break unbreakable tokens (long no-space titles, URLs) so they
       wrap inside the tile's padding instead of bleeding past it. */
    overflow-wrap: break-word;
    word-break: break-word;
}

.info h3 .title {
    font-weight: 500;
    opacity: 1;
}

.tile-count {
    font-size: 0.75rem;
    color: rgba(255, 255, 255, 0.85);
    line-height: 16px;
}

@media (max-width: 900px) {
    .info {
        padding: 10px 10px 12px;
    }
}

.category-nav::-webkit-scrollbar {
    width: 0px;
    height: 0px;
}

.category-nav:hover {
    scrollbar-width: thin;
    scrollbar-color: rgba(0, 0, 0, 0.1) transparent;
}

.category-nav:hover::-webkit-scrollbar {
    width: 6px;
    height: 6px;
}

.category-nav::-webkit-scrollbar-track {
    background: transparent;
    border-radius: 3px;
}

.category-nav::-webkit-scrollbar-thumb {
    background-color: transparent;
    border-radius: 3px;
}

.category-nav:hover::-webkit-scrollbar-thumb {
    background-color: rgba(0, 0, 0, 0.3);
}

.category-toggle-btn:hover {
    background: transparent !important;
}

.category-toggle-btn .icon-svg {
    transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}

.category-toggle-btn[aria-expanded="true"] .icon-svg {
    transform: rotate(180deg);
}

.collapsible-content {
    transition: max-height 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}

.category-name {
    transition: opacity 0.2s ease;
}


.home-explore-btn:hover {
    background: var(--primary-accent-hover);
}

.grid-header,
.page-header {
    display: flex;
    align-items: center;
    margin: 0;
}

.grid-header {
    grid-column: 1 / -1;
    padding: 0;
}

.page-header {
    position: relative;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    text-align: center;
    /* Sit above .album-grid's tiles (each a position:relative
       z-index:955 stacking root anchored to the root context).
       Without this lift, the license info bubble — which hangs 1rem
       below the hero into the grid's column — gets covered by the
       first row of tiles. Still well below the topbar (2100) and
       sort menu (2200). */
    z-index: 1000;
}

/* Sticky topbar that sits ABOVE the hero (not overlaying it) and is
   always fully opaque so white icons stay readable regardless of
   what's behind. Background tint comes from `--page-color`, set in
   JS from `info.item.avgColor` (albums) or `info.item.coverColor`
   (folder top-of-cover avg colour). Fallback is a dark fill so a
   colour-less item still has a readable bar.

   Scroll behaviour: at-top, the bar wears the page's avg-colour tint
   and has 15px top corners that pair with the hero's 15px bottom
   corners. The bar sits 1rem from the viewport top at scroll 0
   (the wrapper's matching 1rem padding-top supplies that offset);
   from scroll 16px on, sticky `top: 0` engages, the bar pins flush to
   the viewport edge, and `html.scrolled` squares its top corners.
   Once the user scrolls past the hero (.past-header is toggled by
   wireTopbarScrollState()), the bar morphs into a fully-rounded 31px
   pill capped at 400px wide and centred in its 1400px column, and
   the tint swaps to a neutral dark. The home topbar is permanently
   in that floating-pill state — no hero to scroll past. The 15px
   curve is reserved for the at-top state where the bar visually
   pairs with the hero's 15px bottom corners.

   The background tint, border-radius, and their transitions live
   directly on the host now — no ::before layer, no shelf, no strip-
   above box-shadow. The host paints its own rounded rectangle and
   that's all. */
.album-grid-wrapper>.top-bar {
    position: sticky;
    /* `top: 0` lets the bar slide flush against the viewport top as
       soon as it hits the edge — at scroll 0 the bar sits at its
       in-flow position (16px below viewport top, matching the
       wrapper's 1rem padding-top), and once 16px of scroll has
       happened it sticks at y=0 with squared top corners (toggled by
       the `html.scrolled` rules below). The past-header / home rules
       further down restore `top: 0.5rem` so the floating pill keeps
       its 8px breathing room. `margin: 0 auto` keeps `auto` on
       left+right so the bar recenters smoothly as `width` shrinks
       during the morph into the pill — using `margin: 0` alone
       (left/right resolve to 0) would force the bar against the
       left edge mid-morph until margin re-resolved to auto. */
    top: 0;
    margin: 0 auto;
    /* Numeric width (not `auto`) so the transition into the
       past-header / home pill width below interpolates instead of
       snapping. */
    width: 100%;
    background: var(--topbar-bg);
    border-radius: 15px 15px 0 0;
    /* 2100 paints the topbar above every in-page element — tiles
       (955) and the Browse dropdown (1150) — and stays below modal
       dialogs (2200), the fullscreen viewer (3000), nav-progress
       (3500), the batch indicator (4000), and the drag-and-drop
       overlay (9999) so those still cover the bar when they open.
       The page-header / wrapper / main-content don't establish
       stacking contexts, so the topbar competes with tiles in the
       same context (raising the z-index alone is enough). */
    z-index: 2100;
    /* Topbar fades in once --page-color has been set on
       documentElement (by the pre-paint bootstrap in index.html, OR
       by app.js after info hydration). top / margin / width / bg /
       border-radius all share the same 0.2s clock so the morph
       between at-top, scrolled, and pill states lands together. */
    opacity: 0;
    transition: opacity 0.2s ease,
        top 0.2s cubic-bezier(0.4, 0, 0.2, 1),
        margin 0.2s cubic-bezier(0.4, 0, 0.2, 1),
        width 0.2s cubic-bezier(0.4, 0, 0.2, 1),
        background-color 0.2s ease,
        border-radius 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}

/* Topbar tint is driven by --topbar-bg, defined on .main-content so
   it cascades down to the topbar host (which paints its own bg).
   Per-context overrides keep the same class-count + html-element
   shape (specificity 0,2,1) so the home / dark-mode rules beat
   past-header's 0,2,0 without depending on file order.
     - html:not(.nh) .main-content     home (light)        -> --primary-accent
     - html.past-header .main-content  scrolled past hero  -> rgb(48 48 48)
   Dark mode mirrors the light cascade on darkened tones:
     - html.dark .main-content              folder/album at rest -> avg cover colour, 10% darker
     - html.dark:not(.nh) .main-content     home                 -> flat #313131
     - html.dark.past-header .main-content  scrolled past hero   -> #464646 (unchanged)
   Outside the topbar / hero shapes the page background is just
   body's default (white in light, #202020 in dark) — no side
   strips, no gradient extension. */
:root {
    --topbar-bg: rgb(var(--page-color, 50, 50, 50));
}

html:not(.nh) .main-content {
    --topbar-bg: var(--primary-accent);
}

html.past-header .main-content {
    --topbar-bg: rgb(48 48 48);
}

/* Dark base: folder/album topbar wears the avg cover colour like light
   mode, darkened 10% so it reads as a dark surface. Same --page-color
   source as the light :root rule above. */
html.dark .main-content {
    --topbar-bg: color-mix(in srgb, rgb(var(--page-color, 50, 50, 50)), black 10%);
}

/* Home keeps a flat neutral dark topbar (no avg tint).
   Specificity (0,3,1) beats the dark base (0,2,1) above. */
html.dark:not(.nh) .main-content {
    --topbar-bg: #313131;
}

/* Scrolled-past-hero keeps its original dark grey — left unchanged.
   (0,3,1) beats the dark base; on a scrolled folder/album page .nh is
   set so the home rule above doesn't apply.) */
html.dark.past-header .main-content {
    --topbar-bg: #464646;
}

/* Scrolled (within the hero, before past-header fires): the bar has
   slid up to viewport top and its rounded top corners square off so
   it reads as a flush header rather than a still-floating pill. The
   past-header / home rules below intentionally have the same
   specificity and win on source order — when past-header is also
   true, the corners round back into the pill shape regardless of
   `.scrolled`. */
html.scrolled .album-grid-wrapper>.top-bar {
    border-radius: 0;
}

/* Under 1200px the wrapper has 0 padding (flush with viewport), so
   the at-top topbar should square its top corners too — the 15px
   pairing with the hero's bottom corners is a desktop-only flourish.
   Past-header / home rules below have higher specificity
   (html.past-header / html:not(.nh)), so the floating pill keeps
   its 31px corners regardless of viewport width. */
@media (max-width: 1200px) {
    .album-grid-wrapper>.top-bar {
        border-radius: 0;
    }
}

/* Past-header (scrolled past the hero) and home both float the bar
   as a fully-rounded 31px pill capped at 400px wide and centred. The
   15px curves are reserved for the at-top state where the bar pairs
   with the hero's matching 15px bottom corners — once the hero is
   gone, the bar is a standalone pill and wears the larger radius. */
html.past-header .album-grid-wrapper>.top-bar,
html:not(.nh) .album-grid-wrapper>.top-bar {
    /* Wrapper side padding (1rem/0 by breakpoint) already
       keeps the pill inside the page's outer margin, so no extra side
       buffer is needed here. Restore `top: 0.5rem` so the pill floats
       again — the default rule sets `top: 0` for the flush at-edge
       slide, which would otherwise leave the pill pinned to viewport
       top. */
    width: min(400px, 100%);
    top: 0.5rem;
    border-radius: 31px;
}

/* Home page only: the wrapper drops to `padding: 0` at <=1200px, so
   the joint rule above would leave the pill flush with the viewport
   edges on narrow screens. The home topbar should always float with
   a 1rem gutter on all four sides regardless of screen size, so
   subtract 2rem from the pill's max width to bake the side gutter
   into the pill itself (margin: 0 auto from the default rule keeps
   it centred) and override `top` to 1rem instead of the past-header
   joint rule's 0.5rem. */
html:not(.nh) .album-grid-wrapper>.top-bar {
    width: min(400px, calc(100% - 2rem));
    top: 1rem;
}

/* Past-header glass: on album/folder pages, once the hero scrolls
   away the topbar takes a translucent dark fill with a backdrop blur
   so the grid behind reads through. The blur applies to the topbar
   element only — the sidebar/sort dropdowns still wear the solid
   --topbar-bg (set by html.past-header .main-content above) so their
   text doesn't sit on a see-through background. Same treatment in
   dark mode — the rgba/blur look already reads as a dark surface so
   no separate dark-mode override is needed. Home (html:not(.nh)) is
   excluded: it has no hero behind it to blur over, and the solid
   --primary-accent fill is part of the brand at that surface. */
html.past-header .album-grid-wrapper>.top-bar {
    background: rgba(0, 0, 0, 0.4);
    backdrop-filter: blur(10px);
    -webkit-backdrop-filter: blur(10px);
}

html[style*="--page-color"] .album-grid-wrapper>.top-bar {
    opacity: 1;
}

/* Topbar buttons (back / search / sort / etc.) and the matching in-hero
   action buttons on album pages: 44px circular hit targets with a
   translucent-white fill that reads against the topbar's avg-colour
   tint as well as the album hero's dark gradient. */
.top-bar .back-btn,
.top-bar .header-action-btn,
.album-hero-text .header-action-btn {
    background: rgba(255, 255, 255, 0.15);
    box-shadow: var(--shadow-inset-highlight);
    color: #fff;
    border: none;
    border-radius: 50%;
    width: 44px;
    height: 44px;
    padding: 0;
    font-size: 0.95rem;
    font-weight: 500;
    transition: background-color 0.2s ease;
}

.top-bar .back-btn:hover,
.top-bar .header-action-btn:hover,
.album-hero-text .header-action-btn:hover {
    background: rgba(255, 255, 255, 0.25);
    border: none;
}

.top-bar .back-btn.active {
    background: rgba(255, 255, 255, 0.35) !important;
    border: none !important;
    color: #fff !important;
}

/* Every SVG path inside the topbar renders white — overrides both the
   button-icon `fill="currentColor"` (which would also be white via
   inherited color, but this is explicit) and the brand-coloured logo
   word marks (logoSvg orange / photoWordSvg black / jungleWordSvg
   orange) so the whole bar reads as one monochrome unit. Includes the
   search-input icons (left search glyph + clear-X), since the input
   now matches the buttons (translucent-white over the topbar fill). */
.top-bar svg path {
    fill: #fff;
}

.grid-header h2 {
    font-size: 2rem;
    text-transform: none;
    color: var(--text-color);
    margin: 0;
    opacity: 0.8;
    z-index: 2;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

.page-header h1 {
    font-size: 2rem;
    text-transform: none;
    color: var(--text-color);
    margin: 0;
    opacity: 0.8;
    z-index: 2;
    white-space: normal;
    overflow: visible;
    text-overflow: clip;
}

.photo-count {
    color: var(--text-muted);
    font-size: 0.95rem;
    font-weight: 400;
    opacity: 0.7;
}

/* Parent-folder breadcrumb anchor in album-page headers. Renders as plain
   text matching the .photo-count style — no underline, no blue — so the
   "X photos · ParentFolder" line reads as a single muted subtitle. */
.photo-count .parent-link {
    color: inherit;
    text-decoration: none;
    cursor: pointer;
}

.photo-count .parent-link:hover {
    text-decoration: underline;
}

.grid-header h2 {
    font-weight: 400;
}

.back-btn,
.header-action-btn {
    position: relative;
    width: 44px;
    height: 44px;
    border-radius: 50%;
    border: 1px solid #dddddd;
    display: flex;
    align-items: center;
    justify-content: center;
    color: #000000;
    flex-shrink: 0;
}

.copy-toast {
    position: absolute;
    right: calc(100% + 12px);
    top: 50%;
    transform: translateY(calc(-50% + 5px));
    background: #fff;
    color: #000;
    padding: 12px 18px;
    border-radius: 20px;
    font-size: 0.85rem;
    font-weight: 500;
    white-space: nowrap;
    pointer-events: none;
    opacity: 0;
    transition: opacity 0.25s ease, transform 0.25s ease;
    z-index: 1000;
}

.copy-toast.show {
    opacity: 1;
    transform: translateY(-50%);
}

.back-btn {
    margin: 0;
}

.back-btn:hover,
.header-action-btn:hover {
    background: #fafafa;
    border: 1px solid #d4d4d4;
}

.back-btn.active {
    background: #ffeae3 !important;
    border: 1px solid #ffeae3 !important;
    color: var(--primary-accent) !important;
}

.back-btn .icon-svg,
.header-action-btn .icon-svg {
    font-size: 1.3rem;
}

.page-header .name {
    color: #000;
    font-weight: 400;
    max-width: 700px;
    display: inline-block;
}

/* Album hero header: full-width medium cover with the album's average
   colour fading up from the bottom, name + photo-count overlaid in white
   centred at the bottom. The fade is a single linear-gradient layer so
   the GPU treats it as one compositor pass — no per-frame shadow blur. */
.page-header:has(.album-hero) {
    padding: 0;
    gap: 0;
}

.album-hero {
    position: relative;
    width: 100%;
    aspect-ratio: 2 / 1;
    max-height: 500px;
    /* No `overflow: hidden`: the license tooltip extends a few px
       past the hero's bottom edge on narrow viewports, and trapping
       it inside would clip its last bullet. The cover img and fade
       gradient inside both use `inset: 0` so they fit the box
       regardless; clipping was always defensive rather than load-
       bearing. */
    /* No bottom-corner radius on album pages — the photo grid below
       sits flush with the wrapper edges (.album-view .album-grid is
       padded `3px 0`), so a rounded hero bottom would leave a sliver
       of body bg in the corners between hero and grid. */
    border-radius: 0;
    display: flex;
    align-items: flex-end;
    justify-content: center;
    background-color: rgba(var(--avg-color), 1);
    /* Plain opacity fade-in on render. No translateY — that motion is
       reserved for the grid tiles, which use @keyframes tileEnter.
       The cover img inside has its own load-gated opacity fade
       (.album-hero-cover.loaded) that composes on top of this one;
       both reach opacity:1 quickly so there's no visible muddiness. */
    animation: heroFadeIn 0.18s ease-out both;
}

.album-hero-cover {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    object-fit: cover;
    z-index: 1;
    -webkit-user-drag: none;
    user-drag: none;
    /* Same fade-in pattern as the grid photos (.album-view .photo-item img):
       opacity:0 until JS marks the img loaded, then crossfade to 1.
       The hero img is fetchpriority=high and the smallest LCP candidate
       in the album view, so its `load` fires before any grid tile's —
       the visual sequence reads as: avg-color flash → hero fades in →
       grid photos fade in. */
    opacity: 0;
    transition: opacity 0.18s ease-in-out;
    /* Force a permanent compositor layer. The browser would otherwise
       promote the cover on opacity transition start and demote it on
       transition end, causing the parent layer to re-rasterize for
       one frame — a brief paint hitch visible on the page just as
       the hero finishes fading in. The grid tiles use the same trick
       (.album-view .photo-item img — see comment there). */
    transform: translateZ(0);
    will-change: transform;
}

.album-hero-cover.loaded {
    opacity: 1;
}

/* If the medium cover 404s (deleted derivative, mid-purge, broken
   reference, …) hide the img element completely so no broken-image
   glyph leaks through. The .album-hero parent already has the
   avg-color as its solid background, and the .album-hero-fade
   gradient over a flat-colour background is invisible, so the
   header degrades cleanly to a plain colour band with the title
   centred over it. */
.album-hero-cover.broken {
    display: none;
}

/* Folder hero: cover image up top (no text overlay, no avg-color
   gradient — folder pages display name + count + description below the
   cover, not on it), optional circular avatar straddling the cover/meta
   boundary, then the centred meta block on the page background. The
   whole section reuses the same translateY entrance animation as
   .album-hero and the grid tiles. */
.folder-hero {
    width: 100%;
    display: flex;
    flex-direction: column;
    align-items: center;
    /* The folder-hero ships from SSR with an EMPTY cover-wrap (no img
       element — see worker.js's omitCoverImg). It stays at opacity:0
       until app.js hydrates the hero with a real <img>, which loads
       instantly from the <link rel="preload"> the CF worker emitted.
       Once `.folder-hero-cover.loaded` is set, the :has() rule below
       starts the heroFadeIn animation (NOT a transition — transitions
       collapse when the class is added in the same JS task as element
       creation, since the browser sees opacity:0 → opacity:1 only as
       the final value). Same pattern + duration as .album-hero so the
       two pages feel identical on cold load. */
    opacity: 0;
}

.folder-hero:has(.folder-hero-cover.loaded),
.folder-hero:has(.folder-hero-cover.broken) {
    animation: heroFadeIn 0.18s ease-out forwards;
}

@keyframes heroFadeIn {
    from {
        opacity: 0;
    }

    to {
        opacity: 1;
    }
}

.folder-hero-cover-wrap {
    position: relative;
    width: 100%;
    aspect-ratio: 2 / 1;
    max-height: 300px;
    overflow: hidden;
    /* Bottom corners match the at-top topbar's 15px top corners
       so the topbar + cover read as one continuous shape. */
    border-radius: 0 0 15px 15px;
    /* Fill behind the cover image is the folder's top-of-cover avg
       colour — set on documentElement as --page-color by the
       pre-paint bootstrap (or by JS post-hydration). Visible only
       during the brief load window (cover fades in via .loaded) or
       when the cover 404s and the .broken class hides the img. The
       grey fallback kicks in only when no colour has been resolved
       at all (rare). */
    background-color: rgba(var(--page-color, 240, 240, 240), 1);
}

.folder-hero-cover {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    object-fit: cover;
    opacity: 0;
    transition: opacity 0.18s ease-in-out;
    -webkit-user-drag: none;
    user-drag: none;
    /* Permanent compositor layer — same rationale as
       .album-hero-cover: prevents the layer demote at fade-end
       that causes a one-frame repaint hitch. */
    transform: translateZ(0);
    will-change: transform;
}

.folder-hero-cover.loaded {
    opacity: 1;
}

.folder-hero-cover.broken {
    display: none;
}

/* Above 1000px the content column keeps widening to its 1400px cap, so
   let both heroes keep their cover's NATIVE aspect ratio and grow taller
   with the width instead of freezing at a fixed height (which made
   object-fit:cover crop progressively more from 1000→1400px). Album cover
   is 1900x950 (2:1, already the .album-hero ratio); folder cover is
   1900x570 (10:3), so the wrap switches from its ≤1000px 2:1 box to 10:3
   here. The 1400px column cap bounds the height (album → ~700px, folder →
   ~420px). At exactly 1000px each matches its previous fixed height
   (album 500px, folder 300px), so the transition is seamless. */
@media (min-width: 1000px) {
    .album-hero {
        max-height: none;
    }
    .folder-hero-cover-wrap {
        aspect-ratio: 10 / 3;
        max-height: none;
    }
}

.folder-hero-meta {
    width: 100%;
    display: flex;
    flex-direction: column;
    align-items: center;
    padding: 0 1rem 3rem;
    text-align: center;
    gap: 0.5rem;
}

/* Circular avatar: 5px white border on a 140px-content image -> 150px
   outer with box-sizing: border-box. margin-top: -75px (half the outer
   size) pulls it up so exactly half overlaps the cover above. Render
   above the cover with a positive z-index so the white border stays
   on top of the cover's edge. Source is a 400x400 file — exact 2x DPR
   coverage at the 200 CSS-px desktop display size (see the 800px media
   rule below), and the same image is downscaled to fill the 300px
   click-to-expand overlay. */
/* The 150px white ring is now a wrapper that ALWAYS shows white,
   even while the image is loading. Previously the 5px white border
   lived on the <img>, but since the img fades from opacity:0 the
   hero cover behind it was visible through the (transparent)
   border during the fade. Wrapping the img in a white-filled
   circle separates "ring placeholder" from "image content" — the
   ring is always opaque, the img inside fades in over it. */
.folder-hero-avatar-wrap {
    width: 150px;
    height: 150px;
    border: 5px solid #fff;
    border-radius: 50%;
    background-color: rgb(var(--page-color, 50, 50, 50));
    box-sizing: border-box;
    /* The straddle margin lives on the parent .folder-hero-avatar-row
       so the album / photo count cells flanking the avatar lift with
       it as one block. The wrap keeps z-index: 2 so its white ring
       still stacks above the cover's bottom edge. */
    position: relative;
    z-index: 2;
    cursor: pointer;
    /* Display + flex centre the img inside the bordered box so the
       inner image is dead-centred within the white ring at every
       breakpoint. */
    display: flex;
    align-items: center;
    justify-content: center;
}

/* Avatar row: album count on the left, circular avatar centred, photo
   count on the right — all sitting just under the hero cover. The
   row carries the -75px straddle so the avatar still half-overlaps
   the cover above; the grid keeps the avatar dead-centred regardless
   of count text widths, and justify-self pulls each count toward the
   middle so they read as captions hugging the circle. */
.folder-hero-avatar-row {
    /* `--avatar-half` is half the avatar's outer height — the amount
       the row pulls itself up to straddle the cover above, and the
       same value the counts use to push themselves back down inside
       their cells so they sit under the cover instead of bisecting
       its bottom edge. Single source of truth so the <600px shrink
       only needs to redefine the variable. */
    --avatar-half: 75px;
    display: grid;
    grid-template-columns: 1fr auto 1fr;
    align-items: center;
    column-gap: 1rem;
    width: 100%;
    margin-top: calc(-1 * var(--avatar-half));
    color: var(--text-muted);
    font-size: 0.95rem;
}

/* Two-line count stack — used in both the avatar row (flanking the
   circle) and the no-avatar .folder-hero-counts row (below the
   title). Big bold number on top, small uppercase label underneath. */
.folder-album-count,
.folder-photo-count {
    display: flex;
    flex-direction: column;
    align-items: center;
    line-height: 1;
}

.folder-count-number {
    font-size: 1.3rem;
    font-weight: 400;
    color: #000;
    margin-bottom: 0.3rem;
}

.folder-count-label {
    font-size: 0.75rem;
    font-weight: 500;
    /* Same muted as .folder-hero-description so the "photos" /
       "albums" labels read as part of the same secondary type
       layer. Dark-mode override below matches the description's
       dark colour too. */
    color: var(--text-muted);
}

/* In the avatar row only, each count is shifted down by
   --avatar-half/2 so its centre lands halfway between the cover's
   bottom edge and the avatar's bottom edge — putting the block in
   the middle of the avatar's lower (below-the-cover) half. The row
   itself spans from cover_bottom - avatar_half to cover_bottom +
   avatar_half, with `align-items: center` placing each cell's centre
   on the cover line; the translate shifts that centre down by
   half an avatar half = the target midpoint. `transform` doesn't
   re-flow, so the row's box and the h2 below stay where they were. */
.folder-hero-avatar-row .folder-album-count,
.folder-hero-avatar-row .folder-photo-count {
    transform: translateY(calc(var(--avatar-half) / 2));
}

.folder-hero-avatar-row .folder-album-count {
    justify-self: end;
}

.folder-hero-avatar-row .folder-photo-count {
    justify-self: start;
}

/* When the avatar img zooms into the overlay, its visibility is set
   to hidden by wireFolderHeroAvatar — at that point the wrap's
   avg-colour background shows through where the image used to sit,
   so the disc reads as a filled colour ring instead of a void. No
   class toggle on the wrap itself is needed; the background is
   permanent. */

.folder-hero-avatar {
    width: 100%;
    height: 100%;
    border-radius: 50%;
    object-fit: cover;
    background-color: #fff;
    opacity: 0;
    transition: opacity 0.25s ease;
}

.folder-hero-avatar.loaded {
    opacity: 1;
}

/* When the avatar isn't rendered, the meta block butts straight up
   against the cover with no breathing room. Drop in a 1rem top
   padding so the first child (counts row, otherwise the h2) sits
   1rem below the cover's bottom edge — matching the requested spacing
   for the centred counts row in this layout. */
/* No-avatar layout drops the .folder-hero-counts row entirely (the
   template only emits counts when there's an avatar to flank). With
   the counts gone the title sits directly under the cover, so the
   meta needs a generous top breathing room. */
.folder-hero-meta:not(:has(.folder-hero-avatar-wrap)) {
    padding-top: 3rem;
}

.folder-hero-meta h1 {
    margin: 0;
    font-size: 2rem;
    font-weight: 500;
    line-height: 1.2;
    color: #000;
    opacity: 1;
    /* Flex row so the optional fallback icon (rendered when there's
       no folder image / circular avatar) sits inline with the name
       at the same visual size — `.icon-svg` is 1em × 1em, which
       inherits the h1's font-size. */
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: 0.5rem;
    /* Cap the h1 to the meta's 1rem-padded content width so long
       folder names wrap instead of pushing past the screen edge.
       `min-width: 0` lets the inner span shrink (flex items default
       to min-width:auto which refuses to break unbreakable tokens). */
    max-width: 100%;
    min-width: 0;
}

.folder-hero-meta h1 .name {
    overflow-wrap: break-word;
    word-break: break-word;
    min-width: 0;
}

.folder-hero-meta h1>.icon-svg {
    flex: 0 0 auto;
    /* Optical nudge: with `align-items: center` the SVG's geometric
       middle aligns with the line-box middle, but most fonts'
       cap-height sits above that midpoint — visually the icon reads
       slightly high next to the name. ~0.08em down brings it into
       optical alignment without offsetting the baseline so much it
       looks low. Em-based so it scales with the h2 font-size at the
       mobile breakpoint. */
    transform: translateY(0.08em);
}

/* Paired count row used in the cover-less folder fallback and on
   covered folders that have no avatar — album count on the left,
   total photo count on the right, sitting in the meta column below
   the title. Avatar'd folders use .folder-hero-avatar-row above
   instead so the counts flank the circle right under the cover. */
.folder-hero-counts {
    width: 100%;
    display: flex;
    justify-content: center;
    align-items: baseline;
    gap: 2.5rem;
    color: var(--text-muted);
    font-size: 0.95rem;
    opacity: 1;
}

.folder-hero-counts .folder-album-count,
.folder-hero-counts .folder-photo-count {
    flex: 0 0 auto;
}

.folder-hero-description {
    /* Slightly larger margins than the surrounding `gap` provides so
       the description has visible breathing room between the count
       above and the Website button below. */
    margin: 0.75rem 0;
    max-width: 330px;
    color: var(--text-muted);
    font-size: 0.95rem;
    line-height: 1.45;
    white-space: pre-line;
    /* Long URLs / unbreakable tokens wrap inside the meta's 1rem
       side padding instead of bleeding past the screen edge. */
    overflow-wrap: break-word;
    word-break: break-word;
}

.folder-hero-link {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: 0.5rem;
    height: 44px;
    padding: 0 20px 0 14px;
    /* Folder's avg cover colour in light mode; the dark-mode rule
       further down swaps to the same neutral grey the topbar wears
       at-rest in dark mode. */
    background: rgb(var(--page-color, 100, 100, 100));
    color: #fff;
    border-radius: var(--radius-full);
    text-decoration: none;
    font-size: 0.9rem;
    font-weight: 500;
    transition: background-color 0.2s ease;
}

/* Match the topbar / hero action buttons' icon size (1.3rem on
   .back-btn .icon-svg / .header-action-btn .icon-svg) so the globe
   glyph reads as the same family as the topbar icons rather than
   shrunk to the link's own 0.9rem font-size. */
.folder-hero-link>.icon-svg {
    font-size: 1.3rem;
}

/* Smooth darken on hover — only the background animates, so the
   text + icon stay fully white. `color-mix` blends 10% black into
   the avg-colour fill. */
.folder-hero-link:hover {
    background: color-mix(in srgb, rgb(var(--page-color, 100, 100, 100)), black 10%);
}

/* Click-to-expand overlay for the folder avatar. A single fixed-position
   img element is injected into <body> on click; its top/left/width/
   height are animated from the avatar's current bounding rect to a
   centred 300px circle. Both the morph and the backdrop fade share the
   same 0.32s duration + easing so the open/close reads as one motion.
   `pointer-events: auto` on the overlay catches clicks anywhere
   (backdrop OR image) and closes it. */
.avatar-overlay {
    position: fixed;
    inset: 0;
    z-index: 5000;
    pointer-events: auto;
}

.avatar-overlay-backdrop {
    position: absolute;
    inset: 0;
    background-color: rgba(0, 0, 0, 0);
    transition: background-color 0.32s ease;
}

.avatar-overlay.open .avatar-overlay-backdrop {
    background-color: rgba(0, 0, 0, 0.9);
}

.avatar-overlay-img {
    position: fixed;
    border-radius: 50%;
    object-fit: cover;
    cursor: pointer;
    z-index: 5001;
    transition: top 0.32s cubic-bezier(0.4, 0, 0.2, 1),
        left 0.32s cubic-bezier(0.4, 0, 0.2, 1),
        width 0.32s cubic-bezier(0.4, 0, 0.2, 1),
        height 0.32s cubic-bezier(0.4, 0, 0.2, 1);
}

/* Square the folder-hero cover at small viewports — the wrapper
   has 0 page-margin under 1200px so the cover sits flush against
   the viewport edges and a 15px corner would leave the
   avg-colour background showing through. */
@media (max-width: 1200px) {
    .folder-hero-cover-wrap {
        border-radius: 0;
    }
}

/* Folder-hero avatar sizing. Base (above) is the <800px size at
   150px; on wider screens the avatar grows to 200px. --avatar-half is
   kept at half the wrap's size so the row still straddles the cover by
   exactly half the circle at each breakpoint. */
@media (min-width: 800px) {
    .folder-hero-avatar-wrap {
        width: 200px;
        height: 200px;
    }

    .folder-hero-avatar-row {
        --avatar-half: 100px;
    }
}

@media (max-width: 600px) {
    .folder-hero-cover-wrap {
        aspect-ratio: 3 / 2;
    }

    .folder-hero-meta h1 {
        font-size: 1.5rem;
    }
}



/* Fade: opaque avg-color at the bottom, fully transparent at ~55% from
   the bottom so the upper half of the cover stays clean. The colour-stop
   midpoint sits a third of the way up to give a long, soft transition
   rather than a hard band. */
.album-hero-fade {
    position: absolute;
    inset: 0;
    z-index: 2;
    background: linear-gradient(to top,
            rgba(var(--avg-color), 1) 0%,
            rgba(var(--avg-color), 0.85) 18%,
            rgba(var(--avg-color), 0) 55%);
    pointer-events: none;
}

/* On narrower viewports the album hero is shorter, so the meta row
   (license / breadcrumb / sort) crowds the bottom and the title can
   bleed against the cover image above. Reach the gradient higher up
   the cover to give the text more contrast — solid avg-colour still
   anchors the bottom, but the transparent point moves from 55% to
   70% so the upper text sits over a tinted wash instead of bare
   cover. */
@media (max-width: 1000px) {
    .album-hero-fade {
        background: linear-gradient(to top,
                rgba(var(--avg-color), 1) 0%,
                rgba(var(--avg-color), 0.85) 25%,
                rgba(var(--avg-color), 0) 70%);
    }
}

.album-hero-text {
    position: relative;
    z-index: 3;
    width: 100%;
    padding: 1rem;
    text-align: center;
    color: #fff;
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 1rem;
}

/* `.page-header h1 { opacity: 0.8 }` and `.photo-count { opacity: 0.7 }`
   are matched at the same specificity by the rules below, so the explicit
   opacity:1 here is what keeps the white text actually-white over the
   fade. Without it the inherited opacity dilutes the title to ~80% white
   and the count to ~70%, both of which read as washed-out grey. */
.album-hero-text h1 {
    margin: 0;
    font-size: 2.2rem;
    font-weight: 500;
    line-height: 1.15;
    color: #fff;
    opacity: 1;
}

.album-hero-text .name {
    color: #fff;
    max-width: 700px;
    display: inline-block;
    /* `.album-hero-text` keeps a 1rem padding on every side, so the
       title's inline-box always has at least 1rem of breathing room
       from the hero edges. `overflow-wrap: break-word` lets a single
       unbreakable token (a long URL or a no-space title) wrap to a
       new line instead of pushing past that 1rem margin. */
    overflow-wrap: break-word;
    word-break: break-word;
}

/* Three-cell metadata row under the album title:
   - photo count (left)
   - parent-folder breadcrumb (center, slightly larger, no underline)
   - sort toggle (right)
   3-column grid (1fr auto 1fr) puts the breadcrumb dead-center
   relative to the cover regardless of how long the side labels
   are — flex justify-content: space-between would shift the
   middle cell when the right label is longer than the left
   (e.g. "Newest to Oldest" vs "12 photos"). */
.album-hero-text .album-meta-row {
    width: 100%;
    display: grid;
    /* `minmax(0, auto)` lets the middle column shrink below its
       content's min-width when the breadcrumb's parent-folder name
       is long. Without the explicit 0 floor, the column refuses to
       shrink and pushes against the flanking 1fr columns instead of
       allowing the breadcrumb's `text-overflow: ellipsis` to kick
       in. */
    grid-template-columns: 1fr minmax(0, auto) 1fr;
    align-items: center;
    gap: 12px;
}

.album-hero-text .album-meta-row>.album-license-btn {
    justify-self: start;
}

.album-hero-text .album-meta-row>.album-breadcrumb {
    justify-self: center;
}

.album-hero-text .album-meta-row>.album-sort-btn {
    justify-self: end;
}

/* Breadcrumb (avatar + parent name) sits in the middle cell. Slightly
   larger than the muted count/sort cells flanking it; image and text
   sit on the same baseline. No underline since the avatar+name reads
   as a single identity unit, not a "this is a link" affordance. */
.album-hero-text .album-meta-row .album-breadcrumb {
    color: #fff;
    opacity: 1;
    font-size: 1.2rem;
    /* Allow the breadcrumb cell to shrink past its content's
       intrinsic width so the inner span can ellipsis. */
    min-width: 0;
    max-width: 100%;
    overflow: hidden;
}

.album-hero-text .album-meta-row .album-breadcrumb .parent-link,
.album-hero-text .album-meta-row .album-breadcrumb .parent-link:hover,
.album-hero-text .album-meta-row .album-breadcrumb .parent-link:focus,
.album-hero-text .album-meta-row .album-breadcrumb .parent-link:visited {
    color: inherit;
    text-decoration: none;
    display: inline-flex;
    align-items: center;
    gap: 14px;
    vertical-align: middle;
    max-width: 100%;
    min-width: 0;
}

/* Parent-folder name (the text part of the breadcrumb) stays a
   single line — long folder names truncate with an ellipsis rather
   than wrap or push the row taller. `min-width: 0` is the flex-item
   override that lets the span actually shrink below its content's
   intrinsic width. */
.album-hero-text .album-meta-row .album-breadcrumb .parent-link>span {
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    min-width: 0;
}

/* Circular folder avatar rendered inline next to the parent-folder
   name. 33px display — fed by the 70x70 xs variant for crisp render
   at 2x DPR. Dark background fills the circle while the image is in
   flight so the row never shows an empty void; opacity transitions
   to 1 once the load fires (delegate listener in common.js). */
.parent-link-image {
    width: 33px;
    height: 33px;
    border-radius: 50%;
    object-fit: cover;
    flex: 0 0 auto;
    background-color: #333;
    opacity: 0;
    transition: opacity 0.25s ease;
}

.parent-link-image.loaded {
    opacity: 1;
}

/* License info bubble (title + bullet list or paragraph). Anchored
   to the right edge of the license button itself (`position:
   relative` on the button) so the bubble sits 1rem to the right
   of the button and vertically centred against it. Background +
   shadow mirror the sort dropdown so the two read as a single
   design family. */
.album-hero-text .album-meta-row>.album-license-btn {
    position: relative;
}

.album-hero-text .album-meta-row>.album-license-btn .copy-toast {
    position: absolute;
    top: 50%;
    left: calc(100% + 1rem);
    right: auto;
    bottom: auto;
    /* Slightly-darker tone of --topbar-bg — same treatment as the
       sidebar + sort-menu dropdowns so the three popovers read as
       one family one shade deeper than the topbar. */
    background: color-mix(in srgb, var(--topbar-bg), black 30%);
    color: #fff;
    box-shadow: 0 6px 18px rgba(0, 0, 0, 0.18);
    transform: translate(-5px, -50%);
    white-space: normal;
    text-align: left;
    padding: 12px 16px;
    border-radius: 14px;
    min-width: 180px;
    max-width: 260px;
    font-weight: 400;
    /* The page-header's z-index lifts the bubble above the
       .album-grid sibling; the value here only matters within
       .album-hero-text's own stacking context (sits above the hero
       cover + fade). */
    z-index: 10;
}

/* Hover trigger: pointer-device users see the bubble without needing
   to tap. The `.show` class set by the JS click handler still works
   alongside this for tap feedback on touch devices. Both visible
   states settle the horizontal translate to 0 — the bubble fades in
   from 5px left of its final position while keeping the -50%
   vertical center alignment. */
.album-hero-text .album-meta-row>.album-license-btn .copy-toast.show {
    opacity: 1;
    transform: translate(0, -50%);
}

/* Hover trigger only on real pointer devices. On touch, :hover sticks
   after a tap and would keep the bubble open even once the JS removed
   .show — so a second tap / outside tap couldn't dismiss it. Touch
   devices drive visibility purely via the .show toggle in
   wireAlbumHeroActions(). */
@media (hover: hover) {
    .album-hero-text .album-meta-row>.album-license-btn:hover .copy-toast {
        opacity: 1;
        transform: translate(0, -50%);
    }
}

.license-toast-title {
    font-weight: 600;
    font-size: 0.9rem;
    color: #fff;
    margin-bottom: 8px;
}

.license-toast-list {
    list-style: none;
    margin: 0;
    padding: 0;
    display: flex;
    flex-direction: column;
    gap: 4px;
}

.license-toast-list li {
    display: flex;
    align-items: center;
    gap: 6px;
    font-size: 0.78rem;
    color: rgba(255, 255, 255, 0.85);
    line-height: 1.3;
}

.license-toast-text {
    margin: 0;
    font-size: 0.78rem;
    color: rgba(255, 255, 255, 0.85);
    line-height: 1.4;
}

.license-bullet-icon {
    width: 0.9rem;
    height: 0.9rem;
    flex-shrink: 0;
    fill: currentColor;
}

.license-toast-ok .license-bullet-icon,
.license-toast-no .license-bullet-icon {
    color: rgba(255, 255, 255, 0.85);
}

@media (max-width: 600px) {
    .album-hero {
        border-radius: 0;
        aspect-ratio: 3 / 2;
    }

    .album-hero-text h1 {
        font-size: 1.65rem;
    }
}

/* Phone-portrait nudge: bump the album hero just a notch taller than
   the mid-tablet 3/2 ratio so the cover has more presence on narrow
   screens. 4/3 lands ~12% taller than 3/2 at the same width without
   crossing into the square territory that previously made it
   dominate the viewport. Album page only — folder pages keep 3/2
   at this width. */
@media (max-width: 480px) {
    .album-hero {
        aspect-ratio: 4 / 3;
    }
}

.header-search {
    display: flex;
    align-items: center;
}

.header-search .search-icon {
    position: absolute;
    left: 14px;
    display: flex;
    align-items: center;
    justify-content: center;
    color: #888;
    pointer-events: none;
}

.header-search .search-icon .icon-svg {
    font-size: 1.4rem;
    color: #000000;
}

/* Search input matches the topbar buttons: same translucent-white
   fill + inset highlight, white text + placeholder, no border. The
   leading search glyph and clear-X are also white via the broader
   `.top-bar svg path { fill: #fff }` rule — no `.header-search`-
   specific black override anymore. */
.header-search-input {
    height: 44px;
    border-radius: 22px;
    border: none;
    background: rgba(255, 255, 255, 0.15);
    box-shadow: var(--shadow-inset-highlight);
    color: #fff;
    transition: background-color 0.2s ease;
    width: 100%;
    padding: 0 46px 0 46px;
    font-size: 15px;
    font-family: inherit;
}

.header-search-input:hover,
.header-search-input:focus {
    border: none;
    background: rgba(255, 255, 255, 0.25);
}

.header-search-input::placeholder {
    color: rgba(255, 255, 255, 0.65);
}

.search-clear-btn {
    position: absolute;
    right: 8px;
    top: 50%;
    transform: translateY(-50%);
    width: 32px;
    height: 32px;
    border-radius: 50%;
    border: none;
    background: transparent;
    display: flex;
    align-items: center;
    justify-content: center;
    color: #fff;
    cursor: pointer;
    transition: background-color 0.2s ease;
}

.search-clear-btn:hover {
    background: rgba(255, 255, 255, 0.15);
    color: #fff;
}

.search-clear-btn .icon-svg {
    font-size: 1.2rem;
}

/* Justified (Flickr-style) gallery: row-based masonry where each row has a
   variable but consistent height and items keep their aspect ratio. Tile
   dimensions are written inline by the layout pass in common.js#relayoutJustifiedGrid.
   We use inline-block so rows wrap naturally; the layout pass computes the
   exact widths to fully justify each row to the container width. */
.album-view .album-grid {
    display: block;
    /* Photo grid is flush with the wrapper's left/right edges so it
       lines up with the album hero above. Top/bottom 3px keeps a hint
       of breathing room around the masonry; the 3px BETWEEN photos is
       a separate concern handled by relayoutJustifiedGrid in common.js
       (JUSTIFIED_GAP_PX). */
    padding: 3px 0;
    margin: 0;
    max-width: none;
    font-size: 0;
    line-height: 0;
}

.album-view .tile {
    display: inline-block;
    vertical-align: top;
    margin: 0;
    /* width / height are set inline by the layout pass */
    overflow: hidden;
}

.album-grid-wrapper.album-view {
    /* No padding override here. The base .album-grid-wrapper rule
       applies the responsive page margin (1rem / 0 by
       breakpoint) on top + sides, which is what album pages want
       too — the hero and photo grid both inset to the page margin
       so the layout matches folder pages. */
    margin: 0;
}

/* Staggered reveal applied to tiles in the first batch of a fresh navigation.
   `--i` is a per-tile index capped in JS so the tail stays short. */
@keyframes tileEnter {
    from {
        opacity: 0;
        transform: translateY(6px);
    }

    to {
        opacity: 1;
        transform: none;
    }
}

/* fill-mode: backwards (not "both") so the FROM state is held during
   the animation-delay (prevents the tile flashing visible at full
   opacity before its turn) but the TO state isn't latched after the
   animation ends. If we'd kept "both", the keyframe's explicit
   `transform: none` at 100% would persist with animation-priority and
   silently override `.tile:hover { transform: scale(1.01) }` — that
   was why the zoom worked in admin (no .tile-enter is ever added
   there) but not in public. */
.tile.tile-enter {
    animation: tileEnter 0.32s cubic-bezier(0.4, 0, 0.2, 1) backwards;
    animation-delay: calc(var(--i, 0) * 18ms);
}

@media (prefers-reduced-motion: reduce) {

    .tile.tile-enter,
    .album-hero,
    .folder-hero {
        animation: none;
    }
}

/* View Transitions: tighter crossfade than the browser default. */
::view-transition-old(root),
::view-transition-new(root) {
    animation-duration: 0.22s;
    animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}

/* Indeterminate progress bar shown only when a content fetch outruns the
   threshold; toggled by start/endNavProgress in app.js AND by the
   fullscreen viewer's start/endProgress (viewer.js) during large-image
   upgrades. z-index 3500 sits ABOVE the viewer (3000) — otherwise the
   viewer's opaque backdrop would hide the bar during in-viewer loads —
   and below the batch indicator (4000) / drop overlay (9999). */
.nav-progress {
    position: fixed;
    top: 0;
    left: 0;
    width: 100%;
    height: 2px;
    pointer-events: none;
    z-index: 3500;
    opacity: 0;
    transition: opacity 0.18s ease;
    overflow: hidden;
}

.nav-progress.active {
    opacity: 1;
}

.nav-progress::before {
    content: '';
    position: absolute;
    top: 0;
    left: 0;
    height: 100%;
    width: 40%;
    background: var(--primary-accent);
    border-radius: 0 2px 2px 0;
    transform: translateX(-100%);
    animation: navProgressSlide 1.1s cubic-bezier(0.4, 0, 0.2, 1) infinite;
}

@keyframes navProgressSlide {
    0% {
        transform: translateX(-100%);
    }

    100% {
        transform: translateX(350%);
    }
}

@media (prefers-reduced-motion: reduce) {
    .nav-progress::before {
        animation: none;
        transform: translateX(0);
        width: 100%;
        opacity: 0.5;
    }
}

.album-view .tile {
    /* JS (relayoutJustifiedGrid) writes width/height inline. Defaulting to 100%
       here causes tiles to jump to full-width for one frame during resize
       before JS corrects them, creating a visible "flash". */
    display: inline-block;
    vertical-align: top;
    margin: 0;
    overflow: hidden;
}

.tile:hover {
    z-index: 960;
    transform: scale(1.01);
}

.tile:hover .tile-inner {
    box-shadow: 0 3px 12px rgba(var(--avg-color, 100, 100, 100), 0.4);
}

.album-view .tile:hover {
    transform: none;
}

/* Justified tile: the wrapper takes its parent tile's computed size; the
   image fills that exact box. Because the layout pass derives the tile
   dimensions from the photo's own aspect ratio, object-fit only matters
   as a sub-pixel safeguard. */
.album-view .photo-item {
    display: block;
    border: none;
    padding: 0;
    background: #f5f5f5;
    cursor: pointer;
    position: relative;
    overflow: hidden;
    width: 100%;
    height: 100%;
}

/* Dark mode: the #f5f5f5 photo-loading placeholder (the "white flash" noted
   on the img rule below) reads as far too bright against the dark album
   surface while the AVIF loads. Swap it for a dark-theme neutral that sits
   just under the dark page tone, so loading tiles blend in instead of
   flashing white; the photo fades in over it on load. */
html.dark .album-view .photo-item {
    background: #2e2e2e;
}

.album-view .image-container {
    position: absolute;
    inset: 0;
}

.album-view .photo-item img {
    display: block;
    width: 100%;
    height: 100%;
    object-fit: cover;
    /* transition: transform is removed to prevent artifacts during resize */
    transition: opacity 0.3s ease-in-out;
    opacity: 0;
    /* Promote the image to its own GPU layer so a rapid window resize
       (especially across the 1200px breakpoint where the sidebar enters /
       leaves the layout) just rescales the bitmap layer instead of
       re-rasterizing — eliminates the brief white #f5f5f5 flashes that
       happen when reflow outpaces image paint. */
    transform: translateZ(0);
    will-change: transform;
}

.album-view .photo-item img.fully-loaded {
    opacity: 1;
}

/* Same broken-image fallback as .album-cover: a photo tile whose image
   404s / fails to decode is hidden so the .photo-item placeholder
   background shows instead of a broken-image glyph. visibility:hidden for
   the same reason as .album-cover.broken: the img fills its tile (the tile
   owns the layout), and display:none would stop the lazy loader's
   IntersectionObserver from ever retrying a transiently-failed tile. */
.album-view .photo-item img.broken {
    visibility: hidden;
}

.action-btn {
    background: none;
    border: none;
    color: white;
    cursor: pointer;
    min-width: 48px;
    padding: 0 16px;
    height: 48px;
    display: inline-flex;
    align-items: center;
    text-decoration: none;
    font-size: 0.9rem;
    font-weight: 400;
    gap: 8px;
    border-radius: 24px;
}

.action-btn .icon-svg {
    font-size: 24px;
}

/* @admin-start */
body.admin-mode .category-items:not(:empty) {
    min-height: 20px;
    border-radius: 12px;
    transition: background 0.2s ease;
}

.sidebar-ghost {
    opacity: 0.4;
    background: color-mix(in srgb, var(--primary-accent), white 88%) !important;
}

.theme-button {
    color: var(--button-text-color-alt);
    background: var(--bg-white);
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: 0.5rem;
    padding: 0 1rem;
    border-radius: 24px;
    height: 48px;
    font-size: 0.9rem;
    text-decoration: none;
    font-weight: 500;
}

.theme-button[hidden] {
    display: none;
}

.theme-button:disabled {
    opacity: 0.5;
    cursor: not-allowed;
    pointer-events: none;
}

.blue-btn {
    color: #fff !important;
    background: var(--primary-accent) !important;
}

.blue-btn:hover {
    background: var(--primary-accent-hover) !important;
}

.white-btn {
    color: #000000 !important;
    background: #fff;
    border: 1px solid #efefef;
}

.white-btn:hover {
    border: 1px solid #dddddd;
}

.dialog-backdrop {
    position: fixed;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    /* Override the `.main-content > * { max-width: 1400px }` cap from the
       admin layout — the backdrop is a modal that needs to span the full
       viewport, not the centered content column. */
    max-width: none;
    background: rgba(0, 0, 0, 0.4);
    display: none;
    justify-content: center;
    align-items: center;
    /* Above the sticky topbar (2100) so modal dialogs cover it
       completely. Stays below the viewer (3000), nav-progress (3500),
       batch-indicator (4000), drop-overlay (9999). */
    z-index: 2200;
    /* Fade the backdrop in when JS flips display:none → flex. The card
       inside rises into place via .dialog-content below. Close is the
       reverse, driven by the .dialog-closing class (see the dialog
       MutationObserver in admin.html). */
    animation: dialogBackdropIn 0.2s ease both;
}

.dialog-content {
    background: var(--bg-white);
    /* The sticky header provides its own top padding so it can sit flush
       against the rounded corner. Side / bottom padding stay on the
       outer container so form fields keep their familiar gutter. */
    padding: 0 2rem 2.5rem;
    border-radius: 24px;
    margin: 1rem;
    width: 100%;
    max-width: 420px;
    /* Cap the modal height so a long form (Edit Album with many tags,
       file pickers, etc.) doesn't grow past the viewport on shorter
       screens. The internal content scrolls when it overflows. */
    max-height: calc(100vh - 2rem);
    overflow-y: auto;
    -webkit-overflow-scrolling: touch;
    overscroll-behavior: contain;
    display: flex;
    flex-direction: column;
    align-items: center;
    position: relative;
    box-shadow: 0 20px 40px rgba(0, 0, 0, 0.12);
    border: 1px solid var(--border-light);
    /* Card rises + fades into place on open. */
    animation: dialogContentIn 0.24s cubic-bezier(0.22, 1, 0.36, 1) both;
}

/* Close motion: the dialog MutationObserver in admin.html adds
   .dialog-closing while the close animation plays. The JS has ALREADY set
   inline display:none (every close site does that); `display: flex
   !important` here keeps the backdrop rendered for the animation without
   the JS touching inline display — so every `style.display` read still
   sees 'none' during the close (exactly the pre-animation behaviour), and
   a re-open's inline display='flex' is a real none→flex mutation the
   observer can catch to cancel the close. The .dialog-closing rules win on
   specificity over the open animations above. */
.dialog-backdrop.dialog-closing {
    display: flex !important;
    animation: dialogBackdropOut 0.2s ease both;
}
.dialog-backdrop.dialog-closing .dialog-content {
    animation: dialogContentOut 0.2s ease both;
}

@keyframes dialogBackdropIn {
    from { opacity: 0; }
    to { opacity: 1; }
}
@keyframes dialogBackdropOut {
    from { opacity: 1; }
    to { opacity: 0; }
}
@keyframes dialogContentIn {
    from { opacity: 0; transform: translateY(16px); }
    to { opacity: 1; transform: translateY(0); }
}
@keyframes dialogContentOut {
    from { opacity: 1; transform: translateY(0); }
    to { opacity: 0; transform: translateY(16px); }
}

/* Respect reduced-motion: skip the open/close animations entirely. The
   observer also checks this and hides instantly (no .dialog-closing). */
@media (prefers-reduced-motion: reduce) {
    .dialog-backdrop,
    .dialog-content,
    .dialog-backdrop.dialog-closing,
    .dialog-backdrop.dialog-closing .dialog-content {
        animation: none !important;
    }
}

/* Confirm dialog: a compact card reusing .dialog-backdrop/.dialog-content
   (and their open/close animation) but with no sticky header, so it needs its
   own padding + a centered title/message/actions stack. */
/* Sit ABOVE the other dialog backdrops (all z-index 2200) so a confirm opened
   over the edit / album / user modal isn't occluded by it (DOM order would
   otherwise paint this behind them). Still below the viewer (3000). */
#confirmDialog { z-index: 2300; }
.confirm-dialog-content {
    padding: 1.9rem 1.9rem 1.6rem;
    max-width: 360px;
    text-align: center;
}
.confirm-dialog-title {
    margin: 0 0 10px;
    font-size: 1.15rem;
}
/* No-message confirms (e.g. "Log out?") hide the <p>, which normally
   carries the 22px gap above the buttons — move that gap onto the title
   so the buttons don't sit cramped right under it. showConfirm toggles
   this class. */
.confirm-dialog-title.no-msg {
    margin-bottom: 22px;
}
.confirm-dialog-message {
    margin: 0 0 22px;
    color: #666;
    line-height: 1.5;
    font-size: 0.95rem;
}
.confirm-dialog-actions {
    display: flex;
    gap: 10px;
    width: 100%;
}
.confirm-dialog-actions .theme-button { flex: 1; }
/* The shared .delete-btn rule ships `display: none` (the edit modal shows
   its delete button conditionally via JS). The confirm dialog's OK button
   reuses .delete-btn for the red danger styling and must ALWAYS be
   visible — without this override every danger confirm rendered with only
   a Cancel button. */
.confirm-dialog-actions .delete-btn { display: block; }

.close-btn {
    position: absolute;
    top: 15px;
    right: 15px;
    height: 40px;
    width: 40px;
    display: flex;
    align-items: center;
    justify-content: center;
    border-radius: 50%;
    border: 1px solid var(--theme-border-color);
    background: var(--bg-white);
}

.close-btn:hover {
    color: #000;
    border: 1px solid var(--theme-border-color-hover);
}

/* Sticky dialog title bar. Wraps the title (and the close / delete
   circular buttons) so it remains pinned to the top of the dialog as
   the form scrolls. The header owns its top padding (dialog-content
   has padding-top: 0) so it sits flush against the rounded corners
   with no visual gap when content scrolls behind it. Negative side
   margins cancel the dialog-content side padding so the header
   extends edge-to-edge; align-self: stretch overrides the parent's
   align-items: center so the full cross-axis width is taken. A subtle
   border-bottom provides visual separation once content scrolls
   underneath. */
.dialog-header {
    position: sticky;
    top: 0;
    z-index: 3;
    background: var(--bg-white);
    align-self: stretch;
    margin: 0 -2rem 1.25rem;
    padding: 1.5rem 2rem 1rem;
    border-radius: 24px 24px 0 0;
    border-bottom: 1px solid var(--border-light);
    display: flex;
    flex-direction: column;
    align-items: center;
}

.dialog-header h2 {
    margin: 0;
    /* Reserve horizontal room for the absolute close / delete circles
       so a long title doesn't slide under them. */
    padding: 0 56px;
    width: 100%;
    box-sizing: border-box;
    text-align: center;
}

.dialog-content h2 {
    margin-top: 0;
    margin-bottom: 0.5rem;
    text-align: center;
    color: #1a1a1a;
    font-size: 1.5rem;
    font-weight: 600;
}

/* Circular destructive action sitting opposite the close button in the
   sticky dialog header. Used for "Delete folder" / "Delete album" —
   replaces the old text-link variant under the form. */
.dialog-delete-btn {
    position: absolute;
    top: 15px;
    left: 15px;
    height: 40px;
    width: 40px;
    display: flex;
    align-items: center;
    justify-content: center;
    padding: 0;
    border-radius: 50%;
    border: 1px solid var(--theme-border-color);
    background: var(--bg-white);
    color: #e23636;
    cursor: pointer;
    transition: border-color 0.15s ease;
}

.dialog-delete-btn:hover {
    border: 1px solid var(--theme-border-color-hover);
}

.dialog-delete-btn .icon-svg {
    width: 22px;
    height: 22px;
    fill: currentColor;
}

.sortable-ghost {
    opacity: 0.4;
    filter: grayscale(100%);
}

#drop-overlay {
    position: fixed;
    top: 0;
    left: 0;
    right: 0;
    bottom: 0;
    background: rgba(0, 185, 244, 0.9);
    color: white;
    display: flex;
    align-items: center;
    justify-content: center;
    font-size: 2rem;
    font-weight: 600;
    z-index: 9999;
    opacity: 0;
    pointer-events: none;
    transition: opacity 0.2s;
}

#drop-overlay.active {
    opacity: 1;
    pointer-events: all;
}

/* Global in-flight chip: shows whenever any mutating admin /api/
   request is pending so the UI never looks frozen during slow GCS
   writes, retries, etc. Lives bottom-left so it doesn't collide with
   #batchBackgroundIndicator (bottom-right) during big uploads. */
#globalBusyChip {
    position: fixed;
    left: 20px;
    bottom: 20px;
    z-index: 1000;
    display: flex;
    align-items: center;
    gap: 10px;
    padding: 8px 14px 8px 12px;
    background: rgba(31, 41, 55, 0.92);
    color: #f5f7fa;
    font-size: 0.85rem;
    border-radius: 999px;
    box-shadow: 0 4px 18px rgba(0, 0, 0, 0.25);
    pointer-events: none;
    /* Tiny entry animation so it doesn't pop in too abruptly for
       sub-200ms requests that happen to be slow. */
    animation: gbc-fade-in 120ms ease-out;
}

#globalBusyChip .gbc-spinner {
    width: 14px;
    height: 14px;
    border-radius: 50%;
    border: 2px solid rgba(245, 247, 250, 0.25);
    border-top-color: #f5f7fa;
    animation: gbc-spin 0.8s linear infinite;
}

@keyframes gbc-spin {
    to {
        transform: rotate(360deg);
    }
}

@keyframes gbc-fade-in {
    from {
        opacity: 0;
        transform: translateY(4px);
    }

    to {
        opacity: 1;
        transform: translateY(0);
    }
}

/* Floating indicator shown when the user X-closes the upload modal
   while a batch is still running (or sitting at the summary). Click
   reopens the modal. */
#batchBackgroundIndicator {
    position: fixed;
    right: 0.5rem;
    bottom: 0.5rem;
    z-index: 4000;
    min-width: 240px;
    max-width: 320px;
    background: #1a1a1a;
    color: #fff;
    border-radius: 14px;
    padding: 12px 16px;
    box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
    cursor: pointer;
    user-select: none;
    /* Column + gap so a hidden (terminal) bar collapses its space instead
       of leaving the old margin-bottom gap behind. */
    display: flex;
    flex-direction: column;
    gap: 6px;
    transition: transform 0.15s ease;
}

#batchBackgroundIndicator:hover {
    transform: translateY(-2px);
}

#batchBackgroundIndicator[hidden] {
    display: none;
}

#batchBackgroundIndicator.is-success {
    background: #458b60;
}

#batchBackgroundIndicator.is-error {
    background: #8b1f1f;
}

/* Mirrors the dialog H2 — the only prominent line on the pill. */
#batchBackgroundIndicator .bbi-title {
    font-size: 0.9rem;
    font-weight: 600;
    line-height: 1.2;
}

/* Mirrors the dialog's under-bar status line (dimmed on the dark pill). */
#batchBackgroundIndicator .bbi-text {
    font-size: 0.78rem;
    line-height: 1.3;
    color: rgba(255, 255, 255, 0.8);
}

#batchBackgroundIndicator .bbi-bar {
    width: 100%;
    height: 4px;
    background: rgba(255, 255, 255, 0.2);
    border-radius: 2px;
    overflow: hidden;
}

#batchBackgroundIndicator .bbi-bar-fill {
    width: 0%;
    height: 100%;
    background: #fff;
    transition: width 0.2s;
}

/* The bar is hidden for ALL terminal states (success / error / canceled)
   directly in updateBackgroundIndicator so the flex gap collapses with it. */

/* Grid of thumbnails of every file queued in the current batch upload.
   Status drives the overlay tint so the admin sees at-a-glance which
   ones are done, in-flight, queued, failed or cancelled. */
#batchThumbsGrid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(64px, 1fr));
    gap: 6px;
    width: 100%;
    max-height: 260px;
    overflow-y: auto;
    margin-bottom: 12px;
    padding: 4px 0;
}

.batch-thumb {
    position: relative;
    aspect-ratio: 1;
    border-radius: 6px;
    overflow: hidden;
    background: #f4f4f4;
}

.batch-thumb img,
.batch-thumb canvas {
    width: 100%;
    height: 100%;
    object-fit: cover;
    display: block;
}

.batch-thumb::after {
    content: '';
    position: absolute;
    inset: 0;
    background: rgba(255, 255, 255, 0.55);
    pointer-events: none;
    transition: background 0.2s;
}

.batch-thumb.is-uploading::after {
    background: rgba(30, 100, 200, 0.25);
}

.batch-thumb.is-ok::after {
    background: transparent;
}

.batch-thumb.is-failed::after {
    background: rgba(200, 40, 40, 0.35);
}

.batch-thumb.is-cancelled::after {
    background: rgba(0, 0, 0, 0.5);
}

.batch-thumb-status {
    position: absolute;
    top: 3px;
    right: 3px;
    width: 18px;
    height: 18px;
    border-radius: 50%;
    background: rgba(0, 0, 0, 0.6);
    color: #fff;
    font-size: 12px;
    line-height: 18px;
    text-align: center;
    z-index: 2;
    display: none;
}

.batch-thumb.is-uploading .batch-thumb-status,
.batch-thumb.is-committing .batch-thumb-status,
.batch-thumb.is-ok .batch-thumb-status,
.batch-thumb.is-failed .batch-thumb-status,
.batch-thumb.is-cancelled .batch-thumb-status {
    display: flex;
    align-items: center;
    justify-content: center;
}

.batch-thumb.is-ok .batch-thumb-status {
    background: #458b60;
}

.batch-thumb.is-failed .batch-thumb-status {
    background: #8b1f1f;
}

.batch-thumb.is-uploading .batch-thumb-status,
.batch-thumb.is-committing .batch-thumb-status {
    background: #222;
}

/* ---- Upload UI polish: state icons, spinner, progress motion ---- */
/* Inline status icons (check / ✕ / !) + a CSS spinner, shared by the dialog
   title, the per-thumb badges, and the background pill. icons.js has no
   check/spinner/error glyphs, so these are inlined rather than bloating the
   shared icon map (which also injects into worker.js). */
.up-ic,
.up-arrow { width: 1em; height: 1em; flex: none; display: inline-block; stroke-width: 2.5; }
.up-spinner {
    display: inline-block;
    box-sizing: border-box;
    width: 0.95em;
    height: 0.95em;
    flex: none;
    border: 2px solid currentColor;
    border-top-color: transparent;
    border-radius: 50%;
    /* Slow, continuous spin. The title re-renders only on STATE change
       (setUploadTitle), so the element persists and the spin never restarts. */
    animation: up-spin 1.4s linear infinite;
}
@keyframes up-spin { to { transform: rotate(360deg); } }

/* Dialog H2 / pill title: icon + text, vertically centered. */
.upload-title { display: inline-flex; align-items: center; gap: 8px; }
/* Dialog title icon: always black + extra weight (the modal's own close
   button is a separate element and is intentionally left untouched). */
#modalTitle .upload-title .up-ic,
#modalTitle .upload-title .up-spinner { color: #000; }
#modalTitle .upload-title .up-ic { stroke-width: 2.8; }
/* Pill icons stay white (inherit) since the pill background signals state,
   just heavier — and a touch larger so the check/✕/! match the spinner ring. */
#batchBackgroundIndicator .up-ic { stroke-width: 2.8; width: 1.25em; height: 1.25em; }

/* Tabular figures so the live numbers don't shift the line width. */
#batchEta,
#batchBackgroundIndicator .bbi-text {
    font-variant-numeric: tabular-nums;
}

/* Smoother bar fill + rounded leading edge. */
#uploadProgressBar { transition: width 0.25s ease-out; border-radius: 5px; }

/* Processing tail (bytes done, server-side derive/commit in flight): a
   sweeping highlight on the full bar signals "still working". */
#uploadProgressContainer.is-processing,
#batchBackgroundIndicator .bbi-bar.is-processing { position: relative; }
#uploadProgressContainer.is-processing::after,
#batchBackgroundIndicator .bbi-bar.is-processing::after {
    content: '';
    position: absolute;
    inset: 0;
    transform: translateX(-100%);
    animation: up-shimmer 1.2s ease-in-out infinite;
}
/* A white sweep reads on the dialog's green fill; the pill fill is white, so a
   dark sweep is used there instead — same motion, just a visible shade. */
#uploadProgressContainer.is-processing::after {
    background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.45), transparent);
}
#batchBackgroundIndicator .bbi-bar.is-processing::after {
    background: linear-gradient(90deg, transparent, rgba(0, 0, 0, 0.3), transparent);
}
@keyframes up-shimmer { to { transform: translateX(100%); } }

/* Per-thumb badge icon/arrow sizing + weight (heavier than the title icons
   so they read clearly at 11px). */
.batch-thumb-status .up-ic,
.batch-thumb-status .up-arrow { width: 11px; height: 11px; stroke-width: 3.2; }

/* Completion pop on a tile's badge when it flips to ok. */
.batch-thumb.is-ok .batch-thumb-status { animation: up-pop 0.28s ease-out; }
@keyframes up-pop {
    0% { transform: scale(0.5); opacity: 0.4; }
    60% { transform: scale(1.15); }
    100% { transform: scale(1); opacity: 1; }
}

/* Background pill entrance. */
#batchBackgroundIndicator:not([hidden]) { animation: bbi-in 0.16s ease-out; }
@keyframes bbi-in {
    from { opacity: 0; transform: translateY(8px); }
    to { opacity: 1; transform: translateY(0); }
}

/* Subtle "reopen" affordance, top-right of the pill (the whole pill clicks). */
#batchBackgroundIndicator .bbi-expand {
    position: absolute;
    top: 10px;
    right: 12px;
    width: 14px;
    height: 14px;
    opacity: 0.5;
    pointer-events: none;
}
#batchBackgroundIndicator:hover .bbi-expand { opacity: 0.85; }
#batchBackgroundIndicator .bbi-expand svg { width: 100%; height: 100%; display: block; }
#batchBackgroundIndicator .bbi-title { padding-right: 18px; }

@media (prefers-reduced-motion: reduce) {
    .up-spinner,
    #uploadProgressContainer.is-processing::after,
    #batchBackgroundIndicator .bbi-bar.is-processing::after,
    .batch-thumb.is-ok .batch-thumb-status,
    #batchBackgroundIndicator:not([hidden]) { animation: none; }
    #uploadProgressBar { transition: none; }
}

/* Empty-state panel rendered when an album / folder has no entries.
   Whole panel is a single drop-target/click-target; the window-level
   drop handler still catches drops over it. */
.admin-empty-state {
    grid-column: 1 / -1;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    text-align: center;
    padding: 60px 24px;
    min-height: 280px;
    border: 2px dashed #d8d8d8;
    border-radius: 16px;
    background: #fafafa;
    cursor: pointer;
    transition: border-color 0.15s ease, background-color 0.15s ease;
    margin: 16px;
    /* Reset font-size + line-height inherited from .album-view
       .album-grid (which forces them to 0 to pack inline-block tiles
       with no whitespace). Without these resets, the heading and sub
       collapse onto the same baseline and visually overlap. */
    font-size: 1rem;
    line-height: normal;
}

.admin-empty-state:hover {
    border-color: var(--primary-accent);
    background: #f5fbff;
}

.admin-empty-icon {
    width: 56px;
    height: 56px;
    color: #b8b8b8;
    margin-bottom: 12px;
}

.admin-empty-icon .icon-svg {
    width: 100%;
    height: 100%;
}

.admin-empty-heading {
    font-size: 1.1rem;
    font-weight: 500;
    color: #1a1a1a;
    margin-bottom: 6px;
}

.admin-empty-sub {
    font-size: 0.9rem;
    color: #888;
    max-width: 360px;
}

/* Stack of undo toasts at the bottom-center. Each has a label, a
   shrinking countdown bar, and an Undo button. After the countdown the
   toast flips to a "Deleting…" indeterminate-progress state while the
   server call runs, then auto-dismisses. */
#undoToasts {
    position: fixed;
    left: 50%;
    bottom: 20px;
    transform: translateX(-50%);
    z-index: 5000;
    display: flex;
    flex-direction: column;
    gap: 8px;
    pointer-events: none;
}

.undo-toast {
    pointer-events: all;
    background: #1a1a1a;
    color: #fff;
    border-radius: 12px;
    padding: 12px 14px 12px 16px;
    min-width: 320px;
    max-width: 480px;
    display: flex;
    align-items: center;
    gap: 14px;
    box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
    opacity: 0;
    transform: translateY(8px);
    animation: undoToastIn 0.18s ease forwards;
}

@keyframes undoToastIn {
    to {
        opacity: 1;
        transform: translateY(0);
    }
}

.undo-toast.is-dismissing {
    animation: undoToastOut 0.2s ease forwards;
}

@keyframes undoToastOut {
    to {
        opacity: 0;
        transform: translateY(8px);
    }
}

.undo-toast-text {
    flex: 1;
    min-width: 0;
    font-size: 0.9rem;
}

.undo-toast-progress {
    width: 80px;
    height: 4px;
    background: rgba(255, 255, 255, 0.2);
    border-radius: 2px;
    overflow: hidden;
    flex-shrink: 0;
}

.undo-toast-progress-fill {
    height: 100%;
    width: 100%;
    background: #fff;
    transition: width 0.1s linear;
}

.undo-toast.is-committing .undo-toast-progress-fill {
    animation: undoToastIndeterminate 1s linear infinite;
    width: 40%;
}

.undo-toast.is-committing.is-real-progress .undo-toast-progress-fill {
    animation: none;
    /* width set inline by setProgress() */
    transition: width 0.18s linear;
}

@keyframes undoToastIndeterminate {
    0% {
        transform: translateX(-100%);
    }

    100% {
        transform: translateX(250%);
    }
}

.undo-toast-btn {
    flex-shrink: 0;
    background: transparent;
    border: 1px solid rgba(255, 255, 255, 0.4);
    color: #fff;
    border-radius: 8px;
    padding: 6px 12px;
    font-size: 0.82rem;
    cursor: pointer;
    transition: border-color 0.15s ease, background-color 0.15s ease;
}

.undo-toast-btn:hover {
    border-color: #fff;
    background: rgba(255, 255, 255, 0.1);
}

.undo-toast.is-committing .undo-toast-btn {
    display: none;
}

.undo-toast.is-error {
    background: #5a1a1a;
}

/* Plain notification toast (replaces native alert()). Stacks in the
   same #undoToasts container as the undo toasts so messaging is
   consolidated bottom-center. `is-error` switches to a red palette
   for failures / warnings; the default dark tone matches the undo
   toast for visual continuity. Contents are vertically centred so the
   leading icon, message, and close button share a common midline. */
.toast {
    pointer-events: all;
    background: #1a1a1a;
    color: #fff;
    border: 1px solid rgba(255, 255, 255, 0.08);
    border-radius: 12px;
    padding: 14px 14px 14px 18px;
    min-width: 280px;
    max-width: 480px;
    display: flex;
    align-items: center;
    gap: 12px;
    box-shadow: 0 10px 30px rgba(0, 0, 0, 0.35);
    opacity: 0;
    transform: translateY(8px);
    animation: undoToastIn 0.18s ease forwards;
}

.toast.is-dismissing {
    animation: undoToastOut 0.2s ease forwards;
}

.toast.is-error {
    background: #5a1a1a;
    border-color: rgba(255, 255, 255, 0.12);
}

/* Leading status glyph (errors only). Sits on the shared midline with
   the text; tinted a soft red so it reads as a warning against the
   deep-red bubble without shouting. */
.toast-icon {
    flex-shrink: 0;
    display: flex;
    align-items: center;
    color: #ff9b9b;
}

.toast-icon svg {
    width: 22px;
    height: 22px;
    fill: currentColor;
}

.toast-text {
    flex: 1;
    min-width: 0;
    font-size: 0.9rem;
    line-height: 1.35;
    white-space: pre-wrap;
}

.toast-close {
    flex-shrink: 0;
    width: 28px;
    height: 28px;
    display: flex;
    align-items: center;
    justify-content: center;
    background: transparent;
    border: none;
    color: rgba(255, 255, 255, 0.7);
    font-size: 1.3rem;
    line-height: 1;
    padding: 0;
    cursor: pointer;
    border-radius: 50%;
    transition: background-color 0.15s ease, color 0.15s ease;
}

.toast-close:hover {
    background: rgba(255, 255, 255, 0.12);
    color: #fff;
}

.admin-form {
    display: flex;
    flex-direction: column;
    width: 100%;
}

#folderFields {
    display: flex;
    flex-direction: column;
    gap: 1.25rem;
    width: 100%;
}

.admin-form input,
.admin-form select {
    width: 100%;
    box-sizing: border-box;
    padding: 14px 16px;
    margin: 5px 0;
    border: 1.5px solid #eee;
    border-radius: 12px;
    font-size: 16px;
    color: #1a1a1a;
    transition: border-color 0.2s ease, background-color 0.2s ease;
    background: #fcfcfc;
}

.admin-form input:focus,
.admin-form select:focus {
    outline: none;
    border-color: var(--primary-accent);
    background: #fff;
    box-shadow: 0 0 0 4px rgba(0, 185, 244, 0.1);
}

.admin-form input::placeholder {
    color: #aaa;
}

.admin-btn-row {
    display: flex;
    gap: 12px;
    margin-top: 0.5rem;
    width: 100%;
}

/* Stacked variant: Save Changes and Cancel sit full-width on their
   own rows. Used by Edit Folder / Edit Album where the destructive
   action lives in the sticky header as a circular .dialog-delete-btn. */
.admin-btn-row.stacked {
    flex-direction: column;
    gap: 8px;
}

.admin-btn-row.stacked>.theme-button {
    width: 100%;
    flex: none;
}

/* Users dialog ------------------------------------------------------
   Styled to match the other admin dialogs: each user sits in a soft
   rounded card (same border/background as form inputs) with icon-only
   circular actions — edit, lock (suspend) / lock_open (re-enable), and
   delete — mirroring the circular .dialog-delete-btn look. */
.users-list {
    display: flex;
    flex-direction: column;
    gap: 8px;
}

.user-row-wrap {
    border: 1.5px solid #eee;
    border-radius: 12px;
    background: #fcfcfc;
    overflow: hidden;
}

.user-row {
    display: grid;
    grid-template-columns: 1fr auto auto auto;
    gap: 8px;
    align-items: center;
    padding: 10px 10px 10px 14px;
}

/* Inline editor revealed under a row by the edit button. Hairline
   separator + slightly inset padding so it reads as an expansion of
   the same card. Reuses the shared .admin-form field styling. */
.user-edit {
    padding: 6px 14px 14px;
    border-top: 1px solid var(--border-light);
}

.user-edit[hidden] {
    display: none;
}

.user-edit .admin-form {
    margin-top: 6px;
}

/* The overlay field labels mask the input's top border with their own
   background; match it to the card so the mask blends in. */
.user-edit .field-label {
    background: #fcfcfc;
}

.user-edit .admin-btn-row {
    margin-top: 0.75rem;
}

.user-row-main {
    display: flex;
    flex-direction: column;
    gap: 2px;
    min-width: 0;
}

.user-row-email {
    font-weight: 600;
    color: #1a1a1a;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.user-row-email .user-tag {
    font-weight: 400;
    color: #888;
}

.user-row-email .user-tag.disabled {
    color: #c0392b;
}

.user-row-meta {
    font-size: .82rem;
    color: #888;
}

.user-row-btn {
    height: 38px;
    width: 38px;
    display: flex;
    align-items: center;
    justify-content: center;
    padding: 0;
    border-radius: 50%;
    border: 1.5px solid #eee;
    background: #fff;
    color: #555;
    cursor: pointer;
    transition: border-color 0.15s ease, color 0.15s ease, background-color 0.15s ease;
}

.user-row-btn:hover:not(:disabled) {
    border-color: #d4d4d4;
    color: #1a1a1a;
}

.user-row-btn:disabled {
    opacity: .5;
    cursor: not-allowed;
}

.user-row-btn .icon-svg {
    width: 20px;
    height: 20px;
    fill: currentColor;
}

.user-row-btn.danger {
    color: #e23636;
}

.user-row-btn.danger:hover:not(:disabled) {
    border-color: #e23636;
    color: #e23636;
}

/* Collapsible "Add user" form sits below the list, separated by a
   hairline. Summary reads as a section heading; the form inside reuses
   the shared .admin-form field styling. */
.add-user-details {
    border-top: 1px solid var(--border-light);
    padding-top: 6px;
}

.add-user-details>summary {
    cursor: pointer;
    font-weight: 600;
    color: #1a1a1a;
    padding: 8px 2px;
    list-style: none;
}

.add-user-details>summary::-webkit-details-marker {
    display: none;
}

.add-user-details>summary::before {
    content: '+';
    display: inline-block;
    width: 1em;
    font-weight: 400;
    color: #888;
}

.add-user-details[open]>summary::before {
    content: '–';
}

.add-user-details .admin-form {
    margin-top: 8px;
}

.users-form-error {
    color: #c0392b;
    font-size: .85rem;
    min-height: 1.1em;
}

.admin-form .file-input-label {
    font-size: 14px;
    color: #555;
    margin-top: 12px;
    margin-bottom: 4px;
}

.admin-form input[type="file"] {
    padding: 10px 12px;
    font-size: 14px;
}

/* Styled file-picker used in admin dialogs. The native <input type="file">
   is visually hidden but keyboard / a11y accessible via the <label>. The
   label is the click + drop target and renders either an upload CTA or
   the current image as a background-image preview. Clear button is shown
   only when an image is present. */
.admin-form .file-picker {
    margin: 12px 0 4px;
}

/* Overlay labels — render the field label sitting on top of the
   following input/zone's border, the way <fieldset>'s <legend> does
   natively. Inline-block so the label doesn't span the full width;
   position: relative + top shifts it visually onto the border; the
   dialog-bg `background` masks the underlying border behind the
   text. The label still occupies its original block-line space in
   flow, so the input below stays where it would normally land.

   Two `top` values because the gap between label and following
   sibling differs: text inputs ship `margin: 5px 0` so the input
   sits 5 px below the label, while .file-picker-zone has no top
   margin and sits flush against the label. The math centres the
   label glyph on the underlying border in both cases. */
.admin-form .file-picker-label,
.admin-form .field-label {
    display: inline-block;
    /* .admin-form is `display: flex; flex-direction: column` with
       the default `align-items: stretch`, so direct flex children
       (which .field-label is) get stretched to full row width
       regardless of inline-block. align-self: flex-start opts the
       label out of the stretch so its background only masks the
       border behind the actual text. No-op for .file-picker-label
       (it's a span inside .file-picker, not a flex item itself). */
    align-self: flex-start;
    width: fit-content;
    position: relative;
    left: 12px;
    margin: 0;
    padding: 0 6px;
    background: var(--bg-white);
    font-size: 13px;
    line-height: 1;
    color: #1a1a1a;
    z-index: 1;
}

.admin-form .field-label {
    top: 14px;
}

.admin-form .file-picker-label {
    top: 9px;
}

/* Red asterisk appended to required field labels. aria-hidden in
   the HTML keeps it out of the accessible name; the visible glyph
   signals "must be filled" to sighted users. */
.required-mark {
    color: #e23636;
    margin-left: 3px;
    font-weight: 600;
}

/* Character counter injected after any field marked with
   data-char-counter="<max>". Sits right-aligned under the input
   in small grey, flips to the accent red when the user hits the
   limit so they realise typing stops. */
.admin-form .char-counter {
    display: block;
    width: 100%;
    text-align: right;
    margin: 4px 4px 0 0;
    font-size: 0.75rem;
    color: #888;
    font-variant-numeric: tabular-nums;
}

.admin-form .char-counter.over {
    color: #c0392b;
}

html.dark .admin-form .char-counter {
    color: #888;
}

html.dark .admin-form .char-counter.over {
    color: #f87171;
}

.admin-form .file-picker-input {
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip: rect(0, 0, 0, 0);
    border: 0;
}

.admin-form .file-picker-zone {
    position: relative;
    display: flex;
    align-items: center;
    gap: 14px;
    padding: 12px;
    border: 1.5px dashed #d8d8d8;
    border-radius: 14px;
    background: #fcfcfc;
    cursor: pointer;
    transition: border-color 0.15s ease, background-color 0.15s ease;
    min-height: 88px;
}

.admin-form .file-picker-zone:hover,
.admin-form .file-picker-input:focus-visible+.file-picker-zone {
    border-color: var(--primary-accent);
    background: #f5fbff;
}

.admin-form .file-picker-zone.dragover {
    border-color: var(--primary-accent);
    background: #ecf6fe;
}

.admin-form .file-picker-preview {
    flex-shrink: 0;
    width: 72px;
    height: 72px;
    border-radius: 10px;
    background: #ececec center / cover no-repeat;
    overflow: hidden;
    position: relative;
}

.admin-form .file-picker-preview::after {
    content: "+";
    position: absolute;
    inset: 0;
    display: flex;
    align-items: center;
    justify-content: center;
    color: #b8b8b8;
    font-size: 28px;
    font-weight: 300;
    line-height: 1;
}

.admin-form .file-picker-preview.has-image::after {
    content: none;
}

.admin-form .file-picker-cta {
    display: flex;
    flex-direction: column;
    gap: 2px;
    min-width: 0;
    flex: 1;
}

.admin-form .file-picker-cta-label {
    color: #1a1a1a;
    font-weight: 500;
    font-size: 0.92rem;
}

.admin-form .file-picker-cta-help {
    color: #888;
    font-size: 0.8rem;
}

/* Circular X button positioned inside the drop area on the right.
   Was a "Remove" text button below the zone; lives inside the
   <label class="file-picker-zone"> now, with stopPropagation in
   the JS click handler so clicking the X doesn't also trigger the
   label's for-input (which would re-open the file dialog). */
.admin-form .file-picker-clear {
    position: absolute;
    top: 50%;
    right: 12px;
    transform: translateY(-50%);
    margin: 0;
    padding: 0;
    width: 28px;
    height: 28px;
    border-radius: 50%;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    background: #fff;
    border: 1px solid #d6d6d6;
    color: #555;
    cursor: pointer;
    transition: color 0.15s ease, border-color 0.15s ease, background-color 0.15s ease;
    z-index: 2;
}

/* The display: inline-flex above overrides the UA [hidden] { display:none },
   so the X kept occupying its absolute slot on the right even when JS set
   .hidden = true — and the "Accepted formats" help text (laid out without
   the padding-right reservation in that state) would flow under it.
   Restore [hidden]'s hide behaviour explicitly. */
.admin-form .file-picker-clear[hidden] {
    display: none;
}

.admin-form .file-picker-clear:hover {
    color: #c00;
    border-color: #f0c0c0;
    background: #fff5f5;
}

.admin-form .file-picker-clear .icon-svg {
    width: 16px;
    height: 16px;
    fill: currentColor;
}

/* Make room on the right of the CTA text so it doesn't slide
   under the absolute-positioned clear button when one is shown
   (only happens when the picker has an image). */
.admin-form .file-picker-zone:has(.file-picker-clear:not([hidden])) .file-picker-cta {
    padding-right: 36px;
}

/* Hide the "Accepted formats: …" hint once a file is picked. The
   list is only useful before the upload; afterward it competes
   for horizontal space with the absolutely-positioned X button
   (the row can wrap onto a second line at the X's vertical
   centre and visually slide under it). The label above stays
   visible — that's the "Click or drop to replace" affordance. */
.admin-form .file-picker-zone:has(.file-picker-clear:not([hidden])) .file-picker-cta-help {
    display: none;
}

.admin-form .album-cover-preview-zone {
    cursor: default;
    padding: 8px;
    justify-content: center;
}

.admin-form .album-cover-preview-zone:hover {
    border-color: #d8d8d8;
    background: #fcfcfc;
}

.admin-form .album-cover-preview-img {
    display: block;
    max-width: 100%;
    max-height: 220px;
    object-fit: contain;
    border-radius: 8px;
}

.admin-form .file-picker[hidden] {
    display: none;
}

/* Form-row checkbox styled as a full-width clickable card. The whole
   row is the click target (label wraps the checkbox) so the admin
   can hit anywhere along the row — matches the tags-list pattern
   used inside Edit Album. Hover + checked states give clear visual
   feedback. The checkbox itself is scaled up so the tick is easier
   to see and to hit. */
.admin-form .checkbox-row {
    display: flex;
    align-items: center;
    gap: 12px;
    margin: 8px 0 4px;
    padding: 12px 16px;
    min-height: 44px;
    font-size: 15px;
    color: #1a1a1a;
    cursor: pointer;
    user-select: none;
    background: #fcfcfc;
    border: 1.5px solid #eee;
    border-radius: 12px;
    transition: background-color 0.15s ease, border-color 0.15s ease;
}

.admin-form .checkbox-row:hover {
    background: #f5f5f5;
    border-color: #ddd;
}

.admin-form .checkbox-row:has(input[type="checkbox"]:checked) {
    background: #eef4ff;
    border-color: var(--primary-accent);
}

.admin-form .checkbox-row input[type="checkbox"] {
    width: 20px;
    height: 20px;
    margin: 0;
    padding: 0;
    flex-shrink: 0;
    accent-color: var(--primary-accent);
    cursor: pointer;
}

/* --- Edit Album · Tags picker ---------------------------------------- */
/* Tag-folder checklist inside the Edit Album dialog. Each row is a full-
   width label with a generous 44px click target, the tag name on the
   left, and the album count tucked to the right. The whole row is the
   click target (label wraps the checkbox); hover + checked states give
   clear visual feedback. */
.tags-section {
    margin: 12px 0 4px;
}

.tags-section-label {
    font-size: 14px;
    color: #555;
    margin-bottom: 6px;
    font-weight: 500;
}

.tags-list {
    display: flex;
    flex-direction: column;
    gap: 4px;
    max-height: 220px;
    overflow-y: auto;
    border: 1.5px solid #eee;
    border-radius: 12px;
    padding: 4px;
    background: #fcfcfc;
}

.tags-list .tag-row {
    display: flex;
    align-items: center;
    gap: 12px;
    min-height: 44px;
    padding: 6px 12px;
    border-radius: 8px;
    cursor: pointer;
    user-select: none;
    background: transparent;
    transition: background-color 0.15s ease;
}

.tags-list .tag-row:hover {
    background: rgba(0, 0, 0, 0.04);
}

.tags-list .tag-row input[type="checkbox"] {
    width: 18px;
    height: 18px;
    margin: 0;
    flex-shrink: 0;
    accent-color: var(--primary-accent);
    cursor: pointer;
}

.tags-list .tag-row .tag-name {
    flex: 1;
    min-width: 0;
    font-size: 14px;
    color: #1a1a1a;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

.tags-list .tag-row .tag-count {
    flex-shrink: 0;
    font-size: 12px;
    color: #888;
    font-variant-numeric: tabular-nums;
}

/* Checked state: subtle blue tint so the active tags pop out of the
   list at a glance without competing with the row hover. */
.tags-list .tag-row:has(input[type="checkbox"]:checked) {
    background: color-mix(in srgb, var(--primary-accent), transparent 90%);
}

.tags-list .tag-row:has(input[type="checkbox"]:checked):hover {
    background: color-mix(in srgb, var(--primary-accent), transparent 84%);
}

.tags-list .tag-row.is-hidden .tag-name {
    opacity: 0.65;
    font-style: italic;
}

.tags-empty,
.tags-loading {
    font-size: 13px;
    color: #888;
    padding: 12px 4px;
}

html.dark .tags-list {
    background: #1a1a1a;
    border-color: #333;
}

html.dark .tags-list .tag-row {
    color: #ddd;
}

html.dark .tags-list .tag-row .tag-name {
    color: #e6e6e6;
}

html.dark .tags-list .tag-row .tag-count {
    color: #999;
}

html.dark .tags-list .tag-row:hover {
    background: rgba(255, 255, 255, 0.06);
}

.admin-mode .is-hidden-folder>.nav-btn .category-name {
    opacity: 0.55;
    font-style: italic;
}

.admin-mode .is-hidden-folder>.nav-btn .category-name::after {
    content: " (hidden)";
    font-size: 0.85em;
    opacity: 0.75;
}

.delete-btn {
    background-color: #ff4d4d !important;
    color: white !important;
    border: none !important;
    flex: 1;
    display: none;
    font-weight: 500;
}

.delete-btn:hover {
    background-color: #ff3333 !important;
}

.save-btn {
    flex: 2;
    font-weight: 600;
}

/* While withBusyButton has the submit button disabled + aria-busy,
   dim it so it visibly looks inert and prepend a small spinner via
   ::before so the user sees that the click was acknowledged and the
   request is in flight. `cursor: progress` reinforces "wait, working"
   even though `disabled` already suppresses clicks. */
.save-btn[aria-busy="true"] {
    opacity: 0.65;
    cursor: progress;
}

.save-btn[aria-busy="true"]::before {
    content: '';
    display: inline-block;
    width: 14px;
    height: 14px;
    margin-right: 8px;
    border-radius: 50%;
    border: 2px solid rgba(255, 255, 255, 0.4);
    border-top-color: #fff;
    animation: gbc-spin 0.8s linear infinite;
    vertical-align: -2px;
}

.save-btn:disabled:not([aria-busy="true"]) {
    opacity: 0.55;
    cursor: not-allowed;
}

body.admin-mode .reorderable .tile {
    cursor: grab;
}

body.admin-mode .reorderable .tile:active {
    cursor: grabbing;
}

/* Custom-order (swap_vert) sort button is purely informational on the
   admin album page — manual order is what's persisted as the user
   drags tiles, so a sort-mode toggle is meaningless here. Greyed out
   + click suppressed so the dropdown can't open. */
body.admin-mode .album-sort-btn {
    opacity: 0.4;
    pointer-events: none;
    cursor: default;
}

/* Floating admin chrome — a black capsule centered against the bottom
   edge holding the action buttons. On the left it leads with the current
   user's circular folder avatar (email revealed on hover) and an inert
   quota readout. The outer .admin-pill is just a layout container —
   transparent, no chrome of its own. */
.admin-pill {
    position: fixed;
    left: 50%;
    bottom: 0.5rem;
    transform: translateX(-50%);
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 8px;
    background: transparent;
    border: none;
    border-radius: 0;
    box-shadow: none;
    padding: 0;
    /* Above the topbar (2100) but below modal dialogs (2200). */
    z-index: 2150;
    font-size: 0.88rem;
    color: #fff;
    max-width: calc(100vw - 2rem);
}

.admin-pill .admin-pill-buttons {
    display: flex;
    align-items: center;
    gap: 8px;
    padding: 8px;
    background: #000;
    border-radius: 999px;
    box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
}

/* Left-side identity cluster: a circular avatar of the current user's
   owned folder. Sized to match the 44px action buttons so it sits flush
   in the capsule. */
.admin-pill .admin-pill-avatar-wrap {
    position: relative;
    display: inline-flex;
    align-items: center;
    flex-shrink: 0;
    outline: none;
}

.admin-pill .admin-pill-avatar-wrap[hidden] {
    display: none;
}

.admin-pill .admin-pill-avatar {
    width: 44px;
    height: 44px;
    border-radius: 50%;
    object-fit: cover;
    display: block;
    background: rgba(255, 255, 255, 0.15);
    box-shadow: var(--shadow-inset-highlight);
}

/* Email pill shown above the avatar on hover/focus — same surface as the
   "Link copied" toast (.copy-toast), repositioned to sit above and
   centred over the avatar. */
/* Hover info pill — shared by the identity avatar (reveals the email)
   and the sign-out button (reveals "Click to logout"). One rule so the
   two bubbles are visually identical. */
.admin-pill .admin-pill-email-toast,
.admin-pill .admin-pill-logout-toast,
.admin-pill .admin-pill-quota-toast {
    position: absolute;
    bottom: calc(100% + 10px);
    left: 50%;
    transform: translateX(-50%) translateY(5px);
    background: #000;
    color: #fff;
    padding: 12px 18px;
    border-radius: 20px;
    font-size: 0.85rem;
    font-weight: 500;
    white-space: nowrap;
    pointer-events: none;
    opacity: 0;
    transition: opacity 0.25s ease, transform 0.25s ease;
    z-index: 1000;
}

.admin-pill .admin-pill-avatar-wrap:hover .admin-pill-email-toast,
.admin-pill .admin-pill-avatar-wrap:focus-visible .admin-pill-email-toast {
    opacity: 1;
    transform: translateX(-50%) translateY(0);
}

/* Sign-out button anchors its own hover pill (mirrors the avatar's). */
.admin-pill #adminPillSignOutBtn {
    position: relative;
}
.admin-pill #adminPillSignOutBtn:hover .admin-pill-logout-toast,
.admin-pill #adminPillSignOutBtn:focus-visible .admin-pill-logout-toast {
    opacity: 1;
    transform: translateX(-50%) translateY(0);
}

/* Quota readout — wears the create-album pill look but is inert. Shows
   only the used amount; the full "used / limit" lives in a hover pill,
   so it must receive hover (no pointer-events:none) and anchor the toast
   (position:relative). cursor:default + a stable hover background keep it
   reading as a readout, not a button. */
.admin-pill .admin-pill-btn.admin-pill-quota {
    padding: 0 16px;
    cursor: default;
    position: relative;
}
.admin-pill .admin-pill-btn.admin-pill-quota:hover {
    background: rgba(255, 255, 255, 0.15);
}
.admin-pill .admin-pill-btn.admin-pill-quota:hover .admin-pill-quota-toast,
.admin-pill .admin-pill-btn.admin-pill-quota:focus-visible .admin-pill-quota-toast {
    opacity: 1;
    transform: translateX(-50%) translateY(0);
}

.admin-pill .admin-pill-btn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    flex-shrink: 0;
    white-space: nowrap;
    width: 44px;
    height: 44px;
    padding: 0;
    border: none;
    border-radius: 50%;
    background: rgba(255, 255, 255, 0.15);
    box-shadow: var(--shadow-inset-highlight);
    color: #fff;
    cursor: pointer;
    transition: background-color 0.2s ease;
}

/* The element-level display: inline-flex above overrides the
   user-agent display: none that the HTML `hidden` attribute relies
   on, so updateAdminPill()'s `.hidden = true` had no visual effect
   without this explicit override. */
.admin-pill .admin-pill-btn[hidden] {
    display: none;
}

.admin-pill .admin-pill-btn:hover {
    background: rgba(255, 255, 255, 0.25);
}

.admin-pill .admin-pill-btn.create-album-btn {
    width: auto;
    padding: 0 16px 0 12px;
    border-radius: 999px;
    gap: 6px;
    font-size: 0.85rem;
    font-weight: 500;
}

/* Primary action pills (Add photos / Create album / Create folder) wear
   the brand accent as their fill; the other pills (Users, Storage, the
   inert quota readout) keep the default translucent surface. Higher
   specificity than the base .admin-pill-btn background/hover rules, so
   these win without !important. */
.admin-pill .admin-pill-btn.admin-pill-accent {
    background: var(--primary-accent);
}
.admin-pill .admin-pill-btn.admin-pill-accent:hover {
    background: var(--primary-accent-hover);
}

.admin-pill .admin-pill-btn .icon-svg {
    width: 20px;
    height: 20px;
    fill: #fff;
}

/* License radio group — replaces the legacy <select> in the
   edit-album dialog. All four options visible, vertical stack, the
   whole row is the click target. */
.license-radios {
    border: 1.5px solid #eee;
    border-radius: 12px;
    padding: 8px 6px;
    margin: 5px 0;
    background: #fcfcfc;
}

.license-radios legend {
    padding: 0 6px;
    font-size: 14px;
    color: #1a1a1a;
}

.license-option {
    display: flex;
    align-items: center;
    gap: 10px;
    padding: 8px 10px;
    cursor: pointer;
    border-radius: 8px;
    transition: background-color 0.15s ease;
}

.license-option:hover {
    background: #f0f0f0;
}

.license-option input[type="radio"] {
    accent-color: var(--primary-accent, #458b60);
    width: 18px;
    height: 18px;
    margin: 0;
    cursor: pointer;
    flex-shrink: 0;
}

.license-option-text {
    font-size: 14px;
    color: #1a1a1a;
}

.license-option:has(input[type="radio"]:checked) {
    background: rgba(69, 139, 96, 0.08);
}

.license-option:has(input[type="radio"]:checked) .license-option-text {
    font-weight: 600;
}

html.dark .license-radios {
    border-color: #2a2a2a;
    background: #1d1d1d;
}

html.dark .license-radios legend,
html.dark .license-option-text {
    color: #f0f0f0;
}

html.dark .license-option:hover {
    background: #2a2a2a;
}

html.dark .license-option:has(input[type="radio"]:checked) {
    background: rgba(69, 139, 96, 0.18);
}

/* Shared auth/offline card chrome — used by /signin and by the
   admin.html auth guard's offline state. Same visual: viewport-
   centered white card with the monkey logo at the top, centered
   black title, grey description, accent pill button. Subtle 1px
   border (matching .feature-tile on the home page) instead of a
   drop shadow. */
.auth-wrap {
    min-height: 100dvh;
    display: flex;
    align-items: center;
    justify-content: center;
    padding: 20px;
    box-sizing: border-box;
}

.auth-card {
    width: 100%;
    max-width: 300px;
    padding: 36px 32px;
    background: #fff;
    color: #1a1a1a;
    border: 1px solid rgba(0, 0, 0, 0.08);
    border-radius: 16px;
    text-align: center;
    box-sizing: border-box;
}

.auth-card .auth-logo {
    width: 80px;
    height: auto;
    display: block;
    margin: 0 auto 20px;
    color: #000;
}

.auth-card h1 {
    margin: 0 0 10px;
    font-weight: 400;
    font-size: 2rem;
    color: #000;
    text-align: center;
}

.auth-card p {
    margin: 0 0 22px;
    color: #666;
    line-height: 1.5;
    font-size: 0.95rem;
}

.auth-card label {
    display: block;
    text-align: left;
    font-size: .82rem;
    color: #666;
    margin: 14px 0 6px;
}

.auth-card input[type="email"] {
    width: 100%;
    box-sizing: border-box;
    padding: 12px 14px;
    border: 1.5px solid #e0e0e0;
    border-radius: 10px;
    font-size: 1rem;
    background: #fafafa;
    color: #1a1a1a;
    transition: border-color 0.15s ease, background-color 0.15s ease;
}

.auth-card input[type="email"]:focus {
    outline: none;
    border-color: var(--primary-accent);
    background: #fff;
}

.auth-card .auth-btn {
    width: 100%;
    margin-top: 18px;
    padding: 16px 20px;
    border: 0;
    border-radius: 999px;
    background: var(--primary-accent);
    color: #fff;
    font-size: 1rem;
    font-weight: 500;
    cursor: pointer;
    transition: background-color 0.15s ease;
}

.auth-card .auth-btn:hover:not(:disabled) {
    background: var(--primary-accent-hover);
}

.auth-card .auth-btn:disabled {
    opacity: .55;
    cursor: not-allowed;
}

.auth-card .auth-msg {
    font-size: .88rem;
}

.auth-card .auth-msg.ok,
.auth-card .auth-msg.err,
.auth-card .auth-msg.muted {
    margin-top: 14px;
    min-height: 1.2em;
}

.auth-card .auth-msg.ok {
    color: #458b60;
}

.auth-card .auth-msg.err {
    color: #c0392b;
}

.auth-card .auth-msg.muted {
    color: #666;
}

html.dark .auth-card {
    background: #1d1d1d;
    color: #f0f0f0;
    border-color: rgba(255, 255, 255, 0.08);
}

html.dark .auth-card .auth-logo {
    color: #fff;
}

html.dark .auth-card h1 {
    color: #fff;
}

html.dark .auth-card p,
html.dark .auth-card label {
    color: #aaa;
}

html.dark .auth-card input[type="email"] {
    background: #2a2a2a;
    border-color: #3a3a3a;
    color: #f0f0f0;
}

html.dark .auth-card input[type="email"]:focus {
    background: #2f2f2f;
}

html.dark .auth-card .auth-msg.ok {
    color: #458b60;
}

html.dark .auth-card .auth-msg.err {
    color: #f87171;
}

html.dark .auth-card .auth-msg.muted {
    color: #aaa;
}

/* @admin-end */

/* Scroll lock for the fullscreen viewer is enforced in JS (wheel /
   touchmove / scroll-key listeners installed on open, removed on
   close — see viewer.js). The previous CSS approach toggled
   `overflow: hidden` on html + body, which forced `position:
   sticky` on the topbar to re-evaluate against a changed scroll
   context — tiny computed-property shifts then animated over 0.2s
   as a visible flicker on close. The event-blocking lock leaves
   the scroll context untouched, so there's nothing for the
   topbar to recompute or transition. */

.fullscreen-viewer {
    position: fixed;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    z-index: 3000;
    display: flex;
    justify-content: center;
    align-items: center;
    min-width: 370px;
}

.backdrop {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    background-color: #000;
    cursor: pointer;
    z-index: 10;
}

.content {
    position: relative;
    width: 100%;
    height: 100%;
    display: flex;
    align-items: center;
    justify-content: center;
    z-index: 20;
}

.image-wrapper {
    position: absolute;
    top: 0;
    left: 0;
    right: 0;
    bottom: 0;
    display: flex;
    justify-content: center;
    align-items: center;
    touch-action: pan-y;
    user-select: none;
    -webkit-user-drag: none;
    z-index: 5;
}



.fullscreen-viewer img {
    position: absolute;
    display: block;
    /* width and height are set inline by applyPhotoFit() in viewer.js
       so the IMG element box always matches the photo's true visible
       rect (computed from item.aspect + viewport + the optional
       native-size cap). max-width / max-height: 100% are belt-and-
       braces — JS already constrains within the viewport. The View
       Transition that morphs the grid tile into this element therefore
       lands on the exact final rect, regardless of orientation. */
    max-width: 100%;
    max-height: 100%;
    opacity: 1;
    transition: transform 0.3s ease, opacity 0.3s ease;
    user-select: none;
    pointer-events: none;

    /* Center absolute images within the wrapper */
    top: 0;
    left: 0;
    right: 0;
    bottom: 0;
    margin: auto;
}

.photo-layer {
    position: absolute;
    inset: 0;
    display: flex;
    justify-content: center;
    align-items: center;
    pointer-events: none;
}

#fullscreenLarge {
    z-index: 2;
}

#fullscreenThumb {
    z-index: 1;
}

@keyframes pj-viewer-zoom-in {
    from {
        transform: scale(0.94);
        opacity: 0;
    }

    to {
        transform: scale(1);
        opacity: 1;
    }
}

.fullscreen-viewer.entering .image-wrapper {
    animation: pj-viewer-zoom-in 0.3s ease-out;
    transform-origin: center center;
}

/* Fade-out when closing — replaces the previous abrupt `display: none`
   teardown. Duration matches CLOSE_FADE_MS in viewer.js. `forwards` keeps
   opacity at 0 after the keyframes finish so there's no flash before the
   JS teardown swaps `display` to none. Pointer events are killed for the
   duration of the fade so taps on disappearing controls do nothing. */
@keyframes pj-viewer-fade-out {
    from {
        opacity: 1;
    }

    to {
        opacity: 0;
    }
}

.fullscreen-viewer.closing {
    animation: pj-viewer-fade-out 0.14s ease-out forwards;
    pointer-events: none;
}

.control {
    position: absolute;
    top: 50%;
    transform: translateY(-50%);
    background: rgb(34 34 34);
    box-shadow: var(--shadow-inset-highlight);
    color: white;
    border: none;
    border-radius: 50%;
    width: 48px;
    height: 48px;
    cursor: pointer;
    display: flex;
    justify-content: center;
    align-items: center;
    transition: background-color 0.2s ease, opacity 0.3s ease;
    z-index: 100;
}

::view-transition-group(viewer-close),
::view-transition-group(viewer-copy),
::view-transition-group(viewer-prev),
::view-transition-group(viewer-next),
::view-transition-group(viewer-actions) {
    z-index: 1000;
}

.control:hover,
.prev:hover,
.next:hover {
    background: rgb(51, 51, 51);
}

.close {
    top: 15px;
    right: 15px;
    transform: none;
    view-transition-name: viewer-close;
}

/* Copy-link control mirrors the close button: same 48x48 dark circle,
   pinned to the top-left corner (close is top-right). Icon only at every
   width. */
.copy {
    top: 15px;
    left: 15px;
    transform: none;
    view-transition-name: viewer-copy;
}

.prev {
    left: 15px;
    view-transition-name: viewer-prev;
}

.next {
    right: 15px;
    view-transition-name: viewer-next;
}

.actions-top {
    position: absolute;
    top: 15px;
    height: 48px;
    min-width: 48px;
    left: 50%;
    transform: translateX(-50%);
    display: flex;
    align-items: center;
    gap: 10px;
    transition: opacity 0.3s ease;
    z-index: 100;
    view-transition-name: viewer-actions;
}

.download-btn {
    display: flex;
    align-items: center;
    gap: 4px;
    padding: 0 12px 0 16px;
    height: 48px;
    background: rgb(34 34 34);
    box-shadow: var(--shadow-inset-highlight);
    border-radius: 24px;
    color: white;
    text-decoration: none;
    transition: background-color 0.2s ease, opacity 0.3s ease;
}

.download-btn span {
    padding: 0 10px 0 0;
    font-size: 0.9rem;
}

.download-btn .icon-svg {
    font-size: 24px;
}

.download-btn:not(:disabled):hover {
    background: rgb(51, 51, 51);
}

/* Toast pops BELOW the copy button rather than to the left (the
   shared .copy-toast default) — the copy control sits in the top-left
   corner of a black viewport, so a downward toast keeps the bubble
   clear of the screen edge above. Left-anchored to the button so the
   bubble never clips past the viewport's left edge. */
.fullscreen-viewer .copy .copy-toast {
    right: auto;
    left: 0;
    top: calc(100% + 12px);
    transform: translate(0, -5px);
}

.fullscreen-viewer .copy .copy-toast.show {
    transform: translate(0, 0);
}

/* Bottom-center pill showing capture / upload dates over the photo.
   Same dark colour family as the buttons, but smaller text and a
   tighter pill so it reads as ambient info rather than an action. */
.viewer-meta {
    position: absolute;
    bottom: 15px;
    left: 50%;
    transform: translateX(-50%);
    max-width: calc(100% - 30px);
    padding: 6px 14px;
    background: rgb(34 34 34);
    box-shadow: var(--shadow-inset-highlight);
    color: rgba(255, 255, 255, 0.85);
    border-radius: 14px;
    font-size: 0.72rem;
    line-height: 1.4;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
    z-index: 100;
    transition: opacity 0.3s ease;
    pointer-events: none;
    view-transition-name: viewer-meta;
}

.viewer-meta .meta-uploaded {
    color: rgba(255, 255, 255, 0.5);
}

::view-transition-group(viewer-meta) {
    z-index: 1000;
}

@media (max-width: 600px) {
    .viewer-meta {
        font-size: 0.68rem;
        padding: 5px 12px;
        bottom: 12px;
    }
}

/* Controls are normally always visible. The .controls-hidden class is
   toggled by tap-to-hide on compact touch layouts only. */
.fullscreen-viewer.controls-hidden .prev,
.fullscreen-viewer.controls-hidden .next,
.fullscreen-viewer.controls-hidden .copy,
.fullscreen-viewer.controls-hidden .actions-top,
.fullscreen-viewer.controls-hidden .viewer-meta {
    opacity: 0;
    pointer-events: none;
}

.fullscreen-viewer .icon-svg {
    font-size: 24px;
}

.fullscreen-viewer .prev .icon-svg,
.fullscreen-viewer .next .icon-svg {
    font-size: 26px;
}

/* `vdl` (viewer deep-link) is set on <html> by an inline script before
   first paint when the URL is a 3-segment /folder/album/<photoId>. It
   blanks the page so the album grid / sidebar / top-bar never flash
   behind the viewer overlay. The class is removed by the viewer on
   close, at which point the album view becomes visible. */
html.vdl,
html.vdl body {
    background: #000;
}

html.vdl .album-grid-wrapper,
html.vdl .sidebar,
html.vdl .top-bar {
    visibility: hidden;
}

/* Dark theme — toggled by the brightness button on the home topbar
   and persisted in localStorage. Cold loads apply the .dark class on
   <html> via an inline bootstrap in index.html, so first paint is
   already themed. Heros + tile bgs keep their avg-colour fills; only
   the body background and the text that sits on it shift. */

/* Pin the browser's UA colour-scheme to light by default and follow
   our toggle into dark only when `html.dark` is active. Without this
   override, iOS Safari (and other browsers) will auto-darken form
   controls, scrollbars, and default backgrounds when the system is
   in dark mode — producing a "custom dark mode on the fly" that
   bypasses our designed dark theme entirely. The page-level
   `<meta name="color-scheme" content="light">` declares the same
   default for the initial paint; this CSS lets our explicit toggle
   switch UA controls in lockstep with the html.dark class. */
:root {
    color-scheme: light;
}

html.dark {
    color-scheme: dark;
}

/* Smooth the theme flip — every element whose colour, fill, border,
   or background changes between modes gets a short transition so the
   toggle feels gradual instead of a hard cut. Targeted (rather than
   universal `*` selectors) to avoid styling unrelated hover/focus
   transitions on top of their existing timing. The topbar already
   carries its own transition shorthand (opacity + bg) so it's not in
   this list; ditto for its ::before, which has its own bg transition
   from the at-top -> past-header swap. */
body,
.page-header h1,
.folder-hero-meta h1,
.page-header .name,
.folder-hero-link,
.feature-tile h3,
.folder-hero-description,
.home-description,
.photo-count,
.folder-hero-counts,
.folder-hero-avatar-row,
.empty-state,
.feature-tile,
.feature-icon-wrapper .icon-svg,
.folder-hero-avatar-wrap,
.home-branding svg path {
    transition: background-color 0.25s ease, color 0.25s ease, border-color 0.25s ease, fill 0.25s ease;
}

html.dark body {
    background: #202020;
    color: #e8e8e8;
}

/* Topbar tint in dark mode is driven by --topbar-bg (set on
   .album-grid-wrapper in the cascade block above the topbar rules);
   no per-state override needed here. */

/* The three layered popovers — Explore sidebar, Custom-order / Sort
   dropdown, and the license info bubble — wear the avg cover colour
   darkened 20% in dark mode (one shade deeper than the topbar's 10%),
   so they read as the same family one layer above the bar. Derived
   straight from --page-color (not --topbar-bg) so the 20% is measured
   from the avg colour itself rather than compounding the topbar's 10%.
   Light-mode treatment (color-mix from --topbar-bg) is untouched. */
html.dark .sidebar,
html.dark .sort-menu,
html.dark .album-hero-text .album-meta-row>.album-license-btn .copy-toast {
    background: color-mix(in srgb, rgb(var(--page-color, 50, 50, 50)), black 20%);
}

/* Home has no real cover colour (--page-color is the neutral 48,48,48
   placeholder), so its avg-derived fill would just be a flat dark grey.
   Give the Explore sidebar a fixed dark one shade deeper than the home
   topbar (#313131) instead. Only the sidebar shows on home — the sort
   menu and license bubble are album-only. */
html.dark:not(.nh) .sidebar {
    background: #262626;
}

html.dark .page-header h1,
html.dark .folder-hero-meta h1,
html.dark .page-header .name {
    color: #e8e8e8;
}

/* Feature tile headings on home: pure white in dark mode. */
html.dark .feature-tile h3 {
    color: #fff;
}

html.dark .folder-hero-description,
html.dark .photo-count,
html.dark .folder-hero-counts,
html.dark .folder-hero-avatar-row,
html.dark .empty-state {
    color: #999;
}

/* Number stays high-contrast in dark mode — the hard black from
   light mode would vanish on the #202020 body bg. Label is already
   a mid-grey var(--text-muted) #9a9a9a in light mode; bump it a touch
   darker on dark so it still reads as the muted partner to the
   brighter number. */
html.dark .folder-count-number {
    color: #e8e8e8;
}

/* Match the dark-mode description so the "photos" / "albums"
   labels stay in the same secondary type layer as the description
   paragraph in both modes. */
html.dark .folder-count-label {
    color: #999;
}

/* Link button in dark mode wears the same neutral grey the topbar
   uses at-rest; the avg-cover-colour fill from light mode would
   read as a coloured patch against the dark page bg. Decoupled
   from --topbar-bg (which shifts to #464646 on .past-header) so
   the button keeps its fill when the user scrolls past the hero. */
html.dark .folder-hero-link {
    background: #464646;
}

/* Inverse of the light-mode hover: in dark mode the fill is already
   dark, so darkening would vanish into the page bg. Lighten by
   blending 10% white instead. */
html.dark .folder-hero-link:hover {
    background: color-mix(in srgb, #464646, white 10%);
}

/* Home landing supporting text — slightly brighter than the page
   default #999 so they read against the dark bg without going all
   the way to white. */
html.dark .home-description,
html.dark .feature-tile p {
    color: #b0b0b0;
}

/* Home logos (leaf + photo/jungle wordmarks) flip to white in dark
   mode. SVG presentation attributes (fill="...") lose to CSS, so a
   plain `fill: #fff` on the descendant paths overrides the orange /
   black inline fills. */
html.dark .home-branding svg path {
    fill: #fff;
}

/* Feature tiles: bring the icon colour up to white (currentColor on
   .icon-svg) and tint the dashed border so it reads against the
   dark page bg. */
html.dark .feature-tile {
    border-color: rgba(255, 255, 255, 0.12);
}

html.dark .feature-icon-wrapper .icon-svg {
    color: #fff;
}

/* Home explore-library button: flip to a white card with dark text
   in dark mode so it stands out against the #202020 page bg. */
html.dark .home-explore-btn {
    background: #fff;
    color: #2e2e2e;
}

html.dark .home-explore-btn:hover {
    background: #e8e8e8;
}

/* Folder-hero avatar's white ring blends into the dark page bg in
   dark mode — match it to the body so the ring effectively disappears
   while the avg-colour disc inside still reads. */
html.dark .folder-hero-avatar-wrap {
    border-color: #202020;
}

/* Album-tile hover shadow drops the avg-colour glow (looks like a
   coloured halo against the dark page) in favour of a neutral black
   shadow. */
html.dark .tile:hover .tile-inner {
    box-shadow: 0 3px 12px rgba(0, 0, 0, 0.5);
}

/* ============================================================
   Dialogs stay in the light palette in dark mode.

   Dialogs (Create Album, Edit Folder, Edit Profile, Users
   modal, etc.) are admin.html-only — they were never restyled
   for dark mode. Without these overrides, the dialog's white
   card background (--bg-white is fixed) would show inherited
   dark text colours (invisible white-on-white labels), and
   the few dialog-scoped dark rules elsewhere in this file
   (char-counter, license-radios) would re-darken pieces of
   the form.

   Approach: scope-revert every dark rule that targets a
   dialog descendant, and force color-scheme: light on the
   dialog subtree so native UA controls (date pickers,
   checkboxes, native select drop arrows) render against
   the light card instead of the dark page.
   ============================================================ */
html.dark .dialog-backdrop,
html.dark .dialog-content {
    color-scheme: light;
    color: #1a1a1a;
}

html.dark .dialog-content * {
    color-scheme: light;
}

html.dark .dialog-content .admin-form .char-counter {
    color: #888;
}

html.dark .dialog-content .admin-form .char-counter.over {
    color: #c0392b;
}

html.dark .dialog-content .license-radios {
    border-color: #eee;
    background: #fcfcfc;
}

html.dark .dialog-content .license-radios legend,
html.dark .dialog-content .license-option-text {
    color: #1a1a1a;
}

html.dark .dialog-content .license-option:hover {
    background: #f5f5f5;
}

html.dark .dialog-content .license-option:has(input[type="radio"]:checked) {
    background: rgba(69, 139, 96, 0.08);
}

html.dark .dialog-content .tags-list {
    background: #fcfcfc;
    border-color: #eee;
}

html.dark .dialog-content .tags-list .tag-row {
    background: transparent;
    border-color: #eee;
}

html.dark .dialog-content .tags-list .tag-row .tag-name {
    color: #1a1a1a;
}

html.dark .dialog-content .tags-list .tag-row .tag-count {
    color: #888;
}

html.dark .dialog-content .tags-list .tag-row:hover {
    background: rgba(0, 0, 0, 0.04);
}