:root {
    --color-primary: #5865f2;
    --color-on-primary: #ffffff;
    --color-green: #35ed7e;
    --color-magenta: #ec48bd;
    --color-link: #00b0f4;
    /* Bare triplets so CSS can vary only the alpha. The page script
       rewrites these when the admin panel sets custom brand colours. */
    --primary-rgb: 88, 101, 242;
    --green-rgb: 53, 237, 126;
    --magenta-rgb: 236, 72, 189;
    --link-rgb: 0, 176, 244;
    --color-canvas: #0a0d3a;
    --color-surface-indigo: #1e2353;
    --color-surface-onyx: #23272a;
    --color-ink: #ffffff;
    --color-ink-muted: rgba(255, 255, 255, 0.65);
    --color-ink-dark: #000000;
    /* Surfaces and edges that have to flip between themes */
    --surface-glass: rgba(255, 255, 255, 0.06);
    --surface-float: rgba(30, 35, 83, 0.75);
    --border-hairline: rgba(255, 255, 255, 0.08);
    --border-soft: rgba(255, 255, 255, 0.14);
    --shadow-card: 0 8px 32px rgba(0, 0, 0, 0.3);
    --shadow-card-hover: 0 12px 40px rgba(0, 0, 0, 0.4);
    --inset-highlight: inset 0 1px 0 rgba(255, 255, 255, 0.06);
    --title-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
    /* The discount pill floats over artwork, so it carries its own
       fill and ink rather than inheriting the page text colour. */
    --badge-bg: rgba(10, 13, 58, 0.45);
    --badge-border: rgba(255, 255, 255, 0.28);
    --badge-ink: #ffffff;
    /* Slider dot. The glow is kept as a bare rgb triplet so the
       keyframes can vary only its alpha. */
    --dot-active: #ffffff;
    --dot-glow: 255, 255, 255;
    /* Placeholder fill and the sheen that travels across it. Both are
       ink-on-canvas, not brand colours — a skeleton is furniture, and
       tinting it Blurple would make the wait look like a feature. */
    --skeleton-base: rgba(255, 255, 255, 0.07);
    --skeleton-sheen: rgba(255, 255, 255, 0.17);
    /* Disclaimer amber. Deliberately literal, like the badge colours in
       verify-badge.css — a warning wired to a brand token would drag the
       brand along the next time either one changed. It lives here rather
       than being written twice because the corner trigger and the popup's
       own mark are one signal and must not drift apart. */
    --warn: #f5b301;
    --warn-rgb: 245, 179, 1;
    --radius-xs: 6px;
    --radius-sm: 12px;
    --radius-md: 14px;
    --radius-lg: 16px;
    --radius-xl: 40px;
    --radius-pill: 50px;
    --font-display: 'Space Grotesk', 'Hanken Grotesk', sans-serif;
    --font-body: 'Inter', system-ui, sans-serif;
}

/* Light mode. Dark is the default, so this only overrides the tokens
   that have to flip — the brand colours stay put in both themes. */
[data-theme="light"] {
    --color-canvas: #eef1fb;
    --color-surface-indigo: #ffffff;
    --color-ink: #10132e;
    --color-ink-muted: rgba(16, 19, 46, 0.62);
    --surface-glass: rgba(16, 19, 46, 0.05);
    --surface-float: rgba(255, 255, 255, 0.85);
    --border-hairline: rgba(16, 19, 46, 0.1);
    --border-soft: rgba(16, 19, 46, 0.14);
    --shadow-card: 0 8px 26px rgba(38, 45, 90, 0.12);
    --shadow-card-hover: 0 12px 34px rgba(38, 45, 90, 0.18);
    --inset-highlight: inset 0 1px 0 rgba(255, 255, 255, 0.9);
    --title-shadow: none;
    --badge-bg: rgba(255, 255, 255, 0.82);
    --badge-border: rgba(16, 19, 46, 0.12);
    --badge-ink: #10132e;
    --dot-active: var(--color-primary);
    --dot-glow: var(--primary-rgb);
    --skeleton-base: rgba(16, 19, 46, 0.07);
    --skeleton-sheen: rgba(16, 19, 46, 0.13);
}

* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
    font-family: var(--font-body);
}

body {
    background-color: var(--color-canvas);
    color: var(--color-ink);
    min-height: 100vh;
    display: flex;
    flex-direction: column;
    align-items: center;
    padding: 20px;
    background-image:
        radial-gradient(circle at 20% 80%, rgba(var(--primary-rgb),0.28) 0%, transparent 50%),
        radial-gradient(circle at 80% 20%, rgba(var(--magenta-rgb),0.24) 0%, transparent 50%),
        radial-gradient(circle at 50% 50%, rgba(var(--link-rgb),0.12) 0%, transparent 60%);
    background-size: 200% 200%;
    animation: meshDrift 18s ease-in-out infinite;
    transition: background-color 0.4s ease, color 0.4s ease;
}

/* The canvas wash has to be much fainter to read on a light page */
[data-theme="light"] body {
    background-image:
        radial-gradient(circle at 20% 80%, rgba(var(--primary-rgb),0.14) 0%, transparent 50%),
        radial-gradient(circle at 80% 20%, rgba(var(--magenta-rgb),0.12) 0%, transparent 50%),
        radial-gradient(circle at 50% 50%, rgba(var(--link-rgb),0.08) 0%, transparent 60%);
}

@keyframes meshDrift {

    0%,
    100% {
        background-position: 0% 0%, 100% 100%, 50% 50%;
    }

    50% {
        background-position: 100% 50%, 0% 50%, 50% 100%;
    }
}

.container {
    position: relative;
    width: 100%;
    max-width: 600px;
    margin: 0 auto;
    padding: 20px;
}

.header {
    text-align: center;
    margin-bottom: 30px;
}

.logo-wrap {
    --aurora-size: 300px;
    position: relative;
    isolation: isolate;
    display: flex;
    justify-content: center;
    margin-bottom: 14px;
}

/* Ambient aurora — two soft colour clouds drifting behind the logo.
   They live on .logo-wrap, not .logo-orb, because the orb clips its
   overflow and would cut the glow off at the blob edge. */
.logo-wrap::before,
.logo-wrap::after {
    content: '';
    position: absolute;
    top: 50%;
    left: 50%;
    width: var(--aurora-size);
    height: var(--aurora-size);
    margin: calc(var(--aurora-size) / -2) 0 0 calc(var(--aurora-size) / -2);
    border-radius: 50%;
    filter: blur(44px);
    pointer-events: none;
    z-index: 0;
}

.logo-wrap.no-aurora::before,
.logo-wrap.no-aurora::after {
    display: none;
}

.logo-wrap::before {
    background:
        radial-gradient(42% 42% at 32% 34%, rgba(var(--primary-rgb),0.95), transparent 70%),
        radial-gradient(46% 46% at 68% 62%, rgba(var(--link-rgb),0.6), transparent 72%);
    animation: auroraDriftA 16s ease-in-out infinite;
}

.logo-wrap::after {
    background:
        radial-gradient(44% 44% at 70% 32%, rgba(var(--magenta-rgb),0.85), transparent 70%),
        radial-gradient(48% 48% at 34% 68%, rgba(var(--primary-rgb),0.55), transparent 74%);
    animation: auroraDriftB 21s ease-in-out infinite;
}

@keyframes auroraDriftA {
    0%, 100% { transform: translate(-14px, -10px) scale(1); opacity: 0.62; }
    33% { transform: translate(16px, 8px) scale(1.14); opacity: 0.85; }
    66% { transform: translate(-6px, 15px) scale(1.06); opacity: 0.70; }
}

@keyframes auroraDriftB {
    0%, 100% { transform: translate(13px, 11px) scale(1.09); opacity: 0.58; }
    33% { transform: translate(-15px, -7px) scale(1); opacity: 0.80; }
    66% { transform: translate(9px, -15px) scale(1.17); opacity: 0.66; }
}

/* ---------- tap the logo ----------

   The brand pops out on every tap, in the middle of the screen. 760ms end to
   end — long enough to read at a glance, short enough that four taps in a row
   read as four pops rather than as a queue.

   Fixed to the viewport, not to the logo. Anchored to the header it landed
   wherever the header happened to be, which on a phone is the top edge and on
   a tall desktop window is somewhere else again — and a pop that rises out of
   a header near the top of the page spends its last third above the fold,
   fading where nobody can see it. Centred on the viewport it lands in the
   same place on every device, which is what makes it feel deliberate.

   z-index 90 puts it over the page and the back-to-top button (50) and under
   the sensitive-content gate (100) and the disclaimer (120). A pop must never
   cover something the visitor has to answer.

   It takes no pointer events at all, so tapping through one still hits the
   logo underneath and the next tap is never swallowed by the last one's
   leftovers. */
