/* ===========================================================================
   Babes (בייבס) — dark theme, palette sampled from stripchat.com
   (PornTrex-shaped tube grid; color + chrome follow Stripchat).
   Crimson + black, Fredoka, mobile-first, full RTL.
   =========================================================================== */

:root {
    /* Color/surfaces/radius come from the compiled Bootstrap theme (SSOT, built
       from custom-stripchat.scss). These tokens just ALIAS its --bs-* vars so
       components read one name; to re-brand, edit the SCSS, not this file. */

    /* Surfaces (compiled dark = #000 body / #141414 strips / #202020 cards). */
    --bg: var(--bs-body-bg);
    --bg-2: var(--bs-secondary-bg);
    --bg-3: var(--bs-tertiary-bg);
    --card: var(--bs-tertiary-bg);
    --card-2: var(--bs-secondary); /* elevated / hover surface (#2e2e2e) */
    --border: var(--bs-border-color);

    /* Accent = --bs-primary; hover/dim are lighten/darken mixes of it. */
    --accent: var(--bs-primary);
    --accent-hover: color-mix(in srgb, var(--bs-primary) 82%, #fff);
    --accent-dim: color-mix(in srgb, var(--bs-primary) 82%, #000);
    /* Halo/emphasis red — the theme's own lighter red (the compiled build uses it
       for links + primary emphasis). Flat --bs-primary reads too dark for glows
       and for highlights inside illustrations, so those reach for this instead
       of inventing a brighter hex. */
    --accent-glow: var(--bs-primary-text-emphasis);

    --text: var(--bs-body-color);
    --text-2: var(--bs-secondary-color);
    --muted: var(--bs-tertiary-color);

    --header-h: 60px;

    /* Fredoka — the one thing the color-only theme doesn't supply. It is the HEBREW
       brand face and nothing else now: every non-Hebrew locale is overridden below.
       It ships hebrew, latin and latin-ext subsets but no Cyrillic. */
    --font: 'Fredoka', system-ui, 'Segoe UI', Arial, sans-serif;
    --bs-body-font-family: var(--font);
}

/* ===========================================================================
   TWO Latin faces, split by SCRIPT, both tracked -0.02em:
     Urbanist    — en + es (and any future locale that is not Hebrew)
     Inter Tight — ru
   Fredoka stays the Hebrew brand face. It reads informal in Latin for a paid
   product and carries no Cyrillic at all, so under it every Russian glyph falls
   through to system-ui, which reads as a broken page rather than a second font.

   Why two rather than one, and why THESE two — measured in
   /poc/font-specimen.html, per 1000em at weight 400:
     - Urbanist matches Fredoka's vertical proportions EXACTLY (x-height 500,
       cap 700), and its centring offset is 0 — cap-height text sits dead centre
       in a vertically-centred box. That is what keeps a label level with the
       icon or avatar beside it in a pill, a nav item, a button — a face whose
       offset sits at +25 rides visibly low in exactly those places, so the
       measurement is a selection criterion, not a detail.
     - Urbanist has no Cyrillic, so ru needs its own face. Inter Tight was
       picked because it is the closest Cyrillic-capable face TO URBANIST rather
       than to Fredoka: same centring (0), line box 1210 vs 1200, width 539 vs
       536. Switching between en and ru therefore changes almost nothing
       measurable, which is the point of a split done on metrics.

   ⚠ BOTH HALVES OR NEITHER: these rules only NAME families; the matching
   <link>s in templates/partials/chrome/site-head.php are what download them. Either
   half alone drops the locale to system-ui. (The zh rule below is the one
   deliberate exception — system CJK faces ARE its design, so only the rule
   half exists.) Every candidate is self-hosted
   under public/assets/fonts/en/ — that directory is the Latin-face store, not a
   locale gate — and the specimen page compares them across he/en/ru/es.

   - BOTH faces are listed in BOTH stacks, in opposite order, so neither locale
     can produce tofu: an English page carrying a Cyrillic performer name gets
     Inter Tight for those glyphs instead of system-ui, and a Russian page's
     Latin runs stay in Inter Tight. Only the PRIMARY differs per locale.
   - 'Fredoka' is last in both stacks and load-bearing. Neither Latin face has
     Hebrew glyphs, and Hebrew DB content renders untranslated by design inside
     the en/ru/es UI (video titles, category names, member text), so it falls
     through to the brand face rather than to whatever the OS ships. Removing it,
     or dropping the unconditional Fredoka <link>, regresses that silently — the
     page still renders, just in a system Hebrew face.
   - The TRACKING is a deliberate -0.02em on every non-Hebrew locale. Russian
     runs ~30% longer than English in EVERY face, so the measure is where there
     is least to spare; at 19px this buys back 3.8% on a Russian headline and
     4.5% on an English one, for a tightening invisible at UI sizes. (-0.03em
     was tried first and gave 5.7% on ru; it was eased back a step by choice.)
     Widen it toward 0 and fixed-width controls get tight on ru first — that is
     the surface to check if this value is ever changed, and the tracking slider
     on /poc/font-specimen.html is where to test a new one before setting it.
   - ⚠ It also tracks the HEBREW DB CONTENT on these pages: letter-spacing
     inherits and CSS cannot select by script. `partials/videos/video-card`'s title
     carries no dir attribute, so there is no [dir="auto"] hook to exempt it by
     either. Accepted deliberately — sub-pixel at these sizes. If Hebrew data
     ever needs exempting, the fix is a dir="auto" on those cells plus a
     `[dir="auto"] { letter-spacing: normal }` rule here, NOT a smaller value.
   - Only --font is overridden. --bs-body-font-family above indirects through it
     and a custom property resolves at computed-value time, so Bootstrap's body
     font follows on its own — there is no second place to edit.
   - `<html lang>` comes from locale() (layout/main.php); the admin layout
     hard-codes lang="en", so /admin runs on Urbanist.
   - The general rule is "not Hebrew" rather than a locale list, so a new Latin
     locale needs no edit. The trade-off, noted deliberately: a locale in a
     script neither face covers lands on system-ui and needs its own rule
     beside the ru one — Arabic (Baloo Bhaijaan 2) is exactly that case, below,
     and Simplified Chinese is its linkless variant (system CJK faces by design).
     `<html lang>` is always emitted, so there is no lang-less document for
     this selector to catch by accident.
   =========================================================================== */
:root:not(:lang(he)) {
    --font: 'Urbanist', 'Inter Tight', 'Fredoka', system-ui, 'Segoe UI', Arial, sans-serif;
    letter-spacing: -0.02em;
}

/* Russian leads with the Cyrillic face. The selector is COMPOUND on purpose:
   `:root:lang(ru)` alone has the same specificity as the rule above and would win
   only by source order, so a later reshuffle of this file could silently flip
   Russian back to Urbanist — which has no Cyrillic, i.e. straight to system-ui.
   `:not(:lang(he)):lang(ru)` outranks it outright and cannot be reordered wrong. */
:root:not(:lang(he)):lang(ru) {
    --font: 'Inter Tight', 'Urbanist', 'Fredoka', system-ui, 'Segoe UI', Arial, sans-serif;
}

/* Arabic leads with the Arabic face — no other face in the stack carries the
   script, so without this rule ar lands on system-ui. Same compound selector
   as the ru rule, for the same reordering-proof reason. Two Arabic-specific
   facts: letter-spacing returns to normal, because tracking (negative
   included) opens visible gaps inside CONNECTED Arabic letterforms — the
   joins are drawn strokes, not kerning; and 'Fredoka' stays in the stack so
   Hebrew DB content on an Arabic page keeps the brand face, the same
   guarantee the Latin stacks make. The Latin faces stay too: Baloo's own
   latin subset serves Latin runs, and Cyrillic (a performer name) still
   falls through to Inter Tight. The link half is ar-only in
   partials/chrome/site-head — both halves or neither. */
:root:not(:lang(he)):lang(ar) {
    --font: 'Baloo Bhaijaan 2', 'Urbanist', 'Inter Tight', 'Fredoka', system-ui, 'Segoe UI', Arial, sans-serif;
    letter-spacing: normal;
}

/* Simplified Chinese leads with the SYSTEM CJK faces — deliberately linkless:
   every OS ships an excellent Simplified face (PingFang on Apple, YaHei on
   Windows, Noto Sans SC on Android/Linux), and a self-hosted subsetted CJK
   webfont is megabytes, so unlike ru/ar there is NO site-head <link> half to
   this rule. Same compound selector as the ru/ar rules, for the same
   reordering-proof reason. Letter-spacing returns to normal per CJK
   convention — tracking opens visible gaps between full-width characters.
   'Fredoka' stays in the stack so Hebrew DB content on a Chinese page keeps
   the brand face, and the Latin faces serve Latin runs (brand names). */
:root:not(:lang(he)):lang(zh) {
    --font: 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', 'Noto Sans SC', 'Urbanist', 'Inter Tight', 'Fredoka', system-ui, 'Segoe UI', Arial, sans-serif;
    letter-spacing: normal;
}

/* Reboot sets `font-family: inherit` on form controls but NOT letter-spacing, and
   the UA stylesheet does not inherit it either — so without this the buttons,
   inputs and selects would be the one untracked island on an otherwise tracked
   page, and would measure wider than the text beside them. */
:root:not(:lang(he)) button,
:root:not(:lang(he)) input,
:root:not(:lang(he)) select,
:root:not(:lang(he)) textarea {
    letter-spacing: inherit;
}

/* `dir="auto"` on an EMPTY field resolves to LTR, never to the page direction:
   HTML's directionality algorithm reads the element's VALUE and ignores the
   placeholder entirely — so a Hebrew placeholder renders left-aligned (caret on
   the left) on the RTL site until the first character is typed. Verified in
   Chrome: getComputedStyle(input).direction === 'ltr' while the parent is 'rtl'.
   No Bootstrap equivalent, and no markup fix either — dropping dir="auto" would
   trade this for mis-rendered Latin input. So restore the inherited direction
   for exactly as long as the placeholder is on screen; the moment a value exists
   `:placeholder-shown` stops matching and dir="auto" governs again (Latin input
   still flips to LTR). Scoped to [dir="auto"] so a field that states its own
   dir="ltr"/"rtl" is never overridden, and `inherit` (not `rtl`) so the LTR
   admin shell and the three LTR locales stay correct with one rule. */
[dir="auto"]:placeholder-shown {
    direction: inherit;
}

/* The old --bs-* remap bridge is gone — the compiled theme sets those directly.
   Its dark surfaces + link are scoped to :root[data-bs-theme=dark], so the attr
   now lives on <html>, not <body>. */

/* Component-scoped bridge vars. Bootstrap re-declares these variables on the
   component selector itself (.card { --bs-card-bg: … } etc.), which beats
   anything set on [data-bs-theme="dark"] — so they must be scoped to the
   component (same model as .pagination below) to actually apply. */

/* Cards sit on the theme's card surface (--card = #202020), one step above the
   strips (#141414) so cards read as elevated. */
.card {
    --bs-card-bg: var(--card);
    --bs-card-border-color: var(--border);
    --bs-card-cap-bg: transparent;
}

/* Tables sit on the card surface; keep them transparent + bordered. */
.table {
    --bs-table-bg: transparent;
    --bs-table-color: var(--text);
    --bs-table-border-color: var(--border);
    --bs-table-hover-bg: var(--bg-3);
    --bs-table-hover-color: #fff;
}

/* List groups (queue counters) are transparent on their card. */
.list-group {
    --bs-list-group-bg: transparent;
    --bs-list-group-color: var(--text-2);
    --bs-list-group-border-color: var(--border);
}

/* Accordion: pin the surface to theme tokens (Bootstrap would otherwise tint the
   open bg with --bs-primary-bg-subtle); open-row text stays crimson via --accent. */
.accordion {
    --bs-accordion-bg: transparent;
    --bs-accordion-color: var(--text);
    --bs-accordion-border-color: var(--border);
    --bs-accordion-btn-color: var(--text);
    --bs-accordion-btn-bg: var(--bg-2);
    --bs-accordion-active-bg: var(--bg-3);
    --bs-accordion-active-color: var(--accent);
    --bs-accordion-btn-focus-box-shadow: none;
    /* Chevron colour is baked into the SVG (can't read a token) — white for both
       states; the crimson cue is the active-color text, not a hardcoded hex. */
    --bs-accordion-btn-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23ffffff'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e");
    --bs-accordion-btn-active-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23ffffff'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e");
}

/* Dropdown menus (header/admin/typeahead): dark surface + compact items only.
   The active row is left to Bootstrap's default (--bs-primary crimson / white). */
.dropdown-menu {
    --bs-dropdown-bg: var(--bg-3);
    --bs-dropdown-border-color: var(--border);
    --bs-dropdown-link-color: var(--text-2);
    --bs-dropdown-link-hover-bg: var(--card-2);
    --bs-dropdown-link-hover-color: #fff;
    --bs-dropdown-min-width: 200px;
    --bs-dropdown-padding-x: 6px;
    --bs-dropdown-padding-y: 6px;
    --bs-dropdown-font-size: .9rem;
    --bs-dropdown-item-padding-x: 12px;
    --bs-dropdown-item-padding-y: 8px;
    --bs-dropdown-item-border-radius: var(--bs-border-radius-sm);
}

/* Typeahead option list (typeahead.js): cap + scroll a long list under its
   input — Bootstrap has no max-height utility. Pin the menu to the input's own
   width (the enhanced input is already sized to the widest option via w-auto),
   overriding Bootstrap's 10rem min-width: without this a narrow filter (e.g. an
   18–99 age select) gets an oversized menu that, right-aligned in the RTL filter
   toolbar, spills past the viewport edge and forces a horizontal scroll. */
.ta-menu {
    max-height: 280px;
    overflow-y: auto;
    min-width: 100%;
}

/* The site-search suggest (partials/chrome/search-form, dropdown mode) rides
   .ta-menu for the scroll + width but needs a taller cap: it lists up to six
   GROUPS of ~48px rows (a 36px mark each), and 280px shows five rows of thirty.
   Viewport-relative so it never runs past the fold; the px ceiling keeps a tall
   desktop from painting a menu longer than the list. Same specificity, declared
   after .ta-menu, so the later rule wins without !important. */
.ss-menu {
    max-height: min(70vh, 560px);
}

/* Flag mirrored beside a COMMITTED country choice (typeahead.js, data-ta-flag).
   Positioned rather than in-flow because the committed value lives in an
   <input>, whose text can hold no element, and Bootstrap has no utility for
   overlaying one control's own chrome. Logical properties so one rule resolves
   on the RTL Hebrew site and the LTR locales alike; the .dropdown wrapper it
   sits in is already position:relative from Bootstrap. */
.ta-flag {
    position: absolute;
    inset-inline-start: 1rem;
    top: 50%;
    transform: translateY(-50%);
    pointer-events: none;
}

.ta-has-flag {
    padding-inline-start: 2.75rem;
}

/* Brand wordmark (partials/chrome/brand-mark) — an inline <svg> of OUTLINES, not type,
   so no webfont is loaded for the logo and tracking/kerning are baked into the
   path advances. Sized in em so the existing fs-* call sites keep working.
   width = the symbol's 74.99/24 viewBox ratio; the -.125em nudge puts the SVG's
   baseline (y=21 of 24) on the text baseline when it sits inline. */
.brand-mark {
    --bm-accent: var(--bs-primary);
    display: inline-block;
    width: 3.125em;
    height: 1em;
    vertical-align: -.125em;
    fill: currentColor;
}
/* On the header band the accent letters go white so they stay legible on the
   engine's crimson band; a site theme whose band is NOT a brand colour sets
   --header-band-accent (its site.css) to keep the accent there.
   Set as a custom property, not a class on the paths: CSS selectors don't match
   inside a <use> shadow tree, but custom properties inherit into it. */
.brand-mark.bm-on-band {
    --bm-accent: var(--header-band-accent, #fff);
}
/* Monochrome: the accent letters drop to the mark's own colour, so the whole
   wordmark reads as one flat tone (footer + mobile offcanvas). */
.brand-mark.bm-mono {
    --bm-accent: currentColor;
}

/* Header logo size. Passed as brand-mark's `size` (replacing its fs-* default)
   so no utility competes with it. The box is the EM box — glyphs occupy 18 of
   its 24 units — so caps are 0.75x these values.
   ≥768px: the bar is a fixed 60px, and the search field next to it is ~39px, so
   39px here matches that optical weight and still leaves ~10px above and below.
   ≤767.98px: the bar switches to height:auto + padding-block:10px, so its height
   is 20px + the tallest child. That child is .header-actions at ~35.75px (hence
   the ~55.75px bar). Anything up to that is free; past it the bar grows 1:1 —
   which is why this stays at 33.75px instead of matching the desktop size. */
.header-mark {
    font-size: 2.6rem;
}

@media (max-width: 767.98px) {
    .header-mark {
        font-size: 2.25rem;
    }
}

/* Mobile menu button. Below 768px the bar is height:auto and sizes to 20px + its
   tallest child (~35.75px), so this button's BOX has to stay under that — but an
   fs-1 glyph (37.5px) naturally lays out a ~39.8px inline box and would grow the
   bar. Pinning an explicit height and centring the icon in a flex box decouples
   the two: the glyph draws at full size while the button contributes a fixed
   34px to the flex line. The ~2px of glyph overshoot paints outside the box,
   which is harmless — nothing on this row clips. */
.header-burger {
    height: 34px;
}

/* Button weight only (matches the old Fredoka buttons). Every button COLOR + the
   .nav-pills active pill now come from the compiled theme, not from here. */
.btn {
    --bs-btn-font-weight: 500;
}

/* 1rem = 15px site-wide: every Bootstrap rem-based utility (spacing, fs-*)
   scales down with it, so it must live on <html>, not --bs-body-font-size. */
html {
    font-size: 15px;
}

body {
    -webkit-font-smoothing: antialiased;
    /* No Bootstrap equivalent: `manipulation` drops double-tap-to-zoom, which also removes
       the ~300ms tap delay browsers hold open waiting to see if a second tap is coming.
       It is the double-tap lever only; pinch is blocked by the viewport meta (site-head)
       and, on iOS, the gesture handler in core/app.js. */
    touch-action: manipulation;
}

/* iOS Safari auto-zooms the page when a focused control computes under 16 CSS px, and the
   15px root above puts every 1rem .form-control exactly there — so the header search, DM,
   comment and cam-chat inputs all lurched on focus. A literal 16px (not a rem: the threshold
   is a fixed pixel count) removes the trigger at its source, rather than leaning on the
   viewport meta's maximum-scale, which an iOS browser tab ignores. Touch devices only, at
   any width — iPad shares the behaviour, so a phone-width breakpoint would miss it —
   leaving the 15px scale intact on desktop, where no browser does focus-zoom. */
@media (hover: none) and (pointer: coarse) {
    .form-control,
    .form-control-sm,
    .form-select,
    .form-select-sm {
        font-size: 16px;
    }
}

/* EVERY Bootstrap textarea grows with its content — one rule, no per-field markup.
   `field-sizing` ignores the rows attribute, so `--rows` (ui_textarea emits it from its
   `rows` option) restores rows as the MINIMUM height; `--ta-pad` restates Bootstrap's
   vertical padding ×2 per size — the same sum its own textarea min-height uses. Growth
   stops at `--ta-max`, after which the box scrolls. `1lh` follows the 16px touch bump
   above. Browsers without field-sizing get the same box from Hu's fallback in
   core/ui.js, which writes an inline height these min/max still clamp. */
textarea.form-control {
    --ta-pad: .75rem;
    field-sizing: content;
    min-height: calc(var(--rows, 1) * 1lh + var(--ta-pad) + var(--bs-border-width) * 2);
    max-height: var(--ta-max, 60vh);
}
textarea.form-control-sm { --ta-pad: .5rem; }
textarea.form-control-lg { --ta-pad: 1rem; }

/* A chat composer (DM panel, chat room, social thread, AI companion): starts at one
   line, grows to ~5, never shows the resize grip. */
textarea.form-compose {
    --ta-max: 7.5rem;
    resize: none;
}

/* Generic links = the theme's link color (coral #fa5365; crimson primary reads
   too dark on black). Components wanting crimson set var(--accent) themselves. */
/* The site's one link treatment, and deliberately the ONLY declaration here.
   The compiled Bootstrap theme already supplies the brand link colour and its
   hover (--bs-link-color / --bs-link-hover-color), so restating them is
   duplication; the decoration reset is the one thing a colour-only build does
   not provide, since Bootstrap ships `underline` on `a`.

   Left as a bare element selector on purpose: Bootstrap sits in @layer(reboot),
   so an unlayered rule here is the SITE's default and outranks the framework's
   component styling. That is why `.btn-link` renders without an underline like
   every other link — one link treatment, not two. Anything that genuinely wants
   an underline asks for it (the text-decoration-underline utility). */
a {
    text-decoration: none;
}

img {
    max-width: 100%;
    display: block;
}


/* Keeps short pages from collapsing the footer to mid-viewport (no min-vh-60
   utility exists). */
.site-main {
    min-height: 60vh;
}

/* Hides the native scrollbar on a horizontal scroll strip, where swipe (or the
   .scroll-nav-arrow pagers) is the affordance and a native bar would eat height
   from a short strip. Bootstrap ships no scrollbar utility, and the two vendor
   prefixes have to travel together — so this is the ONE definition and consumers
   just add the class beside their `overflow-x-auto`. It hides the bar and
   NOTHING else: the scroll behaviour itself stays with each consumer, so the
   class is inert on an element that isn't overflowing.
   Not used by two of the drag-scrolled rows further down (the third, the /chat
   roster's `.gender-scroll`, does use it — it is a plain row with nothing else
   to say here): .frame-strip and .cat-strip each already own a block here for
   their fade and arrows, and one more declaration there beats a class in the
   markup. */
.scrollbar-none {
    scrollbar-width: none; /* Firefox */
}

.scrollbar-none::-webkit-scrollbar {
    display: none; /* Chromium / WebKit */
}

/* ---------------------------------------------------------------------------
   Header
   --------------------------------------------------------------------------- */
/* The header's top band (partials/chrome/header) — the brand primary with white
   content by default (Stripchat's crimson band). A site theme re-colours it
   through --header-band-bg / --header-band-border in its site.css (sites/README.md
   § Theme); a token rather than .text-bg-primary so the theme needs no override. */
.header-band {
    color: #fff;
    background: var(--header-band-bg, var(--bs-primary));
    border-bottom: var(--header-band-border, 0);
}
/* The band's search pill is a translucent-black fill (header.php's $bg); on a
   DARK band that fill vanishes, so such a theme rings it (--header-band-search-ring,
   a box-shadow — the fill utilities are !important and out of reach). */
.header-band .search-form > .input-group {
    box-shadow: var(--header-band-search-ring, none);
}

/* Fixed header height + gap stay custom: the ≤767px block below re-flows the
   bar (height:auto, tighter gap), which a gap utility's !important would block. */
.header-bar {
    gap: 14px;
    height: var(--header-h);
}

/* Search box shape — shared by every partials/chrome/search-form instance. In the header
   bar it IS a flex item (only ≥992px; hidden below via d-none d-lg-flex): flex:1
   grows it until the 620px cap freezes it, then its two auto margins take the
   bar's leftover space and centre it. On /search + /harmalist/search the form is a
   block child of .container-xxl — not a flex item — so flex:1 is inert there and
   the cap + auto margins just centre the box. The px cap has no Bootstrap
   equivalent, and the ≤767px block below drops it for a full-width row. */
.search-form {
    flex: 1;
    max-width: 620px;
    margin-inline: auto;
}


/* Header actions — flex-shrink stays custom: the ≤991px block below flips it
   to a scrolling flex:1 1 0 row, which a utility's !important would block. */
.header-actions {
    flex-shrink: 0;
}

/* The header's 28px circle mark: the avatar (rest of the circle is utilities in
   the markup) and the language picker's round flag beside it, which wears the
   same class so the two marks cannot differ in size. `.fi.user-avatar` because
   flag-icons' own `.fi.fis` width (1em, two classes) would otherwise
   out-specify the single-class rule. */
.user-avatar,
.fi.user-avatar {
    width: 28px;
    height: 28px;
}

/* The default-avatar bust inside a circle mark (partials/ui/avatar's `gender`
   branch, drawn by partials/chrome/avatar-sprite). Custom because the point is
   the PORTRAIT CROP and Bootstrap has no utility for it: the bust is anchored to
   the bottom of the circle and its shoulders run past the edge, so the circle
   clips them the way a cropped photograph would. Centred with air underneath it
   would read as an icon of a person instead of a picture of one.
   It lives in THIS sheet rather than social.css because the header avatar is on
   every page while social.css is page-opted — the same reason `.gender-ink` sits
   here. No colour of its own: the artwork is `currentColor`, so it inherits the
   ink of whatever tone class the circle is already wearing, and one person's
   mark keeps one colour everywhere. */
.avatar-figure {
    position: absolute;
    bottom: 0;
    inset-inline: 0;
    margin-inline: auto;
    width: 100%;
    aspect-ratio: 1;
}

/* Edge fade on scrollable pill rows — hints there's more off-edge. Applied via
   the JS-toggled `.is-scrollable` (scrollers.js) so non-overflowing rows stay crisp.
   The mask is fixed to the scroll viewport, not the scrolled content.
   Unscoped on purpose: scrollers.js's `scrollers` query is the ONE list of rows that
   opt in, and it is the only thing that ever sets these classes — repeating the
   component names here just gives that list a second copy to drift from.
   Each side is INDEPENDENT: scrollers.js adds .is-fade-left/.is-fade-right only while
   that physical edge still has content behind it, so the fade at the far end of
   a scroll clears instead of dimming a fully-visible first pill. A side with no
   class keeps its 0px stop, which collapses that end of the gradient to a hard
   opaque edge — one mask expression covers all four states. Physical (not
   logical) because a mask gradient has no `to inline-end`; app.js resolves
   start/end against the element's own computed direction, so RTL is handled
   there rather than duplicated in a [dir] pair here. */
.is-scrollable {
    -webkit-mask-image: linear-gradient(to right, transparent 0, #000 var(--fade-left, 0px), #000 calc(100% - var(--fade-right, 0px)), transparent 100%);
    mask-image: linear-gradient(to right, transparent 0, #000 var(--fade-left, 0px), #000 calc(100% - var(--fade-right, 0px)), transparent 100%);
}

.is-fade-left {
    --fade-left: 22px;
}

.is-fade-right {
    --fade-right: 22px;
}

/* A mask clips absolutely-positioned descendants, so the fade would erase the
   watch row's playlist panel (it hangs BELOW the row) the moment it opens —
   verified: with the mask on, the open panel paints nothing at all. Drop the
   mask in exactly the state the overflow unclip further down already keys off,
   which returns the row to its pre-fade rendering while the panel is open.
   Any future scroller with a dropdown/popover child needs the same pair. */
.watch-actions.is-scrollable:has(.pl-panel:not([hidden])) {
    -webkit-mask-image: none;
    mask-image: none;
}


/* Mobile offcanvas — narrower than Bootstrap's 400px default (variable API). */
.mobile-nav {
    --bs-offcanvas-width: 280px;
}

/* Nav-link hover surface — no utility for hover states. Dropdown items (the
   lang-picker's menu) keep Bootstrap's own dropdown colours and active state:
   these selectors outrank .dropdown-item and would repaint the active row. */
.mobile-nav a:not(.dropdown-item) {
    color: var(--text-2);
}

.mobile-nav a:not(.dropdown-item):hover {
    background: var(--card);
    color: #fff;
}

/* ---------------------------------------------------------------------------
   Modal backdrop
   --------------------------------------------------------------------------- */

/* Darker + blurred page behind every Bootstrap modal. The darkness goes through
   Bootstrap's own --bs-backdrop-* API; the blur has no Bootstrap equivalent.
   The dimming lives in the BACKGROUND alpha with the element opacity at 1,
   because backdrop-filter is composited at the element's opacity — the stock
   .5 opacity would show the blur only half-strength. The .fade transition still
   animates 0 → 1, so the blur fades in with the dim. */
.modal-backdrop {
    --bs-backdrop-bg: rgb(0 0 0 / .75);
    --bs-backdrop-opacity: 1;
    -webkit-backdrop-filter: blur(6px);
    backdrop-filter: blur(6px);
}

/* ---------------------------------------------------------------------------
   Badges
   --------------------------------------------------------------------------- */

/* Near-square badges are the house style — the grid chrome (thumbnails, covers,
   person tiles) is flat, and a rounded overlay badge on a square thumb reads as
   a stray. 2px is a softened corner, not a rounded one: it kills the razor point
   of a true square while staying far below Bootstrap's own 0.375rem, so the
   badge still reads as flush with the tile it sits on. Overridden through
   Bootstrap's own --bs-badge-* variable API here rather than in
   custom-stripchat.scss, so the compiled vendor build stays the colour-only
   reskin it is documented to be.
   Deliberately NOT !important: the .rounded-pill utility is, so the badges that
   must stay pills (credit chips, the header count pill, the verified badges,
   the admin picker chips) keep winning without an exception list here. */
.badge {
    --bs-badge-border-radius: 2px;
}

/* A flag inside a badge (the Israeli badge on video cards, ui_flag). Sized
   and placed against the LETTER INK, not the line box — nothing in Bootstrap
   knows where a font's ink sits. Measured on the card badge (11.25px Fredoka
   bold, Hebrew): the letters span ~0.76em, and their centre sits ~0.055em
   ABOVE the centre of the line box the flex row centres on (Fredoka reserves
   ascender space Hebrew never uses). A flag at the full 1em therefore
   centred on the line box reads as sitting low. So: 1.1em — deliberately
   taller than the letters, a circle that small stops reading as a flag — and
   a sub-pixel lift onto the ink centre (in the flag's own em, so it scales).
   The overhang is symmetric now, which is what makes it read as centred. */
.badge .fi {
    font-size: 1.1em;
    transform: translateY(-.05em);
}

/* The flag for the Arabic LANGUAGE (Languages::FLAGS 'ar' => 'pan-arab'): the
   1916 Arab Revolt flag, the pan-Arab mark no single state owns. flag-icons
   ships only country flags and the Arab League's, so the class is ours, in
   flag-icons' own two-rule shape (4x3, and 1x1 under .fis). */
.fi-pan-arab { background-image: url(../img/flags/pan-arab-4x3.svg); }
.fi-pan-arab.fis { background-image: url(../img/flags/pan-arab-1x1.svg); }


/* ---------------------------------------------------------------------------
   Section headings
   --------------------------------------------------------------------------- */

/* Accent bar before the heading — pseudo-element, no utility equivalent
   (layout/typography are utilities in the markup). */
.section-title::before {
    content: '';
    position: absolute;
    inset-inline-start: 0;
    top: 50%;
    transform: translateY(-50%);
    width: 5px;
    height: 1.1em;
    background: var(--accent);
    border-radius: 3px;
}


/* ---------------------------------------------------------------------------
   Video archive pages (category / tag) — templates/categories/show.php +
   templates/tags/show.php, via partials/ui/archive-head + a sticky sort-bar.
   --------------------------------------------------------------------------- */

/* The section is a GRID (the .d-grid utility, in the markup) for ONE reason: it
   is what lets the sort bar be `position: sticky` AND still share a row with the
   heading on desktop. A sticky item is constrained by its PARENT's box, so the
   bar has to be a direct child of the tall section to stay pinned for the length
   of the page — a `d-lg-flex` header row would scope it to a wrapper that
   scrolls away after a few hundred pixels (the classic sticky trap), and the
   only way out of that is duplicating the pills in the DOM under d-none/
   d-lg-flex. Grid is what puts a direct child of the section into the heading's
   visual row without moving it in the flow. One instance, one place.

   A bare grid is single-column with auto-placement, so MOBILE needs no PLACEMENT
   rule — the children stack in source order exactly as plain block flow would,
   and the breadcrumb, video grid and pagination need no wrapper of their own.
   It does need the rule below, and that is the one way a grid is NOT block flow:
   a grid item's automatic minimum size is its MIN-CONTENT size, and the implicit
   `auto` track is sized to fit it. So one child that refuses to wrap sizes the
   whole section, and the PAGE scrolls sideways instead of that child. It is not
   hypothetical and it is not confined to one child: /videos/category/lesbian
   blew a 378px section out to 946px because one card carried nine credits, and
   `.vc-credits` is `flex-nowrap` — its min-content is the strip laid end to end
   (measured 910px), which propagates up through .vc-meta and the .row to the
   track. The israeli category renders fine at the same width for no better
   reason than that its cards name nobody, so the defect is invisible until a
   card is credited. Zeroing the automatic minimum on every child is what makes
   the section behave like the block flow the markup reads as; each scrolling
   strip inside then scrolls, which is what it was built to do. Same fix, same
   reason, as .pagination-bar's own min-width:0 further down — that one was
   found first, from the pager, and read as being about the pager. */
.archive > * {
    min-width: 0;
}

/* Pinned under the site header while the page scrolls. Mobile is the case this
   exists for: it is where most of the traffic is, where the viewport is short
   enough that the sort pills leave the screen immediately, and where going back
   for them costs a scroll to the top.

   `top` reads --header-h-live, which core/app.js measures. The --header-h token
   is only a floor, NOT the answer, for two independent reasons — measured 104px
   against a 60px token on a desktop render:
     · <header class="sticky-top"> stacks TWO rows, the crimson .header-bar AND
       the .cat-strip category strip below it. --header-h describes the first row
       alone, so it under-shoots the sticky header at EVERY width.
     · the ≤767px block near the foot of this file gives .header-bar
       `height: auto` + flex-wrap, so even that first row's height varies with
       width and with whether the auth buttons wrapped.
   Hardcode either and the pills slide up under the header on exactly the widths
   this feature exists for. The token stays as the fallback only so a JS-less
   render pins too low rather than not at all.

   The opaque background is the `bg-body` utility on the WRAPPER, and it must
   never move onto .sort-bar itself: scrollers.js lists that row in FADE_ROWS, so once
   it overflows it carries a mask-image edge fade — and a mask fades the
   element's background too, which would let thumbnails show through both ends of
   the pinned bar. The wrapper paints, the nav keeps its mask. It needs no
   gutter bleed: a .row's negative margin is cancelled by its columns' padding,
   so the thumbnails line up with the container's CONTENT edge and nothing ever
   scrolls through the gutter beside the pills. */
.a-sort {
    position: sticky;
    /* Under Bootstrap's .sticky-top (1020, which the site header uses): on a tie
       the later element in the DOM wins, and the archive comes after the header —
       so an equal value would paint the pills OVER the brand band. */
    z-index: 1010;
    /* ⚠ TEMPORARY (2026-09-26) — a -4px real-device experiment for the gap real
       phones show between the header and this bar (not reproducible in
       emulation). Harmless where the offset is already exact: the 4px overlap
       lands inside this wrapper's own py-2 padding, under the header (z 1020).
       Scoped HERE, not in app.js, so the chat panels that also read
       --header-h-live are untouched. Revert to the plain var() once measured. */
    top: calc(var(--header-h-live, var(--header-h)) - 4px);
}

/* Desktop: the heading and the sort bar share one row, vertically centred on
   each other, and the bar stops being sticky — a tall viewport and a mouse do
   not need it, and a bar that stayed pinned here would detach from the heading
   it belongs to. Everything else keeps the full width. The wrapper's bg-body/
   py-2 utilities simply stop being visible: body-coloured on the body, inside a
   centred row. */
@media (min-width: 992px) {
    .archive {
        grid-template-columns: minmax(0, 1fr) auto;
        align-items: center;
    }

    .archive > * {
        grid-column: 1 / -1;
    }

    /* Consecutive siblings taking column 1 then column 2, so auto-placement
       lands the bar in the heading's own row rather than the next one. */
    .archive > .a-head {
        grid-column: 1;
    }

    .archive > .a-sort {
        grid-column: 2;
        position: static;
    }
}


/* ---------------------------------------------------------------------------
   Video cards (the grid itself is Bootstrap row-cols in the markup)
   --------------------------------------------------------------------------- */
/* Fixed-ratio media box; Bootstrap's .ratio can't be used here — its `.ratio>*`
   rule would stretch the corner badges to 100%×100%. */
/* --tile-radius: the corner of every image TILE (video thumbs, .cat-card) —
   square by default (the engine look), a site theme rounds them in its site.css. */
.vc-thumb {
    aspect-ratio: 16 / 9;
    border-radius: var(--tile-radius, 0);
}

/* Decorative empty-poster gradient — no utility equivalent. */
.vc-thumb-placeholder {
    color: #3a3a3a;
    background: linear-gradient(135deg, #1a1a1a, #232323);
}

.vc-thumb::after {
    content: '';
    position: absolute;
    inset: 0;
    border-radius: inherit;
    box-shadow: inset 0 0 0 2px transparent;
    transition: box-shadow .15s ease;
    pointer-events: none;
}

/* Two states share the ring, and they mean different things: pointing at a card,
   and a clip actually playing on it. The hover half is scoped to pointers that
   can really hover — a touch browser latches :hover on the last tapped element,
   which would leave a ring on a card with nothing running behind it. */
@media (hover: hover) {
    .vc-thumb:hover::after {
        box-shadow: inset 0 0 0 2px var(--accent);
    }
}

/* Set by app.js for the life of a preview clip, on either input. */
.vc-thumb.is-previewing::after {
    box-shadow: inset 0 0 0 2px var(--accent);
}

.vc-title {
    color: var(--bs-body-color);
    font-weight: 500; /* fw-medium, kept here so the markup carries one class */
    font-size: .92rem;
    line-height: 1.35;
    display: -webkit-box;
    -webkit-line-clamp: 2;
    -webkit-box-orient: vertical;
    overflow: hidden;
    /* Imported titles can contain long unbreakable tokens (URLs/EN words);
       break them so they can't expand the grid track and overflow the page. */
    overflow-wrap: anywhere;
}

.vc-title:hover {
    color: var(--accent);
}

/* A grid cell is a flex item whose `min-width` defaults to `auto`, i.e. its
   content's minimum — so a non-wrapping credit strip could size the COLUMN
   rather than scrolling inside it, widening cards past their row-cols share.
   Bootstrap ships no min-w-0 utility, so this is the same recipe .header-actions
   uses further down. It belongs on the cell, not the strip: the strip is what
   overflows, the cell is what must refuse to grow for it. */
.vc-card {
    min-width: 0;
}

/* Credits and the view/like counts share ONE row under the title, so it is never
   empty — every video has counts even when nothing is credited, which is what
   keeps a blank band off the ~39% of videos carrying no credit at all (a studio
   credit alone counts: far more videos name a studio than name a cast). */
.vc-meta {
    color: var(--bs-secondary-text-emphasis);
    font-size: .78rem;
    line-height: 1.35rem;
}

/* The scrolling half. min-width:0 is what lets a flex-grow item shrink BELOW its
   content — without it `flex-grow-1` still refuses to go under min-content, the
   row widens, and the counts beside it get pushed out of the card. Scroll
   behaviour is the overflow-x-auto utility in the markup + the shared
   .scrollbar-none; the edge fade comes from .is-scrollable (scrollers.js). */
.vc-credits {
    min-width: 0;
}

/* The strip scrolls; its items keep their width instead of being squeezed. */
.vc-credits > * {
    flex-shrink: 0;
}

/* Fixed-px credit mark (circle/colors are utilities in the markup, like the
   other *-avatar sizes). It must be sized in px rather than em because the SAME
   class renders in two different type contexts — .vc-meta's .78rem strip and a
   btn-sm chip — and an em-sized circle would come out a different diameter in
   each. The explicit font-size normalizes the monogram letter and the studio
   glyph for the same reason. Sized to sit inside .vc-meta's 1.35rem line box
   without growing the row, and FIXED so the strip's scroll width is final before
   any image loads — app.js measures .vc-credits on border-box only, so a mark
   that resized on decode would leave the edge fade stale. */
.credit-avatar {
    width: 18px;
    height: 18px;
    font-size: .68rem;
}

/* The credit CHIP's mark (`partials/videos/credit-link` `chip` variant — the
   watch page's two credit rows, the /studios/{slug} cast rail, the /u/{id}
   studio rail). A SEPARATE token rather than a bigger `.credit-avatar` because
   that one is pinned to the card strip's measured scroll width above; this one
   answers to the chip's TWO-LINE label (the bilingual name over the gender
   facet, ~2.3rem at lh-sm), so the circle sits flush with the text block
   instead of floating beside it. The font-size normalizes the monogram letter
   and the studio glyph at this diameter, as `.credit-avatar` does at its own. */
.credit-chip-avatar {
    width: 36px;
    height: 36px;
    font-size: 1rem;
}

/* The linked credit is a text-body-emphasis utility (!important), so a plain
   color declaration can't win on hover — override the variable it reads. */
.vc-credit:hover {
    color: var(--accent);
    --bs-emphasis-color: var(--accent);
}

/* A DEAD category chip in the /videos facet panel (partials/videos/facet-panel-
   body — an OFF chip with no matching video, or the VideoFacets::MAX bound
   reached). Bootstrap's stock .65 disabled opacity left it reading as nearly
   live beside the clickable chips, so it is dimmed further and its thumbnail
   greyed. Scoped to the OUTLINE (off) chips: the locked scope chip is also
   `.disabled` but solid, and must stay in full colour. */
#vf-chips [class*="btn-outline-"].disabled {
    --bs-btn-disabled-opacity: .4;
}
#vf-chips [class*="btn-outline-"].disabled img {
    filter: grayscale(1);
}


/* ---------------------------------------------------------------------------
   Ad slots (ui_ad_slot) — the reserved boxes core/ads.js fills.
   Each box is a Bootstrap .ratio; the stock ratio classes stop at 21x9, so
   the three banner geometries set --bs-aspect-ratio (Bootstrap's own custom-
   ratio mechanism) here, plus the creative's native width as a cap so a 728px
   strip never scales UP on a wide column (it scales down freely). `cover`
   uses the stock ratio-16x9 and needs no rule. The ratio's percentage is
   height / width of the format (Ad::FORMATS).
   --------------------------------------------------------------------------- */
.ad-slot-leaderboard {
    --bs-aspect-ratio: 12.3626%;
    max-width: 728px;
}

.ad-slot-rectangle {
    --bs-aspect-ratio: 83.3333%;
    max-width: 300px;
}

.ad-slot-mobile-banner {
    --bs-aspect-ratio: 31.25%;
    max-width: 320px;
}


/* ---------------------------------------------------------------------------
   Category cards (the grid itself is Bootstrap row-cols in the markup)
   --------------------------------------------------------------------------- */
/* Fixed-ratio media box (same .ratio>* badge-stretching caveat as .vc-thumb). */
.cat-cover {
    aspect-ratio: 16 / 9;
}

/* Hover zoom — no Bootstrap hover/animation utilities. */
.cat-cover img {
    transition: transform .25s ease;
}

.cat-card:hover .cat-cover img {
    transform: scale(1.05);
}

.cat-cover::after {
    content: '';
    position: absolute;
    inset: 0;
    box-shadow: inset 0 0 0 2px transparent;
    transition: box-shadow .15s ease;
    pointer-events: none;
}

.cat-card:hover .cat-cover::after {
    box-shadow: inset 0 0 0 2px var(--accent);
}

/* Parent-hover accent on the caption — no utility for hover states. */
.cat-name {
    color: var(--text);
}

/* Hover accent border, the .pcard treatment — no Bootstrap hover-state utility.
   A no-op on the tiles that carry no `.card` surface (the section/studio grids). */
.cat-card {
    border-radius: var(--tile-radius, 0);
    transition: border-color .15s ease;
}

.cat-card:hover {
    border-color: var(--bs-primary);
}

/* Two-line blurb on a directory-site card — Bootstrap ships no line-clamp
   utility (same recipe as .vc-title above). The scraped blurbs carry Latin
   brand names and bare URLs, so break them or one token widens the grid track. */
.cat-desc {
    display: -webkit-box;
    -webkit-line-clamp: 2;
    -webkit-box-orient: vertical;
    overflow: hidden;
    overflow-wrap: anywhere;
}

.cat-card:hover .cat-name {
    color: var(--accent);
}

/* ---------------------------------------------------------------------------
   Pagination
   --------------------------------------------------------------------------- */
/* Pagination theme — brand Bootstrap's .pagination via its --bs-pagination-*
   API (card-grey rest, red active, bordered dividers), matching the dark UI.
   Markup is the standard .pagination / .page-item / .page-link in
   partials/ui/pagination.php. */
/* The bar must be free to be NARROWER than its own content — the number strip
   between prev/next SCROLLS, so its content width is not a floor. Custom CSS
   because Bootstrap ships no min-width utility, and `mw-100` cannot serve: a
   min-width always wins over a max-width, so the automatic minimum is what has
   to be zeroed. Without it a GRID or FLEX ancestor (the archive pages'
   `.container-xxl.archive.d-grid`) sizes the bar to prev + every number + next
   — measured 392px inside a 347px cell — and the whole PAGE scrolls sideways
   instead of the strip. The inner rule is the same fix one level down, for a
   caller whose own wrapper is the flex container. */
.pagination-bar,
.pagination-scroll {
    min-width: 0;
}

/* Hide the native bar ONLY once core/scrollers.js has stamped `is-scrollable` —
   that class is this row's proof that an affordance exists to replace it (the
   mask fade, from the same measurement). Both layouts load scrollers.js; keying
   on the state still means a page without it keeps a working scrollbar. */
.pagination-scroll.is-scrollable {
    scrollbar-width: none; /* Firefox */
}

.pagination-scroll.is-scrollable::-webkit-scrollbar {
    display: none; /* Chromium / WebKit */
}

.pagination {
    /* Bootstrap's .pagination-lg values, restated here rather than adding the
       class: Bootstrap sits in @layer(reboot), so this unlayered rule would
       override .pagination-lg's sizing anyway. Large because the bar is the only
       way off a listing page on a phone, and its numbers are single characters —
       the smallest touch targets on an archive page. */
    --bs-pagination-padding-x: 1.5rem;
    --bs-pagination-padding-y: 0.75rem;
    --bs-pagination-font-size: 1.25rem;
    --bs-pagination-border-radius: var(--bs-border-radius-lg);
    --bs-pagination-bg: var(--card);
    --bs-pagination-color: var(--text-2);
    --bs-pagination-border-color: var(--border);
    --bs-pagination-hover-bg: var(--card-2);
    --bs-pagination-hover-color: #fff;
    --bs-pagination-hover-border-color: var(--border);
    --bs-pagination-focus-bg: var(--card-2);
    --bs-pagination-focus-color: #fff;
    --bs-pagination-focus-box-shadow: none;
    --bs-pagination-active-bg: var(--accent);
    --bs-pagination-active-border-color: var(--accent);
    --bs-pagination-active-color: #fff;
    --bs-pagination-disabled-bg: var(--card);
    --bs-pagination-disabled-color: var(--muted);
    --bs-pagination-disabled-border-color: var(--border);
}

/* The two lists say different things, so they are branded differently: prev/next
   are the bar's ACTIONS and wear the solid accent, while the current page is
   STATE and wears the outlined one — filling it as well would put three crimson
   blocks in one strip and leave nothing marking where you are. Both go through
   the --bs-pagination-* variable API, so the brand colour still comes from the
   compiled build's --bs-primary and nothing here restates a hex. */
.pagination-step {
    --bs-pagination-bg: var(--accent);
    --bs-pagination-color: #fff;
    --bs-pagination-border-color: var(--accent);
    --bs-pagination-hover-bg: var(--accent-hover);
    --bs-pagination-hover-color: #fff;
    --bs-pagination-hover-border-color: var(--accent-hover);
    --bs-pagination-focus-bg: var(--accent-hover);
    --bs-pagination-focus-color: #fff;
}

.pagination-scroll {
    --bs-pagination-active-bg: transparent;
    --bs-pagination-active-color: #fff;
    --bs-pagination-active-border-color: var(--accent);
}

/* ---------------------------------------------------------------------------
   Watch page
   --------------------------------------------------------------------------- */
.watch {
    display: grid;
    /* minmax(0, …) lets the column shrink below its content's intrinsic width,
       so the scrollable .watch-taxonomy is constrained instead of overflowing
       the viewport on mobile (the desktop two-column rule does the same). */
    grid-template-columns: minmax(0, 1fr);
    gap: 24px;
    align-items: start;
}



/* ----- Performer go-live CTA (header) ----- */
/* The btn-light pill keeps its Bootstrap size and colours; this only adds motion:
   a thin light arc that sweeps slowly around the pill's edge, drawn OUTSIDE it on a
   ring-masked pseudo-element, so the box (and the header row) never moves. White +
   the brand coral, because it rides the crimson band where a crimson arc vanishes.
   The icon breathes like an on-air light. @property makes --cta-angle animatable;
   a browser without it shows a static ring, which is still a fine button. */
@property --cta-angle {
    syntax: '<angle>';
    initial-value: 0deg;
    inherits: false;
}

.btn-live-cta {
    position: relative;
    isolation: isolate;
}

.btn-live-cta::before {
    content: '';
    position: absolute;
    inset: -3px;
    z-index: -1;
    padding: 2px;
    border-radius: inherit;
    background: conic-gradient(from var(--cta-angle),
        transparent 0deg 200deg,
        color-mix(in srgb, var(--accent-glow) 70%, transparent) 260deg,
        #fff 330deg,
        transparent 360deg);
    -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
    -webkit-mask-composite: xor;
    mask: linear-gradient(#000 0 0) content-box exclude, linear-gradient(#000 0 0);
    animation: cta-sweep 3.2s linear infinite;
    pointer-events: none;
}

.btn-live-cta .bi-broadcast {
    color: var(--accent);
    animation: cta-on-air 1.6s ease-in-out infinite;
}

@keyframes cta-sweep {
    to { --cta-angle: 360deg; }
}

@keyframes cta-on-air {
    50% { opacity: .45; }
}

@media (prefers-reduced-motion: reduce) {
    .btn-live-cta::before,
    .btn-live-cta .bi-broadcast {
        animation: none;
    }
}

/* ----- Category strip (header) ----- */
/* No scrollbar at any width. Three affordances already cover this row —
   touch-swipe, the ≥768px .scroll-nav arrows, and mouse drag-scrolling (scrollers.js
   DRAG_ROWS) — so a handle would be a fourth, in a band that is header chrome on
   EVERY page: 8px of permanent bar here is the most expensive 8px on the site.
   Unconditional, so unlike .frame-strip this one COULD use .scrollbar-none — but
   that class earns its place in the markup only on a row that is otherwise plain,
   and this row already owns a block here for its fade and its arrows. */
.cat-strip {
    scrollbar-width: none;
}

.cat-strip::-webkit-scrollbar {
    display: none;
}

/* ----- Frame strip (watch page) ----- */
/* The .frame-strip tile-box height + the .frame-tile sprite-crop box live in the
   shared assets/css/frame-strip.css (the admin video-edit page renders the same
   strip). This sheet loads on BOTH layouts, and so does core/scrollers.js, so the
   watch page and the admin preview scroll identically.

   There is no bar because scrollers.js gives this row mouse DRAG-scrolling (DRAG_ROWS)
   on top of the pager arrows and touch-swipe, which leaves an 8px handle under
   the tiles as pure clutter. Nothing then needs the desktop height bump
   frame-strip.css declares to seat one, so the box is the 96px tile height at
   every width — which is also why .scroll-nav-arrow's base `top: 50%` centres
   its arrows on the tiles here with no per-strip override. */
.frame-strip {
    scrollbar-width: none;
}

.frame-strip::-webkit-scrollbar {
    display: none;
}

/* Specificity, not !important: theme.css loads BEFORE frame-strip.css, so a bare
   `.frame-strip` here would LOSE the desktop bump to the shared sheet's own rule
   further down the cascade. The wrap descendant beats it — the .scroll-nav-arrow
   precedent. Takes the tile height back from --frame-tile-h (owned by
   frame-strip.css) rather than restating a px value: the tile shrinks below lg,
   and a hardcoded 96 here would leave the watch-page row a tall box around short
   tiles at 768-991px. */
@media (min-width: 768px) {
    .frame-strip-wrap > .frame-strip {
        height: var(--frame-tile-h);
    }
}

/* ----- Drag-scrolled rows (scrollers.js DRAG_ROWS) ----- */
/* Unscoped and keyed on .is-draggable, which scrollers.js stamps as it binds — the
   .is-scrollable precedent above: DRAG_ROWS is the ONE list of rows that opt in,
   so naming the components here again would just give that list a second copy to
   drift from.
   The grab cursor needs .is-scrollable too, so a row short enough to fit shows no
   affordance it cannot honour — app.js toggles it off the same measurement that
   drives the fade and the arrows.
   user-select while dragging: a press that turns into a drag would otherwise
   select the timestamp badges or chip labels it sweeps across. */
.is-draggable.is-scrollable {
    cursor: grab;
}

.is-draggable.is-dragging {
    cursor: grabbing;
    user-select: none;
}

/* .frame-tile (and the .frame-strip 96px height) live in frame-strip.css —
   see the section note above. */

/* Paging arrows — fixed-px circle over a .scroll-nav's edges, shared by the
   frame strip and the header category strip (wired generically in scrollers.js).
   PHYSICAL left/right on purpose: inside an RTL page start-0/end-0 compile to
   mirrored edges, so pin by physical side. Centred here (not via top-50
   utilities) so per-strip overrides needn't fight a utility !important.
   Per-strip markup decides which viewports lay them out: the frame strip is
   d-flex (shown on every viewport), the header category strip is d-none d-md-flex
   (desktop only). Wherever laid out, JS fades each in/out via .is-shown as that
   direction's edge is reached. visibility (not display) so the fade can transition
   AND a faded-out arrow can't capture clicks over the row beneath it. */
.scroll-nav-arrow {
    width: 32px;
    height: 32px;
    z-index: 2;
    top: 50%;
    transform: translateY(-50%);
    opacity: 0;
    visibility: hidden;
    transition: opacity .2s ease, visibility .2s ease;
}

.scroll-nav-arrow.is-shown {
    opacity: 1;
    visibility: visible;
}

.scroll-nav-arrow[data-pager-left] {
    left: 4px;
}

.scroll-nav-arrow[data-pager-right] {
    right: 4px;
}

/* The /chat roster's gender row is a btn-sm strip — 29px tall, measured — so the
   shared 32px circle would stand PROUD of the row it pages, top and bottom. Keyed
   on the ROW's class through the wrapper rather than a modifier on the buttons,
   because ui_scroll_arrows() emits one identical pair everywhere and the size is a
   property of what is being paged, not of the arrow (the `:has()` precedent is the
   .watch-actions rule above). Only visible in the LTR locales, where the labels are
   long enough to overflow 268px at all — Hebrew's four pills fit exactly. */
.scroll-nav:has(> .gender-scroll) > .scroll-nav-arrow {
    width: 24px;
    height: 24px;
}

/* Respect reduced-motion: drop the fade (arrows still show/hide, just instant). */
@media (prefers-reduced-motion: reduce) {
    .scroll-nav-arrow {
        transition: none;
    }
}



/* ----- Comments ----- */
/* Fixed-px letter avatar (rest of the circle is utilities in the markup). */
.comment-avatar {
    width: 38px;
    height: 38px;
}

@media (min-width: 992px) {
    .watch {
        grid-template-columns: minmax(0, 1fr) 360px;
    }
}

/* ---------------------------------------------------------------------------
   Upload form
   --------------------------------------------------------------------------- */

/* Fixed content cap for the upload form — no max-width utility for 760px. */
.upload-page {
    max-width: 760px;
}

/* ui_upload()'s dropzone look: a dashed drop target + the drag state
   core/upload.js toggles (.dragover) — border utilities can't do dashed, and
   hover/drag states have no utility API. */
.dropzone {
    position: relative;
    border: 2px dashed var(--border);
    border-radius: var(--bs-border-radius);
    background: var(--bg-2);
    transition: border-color .15s ease, background .15s ease;
}

.dropzone.dragover {
    border-color: var(--accent);
    background: rgba(var(--bs-primary-rgb), .06);
}

/* Invalid state. Bootstrap's .is-invalid styles the CONTROL, and this control is
   a transparent full-size overlay — a red border on it would be invisible — so
   :has() lifts the state onto the dashed box the member actually sees. One rule
   covers both paths: the server round-trip (field_class() renders is-invalid)
   and Hv, which sets the same class on the same input client-side. */
.dropzone:has(.dropzone-input.is-invalid) {
    border-color: var(--bs-danger);
}

/* Inputs don't get a pointer cursor from the reboot (the invisible input IS
   the click target here). */
.dropzone-input {
    cursor: pointer;
}

/* The pill look takes drops too; its drag state rides the same class on the
   pill's own field box. */
.file-pill.dragover .form-control {
    border-color: var(--accent);
}

/* Password show/hide eye (ui_input type=password): Bootstrap's is-invalid/is-valid
   icon is painted in the same end-side padding the eye sits in, so it is dropped
   here — the coloured border still carries the state. */
.pw-field .form-control {
    background-image: none;
}

/* Upload queue rows (ui_upload() `queue`): a fixed preview square and a thin
   per-file progress bar — no size utility exists for either. */
.upload-thumb {
    width: 3rem;
    height: 3rem;
}

.upload-meter {
    height: .35rem;
}


/* ---------------------------------------------------------------------------
   Profile
   --------------------------------------------------------------------------- */
/* Fixed-px letter avatar (circle/colors are utilities in the markup). */
.profile-avatar {
    width: 64px;
    height: 64px;
}

/* ----- Person facets (ui_gender) ----- */
/* The per-gender ACCENT, in the GLOBAL sheet because a person is named on
   every layer — the watch page's credit chips, /videos/performers, the chat
   rows and the profile hero all render `ui_gender()` — and a page that names
   one without this palette paints the glyph grey with nothing failing (which
   is how the watch page shipped before these moved here from social.css).
   `--g` is the ONE stripchat-sampled swatch per gender; the modifier sits on
   `ui_gender()`'s WRAPPER (and on the picker's `.gender-pill` / `.gender-tile`
   controls, which read the same variable), so it reaches whichever child takes
   the accent — the glyph, with the word beside it muted. Never a second copy of
   these hex values anywhere. */

/* Gender tiles (ui_gender_picker 'tiles': the register wizard's steps 1+2 and
   the 18+ gate's "I am…" row) — Stripchat-style neon glyph cards: square tile,
   colored glowing gender symbol, label below; the checked tile gets a colored
   border + halo + tint. No Bootstrap component expresses a per-value colored
   selection card, hence custom. In the GLOBAL sheet because the age gate renders
   over every public page, most of which load no social.css. (The pills' shape
   stays in social.css: every pill surface opts that sheet in.)
   `--g` here is the neutral fallback for a tile with NO gender modifier (the
   picker's "any" option). This rule must stay ABOVE the modifiers: it has their
   specificity, so placed after them it would win on order and grey every tile. */
.gender-tile {
    --g: var(--bs-secondary-color);
    flex: 1 1 0;
    max-width: 130px;
    aspect-ratio: 1;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: .5rem;
    background: var(--bs-tertiary-bg);
    border: 1px solid var(--bs-border-color);
    border-radius: 1rem;
    cursor: pointer;
    /* Positioning context for .gender-tile-check. */
    position: relative;
    transition: border-color .15s, box-shadow .15s, background-color .15s;
}

.gender-man   { --g: #58b7f5; } /* guys blue */
.gender-woman { --g: #ff5c8a; } /* girls pink */
.gender-trans { --g: #b678f0; } /* trans purple */

.gender-ink {
    color: var(--g);
}

/* The gender symbol itself. Named rather than `.gender-tile .bi`, which now
   matches the checked badge too and would render it at 2.75rem with a halo. */
.gender-tile-glyph {
    font-size: 2.75rem;
    line-height: 1;
    color: var(--g);
    filter: drop-shadow(0 0 8px color-mix(in srgb, var(--g) 55%, transparent));
}

.gender-tile span {
    font-size: .95rem;
    font-weight: 600;
    color: var(--g);
}

.gender-tile:hover {
    border-color: color-mix(in srgb, var(--g) 60%, var(--bs-border-color));
}

.btn-check:checked + .gender-tile {
    border-color: var(--g);
    background: color-mix(in srgb, var(--g) 10%, var(--bs-tertiary-bg));
    box-shadow: 0 0 14px color-mix(in srgb, var(--g) 40%, transparent);
}

/* The CHECKED badge. A btn-check tile hides its input, so "selected" was
   carried only by a border + halo + tint — and :hover already paints a 60%
   border, so the two states are near-identical on the very tile a mouse is
   pointing at, which is where the question is actually asked. The badge says it
   outright. Top-INLINE-start via a logical property: top-right on the RTL site,
   top-left on the LTR locales, one rule. Absolutely positioned, so it is not a
   flex item of the tile's column and adds neither a gap nor a height. */
.gender-tile-check {
    position: absolute;
    top: .4rem;
    inset-inline-start: .4rem;
    font-size: 1.15rem;
    line-height: 1;
    color: var(--g);
    opacity: 0;
    transform: scale(.6);
    transition: opacity .15s, transform .15s;
}

.btn-check:checked + .gender-tile .gender-tile-check {
    opacity: 1;
    transform: none;
}

.btn-check:focus-visible + .gender-tile {
    /* Keyboard focus ring in the tile's own color (btn-check hides the input). */
    box-shadow: 0 0 0 .25rem color-mix(in srgb, var(--g) 35%, transparent);
}


/* ---------------------------------------------------------------------------
   Footer
   --------------------------------------------------------------------------- */
/* Hover accent on footer links — text utilities (!important) would kill the
   hover swap, so the color pair stays custom. */
.footer-col a {
    color: var(--text-2);
}

.footer-col a:hover {
    color: var(--accent);
}

/* Footer trust/safety badges — the grey is baked into the SVG files (an <img>
   SVG renders isolated, so page `color`/currentColor can't reach it); the hover
   brighten is a CSS filter, which DOES apply to an <img>. No Bootstrap utility
   covers this.
   Deliberately small and dim: these are secondary reassurance marks, not a
   footer feature. The marks arrive at different intrinsic sizes and the
   width/height attributes must stay intrinsic to reserve layout space, so cap the
   RENDERED height here — Bootstrap has no max-image-height utility. */
.trust-badge img {
    max-height: 30px;
    width: auto;
    height: auto;
    opacity: 0.7;
    transition: filter 0.15s ease-in-out, opacity 0.15s ease-in-out;
}

.trust-badge:hover img,
.trust-badge:focus-visible img {
    opacity: 1;
    filter: brightness(1.4);
}


/* ---------------------------------------------------------------------------
   Age gate
   --------------------------------------------------------------------------- */
/* Shown or not is decided before first paint by core/age-check.js, which stamps
   `html.age-ok` for a visitor who has confirmed — the page itself is identical
   for everyone (edge-cached). Until then the gate covers the page and the page
   does not scroll behind it. */
html.age-ok .age-gate {
    display: none;
}

html:not(.age-ok):has(#ageGate) body {
    overflow: hidden;
}

/* Full-viewport modal overlay above everything (z-2000) + backdrop blur — a
   positioned overlay outside Bootstrap's modal component. */
.age-gate {
    position: fixed;
    inset: 0;
    z-index: 2000;
    background: rgba(0, 0, 0, .92);
    backdrop-filter: blur(6px);
    display: flex;
    justify-content: center;
    padding: 20px;
    /* The box is ~440px tall, so it exceeds a landscape phone (~390px) and any
       desktop at 200% zoom. A fixed overlay has no page scroll behind it to fall
       back on, so it must scroll itself. */
    overflow-y: auto;
}

/* Centred with `margin: auto`, NOT `align-items: center` — a centred flex ITEM
   taller than its container overflows BOTH ends, and the part above the top edge
   is unreachable even once the container scrolls (there is no negative scroll
   position). `margin: auto` centres while short and pins to the start once tall,
   so the whole dialog stays reachable. */
.age-gate-box {
    max-width: 460px;
    margin: auto;
}

/* The gate's "I am…" tiles on a phone: three tiles share a ~270px row, so each
   is ~86px square and the full 2.75rem glyph crowds its label. A smaller glyph
   and gap keep the tile reading as a choice, not an icon. Gate-scoped: the
   register wizard's tiles have the page to themselves and keep their size. */
@media (max-width: 575.98px) {
    .age-gate .gender-tile {
        gap: .25rem;
    }

    .age-gate .gender-tile-glyph {
        font-size: 1.85rem;
    }
}




/* Shrink the bell/DM unread pills (ui_count_badge). Custom because .badge's
   .75em is Bootstrap's floor — no smaller font-size utility exists. */
.count-badge {
    font-size: .6em;
}

/* The actions own the bar's free space across the WHOLE range where the search is
   out of the bar (<992px — d-none d-lg-flex on the header instance). Without this
   they bunch against the logo from 768–991px. Deliberately not an ms-auto utility
   on the nav: auto margins are split equally among every auto margin on the flex
   line, so one there would also join the ≥992px split against .search-form's two
   and shift the search box ~38px off centre. Keeping it a <992px media rule leaves
   the desktop bar untouched. flex-basis:0 is essential — flex line-breaking uses
   the item's base size, so a content-sized basis (~196px) wraps even with
   min-width:0; a 0 basis (grown to fill the leftover space) never does. */
@media (max-width: 991.98px) {
    .header-actions {
        flex: 1 1 0;
        min-width: 0;
        overflow-x: auto;
        /* overflow-x:auto computes overflow-y to auto — a scroll container clips
           BOTH axes — which cut the top off the translate-middle bell/DM count
           badges (they overhang their link by ~5px). Pad the scrollport to give
           them room, then pull the padding straight back out with an equal
           negative margin so the bar's height and centring don't move. Custom
           because Bootstrap ships no negative-margin utilities by default
           ($enable-negative-margins), so py-2 alone would grow the ≤767px bar. */
        padding-block: 8px;
        margin-block: -8px;
    }

    /* The buttons keep their size; the container scrolls. */
    .header-actions > * {
        flex-shrink: 0;
    }

    /* End-align the group via an auto margin (NOT justify-content:flex-end,
       which makes start-side overflow unreachable). The auto margin takes the
       free space when the buttons fit, and collapses to 0 when they overflow,
       so the row scrolls normally from the start instead of clipping.
       ⚠ The first child must therefore carry NO margin utility: every m*-*
       utility is !important on both physical sides, which beats this auto
       margin at any specificity and silently un-aligns the whole logged-in
       group. That is why the bell/DM/browse icons space themselves with a
       `gap` on their own wrapper instead of `mx-1` (partials/chrome/header.php). */
    .header-actions > :first-child {
        margin-inline-start: auto;
    }

    /* Don't clip the user dropdown when it's open (overflow-x implies an
       overflow-y that would otherwise cut off the menu). */
    .header-actions:has(.dropdown-menu.show) {
        overflow: visible;
    }
}

/* Small-screen re-flow: a compact auto-height bar, and the page-level search
   boxes (/search, /harmalist/search) drop the 620px cap to go full-width. */
@media (max-width: 767.98px) {
    .header-bar {
        flex-wrap: wrap;
        height: auto;
        padding-block: 10px;
        /* Tighter gap so the auth buttons fit on the top row without needing
           to scroll on common phone widths (the scroll stays as a fallback). */
        gap: 8px;
    }

    .search-form {
        max-width: none;
    }

}

/* The user-menu toggle is an avatar-sized circle at EVERY width: the name it
   used to carry beside the mark is now `visually-hidden` (it is the button's
   accessible name and nothing else), so the default pill padding would leave an
   oval of dead space — and a filled-white blob once .show/:active — around the
   lone 28px avatar. Fixed 40px keeps a proper touch target and centres both the
   photo and the letter-monogram variant. No Bootstrap equivalent for a
   padding-free fixed-px circular button. */
/* btn-outline-light fills its bg solid white on hover/active/.show; on this
   40px circle wrapping a 28px avatar that white fill shows in the ring of
   space around the photo once the menu is open. Neutralise the interaction
   states via the button's own --bs-* vars (out-specifies Bootstrap's
   .btn:first-child:active without !important) so it stays the transparent
   outlined circle it is at rest. */
.user-menu-toggle {
    width: 40px;
    height: 40px;
    padding: 0;
    border-radius: 50%;
    justify-content: center;
    --bs-btn-hover-bg: transparent;
    --bs-btn-active-bg: transparent;
    --bs-btn-hover-border-color: var(--bs-btn-border-color);
    --bs-btn-active-border-color: var(--bs-btn-border-color);
}

.user-menu-toggle:focus,
.user-menu-toggle:focus-visible,
.user-menu-toggle:active,
.user-menu-toggle.show {
    box-shadow: none;
}

/* The signed-in email atop the user menu. `contain: inline-size` drops the row
   from the dropdown's intrinsic width, so the OTHER rows size the menu and a long
   address truncates (`.text-truncate` on the inner span) instead of stretching it.
   The span is dir="ltr" so an address reads and clips from its end. One step
   below `.small`: identification, not a control. No Bootstrap equivalent. */
.user-menu-email {
    contain: inline-size;
    font-size: 0.75rem;
}

/* Breadcrumb trail (taxonomy / watch / harmalist) — themed via Bootstrap's
   .breadcrumb variable API. */
.breadcrumb {
    /* One step below Bootstrap's `.small` (.875rem): the trail is navigation
       furniture above the page heading, not content. Set through the variable
       API rather than a `small` class on the <ol>, so the size lives in ONE
       place - this partial is the only breadcrumb markup in the tree. */
    --bs-breadcrumb-font-size: 0.8125rem;
    /* U+203A: bidi-mirrored, so under the page's RTL context it renders as ‹,
       pointing along the right-to-left reading flow. */
    --bs-breadcrumb-divider: '\203A';
    --bs-breadcrumb-divider-color: var(--bs-tertiary-color);
    --bs-breadcrumb-item-active-color: var(--bs-tertiary-color);
}

/* Bootstrap floats the `›` divider (float:right in the RTL build), and a float
   does NOT contribute to its flex item's intrinsic width — so in the scrolling
   one-row trail each <li> sized to its TEXT only and the divider painted 13px
   outside the box, on top of the previous crumb. Inline instead of floated:
   the divider is measured with the text. No utility for a ::before override. */
.breadcrumb-item + .breadcrumb-item::before {
    float: none;
    display: inline;
}


/* ---------------------------------------------------------------------------
   Scroll-to-top button
   --------------------------------------------------------------------------- */
/* Animated fade+rise reveal — Bootstrap has no scroll-reveal transition
   utility. Position/shape/color are utilities in the markup; app.js toggles
   .is-visible once the page is scrolled. Fixed-px circle (btn padding can't
   make an icon button perfectly round). */
.to-top {
    width: 44px;
    height: 44px;
    z-index: 1030; /* above sticky page chrome, below modals/age-gate */
    opacity: 0;
    visibility: hidden;
    transform: translateY(12px);
    transition: opacity .25s ease, transform .25s ease, visibility 0s .25s;
}

.to-top.is-visible {
    opacity: 1;
    visibility: visible;
    transform: none;
    transition-delay: 0s;
}

/* Share button (partials/chrome/share-fab) — the scroll-to-top's mirror in the
   opposite bottom corner, phones only, same fixed-px circle as .to-top. Solid
   info (blue), matching the watch page's Download pill and Scenes button. Sits below the
   outbound-call panel (z-index 1029) that shares its corner. Hidden on the chat
   stage, whose one-viewport shell reaches the bottom edge. */
.share-fab {
    z-index: 1028;
}
.share-fab-btn {
    width: 44px;
    height: 44px;
}
body:has(.chat-stage) .share-fab {
    display: none;
}

/* --- Photo lightbox: carousel controls ---
   Lives HERE, not in social.css: the lightbox markup (partials/chrome/photo-lightbox)
   and assets/js/core/photo-lightbox.js render on BOTH layouts site-wide, while
   social.css is only linked per-controller via pageStyles. Any page carrying a
   ui_photo_img() tile can open the lightbox, so its styles must sit in the
   stylesheet every layout loads.

   Prominent circular prev/next arrows that always point OUTWARD (toward their own
   edge) in both RTL and LTR. Custom because Bootstrap has no circular-backed
   control variant; the chevron direction is pinned per writing-direction (rather
   than relying on the compiled build's RTL flip) so it stays correct when LTR
   locales are added. The dark theme's default --bs-carousel-control-icon-filter
   (invert) would turn the white chevron black — invisible over the
   object-fit-contain letterbox — so the filter is cleared. Scoped to
   #photoLightbox so no other carousel is affected. */
#photoLightbox .carousel-control-prev,
#photoLightbox .carousel-control-next {
    opacity: 1;
}
#photoLightbox .carousel-control-prev-icon,
#photoLightbox .carousel-control-next-icon {
    width: 3.5rem;
    height: 3.5rem;
    border-radius: 50%;
    background-color: rgba(0, 0, 0, 0.55);
    background-size: 40%;
    background-repeat: no-repeat;
    background-position: center;
    filter: none; /* keep the white chevron — override the theme's invert filter */
}
/* The control physically at the right edge shows a right-pointing chevron; the
   one at the left edge a left-pointing chevron. In RTL prev sits right + next
   left; LTR is the mirror — so the glyph is keyed on both dir and prev/next. */
[dir="rtl"] #photoLightbox .carousel-control-prev-icon,
[dir="ltr"] #photoLightbox .carousel-control-next-icon {
    background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708'/%3e%3c/svg%3e");
}
[dir="rtl"] #photoLightbox .carousel-control-next-icon,
[dir="ltr"] #photoLightbox .carousel-control-prev-icon {
    background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M11.354 1.646a.5.5 0 0 1 0 .708L5.707 8l5.647 5.646a.5.5 0 0 1-.708.708l-6-6a.5.5 0 0 1 0-.708l6-6a.5.5 0 0 1 .708 0'/%3e%3c/svg%3e");
}
#photoLightbox .carousel-control-prev:hover .carousel-control-prev-icon,
#photoLightbox .carousel-control-next:hover .carousel-control-next-icon {
    background-color: rgba(0, 0, 0, 0.8);
}
/* The close button — the arrows' circular look, larger than stock .btn-close
   (1em) so it reads as the way out on a phone. The X is drawn white directly and
   the dark theme's invert filter cleared: that filter applies to the whole
   element, so it would also turn the dark disc light. */
#photoLightbox .btn-close {
    width: 3.5rem;
    height: 3.5rem;
    padding: 0;
    border-radius: 50%;
    opacity: 1;
    filter: none;
    background-color: rgba(0, 0, 0, 0.55);
    background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M.293.293a1 1 0 0 1 1.414 0L8 6.586 14.293.293a1 1 0 1 1 1.414 1.414L9.414 8l6.293 6.293a1 1 0 0 1-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 0 1-1.414-1.414L6.586 8 .293 1.707a1 1 0 0 1 0-1.414'/%3e%3c/svg%3e");
    background-size: 40%;
    box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.35);
}
#photoLightbox .btn-close:hover {
    background-color: rgba(0, 0, 0, 0.8);
}
/* Hidden-tab privacy blur on an open slide (app.js visibilitychange) — the
   lightbox can be showing a DM photo, whose own .dm-img blur lives in social.css. */