.logo-pops {
    position: fixed;
    inset: 0;
    z-index: 90;
    display: grid;
    /* Bottom of the screen, centred. The go-top button lives bottom-right,
       so the pop is pulled up clear of it rather than landing on top. */
    place-items: end center;
    padding: 0 16px 96px;
    pointer-events: none;

    /* The logo runs a border-radius animation, which is not composited and
       repaints every frame. Without this, appending a pop invalidates layout
       for the whole page and those repaints hitch — which is the jitter you
       see when tapping fast. `contain` walls this subtree off so nothing
       inside it can make the rest of the page recalculate. */
    contain: layout paint style;
}

/* Wide screens: down in the left corner, out of the way of the column. On a
   phone there is no left corner worth the name — the column is the screen —
   so it stays centred there. */
@media (min-width: 900px) {
    .logo-pops {
        place-items: end start;
        padding: 0 0 64px 48px;
    }
}

.logo-pop {
    grid-area: 1 / 1;
    /* Promoted to its own compositor layer. transform and opacity are the two
       properties a browser can animate without repainting, and saying so up
       front stops the first frame of every pop from costing a paint the logo
       has to share. */
    will-change: transform, opacity;
    font-family: var(--font-display);
    font-size: clamp(1.15rem, 6.5vw, 2.1rem);
    font-weight: 800;
    letter-spacing: 3px;
    text-transform: uppercase;
    white-space: nowrap;
    text-align: center;
    user-select: none;
    -webkit-user-select: none;

    /* White in both themes, with a dark rim under it and the brand glow
       around it. The rim is what keeps it legible on the pale half of the
       light theme; the glow is what makes it belong to this page rather than
       look like a system notification. */
    color: var(--color-on-primary);
    text-shadow:
        0 1px 3px rgba(0, 0, 0, 0.7),
        0 0 12px rgba(var(--primary-rgb), 0.95),
        0 0 34px rgba(var(--primary-rgb), 0.8);

    /* easeOutQuad, not an expo. A hard ease-out compresses the whole thing
       into the first fifth: with cubic-bezier(0.16, 1, 0.3, 1) the pop was
       already down to 30% opacity 220ms in — faint, and faint is invisible.
       A gentle curve keeps the keyframe percentages close to real time, so
       the holds below mean what they say. */
    animation: logoPop 760ms cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
}

/* Out fast, hold, then up and away. The overshoot at 14% is what makes it
   read as a pop rather than as something merely appearing; the hold to 60%
   is what gives the eye time to read it; the rest is the slide and the fade. */
@keyframes logoPop {
    0% {
        opacity: 0;
        transform: translateY(14px) scale(0.68);
    }

    14% {
        opacity: 1;
        transform: translateY(0) scale(1.14);
    }

    26% {
        opacity: 1;
        transform: translateY(-8px) scale(1);
    }

    60% {
        opacity: 1;
        transform: translateY(-44px) scale(1);
    }

    100% {
        opacity: 0;
        transform: translateY(-92px) scale(0.94);
    }
}

/* Still says something happened, without the travel. */
@media (prefers-reduced-motion: reduce) {
    .logo-pop {
        animation: logoPopFade 620ms ease forwards;
    }

    @keyframes logoPopFade {
        0% { opacity: 0; }
        20% { opacity: 1; }
        100% { opacity: 0; }
    }
}

.logo-orb {
    position: relative;
    z-index: 1;
    width: 168px;
    height: 168px;
    overflow: hidden;
    border-radius: 58% 42% 56% 44% / 44% 58% 42% 56%;
    animation: blobMorph 14s ease-in-out infinite;

    /* ---- the tap, and deliberately nothing else ----

       No hover, no :active, no transform, no filter. The orb is running a
       border-radius animation the whole time it is on screen, and any state
       that changes how it is painted lands in the middle of that animation
       and shows up as a stutter in the blob.

       These four lines only take things away:

         tap-highlight   Android paints a dark box over anything tappable.
                         That was the black flash on every tap.
         touch-action    tells the browser this wants taps and nothing else,
                         so it stops holding each one back for 300ms to see
                         whether a double-tap-to-zoom is coming — which is
                         what made fast tapping feel like it was catching.
         touch-callout   long-press on iOS otherwise offers to save the image.
         user-select     and a fast double-tap otherwise selects it. */
    -webkit-tap-highlight-color: transparent;
    touch-action: manipulation;
    -webkit-touch-callout: none;
    -webkit-user-select: none;
    user-select: none;
}

/* Organic liquid-blob morph — the circle's outline flows continuously */
@keyframes blobMorph {

    0%,
    100% {
        border-radius: 52% 48% 45% 55% / 50% 45% 55% 50%;
    }

    20% {
        border-radius: 42% 58% 56% 44% / 56% 52% 48% 44%;
    }

    40% {
        border-radius: 58% 42% 38% 62% / 44% 58% 42% 56%;
    }

    60% {
        border-radius: 46% 54% 60% 40% / 60% 42% 58% 40%;
    }

    80% {
        border-radius: 60% 40% 48% 52% / 42% 56% 44% 58%;
    }
}

.brand-logo {
    display: block;
    width: 100%;
    height: 100%;
    object-fit: cover;
    /* A dragged image is a drag that never becomes a tap. */
    -webkit-user-drag: none;
    user-select: none;
    pointer-events: none;
}

.social-row {
    position: relative;
    z-index: 1;
    display: flex;
    justify-content: center;
    gap: 14px;
}

.social-link {
    width: 46px;
    height: 46px;
    display: flex;
    align-items: center;
    justify-content: center;
    border-radius: 50%;
    color: var(--color-ink);
    background: var(--surface-glass);
    border: 1px solid var(--border-soft);
    backdrop-filter: blur(8px);
    transition:
        transform 0.3s ease,
        background 0.3s ease,
        border-color 0.3s ease,
        box-shadow 0.3s ease;
}

.social-link svg {
    width: 22px;
    height: 22px;
}

.social-link:hover {
    transform: translateY(-3px);
}

.social-link.telegram:hover {
    background: rgba(var(--link-rgb),0.22);
    border-color: rgba(var(--link-rgb),0.65);
    box-shadow: 0 8px 22px rgba(var(--link-rgb),0.3);
}

.social-link.instagram:hover {
    background: rgba(var(--magenta-rgb),0.22);
    border-color: rgba(var(--magenta-rgb),0.65);
    box-shadow: 0 8px 22px rgba(var(--magenta-rgb),0.3);
}

/* No backdrop-filter here on purpose. There used to be one, and it could
   never have shown: --color-surface-indigo is fully opaque in both themes, so
   the blurred backdrop was painted over the instant it was computed. What it
   did do was cost a full blur pass on every frame the card was growing.

   `transition` names its three properties rather than `all`, so a height
   change cannot drag unrelated properties into the same animation. */
.link-block {
    background-color: var(--color-surface-indigo);
    border-radius: var(--radius-lg);
    margin-bottom: 16px;
    overflow: hidden;
    box-shadow: var(--shadow-card), var(--inset-highlight);
    transition:
        transform 0.3s ease,
        border-color 0.3s ease,
        box-shadow 0.3s ease;
    border: 1px solid var(--border-hairline);
}

/* Fine pointers only. A phone has no way to un-hover: tap the exclusive card
   to open it and the lift, the border and the shadow stay on for as long as
   the visitor stays on the page, so the card sits there looking pressed. The
   ordinary rows get away with it because tapping one navigates away — the
   exclusive one does not go anywhere. Guarded as a set, so the whole page
   behaves the same way rather than one card being the exception. */
@media (hover: hover) and (pointer: fine) {
    .link-block:hover {
        border-color: rgba(var(--primary-rgb),0.55);
        transform: translateY(-2px);
        box-shadow: var(--shadow-card-hover), var(--inset-highlight);
    }
}

.collapsed-view {
    display: flex;
    align-items: center;
    padding: 18px 22px;
    cursor: pointer;
    position: relative;
    color: inherit;
    text-decoration: none;
}

.left-image {
    width: 60px;
    height: 60px;
    border-radius: var(--radius-sm);
    object-fit: cover;
    margin-right: 18px;
    border: 2px solid rgba(var(--primary-rgb),0.4);
    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}

.link-title {
    font-family: var(--font-display);
    font-size: 1.25rem;
    font-weight: 700;
    flex-grow: 1;
    text-shadow: var(--title-shadow);
}

.dropdown-arrow {
    flex-shrink: 0;
    width: 34px;
    height: 34px;
    display: flex;
    align-items: center;
    justify-content: center;
    color: var(--color-primary);
    background: rgba(var(--primary-rgb),0.12);
    border: 1px solid rgba(var(--primary-rgb),0.35);
    border-radius: 50%;
    transition:
        transform 0.45s cubic-bezier(0.4, 0, 0.2, 1),
        background 0.3s ease,
        border-color 0.3s ease,
        color 0.3s ease;
}

.dropdown-arrow svg {
    width: 17px;
    height: 17px;
}

@media (hover: hover) and (pointer: fine) {
    .collapsed-view:hover .dropdown-arrow {
        background: rgba(var(--primary-rgb),0.24);
        border-color: rgba(var(--primary-rgb),0.65);
    }
}

/* Right-pointing arrow on the plain link rows. It sits in the same
   34px slot as .dropdown-arrow — with border-box sizing the padding
   shrinks the glyph to 20px — so both icons share a centre line. */
.link-arrow {
    flex-shrink: 0;
    width: 34px;
    height: 34px;
    padding: 7px;
    color: var(--color-ink-muted);
    transition: transform 0.3s ease, color 0.3s ease;
}

@media (hover: hover) and (pointer: fine) {
    .collapsed-view:hover .link-arrow {
        color: var(--color-primary);
        transform: translateX(5px);
    }
}

/* Opens to the content's OWN height, by interpolating a grid row from 0fr to
   1fr. It used to animate `max-height: 0 -> 1000px` over a panel that is
   really 388px: the easing curve was spread across 1000px, so everything the
   visitor could see finished in the first third of the duration and the panel
   then sat still for the rest — and on the way back it stalled first and
   slammed shut at the end. That stall is what read as a jitter. There is no
   guessed number here any more, so the curve lands exactly when the movement
   does, whatever the card grows to.

   `min-height: 0` on the inner element is load-bearing: a grid item defaults
   to `min-height: auto`, which refuses to shrink below its content and would
   stop the row collapsing at all. */
.expanded-view {
    display: grid;
    grid-template-rows: 0fr;
    transition: grid-template-rows 0.42s cubic-bezier(0.32, 0.72, 0, 1);
}

.link-block.open .expanded-view {
    grid-template-rows: 1fr;
}

.expanded-inner {
    min-height: 0;
    overflow: hidden;
}

.link-block.open .dropdown-arrow {
    transform: rotate(180deg);
    background: #613bff5e;
    border-color: var(--color-primary);
    color: var(--color-ink);
}

.image-container {
    position: relative;
    margin: 0 22px 22px;
    border-radius: var(--radius-lg);
    overflow: hidden;
    aspect-ratio: 16/9;
    box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
    border: 1px solid var(--border-hairline);
    /* let the browser own vertical panning, we take horizontal swipes */
    touch-action: pan-y;
}

.slider-track {
    display: flex;
    height: 100%;
    transition: transform 0.7s cubic-bezier(0.65, 0, 0.35, 1);
}

.slide {
    min-width: 100%;
    height: 100%;
    overflow: hidden;
}

/* Off-screen media sits zoomed in and dimmed, then settles as it
   becomes the active slide — gives each change some weight. */
.slide-media {
    display: block;
    width: 100%;
    height: 100%;
    object-fit: cover;
    transform: scale(1.18);
    opacity: 0.45;
    transition:
        transform 1s cubic-bezier(0.22, 1, 0.36, 1),
        opacity 0.6s ease;
}

.slide.active .slide-media {
    transform: scale(1);
    opacity: 1;
}

.slider-dots {
    position: absolute;
    left: 50%;
    bottom: 12px;
    transform: translateX(-50%);
    z-index: 3;
    display: flex;
    gap: 8px;
}

.dot {
    position: relative;
    width: 9px;
    height: 9px;
    padding: 0;
    border: none;
    border-radius: var(--radius-pill);
    background: rgba(255, 255, 255, 0.45);
    cursor: pointer;
    /* No double-tap-zoom pause between the tap and the slide changing. */
    touch-action: manipulation;
    transition:
        width 0.45s cubic-bezier(0.65, 0, 0.35, 1),
        background 0.35s ease;
}

/* A 9px dot is a 9px target, and DESIGN.md asks for 44. The dot stays 9px
   because that is the design; an invisible pad around it carries the tap.

   Widened by 4px a side and no more: the dots sit 8px apart, so 4 is exactly
   what fits before one dot's hit area starts covering its neighbour's and
   sending the visitor to the wrong slide — worse than a small target, not
   better. The height is where the win is anyway: a thumb misses a 9px strip
   vertically long before it misses horizontally. */
.dot::before {
    content: '';
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    width: calc(100% + 8px);
    height: 44px;
}

.dot.active {
    width: 28px;
    background: var(--dot-active);
    animation: dotGlow 1.9s ease-in-out infinite;
}

@keyframes dotGlow {
    0%, 100% { box-shadow: 0 0 9px 1px rgba(var(--dot-glow), 0.75); }
    50% { box-shadow: 0 0 18px 4px rgba(var(--dot-glow), 1); }
}

/* Translucent discount pill floating over the preview art */
.discount-badge {
    position: absolute;
    top: 12px;
    right: 12px;
    z-index: 3;
    display: flex;
    align-items: center;
    gap: 5px;
    padding: 5px 11px;
    border-radius: var(--radius-pill);
    background: var(--badge-bg);
    backdrop-filter: blur(12px);
    -webkit-backdrop-filter: blur(12px);
    border: 1px solid var(--badge-border);
    color: var(--badge-ink);
    font-family: var(--font-display);
    font-size: 0.75rem;
    font-weight: 700;
    letter-spacing: 0.4px;
    box-shadow: 0 6px 20px rgba(0, 0, 0, 0.28);
    transition: background 0.3s ease, color 0.3s ease, border-color 0.3s ease;
}

.discount-badge svg {
    width: 13px;
    height: 13px;
    flex-shrink: 0;
}

/* Highest-intent CTA — electric green, per DESIGN.md.

   The whole recipe is written against --buy-base / --buy-rgb rather than the
   green token directly, so a .tone-* class can swap the hue and change
   nothing else: same gradient, same sweep, same pulse, same ink. Left alone
   these resolve to the green, which is the default and the DESIGN.md rule. */
.buy-now {
    --buy-base: var(--color-green);
    --buy-rgb: var(--green-rgb);
    --buy-ink: var(--color-ink-dark);

    display: block;
    position: relative;
    overflow: hidden;
    margin: 0 22px 22px;
    padding: 16px 24px;
    background: linear-gradient(120deg,
            color-mix(in srgb, var(--buy-base) 82%, black),
            var(--buy-base),
            color-mix(in srgb, var(--buy-base) 68%, white),
            var(--buy-base),
            color-mix(in srgb, var(--buy-base) 82%, black));
    background-size: 300% 100%;
    color: var(--buy-ink);
    font-family: var(--font-display);
    font-size: 1.1rem;
    font-weight: 800;
    letter-spacing: 1px;
    text-align: center;
    text-decoration: none;
    border-radius: var(--radius-sm);
    transition: transform 0.25s ease;
    animation:
        buyPulse 2.4s ease-in-out infinite,
        buyGradient 5s linear infinite;
}

.buy-now:hover {
    transform: translateY(-3px) scale(1.015);
}

/* A highlight sweeps across the button on a loop */
.buy-now::after {
    content: '';
    position: absolute;
    top: 0;
    left: -60%;
    width: 40%;
    height: 100%;
    background: linear-gradient(100deg, transparent, rgba(255, 255, 255, 0.75), transparent);
    transform: skewX(-20deg);
    animation: buyShine 3.2s ease-in-out infinite;
}

@keyframes buyPulse {
    0%, 100% { box-shadow: 0 0 0 0 rgba(var(--buy-rgb),0.5); }
    50% { box-shadow: 0 0 30px 7px rgba(var(--buy-rgb),0.45); }
}

/* ---------- buy button tones ----------

   One per verified-tick shape, applied by ilkh.js when the owner has asked
   the button to follow the badge. Each is the badge's own base colour — the
   deeper of its two gradient stops, the one you actually read the badge as —
   lifted straight out of verify-badge.css.

   Only colour is here — a fill, a glow triplet and an ink. Nothing sets a
   background recipe, a shadow, or an animation, because the point is that
   the button looks exactly like itself in a different colour. If a rule that
   is not a colour ends up in this block, it is in the wrong block.

   --buy-ink is the badge's OWN tick stroke from verify-badge.css, not a
   contrast pick of our own: white on every shape except the yellow seal,
   where white vanishes and the tick goes dark. The label and the tick it
   borrows from therefore always read as the same mark.

   `hex` borrows the brand magenta token rather than a literal, so it keeps
   following the owner's colour picker the way the badge does. The other five
   are literal for the same reason they are literal in verify-badge.css:
   wiring them to brand tokens would drag the rest of the page along. */
.buy-now.tone-burst {
    --buy-base: var(--tick-blue);
    --buy-rgb: var(--tick-blue-rgb);
    --buy-ink: #ffffff;
}

.buy-now.tone-hex {
    --buy-base: var(--color-magenta);
    --buy-rgb: var(--magenta-rgb);
    --buy-ink: #ffffff;
}