#photoLightbox .carousel-item img.blurred {
    filter: blur(22px);
}

/* --- Collapse toggle chevron (ui_collapse_toggle + the earnings row toggle +
   the partials/ui/clamp show-more toggle) ---
   The glyph is always bi-chevron-down; the trigger's aria-expanded flips when
   the panel shows/hides (Bootstrap for collapse, app.js for [data-clamp-toggle]),
   so rotating off that attribute animates the arrow in sync. Custom because
   Bootstrap ships no rotate-on-expand utility (its own accordion button uses
   the same technique). */
[data-bs-toggle="collapse"] > .bi-chevron-down,
[data-clamp-toggle] > .bi-chevron-down {
    display: inline-block; /* transforms don't apply to inline boxes */
    transition: transform .2s ease;
}

[data-bs-toggle="collapse"][aria-expanded="true"] > .bi-chevron-down,
[data-clamp-toggle][aria-expanded="true"] > .bi-chevron-down {
    transform: rotate(180deg);
}

@media (prefers-reduced-motion: reduce) {
    [data-bs-toggle="collapse"] > .bi-chevron-down,
    [data-clamp-toggle] > .bi-chevron-down {
        transition: none;
    }
}

/* ui_upload()'s pill draws no focus ring of its own: its trigger is a real <button>, which
   carries Bootstrap's :focus-visible ring. A :focus-within rule on .file-pill
   would double-ring it. */