.buy-now.tone-circle {
    --buy-base: var(--tick-green);
    --buy-rgb: var(--tick-green-rgb);
    --buy-ink: #ffffff;
}

/* The one dark tick, and so the one dark label. */
.buy-now.tone-scallop {
    --buy-base: var(--tick-yellow);
    --buy-rgb: var(--tick-yellow-rgb);
    --buy-ink: #3d2b00;
}

.buy-now.tone-hexRed {
    --buy-base: var(--tick-red);
    --buy-rgb: var(--tick-red-rgb);
    --buy-ink: #ffffff;
}

.buy-now.tone-hexViolet {
    --buy-base: var(--tick-violet);
    --buy-rgb: var(--tick-violet-rgb);
    --buy-ink: #ffffff;
}

@keyframes buyGradient {
    0% { background-position: 0% 50%; }
    100% { background-position: 300% 50%; }
}

@keyframes buyShine {
    0% { left: -60%; }
    60%, 100% { left: 130%; }
}

/* Floating back-to-top control */
.go-top {
    position: fixed;
    right: 22px;
    bottom: 22px;
    z-index: 50;
    width: 46px;
    height: 46px;
    display: flex;
    align-items: center;
    justify-content: center;
    padding: 0;
    color: var(--color-ink);
    background: var(--surface-float);
    backdrop-filter: blur(10px);
    -webkit-backdrop-filter: blur(10px);
    border: 1px solid rgba(var(--primary-rgb),0.45);
    border-radius: 50%;
    cursor: pointer;
    opacity: 0;
    visibility: hidden;
    transform: translateY(14px);
    /* Tells the browser this element wants taps and nothing else — no
       double-tap zoom, so it stops holding the tap back to see whether a
       second one is coming. */
    touch-action: manipulation;
    -webkit-tap-highlight-color: transparent;
    transition:
        opacity 0.3s ease,
        transform 0.3s ease,
        visibility 0.3s ease,
        background 0.3s ease,
        border-color 0.3s ease;
}

.go-top.show {
    opacity: 1;
    visibility: visible;
    transform: translateY(0);
}

/* Hover is a mouse idea. Unguarded, a phone hands the FIRST tap to this
   state instead of to the click — which is why the button was reported as
   needing two taps — and then leaves the state stuck on afterwards, so the
   button sits there looking pressed. Fine pointers only. */
@media (hover: hover) and (pointer: fine) {
    .go-top:hover {
        background: var(--color-primary);
        border-color: var(--color-primary);
        transform: translateY(-3px);
        box-shadow: 0 8px 22px rgba(var(--primary-rgb),0.45);
    }
}

/* Touch has no hover to answer with, so the press itself is the feedback.
   Declared after .show so it outranks that rule's transform. */
.go-top:active {
    background: var(--color-primary);
    border-color: var(--color-primary);
    transform: scale(0.93);
}

/* Set for the length of a back-to-top trip. The body's 18s gradient drift is
   a full-page repaint per frame and cannot be composited; nothing notices it
   standing still for half a second, and the scroll gets those frames back. */
html.scrolling-top body {
    animation-play-state: paused;
}

.go-top svg {
    width: 20px;
    height: 20px;
}

/* Maintenance screen. Swapped in for the whole page when the admin
   panel turns it on; ?preview=1 lets the owner past it.

   One idea carries this screen: the scrolling band. Everything else
   is plain type on the page canvas, so the band is what you notice
   rather than five effects competing for attention. */
.maintenance {
    display: none;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: 40px;
    width: 100%;
    min-height: calc(100vh - 40px);
}

body.maintenance-on .container,
body.maintenance-on .go-top {
    display: none;
}

body.maintenance-on .maintenance {
    display: flex;
}

/* Full-bleed band — breaks out of the body padding to run edge to edge */
.marquee {
    width: 100vw;
    margin-left: calc(50% - 50vw);
    overflow: hidden;
    padding: 14px 0;
    background: var(--color-primary);
}

.marquee-track {
    display: flex;
    width: max-content;
    animation: marqueeScroll 26s linear infinite;
}

.marquee.reverse .marquee-track {
    animation-direction: reverse;
}

/* Every item is the same width and the list is printed twice, so
   shifting by half the track lands exactly on the copy. */
.marquee-track span {
    padding-right: 30px;
    font-family: var(--font-display);
    font-size: 1.4rem;
    font-weight: 800;
    letter-spacing: 1.5px;
    color: var(--color-on-primary);
    text-transform: uppercase;
    white-space: nowrap;
}

@keyframes marqueeScroll {
    from { transform: translateX(0); }
    to { transform: translateX(-50%); }
}

.maintenance-body {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 16px;
    padding: 0 24px;
    text-align: center;
}

.maintenance-title {
    max-width: 16ch;
    font-family: var(--font-display);
    font-size: 2.4rem;
    font-weight: 800;
    line-height: 1.05;
    letter-spacing: -1px;
    text-transform: uppercase;
    animation: maintenanceBreathe 6s ease-in-out infinite;
}

/* A long, shallow breath. Nothing moves and nothing changes size — only the
   weight of the ink and a faint halo behind it — so the scrolling band above
   stays the thing you actually look at. */
@keyframes maintenanceBreathe {
    0%,
    100% {
        opacity: 0.72;
        text-shadow: 0 0 0 rgba(var(--primary-rgb), 0);
    }

    50% {
        opacity: 1;
        text-shadow: 0 0 26px rgba(var(--primary-rgb), 0.4);
    }
}

.maintenance-message {
    max-width: 44ch;
    color: var(--color-ink-muted);
    font-size: 1.02rem;
    line-height: 1.6;
}

.maintenance-socials {
    margin-top: 10px;
}

.maintenance-wordmark {
    margin-top: 4px;
    font-family: var(--font-display);
    font-size: 0.78rem;
    font-weight: 700;
    letter-spacing: 3px;
    color: var(--color-ink-muted);
    opacity: 0.65;
}

@media (max-width: 480px) {
    .maintenance {
        gap: 30px;
    }

    .maintenance-title {
        font-size: 1.75rem;
    }

    .marquee-track span {
        font-size: 1.1rem;
        padding-right: 22px;
    }
}

/* ---------- 18+ gate ---------- */

.age-gate {
    display: none;
    position: fixed;
    inset: 0;
    z-index: 100;
    align-items: center;
    justify-content: center;
    padding: 24px;
    background: rgba(6, 8, 34, 0.86);
    backdrop-filter: blur(20px);
    -webkit-backdrop-filter: blur(20px);
}

[data-theme="light"] .age-gate {
    background: rgba(228, 232, 248, 0.88);
}

body.age-locked {
    overflow: hidden;
}

body.age-locked .age-gate {
    display: flex;
}

/* Type carries this one — the age itself is the headline. Card chrome
   matches the link rows so the gate reads as part of the page. */
.age-card {
    width: 100%;
    max-width: 400px;
    padding: 34px 28px;
    text-align: center;
    background: var(--color-surface-indigo);
    border: 1px solid var(--border-hairline);
    border-radius: var(--radius-xl);
    box-shadow: var(--shadow-card);
    animation: ageCardIn 0.4s cubic-bezier(0.22, 1, 0.36, 1);
}

@keyframes ageCardIn {
    from { opacity: 0; transform: translateY(14px); }
    to { opacity: 1; transform: none; }
}

/* Was the big "18+" set in display type; now the struck-through eye. The
   icon is the hero the way the number used to be, so it keeps the same
   optical weight rather than shrinking to a decorative glyph. */
.age-badge {
    display: flex;
    justify-content: center;
    line-height: 1;
}

.age-badge svg {
    width: 58px;
    height: 58px;
    stroke: var(--color-ink);
}

.age-title {
    margin: 18px 0 8px;
    font-family: var(--font-display);
    font-size: 1.85rem;
    font-weight: 800;
    letter-spacing: -0.5px;
    line-height: 1.2;
}

.age-message {
    color: var(--color-ink-muted);
    font-size: 0.95rem;
    line-height: 1.6;
}

.age-actions {
    display: flex;
    flex-direction: column;
    gap: 9px;
    margin-top: 26px;
}

.age-btn {
    padding: 14px 20px;
    border: 1px solid transparent;
    border-radius: var(--radius-sm);
    font-family: var(--font-body);
    font-size: 0.98rem;
    font-weight: 600;
    cursor: pointer;
    transition: background 0.2s ease, border-color 0.2s ease, color 0.2s ease;
}

/* Blurple, not green — green stays reserved for the buy CTA */
.age-yes {
    background: var(--color-primary);
    color: var(--color-on-primary);
}

.age-yes:hover {
    background: color-mix(in srgb, var(--color-primary) 86%, white);
}

.age-no {
    background: transparent;
    border-color: var(--border-soft);
    color: var(--color-ink-muted);
}

.age-no:hover {
    border-color: var(--color-ink-muted);
    color: var(--color-ink);
}