/* Long-form prose rendered from stored HTML — the category SEO article and the
   rich-text editor surface that authors it.

   Deliberately NO per-element typography here: the sanitizer emits bare semantic
   tags, and bare tags are exactly what the theme already styles, so an article
   heading matches every other heading on the site for free. Re-scaling h2/p/li
   under this class would fork the type scale — a second place to change when the
   theme changes, and a block that reads as "not quite the rest of the site".
   Only the rules below earn their place, and none of them is typography. */

/* Trim the outer margins the first/last child would otherwise push through the
   container, so the block sits on the spacing its caller chose. */
.rich-text > *:first-child { margin-top: 0; }
.rich-text > *:last-child { margin-bottom: 0; }

/* A contenteditable has no intrinsic height, so the editor needs a floor. */
.rich-text-editor .rich-text { min-height: 24rem; overflow-y: auto; }

/* A stored-body <img> is bare (the sanitizer strips classes), and a bare img
   has no max-width — without this a full-size upload overflows the column on
   mobile. Layout, not typography, so it belongs here. */
.rich-text img { max-width: 100%; height: auto; }

/* Same reason as the img rule, and the one place "bare tags are already styled"
   does NOT hold: HtmlSanitizer admits the whole table family (a comparison table
   is real article content — unwrapping one would concatenate every cell into an
   unreadable run), but Bootstrap's table styling lives behind the `.table` CLASS
   and the sanitizer strips every class. So a stored table inherits reboot only:
   no cell padding and no borders, i.e. cells touching each other.
   Stored markup cannot carry a `.table-responsive` wrapper either, so the TABLE
   becomes its own scroll container: display:block lets overflow-x apply (the
   rows then sit in an anonymous table box inside it), so a table wider than a
   phone scrolls sideways inside the column instead of pushing the whole page
   wider than the viewport. Wrapping cells alone was not enough — a 5-column
   comparison table's min-content still exceeds a 360px screen, and before it
   got there every cell had shrunk to one word per line. The cell min-width keeps
   columns readable and lets the scroll take over earlier. */
.rich-text table { display: block; max-width: 100%; overflow-x: auto; }
.rich-text th,
.rich-text td { min-width: 8rem; padding: 0.5rem 0.75rem; border: 1px solid var(--bs-border-color); vertical-align: top; }

/* Collapsed content block (partials/ui/clamp): a body too tall to hand a visitor
   whole renders clamped to a short preview fading into the page background, and
   app.js animates max-height to the measured content height on "show more".
   Custom because Bootstrap's collapse has no partially-visible preview state.
   The preview cap lives in --clamp-h so app.js reads the SAME value when
   deciding whether the body already fits (and drops the affordance); the
   default below is prose, and a caller clamping something else (the studio
   page's performer chip rows) overrides it per render. */
.clamp-box {
    --clamp-h: 5.5rem; /* ≈3 lines of body text */
    position: relative;
    overflow: hidden;
    max-height: var(--clamp-h);
    transition: max-height .35s ease;
}

/* The fade that says "there is more below" — gone once opened. It fades into
   the SURFACE the block sits on, which is the page for the taxonomy articles and
   the studio page's cast rail, but the card (#202020, two steps lighter) for the
   studios rail inside the /u/{id} videos card — a fade to the page colour there
   paints a black smear across a grey card. --clamp-bg is that surface; the
   .card override below scopes it the way Bootstrap scopes --bs-card-bg. */