/* ---------- promos ----------

   Ordinary link rows that ask for attention on a loop. Three movements to
   choose from; every promo also glows, because the glow is what says "this
   one" and the movement is only how it says it.

   Both animations run on .link-block itself, and it has to be that element:
   .link-block sets `overflow: hidden` to clip its expanded panel, so scaling
   anything INSIDE it just pushes that child under the clip and shaves the
   thumbnail and title off at the edges. The block has nothing clipping it.

   That costs the promo its fadeInUp entrance — two animations on one element
   that both touch `transform` do not blend, the later simply wins — so the
   base rule's `opacity: 0` is undone here by hand. A promo appears instead
   of sliding in, which nobody will notice on a row that is about to move by
   itself anyway. It also means a promo does not lift on hover; the hover
   transform loses to a running animation, which is a rule of the cascade and
   not something worth fighting.

   3s cycles with the movement packed into the first fifth: about half a
   second of motion, then two and a half of stillness. A row that never
   stops moving stops reading as a signal and starts reading as a fault. */

/* The glow colour is the owner's choice, not the page's — one of the five
   the verified tick wears, picked per promo in the panel. The triplets are
   defined once in verify-badge.css, which both pages load, so a badge and a
   promo asking for the same colour genuinely get the same colour.

   Magenta is the default because that is what promos wore before the picker
   existed; a config saved without a glow keeps looking the way it did. */
.link-block.promo {
    --promo-rgb: var(--magenta-rgb);

    opacity: 1;
    border-color: rgba(var(--promo-rgb), 0.5);
    animation: promoGlow 3s ease-in-out infinite;
}

.link-block.promo.glow-blue { --promo-rgb: var(--tick-blue-rgb); }
.link-block.promo.glow-magenta { --promo-rgb: var(--magenta-rgb); }
.link-block.promo.glow-green { --promo-rgb: var(--tick-green-rgb); }
.link-block.promo.glow-yellow { --promo-rgb: var(--tick-yellow-rgb); }
.link-block.promo.glow-red { --promo-rgb: var(--tick-red-rgb); }
.link-block.promo.glow-violet { --promo-rgb: var(--tick-violet-rgb); }


@keyframes promoGlow {
    0%, 100% {
        box-shadow:
            0 0 0 0 rgba(var(--promo-rgb), 0),
            var(--shadow-card);
    }

    30% {
        box-shadow:
            0 0 22px 2px rgba(var(--promo-rgb), 0.45),
            var(--shadow-card);
    }
}

/* Glow plus movement, in one list. They do not collide: promoGlow only
   touches box-shadow and the movement only touches transform. */
.link-block.promo.anim-pop {
    animation:
        promoGlow 3s ease-in-out infinite,
        promoPop 3s ease-in-out infinite;
}

.link-block.promo.anim-wobble {
    animation:
        promoGlow 3s ease-in-out infinite,
        promoWobble 3s ease-in-out infinite;
}

.link-block.promo.anim-smoothPop {
    animation:
        promoGlow 3s ease-in-out infinite,
        promoSmoothPop 3s cubic-bezier(0.22, 1, 0.36, 1) infinite;
}

/* The third property, and the reason this one can share the row with the
   glow as happily as the other two do: promoGlow owns box-shadow, the
   movements own transform, and this owns opacity. Nothing overlaps, so the
   shorthand lists below never fight. */
.link-block.promo.anim-blink {
    animation:
        promoGlow 3s ease-in-out infinite,
        promoBlink 3s steps(1, end) infinite;
}

/* Staggered, so three promos take their turn instead of moving in unison —
   which reads as one wall of movement, and as something wrong with the page
   rather than three things worth a look. One delay covers both animations on
   the row, so the glow travels with the movement.

   These MUST come after the .anim-* rules above. Those set the `animation`
   shorthand, and a shorthand resets every longhand it covers — including
   animation-delay. Same specificity, so source order is the whole argument;
   put these first and the stagger silently disappears.

   Classes rather than an inline style, because `style-src-attr` is 'none'. */
.link-block.promo.delay-1 { animation-delay: 0.5s; }
.link-block.promo.delay-2 { animation-delay: 1s; }

/* Sharp, with a small overshoot and settle — it should read as a tap. */
@keyframes promoPop {
    0% { transform: scale(1); }
    4% { transform: scale(1.05); }
    9% { transform: scale(0.985); }
    14% { transform: scale(1.014); }
    19%, 100% { transform: scale(1); }
}

/* Side to side, decaying. Degrees stay under 1.5 — the row is a wide bar and
   a big rotation on one of those looks broken rather than lively. */
@keyframes promoWobble {
    0% { transform: rotate(0deg); }
    4% { transform: rotate(-1.2deg); }
    8% { transform: rotate(1deg); }
    12% { transform: rotate(-0.6deg); }
    16% { transform: rotate(0.3deg); }
    20%, 100% { transform: rotate(0deg); }
}

/* The same idea as pop with the edges taken off: one swell, no overshoot. */
@keyframes promoSmoothPop {
    0% { transform: scale(1); }
    11% { transform: scale(1.032); }
    28%, 100% { transform: scale(1); }
}

/* Two hard flashes and then still, which is what a blink is — the timing
   function is steps(1) so it cuts rather than fades. A fade would be a
   pulse, and there is already a pulse on this row: the glow.

   It stops at 0.28 rather than 0. The row is a wide bar carrying a picture
   and a title, and a bar that vanishes outright twice reads as the page
   failing to paint rather than as something asking for attention. Same
   argument as the wobble keeping under 1.5 degrees. */
@keyframes promoBlink {
    0%, 7%, 14%, 100% { opacity: 1; }
    3.5%, 10.5% { opacity: 0.28; }
}

/* ---------- folded link batches ----------

   Everything past the fold mark. Opens the same way the EXCLUSIVE card does
   — a grid row from 0fr to 1fr — so it animates to the height of whatever
   is inside instead of to a guessed ceiling. */

/* The seam between the rows shown outright and the first folded batch. One
   dot, and deliberately nothing else: no line, no label. It marks "the list
   carries on past here" without competing with the button below it, which is
   the thing to actually read.

   Only the halo breathes — the dot itself never moves or changes size, which
   is what keeps it a marker rather than one more thing bouncing on the page.
   Same restraint as the maintenance headline and the verified tick, and it
   is why the animation touches box-shadow alone. Slow, too: six seconds, so
   it reads as something glowing rather than something blinking.

   White in dark mode is the whole idea; in light mode a white dot on a near
   white page is an invisible separator, so it takes the ink colour there. */
.link-seam {
    --seam: 255, 255, 255;

    display: flex;
    align-items: center;
    justify-content: center;
    height: 30px;
    margin-bottom: 14px;
}

/* The brand colour, not the ink — the same swap the slider's active dot makes
   in light mode (`--dot-active`). Both are a small glowing dot marking a
   position in a list, and they were the same white on a dark page; on a light
   one the slider dot went Blurple and this one went near-black, so the two
   stopped looking like the same idea. It follows the owner's primary colour,
   so changing that moves both together. */
[data-theme="light"] .link-seam {
    --seam: var(--primary-rgb);
}

.link-seam::before {
    content: '';
    width: 7px;
    height: 7px;
    border-radius: 50%;
    background: rgb(var(--seam));
    animation: seamBreathe 6s ease-in-out infinite;
}

@keyframes seamBreathe {
    0%, 100% {
        box-shadow:
            0 0 6px 1px rgba(var(--seam), 0.45),
            0 0 14px 4px rgba(var(--seam), 0.14);
    }

    50% {
        box-shadow:
            0 0 11px 3px rgba(var(--seam), 0.95),
            0 0 28px 10px rgba(var(--seam), 0.4);
    }
}

.link-fold {
    margin-bottom: 16px;
}

.fold-head {
    width: 100%;
    display: flex;
    align-items: center;
    gap: 10px;
    padding: 15px 22px;
    color: var(--color-ink);
    background-color: var(--color-surface-indigo);
    border: 1px solid var(--border-hairline);
    border-radius: var(--radius-lg);
    box-shadow: var(--shadow-card), var(--inset-highlight);
    font-family: var(--font-display);
    font-size: 1rem;
    font-weight: 700;
    text-align: left;
    cursor: pointer;
    transition: border-color 0.3s ease, box-shadow 0.3s ease;
}

/* Same reason as .link-block — the fold button is still on screen after the
   tap that opened it. */
@media (hover: hover) and (pointer: fine) {
    .fold-head:hover {
        border-color: rgba(var(--primary-rgb), 0.55);
        box-shadow: var(--shadow-card-hover), var(--inset-highlight);
    }
}

.fold-label {
    flex-grow: 1;
}