.clamp-box::after {
    content: "";
    position: absolute;
    inset-inline: 0;
    bottom: 0;
    height: 2.75rem;
    background: linear-gradient(to bottom, transparent, var(--clamp-bg, var(--bg)));
    pointer-events: none;
    transition: opacity .35s ease;
}

.card .clamp-box { --clamp-bg: var(--bs-card-bg); }

.clamp-box.is-open::after { opacity: 0; }

/* Body fits inside the preview — no clamp, no fade (app.js also hides the button). */
.clamp-box.is-static { max-height: none; }
.clamp-box.is-static::after { display: none; }

@media (prefers-reduced-motion: reduce) {
    .clamp-box,
    .clamp-box::after {
        transition: none;
    }
}

/* A capped chip group (ui_chip_group `max` → [data-chip-max]): once the limit
   is reached the unticked chips go disabled, and Bootstrap's stock disabled
   look (opacity .65) reads as merely dimmer, not as "unavailable". So the
   locked chips fade much further, lose their colour and shrink a touch, and the
   change is eased so the member SEES the group lock and unlock as they tick.
   Custom because Bootstrap has no disabled-state tuning beyond the opacity
   variable, and no transition on opacity/transform. */
[data-chip-max] .btn {
    transition: opacity .3s ease, filter .3s ease, transform .3s ease,
        color .15s ease-in-out, background-color .15s ease-in-out, border-color .15s ease-in-out;
}