.fold-count {
    flex-shrink: 0;
    padding: 3px 9px;
    border-radius: var(--radius-pill);
    background: rgba(var(--primary-rgb), 0.16);
    border: 1px solid rgba(var(--primary-rgb), 0.32);
    color: var(--color-primary);
    font-family: var(--font-body);
    font-size: 0.78rem;
    font-weight: 600;
}

.fold-arrow {
    flex-shrink: 0;
    width: 20px;
    height: 20px;
    color: var(--color-primary);
    transition: transform 0.45s cubic-bezier(0.4, 0, 0.2, 1);
}

.link-fold.open .fold-arrow {
    transform: rotate(180deg);
}

.fold-body {
    display: grid;
    grid-template-rows: 0fr;
    transition: grid-template-rows 0.42s cubic-bezier(0.32, 0.72, 0, 1);
}

.link-fold.open .fold-body {
    grid-template-rows: 1fr;
}

/* min-height: 0 is load-bearing — a grid item defaults to min-height: auto,
   which refuses to shrink below its content and stops the row collapsing. */
.fold-inner {
    min-height: 0;
    overflow: hidden;
}

/* The rows inside a fold are revealed by the fold, not one at a time. Their
   own staggered entrance would run while the batch is still closed and be
   over before anyone saw it. */
.fold-inner .link-block {
    animation: none;
    opacity: 1;
}

.fold-inner .link-block:first-child {
    margin-top: 16px;
}

@media (max-width: 640px) {
    .fold-head {
        padding: 14px 18px;
        font-size: 0.94rem;
    }
}

/* ---------- disclaimer ----------

   Built out of the page's own surfaces — same card as a link row, same glass
   as the theme switch. Only the warning glyph is amber. An earlier version
   inverted the whole popup against the theme and put the button in amber
   too; both shouted, and a standing note is not an alert. The one coloured
   thing left is the mark that says which note this is. */

.disclaimer-layer {
    position: fixed;
    inset: 0;
    z-index: 120;
    display: flex;
    align-items: center;
    justify-content: center;
    padding: 24px;
    /* Same veil as the sensitive-content gate, so the two overlays match */
    background: rgba(6, 8, 34, 0.86);
    backdrop-filter: blur(20px);
    -webkit-backdrop-filter: blur(20px);
    opacity: 0;
    visibility: hidden;
    transition: opacity 0.3s ease, visibility 0.3s ease;
}

[data-theme="light"] .disclaimer-layer {
    background: rgba(228, 232, 248, 0.88);
}

body.disclaimer-open .disclaimer-layer {
    opacity: 1;
    visibility: visible;
}

/* Squared off to --radius-lg, the link rows' own corner. The card used to
   carry --radius-xl (40px), which on a box this size read as a pill rather
   than a panel and matched nothing else on the page. */
.disclaimer-card {
    position: relative;
    width: 100%;
    max-width: 480px;
    max-height: 80vh;
    overflow-y: auto;
    padding: 28px 26px 26px;
    background: var(--color-surface-indigo);
    color: var(--color-ink);
    border: 1px solid var(--border-hairline);
    border-radius: var(--radius-lg);
    box-shadow: var(--shadow-card), var(--inset-highlight);
    transform: translateY(16px) scale(0.98);
    transition: transform 0.36s cubic-bezier(0.22, 1, 0.36, 1);
}

body.disclaimer-open .disclaimer-card {
    transform: none;
}

/* Squared to match the card it sits in, and in the page's glass rather than
   a colour of its own — the amber is saved for the mark in the heading, and
   two amber things on one small card would compete. */
.disclaimer-close {
    position: absolute;
    top: 12px;
    right: 12px;
    width: 30px;
    height: 30px;
    display: flex;
    align-items: center;
    justify-content: center;
    padding: 0;
    color: var(--color-ink-muted);
    background: var(--surface-glass);
    border: 1px solid var(--border-soft);
    border-radius: var(--radius-xs);
    cursor: pointer;
    touch-action: manipulation;
    transition:
        color 0.2s ease,
        background 0.2s ease,
        border-color 0.2s ease,
        transform 0.25s ease,
        box-shadow 0.25s ease;
}

/* 30px drawn, 44px to hit — the pad reaches out into the card's own corner
   padding, which has nothing else in it. */
.disclaimer-close::before {
    content: '';
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    width: 44px;
    height: 44px;
}

.disclaimer-close:hover {
    color: var(--color-ink);
    background: rgba(var(--warn-rgb), 0.14);
    border-color: rgba(var(--warn-rgb), 0.5);
}

.disclaimer-close svg {
    width: 14px;
    height: 14px;
}

/* Two short beats a second after the card lands. The popup opens itself on
   a first visit, so the visitor did not ask for it and has no reason to look
   for a way out — this points at one without moving anything else on the
   card. Delayed rather than immediate so it happens *after* they have
   started reading, where it registers as an answer rather than noise.

   Scoped to the open state so it replays every time the card is opened, and
   cannot run while the layer is hidden. */
body.disclaimer-open .disclaimer-close {
    animation: disclaimerNudge 0.5s ease-in-out 1s 2;
}

@keyframes disclaimerNudge {
    0%, 100% {
        transform: scale(1);
        box-shadow: 0 0 0 0 rgba(var(--warn-rgb), 0);
    }

    50% {
        transform: scale(1.14);
        box-shadow: 0 0 0 6px rgba(var(--warn-rgb), 0.18);
    }
}

.disclaimer-head {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 9px;
    margin-bottom: 16px;
    font-family: var(--font-display);
    font-size: 0.9rem;
    font-weight: 800;
    letter-spacing: 2.5px;
}

.disclaimer-mark {
    width: 19px;
    height: 19px;
    flex-shrink: 0;
    color: var(--warn);
}

.disclaimer-text {
    color: var(--color-ink-muted);
    font-size: 0.98rem;
    line-height: 1.65;
    text-align: center;
    text-wrap: pretty;
}

/* Directly under the theme switch, on every screen size: same column, same
   glass, same 44px, so the two read as one stack of page controls rather
   than a second floating thing somewhere else. 20 + 44 + 10 = 74 — change
   the switch's size or offset and this number moves with it. */
.disclaimer-trigger {
    position: absolute;
    top: 74px;
    right: 20px;
    z-index: 5;
    width: 44px;
    height: 44px;
    display: none;
    align-items: center;
    justify-content: center;
    padding: 0;
    color: var(--warn);
    background: var(--surface-float);
    backdrop-filter: blur(10px);
    -webkit-backdrop-filter: blur(10px);
    border: 1px solid var(--border-soft);
    border-radius: 50%;
    cursor: pointer;
    transition:
        transform 0.3s ease,
        border-color 0.3s ease,
        box-shadow 0.3s ease;
}

body.has-disclaimer .disclaimer-trigger {
    display: flex;
}

.disclaimer-trigger svg {
    width: 20px;
    height: 20px;
}

@media (hover: hover) and (pointer: fine) {
    .disclaimer-trigger:hover {
        transform: translateY(-2px);
        border-color: rgba(var(--warn-rgb), 0.55);
        box-shadow: 0 0 16px rgba(var(--warn-rgb), 0.26);
    }
}

@media (max-width: 480px) {
    .disclaimer-card {
        padding: 24px 20px 22px;
    }

    .disclaimer-text {
        font-size: 0.94rem;
    }
}

/* Theme switch — same glass language as .go-top and .social-link */
.theme-toggle {
    position: absolute;
    top: 20px;
    right: 20px;
    z-index: 5;
    width: 44px;
    height: 44px;
    display: flex;
    align-items: center;
    justify-content: center;
    padding: 0;
    color: var(--color-ink);
    background: var(--surface-float);
    backdrop-filter: blur(10px);
    -webkit-backdrop-filter: blur(10px);
    border: 1px solid var(--border-soft);
    border-radius: 50%;
    cursor: pointer;
    transition:
        background 0.3s ease,
        border-color 0.3s ease,
        transform 0.3s ease,
        box-shadow 0.3s ease;
}

@media (hover: hover) and (pointer: fine) {
    .theme-toggle:hover {
        transform: translateY(-2px);
        border-color: rgba(var(--primary-rgb),0.6);
        box-shadow: 0 8px 22px rgba(var(--primary-rgb),0.35);
    }
}

/* Both icons are stacked; the inactive one spins away and fades out */
.theme-toggle svg {
    position: absolute;
    width: 20px;
    height: 20px;
    transition:
        transform 0.45s cubic-bezier(0.4, 0, 0.2, 1),
        opacity 0.35s ease;
}

.icon-sun {
    opacity: 1;
    transform: rotate(0) scale(1);
}

.icon-moon {
    opacity: 0;
    transform: rotate(-90deg) scale(0.4);
}

[data-theme="light"] .icon-sun {
    opacity: 0;
    transform: rotate(90deg) scale(0.4);
}

[data-theme="light"] .icon-moon {
    opacity: 1;
    transform: rotate(0) scale(1);
}