[data-chip-max] .btn-check:disabled + .btn {
    opacity: .3;
    filter: grayscale(1);
    transform: scale(.94);
    border-style: dashed;
}

@media (prefers-reduced-motion: reduce) {
    [data-chip-max] .btn { transition: none; }
}

/* --- Member blur sheet (partials/chrome/blur-sheet + core/blur-sheet.js) ---
   HERE, not in social.css: the sheet is rendered by the main layout and opened
   from the profile photos tab, the /social/messages thread AND the /chat shell,
   not all of which load social.css. Custom because Bootstrap has no positioned
   overlay editor: the stage shrinks to the drawn picture (inline-block around
   the canvas), the layer covers exactly that picture, and every box is placed
   in % of it — so a box's fraction IS the region the server mosaics. Physical
   left/top/right on purpose: the stage is dir="ltr" (image geometry has no
   reading direction). Handles and the remove button are thumb-sized for phones.
   `touch-action: none` lets a finger drag a box instead of scrolling the page. */
.blur-stage {
    position: relative;
    display: inline-block;
    max-width: 100%;
    touch-action: none;
    user-select: none;
    -webkit-user-select: none;
}

.blur-stage canvas {
    display: block;
    max-width: 100%;
    max-height: 62vh;
    width: auto;
    height: auto;
}