/* ---------- the theme flip ----------

   Set by the toggle for the two frames the change takes, then dropped.

   Flipping data-theme rewrites about forty tokens on :root at once, and each
   element then answered at its own speed: the body eased its background-color
   over 400ms while its background-image — three gradients, and not an
   animatable property — snapped in the first frame; the link rows snapped
   because their transition list does not name a background; the social
   buttons crawled because theirs does. Half the page arrived, the other half
   caught up four tenths of a second later. That was the jitter.

   A theme is one decision, so it lands in one frame. */
html.theme-switching *,
html.theme-switching *::before,
html.theme-switching *::after {
    transition: none !important;
}

/* Except the sun and moon. That spin IS the answer to the tap, and it is a
   transform and an opacity on a 20px icon — composited, and free. */
html.theme-switching .theme-toggle svg {
    transition:
        transform 0.45s cubic-bezier(0.4, 0, 0.2, 1),
        opacity 0.35s ease !important;
}

/* The body's 18s gradient drift cannot be composited, so every frame of it
   is a full-page repaint. Re-rasterising a 200%-wide three-gradient mesh in
   the same frame the whole palette changes is the most expensive moment on
   the page; it stands still for that one frame. */
html.theme-switching body {
    animation-play-state: paused;
}

/* The crossfade itself.

   A theme change cannot be tweened property by property — the canvas mesh is
   a background-image, and images do not interpolate. A view transition side-
   steps that: the browser photographs the page before the change and fades
   that picture out over the new one, so the gradients, the card fills, the
   text and the borders all move together at one speed.

   Chrome's default root transition blends the two snapshots with
   `plus-lighter`, which is right for two states of the same colour and wrong
   for two palettes — dark over light comes out washed and bright halfway
   through. A plain crossfade is what this needs, and turning the blend off
   is why the image-pair has to stop isolating. */
::view-transition-image-pair(root) {
    isolation: auto;
}

::view-transition-old(root),
::view-transition-new(root) {
    mix-blend-mode: normal;
    display: block;
    animation-duration: 0.42s;
    animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}

::view-transition-old(root) {
    animation-name: themeFadeOut;
}

::view-transition-new(root) {
    animation-name: themeFadeIn;
}

@keyframes themeFadeOut {
    to {
        opacity: 0;
    }
}

@keyframes themeFadeIn {
    from {
        opacity: 0;
    }
}

/* ---------- zoom and selection lock ----------

   Put on the body by initPageLock() when the owner turns it on. One switch,
   three ways in.

   `touch-action` names the gestures that survive — panning, in both
   directions. Pinch and double-tap zoom are simply not on the list, so the
   browser drops them on the compositor rather than waiting to hear from a
   JS handler. Ctrl+wheel and the zoom keys are not gestures and are stopped
   in the script. */
body.page-locked {
    touch-action: pan-x pan-y;
    -webkit-user-select: none;
    user-select: none;
    -webkit-touch-callout: none;
}



.footer {
    margin-top: 40px;
    text-align: center;
    padding: 22px;
    color: var(--color-ink-muted);
    font-size: 1rem;
    width: 100%;
    max-width: 600px;
}

.footer-content {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 8px;
    font-weight: 500;
}

/* .verify-badge lives in verify-badge.css — shared with the admin panel,
   which previews the same three shapes. */

/* Responsive adjustments */
@media (max-width: 640px) {
    .container {
        padding: 10px;
    }

    .logo-wrap {
        --aurora-size: 250px;
    }

    .logo-orb {
        width: 136px;
        height: 136px;
    }

    .collapsed-view {
        padding: 16px 18px;
    }

    .left-image {
        width: 52px;
        height: 52px;
        margin-right: 16px;
    }

    .link-title {
        font-size: 1.1rem;
    }

    .image-container {
        margin: 0 18px 18px;
    }

    .buy-now {
        margin: 0 18px 18px;
    }

}

@media (max-width: 480px) {
    .logo-wrap {
        --aurora-size: 210px;
    }

    .logo-orb {
        width: 112px;
        height: 112px;
    }

    .collapsed-view {
        padding: 14px 16px;
    }

    .left-image {
        width: 48px;
        height: 48px;
    }

    .buy-now {
        padding: 14px 20px;
        font-size: 1rem;
    }
}

/* ---------- the large row ----------

   The second look for the ORDINARY rows, switched on by "Large post cards" in
   the panel's Page extras. The picture takes half the row at 1:1 and runs to
   the card's own edges, the title is centred in the other half, and the arrow
   drops into the bottom corner.

   Hung off <body data-large-cards> rather than a class on the card, because
   the rows inside a fold are built in the browser by buildRow() in
   public-page.js and never see anything Astro wrote on a card. An ancestor
   attribute reaches those rows without that function having to know this look
   exists at all.

   :not(.exclusive):not(.promo) is the whole of the scoping. Both of those
   share .collapsed-view, .left-image and .link-title with these rows and
   neither is meant to change — an unqualified selector would redraw the paid
   card and the promos along with the list.

   These sit AFTER the 640px and 480px blocks above and out-specify them on
   their own — a media query adds no specificity — so the compact thumbnail
   sizes up there keep describing the compact look and are left alone. */
body[data-large-cards="1"] .link-block:not(.exclusive):not(.promo) .collapsed-view {
    /* The arrow's footprint, named once: the title reserves it at both ends
       and the arrow is drawn from it, so the two cannot drift apart.

       Both numbers are quoted from the compact row rather than chosen: 34px
       is .link-arrow's own box, and the inset is .collapsed-view's own
       horizontal padding. That is what puts this arrow in the same column as
       the ones on the exclusive card and the promos above it — they are
       flush-right inside that padding, so matching it lines up the boxes and
       the glyph centres together. Anything else reads as a kink in the
       column, which is exactly what a 12px inset did.

       Equal on both edges, so the corner itself is square. */
    --large-arrow: 34px;
    --large-arrow-inset: 22px;

    padding: 0;
    /* Not `center`: the title half has to fill the picture's height before
       there is anything to centre its text inside. Stretch also means an
       unusually long title grows the row and takes the picture up with it,
       rather than leaving a band of bare card under it. */
    align-items: stretch;
}

/* .link-block already clips (overflow: hidden), so the card's own radius is
   what rounds the picture's two left corners. A radius of its own here would
   round all four and leave two slivers of card showing behind it. */
body[data-large-cards="1"] .link-block:not(.exclusive):not(.promo) .left-image {
    width: 50%;
    height: auto;
    /* Holds the row's height before the picture arrives, so a list of these
       does not reflow as they load. object-fit: cover comes from the base
       rule and is what makes a non-square source crop rather than squash. */
    aspect-ratio: 1 / 1;
    flex-shrink: 0;
    margin-right: 0;
    border: 0;
    border-radius: 0;
    box-shadow: none;
}

/* The arrow's footprint is reserved at BOTH ends, which is what keeps the
   text reading as centred in its half while still clearing the corner the
   arrow occupies. */
body[data-large-cards="1"] .link-block:not(.exclusive):not(.promo) .link-title {
    display: flex;
    align-items: center;
    justify-content: center;
    text-align: center;
    padding: calc(var(--large-arrow) + var(--large-arrow-inset)) 14px;
    /* A flex item defaults to min-width: auto, which refuses to wrap a long
       word and would push the picture below half the row. */
    min-width: 0;
    overflow-wrap: anywhere;
}

body[data-large-cards="1"] .link-block:not(.exclusive):not(.promo) .link-arrow {
    position: absolute;
    right: var(--large-arrow-inset);
    bottom: var(--large-arrow-inset);
    width: var(--large-arrow);
    height: var(--large-arrow);
    /* Keeps the glyph the same fraction of the box at either size. */
    padding: calc(var(--large-arrow) * 0.2);
}

/* .collapsed-view drops to 18px of side padding here, so the inset that
   quotes it drops too — otherwise the arrows only line up on a desktop. */
@media (max-width: 640px) {
    body[data-large-cards="1"] .link-block:not(.exclusive):not(.promo) .collapsed-view {
        --large-arrow-inset: 18px;
    }
}

/* 16px there, and the type comes down as well: the row is only around 157px
   tall on a phone, and the reserved band is taking 100 of them. */
@media (max-width: 480px) {
    body[data-large-cards="1"] .link-block:not(.exclusive):not(.promo) .collapsed-view {
        --large-arrow-inset: 16px;
    }

    body[data-large-cards="1"] .link-block:not(.exclusive):not(.promo) .link-title {
        padding: calc(var(--large-arrow) + var(--large-arrow-inset)) 12px;
        font-size: 1rem;
    }
}

/* Glowing effect for open blocks */
.link-block.open {
    border-color: rgba(var(--primary-rgb),0.6);
    box-shadow:
        0 12px 40px rgba(var(--primary-rgb),0.2),
        inset 0 1px 0 rgba(255, 255, 255, 0.06);
}

/* The one paid card. A gradient border flows around it and a coloured
   halo lifts it off the canvas so it reads as the hero of the list.
   Declared after .link-block.open so it keeps its border when open. */
.link-block.exclusive,
.link-block.exclusive.open {
    border: 1.5px solid transparent;
    background:
        linear-gradient(var(--color-surface-indigo), var(--color-surface-indigo)) padding-box,
        linear-gradient(120deg, var(--color-primary), var(--color-magenta), var(--color-link), var(--color-primary)) border-box;
    background-size: 100% 100%, 300% 100%;
    box-shadow:
        0 10px 38px rgba(var(--primary-rgb),0.3),
        0 0 24px rgba(var(--magenta-rgb),0.16);
    animation:
        fadeInUp 0.5s ease forwards,
        exclusiveBorder 9s linear infinite;
}

@keyframes exclusiveBorder {
    0% { background-position: 0% 0%, 0% 50%; }
    100% { background-position: 0% 0%, 300% 50%; }
}

/* ---------- the EXCLUSIVE label ----------

   The one word on the page that has to look like it costs something. Set in
   the Apple system face, which is what a phone already has and what reads as
   expensive on one; Space Grotesk catches everything else so a Windows or
   Android visitor still gets the display weight rather than Arial.

   Uppercase in the stylesheet and not only in the config, so the owner can
   type it however they like and it still arrives in caps.

   Gold on dark, red on light. The colours are the metal and the lacquer —
   both are three stops, not two, because a two-stop gradient across a short
   word reads as a flat tint and the point is the sheen across the letters.

   `background-clip: text` needs `-webkit-text-fill-color: transparent`, and
   that makes text-shadow invisible: there is no fill for it to sit under.
   The glow is a filter on the element instead, and the edge is a real stroke
   — which paints over the gradient and is what stops the pale end of the gold
   from dissolving into a light card. */
.exclusive .link-title {
    font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'SF Pro Text',
        'Helvetica Neue', var(--font-display), sans-serif;
        font-style: italic;
    font-weight: 800;
    text-transform: uppercase;
    letter-spacing: 2.4px;

    background: linear-gradient(100deg, #ffe856 0%, #f3c53f 38%, #d99a08 62%, #ffeaa7 100%);
    -webkit-background-clip: text;
    background-clip: text;
    -webkit-text-fill-color: transparent;

    /* Two things were making this word look out of focus, and only one of
       them was obvious.

       The 10px glow was the visible half: a halo that wide around a 17.6px
       glyph reads as blur rather than as light. It is 4px now — enough to
       lift the word off the card, not enough to smear it.

       The other half is that `filter` puts the element on its own
       compositing layer, and a composited layer loses subpixel antialiasing
       and falls back to grayscale. That softens every edge, and it is most
       obvious at fractional device pixel ratios — a 1.25x display shows it
       plainly where a 2x one hides it.

       The filter cannot simply go: this is gradient text, so the fill is
       transparent and `text-shadow` would paint OVER the gradient rather
       than behind it. drop-shadow applies to the composited result and is
       the only way to get a shadow behind it — see `text-shadow: none`
       below, which is deliberate and not a leftover.

       The 0.4px stroke is gone outright. Under a pixel it never drew an edge,
       only a second antialiased fringe alongside the real one, which is the
       same smudge the 380px media query below used to remove by hand. */
    -webkit-text-stroke: 0;
    filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.45)) drop-shadow(0 0 4px rgba(243, 197, 63, 0.30));
    text-shadow: none;
}

[data-theme="light"] .exclusive .link-title {
    background: linear-gradient(100deg,#9880fc 0%, #3525b9 40%, #3525b9d4 72%, #9880fc 100%);
    -webkit-background-clip: text;
    background-clip: text;
    /* The same trim, and this palette needed it more: a near-opaque white
       glow 10px wide around dark violet text was the blurriest thing on
       either theme. Kept wider-alpha than the dark side because here the
       halo is doing work — it separates the violet from a near-white card
       rather than merely decorating it. */
    -webkit-text-stroke: 0;
    filter: drop-shadow(0 1px 0 #f7fcfe) drop-shadow(0 0 4px rgba(247, 252, 254, 0.8));
}

/* Only the tracking is narrowed here now. The stroke this block used to
   switch off is gone from the base rule, for the same reason it was switched
   off here. */
@media (max-width: 380px) {
    .exclusive .link-title {
        letter-spacing: 1.6px;
    }
}

/* Smooth scroll for the page */
html {
    scroll-behavior: smooth;
}

/* Animation for link blocks on page load */
@keyframes fadeInUp {
    from {
        opacity: 0;
        transform: translateY(20px);
    }

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

.link-block {
    animation: fadeInUp 0.5s ease forwards;
    opacity: 0;
}

.link-block:nth-child(1) {
    animation-delay: 0.1s;
}

.link-block:nth-child(2) {
    animation-delay: 0.15s;
}

.link-block:nth-child(3) {
    animation-delay: 0.2s;
}

.link-block:nth-child(4) {
    animation-delay: 0.25s;
}

.link-block:nth-child(5) {
    animation-delay: 0.3s;
}

.link-block:nth-child(6) {
    animation-delay: 0.35s;
}

.link-block:nth-child(7) {
    animation-delay: 0.4s;
}

.link-block:nth-child(8) {
    animation-delay: 0.45s;
}

.link-block:nth-child(9) {
    animation-delay: 0.5s;
}

.link-block:nth-child(10) {
    animation-delay: 0.55s;
}

/* ---------- loading skeletons ----------

   Two things share one look here: the stand-in rows that ship in the
   markup, and every real picture on its way in. Both are the page saying
   "this is coming" in the shape of the thing that is coming — which is
   the whole trick. A spinner tells the visitor to wait; a skeleton tells
   them what they are waiting for, and they stay.

   There is no setting for any of this. A page that is only fast when a
   toggle is on is not a fast page. */

.skel,
.media-loading {
    background-color: var(--skeleton-base);
    background-image: linear-gradient(100deg,
            transparent 28%,
            var(--skeleton-sheen) 46%,
            var(--skeleton-sheen) 54%,
            transparent 72%);
    background-repeat: no-repeat;
    background-size: 240% 100%;
    animation: skelSheen 1.45s linear infinite;
}

@keyframes skelSheen {
    from { background-position: 160% 0; }
    to { background-position: -60% 0; }
}

/* A picture that failed keeps the flat tint but stops moving. A shimmer
   that never resolves reads as "still coming", and the visitor waits for
   something that is never going to arrive. */
.media-failed {
    background-color: var(--skeleton-base);
}

/* Alt text must not sit on top of the placeholder, and a half-drawn
   image should not show its own edges before it is whole. */
img.media-loading,
img.media-failed {
    color: transparent;
}

/* The slider does nothing behind a closed card — no timer, no video
   decoding — and its placeholders hold to the same rule. Nobody is
   looking at an animation inside a panel with no height. */
.link-block:not(.open) .slide-media.media-loading {
    animation: none;
}

/* Stand-in rows. Every number below is copied from .collapsed-view,
   .left-image and .link-arrow rather than eyeballed, so the real rows
   land exactly where these were and the page does not lurch. */
.skel-list {
    pointer-events: none;
}

.skel-row {
    display: flex;
    align-items: center;
    padding: 18px 22px;
    margin-bottom: 16px;
    background-color: var(--color-surface-indigo);
    border: 1px solid var(--border-hairline);
    border-radius: var(--radius-lg);
    box-shadow: var(--shadow-card), var(--inset-highlight);
}

.skel-thumb {
    flex-shrink: 0;
    width: 60px;
    height: 60px;
    margin-right: 18px;
    border-radius: var(--radius-sm);
}

/* Ragged widths, because five identical bars read as a pattern rather
   than as text that has not arrived yet. */
.skel-line {
    flex-grow: 1;
    height: 15px;
    margin-right: 18px;
    border-radius: var(--radius-xs);
}

.skel-line.w-lg { max-width: 60%; }
.skel-line.w-md { max-width: 46%; }
.skel-line.w-sm { max-width: 34%; }

/* Pinned right, where .link-arrow ends up. The line above stops short of
   it because a title is short, not because the row is narrow. */
.skel-arrow {
    flex-shrink: 0;
    margin-left: auto;
    width: 34px;
    height: 34px;
    border-radius: 50%;
}

/* Same 46px circle as .social-link, so the icons drop straight in */
.skel-pill {
    display: block;
    width: 46px;
    height: 46px;
    border-radius: 50%;
}

@media (max-width: 640px) {
    .skel-row {
        padding: 16px 18px;
    }

    .skel-thumb {
        width: 52px;
        height: 52px;
        margin-right: 16px;
    }
}

@media (max-width: 480px) {
    .skel-row {
        padding: 14px 16px;
    }

    .skel-thumb {
        width: 48px;
        height: 48px;
    }
}