.blur-layer {
    position: absolute;
    inset: 0;
    cursor: crosshair;
}

.blur-box {
    position: absolute;
    border: 2px solid var(--bs-primary);
    border-radius: .25rem;
    box-shadow: 0 0 0 1px rgba(0, 0, 0, .6);
    cursor: move;
}

.blur-box-remove {
    position: absolute;
    top: -.95rem;
    right: -.95rem;
    width: 1.9rem;
    height: 1.9rem;
    display: flex;
    align-items: center;
    justify-content: center;
    padding: 0;
    border: 0;
    border-radius: 50%;
    background: var(--bs-primary);
    color: #fff;
    font-size: .8rem;
}

.blur-box-handle {
    position: absolute;
    right: -.75rem;
    bottom: -.75rem;
    width: 1.5rem;
    height: 1.5rem;
    border-radius: 50%;
    background: #fff;
    border: 2px solid var(--bs-primary);
    cursor: nwse-resize;
}

/* Banner band (partials/ui/taxonomy-header's `$banner` — a studio page's cover):
   a short fixed-px media box (Bootstrap's .ratio scales height with width — too
   tall on desktop, too short on mobile); the img covers it, anchored to the top.
   Moved here from the retired social.css, which public pages never loaded. */
.profile-cover {
    height: 200px;
}

.profile-cover > img {
    object-position: top;
}

@media (max-width: 575.98px) {
    .profile-cover {
        height: 120px;
    }
}
