/* ==========================================================================
   Background — ember particles + gradient blur blobs
   ========================================================================== */

#ember-canvas {
  position: fixed;
  inset: 0;
  z-index: 0;
  pointer-events: none;
}

.bg-glow {
  position: fixed;
  z-index: 0;
  border-radius: 50%;
  filter: blur(90px);
  opacity: 0.35;
  pointer-events: none;
  animation: drift 22s ease-in-out infinite alternate;
}

.bg-glow--1 {
  width: 480px; height: 480px;
  background: radial-gradient(circle, var(--accent), transparent 70%);
  top: -120px; left: -100px;
}

.bg-glow--2 {
  width: 560px; height: 560px;
  background: radial-gradient(circle, var(--ember-2), transparent 70%);
  bottom: -160px; right: -140px;
  animation-delay: -8s;
}

@keyframes drift {
  from { transform: translate(0, 0) scale(1); }
  to   { transform: translate(40px, 30px) scale(1.08); }
}

/* ==========================================================================
   Page shell
   ========================================================================== */

.dashboard-wrap {
  position: relative;
  z-index: 1;
  max-width: 1280px;
  margin: 0 auto;
  padding: 28px 24px 64px;
}

/* ---------- Header ---------- */

.site-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 20px;
  flex-wrap: wrap;
  padding-bottom: 24px;
}

.brand {
  display: flex;
  align-items: center;
  gap: 10px;
  font-family: 'Fraunces', serif;
  font-size: 20px;
}

/* The avatar AND the wordmark are one link home, so the anchor — not
   .brand — is now the flex row that spaces them. Inherits colour so wrapping
   the text in a link doesn't restyle the wordmark. */
.brand-link {
  display: flex;
  align-items: center;
  gap: 10px;
  color: inherit;
  text-decoration: none;
}
.brand-link:hover em { color: var(--ember-2); }

.brand svg { width: 30px; height: 30px; flex-shrink: 0; }
.brand em { color: var(--accent); font-style: normal; }

/* The wordmark is the homepage's <h1> (see index.php's own note there). These
   rules exist only so marking it up as a heading changes nothing visually —
   a UA's default h1 font-size and margins would otherwise blow the header
   row apart. */
.brand-title {
  margin: 0;
  font-size: inherit;
  font-weight: inherit;
  line-height: 1.15;
  display: flex;
  flex-direction: column;
}

/* Standard visually-hidden pattern (clipped, not display:none — a
   display:none heading child is dropped from the accessible name entirely,
   which is exactly what devalues an h1 for SEO; this keeps it present for
   screen readers/search engines while taking no visible space). Generic on
   purpose, not scoped to .brand-tagline, so anything else that needs
   "present but not shown" later can reuse it instead of a new one-off rule. */
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

.brand-avatar {
  width: 34px; height: 34px;
  border-radius: 50%;
  object-fit: cover;
  flex-shrink: 0;
  border: 2px solid var(--accent);
}

.social-links {
  display: flex;
  gap: 10px;
}

.social-link {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 38px; height: 38px;
  border-radius: 50%;
  background: var(--bg-raised);
  border: 1px solid var(--line);
  color: var(--ink-dim);
  transition: color var(--transition-fast), border-color var(--transition-fast), transform var(--transition-fast);
}

.social-link:hover {
  color: var(--accent);
  border-color: var(--accent);
  transform: translateY(-2px);
}

.social-link svg { width: 16px; height: 16px; }
.social-link i { font-size: 15px; }

/* ---------- Live banner (Twitch) ---------- */

/* A <button> now, not an <a> — clicking it opens the floating widget below
   instead of navigating to twitch.tv (see assets/js/live-stream-widget.js).
   The first four properties past padding are just native-button-chrome
   resets so it still looks exactly like the plain banner it used to be. */
.live-banner {
  display: flex;
  align-items: center;
  gap: 10px;
  background: linear-gradient(90deg, rgba(228, 120, 90, 0.16), rgba(228, 120, 90, 0.04));
  border: 1px solid var(--accent);
  border-radius: var(--radius-sm);
  padding: 12px 18px;
  margin-bottom: 20px;
  color: var(--ink);
  font-size: 14px;
  font-weight: 600;
  width: 100%;
  font-family: inherit;
  text-align: left;
  cursor: pointer;
  appearance: none;
  -webkit-appearance: none;
  transition: filter var(--transition-fast), transform var(--transition-fast);
}

.live-banner:hover { filter: brightness(1.08); transform: translateY(-1px); }

.live-banner-dot {
  width: 9px; height: 9px;
  border-radius: 50%;
  background: var(--accent);
  flex-shrink: 0;
  animation: live-pulse 1.6s ease-in-out infinite;
}

@keyframes live-pulse {
  0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(228, 120, 90, 0.5); }
  50% { opacity: 0.6; box-shadow: 0 0 0 6px rgba(228, 120, 90, 0); }
}

.live-banner-text { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

.live-banner-viewers {
  font-size: 12px;
  font-weight: 500;
  color: var(--ink-dim);
  flex-shrink: 0;
}

/* ---------- Floating live stream widget ---------- */
/* Opened by clicking .live-banner rather than navigating to Twitch.
   position:fixed to the viewport (not the page) so it keeps floating over
   whatever a visitor scrolls to. It doesn't survive a full page navigation
   on its own — nothing in a classic multi-page app does — but
   live-stream-widget.js reopens it on the next page automatically as long
   as it hasn't been closed this tab session; see that file and
   renderLiveStreamWidget()'s comment in includes/content.php. */
.live-stream-widget {
  position: fixed;
  right: 18px;
  bottom: 18px;
  width: min(360px, calc(100vw - 24px));
  background: var(--bg);
  border: 1px solid var(--accent);
  border-radius: var(--radius);
  box-shadow: var(--shadow-glow), 0 12px 32px rgba(0, 0, 0, 0.45);
  z-index: 55;
  overflow: hidden;
  animation: live-widget-in var(--transition-panel) both;
}

.live-stream-widget[hidden] { display: none; }

@keyframes live-widget-in {
  from { opacity: 0; transform: translateY(16px) scale(0.98); }
  to   { opacity: 1; transform: translateY(0) scale(1); }
}

.live-stream-widget-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  padding: 6px 6px 6px 14px;
  background: linear-gradient(90deg, rgba(228, 120, 90, 0.16), rgba(228, 120, 90, 0.04));
}

.live-stream-widget-title {
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: 13px;
  font-weight: 600;
  color: var(--ink);
}

/* A real 44x44 touch target, not the tiny corner X most sites get away
   with — this one has to actually be easy to hit on the first try. */
.live-stream-widget-close {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 44px;
  height: 44px;
  flex-shrink: 0;
  background: transparent;
  border: none;
  border-radius: 50%;
  color: var(--ink-dim);
  font-size: 16px;
  cursor: pointer;
  transition: background var(--transition-fast), color var(--transition-fast);
}

.live-stream-widget-close:hover,
.live-stream-widget-close:focus-visible {
  background: rgba(228, 120, 90, 0.18);
  color: var(--ink);
}

.live-stream-widget-frame-wrap {
  position: relative;
  width: 100%;
  aspect-ratio: 16 / 9;
  background: #000;
}

.live-stream-widget-frame-wrap iframe {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  border: 0;
}

.live-stream-widget-link {
  display: block;
  padding: 10px 14px;
  font-size: 12px;
  font-weight: 600;
  color: var(--ink-dim);
  text-align: center;
  transition: color var(--transition-fast);
}

.live-stream-widget-link:hover { color: var(--accent); }

@media (max-width: 480px) {
  .live-stream-widget { left: 12px; right: 12px; bottom: 12px; width: auto; }
}

/* ---------- Search bar ---------- */
/* Lives inside .site-header's own flex row now, between .brand and
   .social-links (that flex row already has justify-content:space-between,
   so a third child naturally lands in the middle) — flex-grow lets it fill
   the space between the other two on wide screens, min/max-width keeps it
   from getting crushed or absurdly wide, and .site-header's own
   flex-wrap:wrap already drops it to its own full-width line on a narrow
   window without any extra rule needed here. */

.search-bar {
  position: relative;
  flex: 1 1 220px;
  max-width: 420px;
}

.search-bar-icon {
  position: absolute;
  left: 16px;
  top: 50%;
  transform: translateY(-50%);
  color: var(--ink-dim);
  font-size: 13px;
  pointer-events: none;
}

.search-bar input[type="search"] {
  width: 100%;
  background: var(--bg);
  border: 1px solid var(--line);
  color: var(--ink);
  font-family: inherit;
  font-size: 14px;
  padding: 12px 16px 12px 40px;
  border-radius: var(--radius-sm);
  transition: border-color var(--transition-fast);
}

.search-bar input[type="search"]::placeholder { color: var(--ink-dim); }
.search-bar input[type="search"]:focus { outline: none; border-color: var(--accent); }

/* ---------- Category nav ---------- */

.category-nav {
  display: flex;
  gap: 10px;
  flex-wrap: wrap;
  margin-bottom: 28px;
}

.cat-btn {
  background: var(--bg-raised);
  border: 1px solid var(--line);
  color: var(--ink-dim);
  padding: 9px 18px;
  border-radius: 999px;
  cursor: pointer;
  font-size: 13.5px;
  font-family: inherit;
  transition: all var(--transition-fast);
}

.cat-btn:hover { color: var(--ink); border-color: var(--accent); }

.cat-btn.active {
  background: var(--accent);
  border-color: var(--accent);
  color: var(--bg);
  font-weight: 600;
}

/* ---------- Layout: content hub (2/3) + right panel (1/3) ---------- */

.dashboard-layout {
  display: flex;
  align-items: flex-start;
  gap: 24px;
}

.content-hub {
  flex: 1 1 auto;
  min-width: 0;
}

.hub-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 18px;
}

.hub-head h2 { font-size: 22px; }

/* "Latest first" sort toggle (feedback: "Add a button or something
   suitable on the start page to sort on latest") — same pill look as
   .cat-btn, kept as its own class rather than reusing that one so
   initCategoryFilter()'s own button selector (dashboard.js) never has to
   account for a pill that isn't a category. */
.sort-toggle-btn {
  background: var(--bg-raised);
  border: 1px solid var(--line);
  color: var(--ink-dim);
  padding: 8px 16px;
  border-radius: 999px;
  cursor: pointer;
  font-size: 13px;
  font-family: inherit;
  white-space: nowrap;
  transition: all var(--transition-fast);
}

.sort-toggle-btn:hover { color: var(--ink); border-color: var(--accent); }

.sort-toggle-btn.active {
  background: var(--accent);
  border-color: var(--accent);
  color: var(--bg);
  font-weight: 600;
}

.hub-empty {
  color: var(--ink-dim);
  border: 1px dashed var(--line);
  border-radius: var(--radius);
  padding: 40px;
  text-align: center;
}

/* ---------- Card grid ---------- */

.card-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
  gap: 18px;
}

.card-item {
  background: var(--bg-raised);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  overflow: hidden;
  cursor: pointer;
  transition: transform var(--transition-fast), border-color var(--transition-fast), box-shadow var(--transition-fast);
  display: flex;
  flex-direction: column;
}

.card-item:hover,
.card-item:focus-visible,
.card-item.is-active {
  transform: translateY(-4px);
  border-color: var(--accent);
  box-shadow: var(--shadow-glow);
}

.card-thumb {
  position: relative;
  aspect-ratio: 16 / 10;
  background: var(--bg-elevated) center / cover no-repeat;
  display: flex;
  align-items: flex-end;
  padding: 10px;
}

.card-thumb .card-icon {
  width: 30px; height: 30px;
  border-radius: 50%;
  background: rgba(0,0,0,0.45);
  backdrop-filter: blur(4px);
  display: flex; align-items: center; justify-content: center;
  color: var(--ink);
  position: absolute;
  top: 10px; right: 10px;
}
.card-thumb .card-icon svg { width: 15px; height: 15px; }
.card-thumb .card-icon i { font-size: 14px; }

/* Like button — a plain toggle, no public count (see renderLikeButton() in
   includes/content.php). Two placements share the .like-btn base: a small
   circular chip in a card's thumbnail (bottom-right — top corners are
   already .card-badge/.card-icon), and a bigger one beside an item page's
   own <h1> (.like-btn--hero). */
.like-btn {
  border: none;
  background: transparent;
  cursor: pointer;
  color: var(--ink-dim);
  padding: 0;
  line-height: 1;
  transition: color .15s, transform .1s;
}
.like-btn:active { transform: scale(0.9); }
.like-btn.is-liked { color: var(--accent); cursor: default; }

.view-title-row { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
.like-btn--hero { font-size: 26px; flex-shrink: 0; margin-top: 4px; }
.like-donate-nudge {
  display: block;
  margin-top: 4px;
  font-size: 12px;
  color: var(--accent);
  text-decoration: underline;
  text-decoration-color: var(--line);
}

/* Hidden until body.likes-unlocked — hearts on cards only start appearing
   once a visitor has liked something on an item page first (progressive,
   not front-loaded on a first-time visit). Matches .card-icon's own
   30x30/blur treatment for visual consistency, in the one free corner. */
.card-thumb .like-btn--card {
  display: none;
  width: 30px; height: 30px;
  border-radius: 50%;
  background: rgba(0,0,0,0.45);
  backdrop-filter: blur(4px);
  align-items: center; justify-content: center;
  color: var(--ink);
  position: absolute;
  bottom: 10px; right: 10px;
}
.card-thumb .like-btn--card i { font-size: 14px; }
.card-thumb .like-btn--card.is-liked { color: var(--accent); }
body.likes-unlocked .card-thumb .like-btn--card { display: flex; }

.card-badge {
  position: absolute;
  top: 10px; left: 10px;
  background: var(--accent-soft);
  color: var(--accent);
  font-size: 10px;
  font-weight: 700;
  letter-spacing: 0.05em;
  text-transform: uppercase;
  padding: 3px 8px;
  border-radius: 999px;
}

/* A merch design bundled from 2+ real products (see $designCardsByKey in
   index.php) — a solid accent chip in the corner .card-icon would
   otherwise use, so a bundle reads as one at a glance instead of looking
   like an ordinary single product. */
.card-options-badge {
  position: absolute;
  top: 10px; right: 10px;
  background: var(--accent);
  color: var(--bg);
  font-size: 10px;
  font-weight: 700;
  padding: 3px 8px;
  border-radius: 999px;
}

.card-body {
  padding: 14px 16px 16px;
  display: flex;
  flex-direction: column;
  gap: 6px;
  flex: 1;
}

.card-meta {
  font-size: 11px;
  color: var(--ink-dim);
  text-transform: uppercase;
  letter-spacing: 0.05em;
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: 6px;
}

/* Distinguishes one variant card from another sharing the same script
   (e.g. an audio cut vs. a video cut) — see resolveCardVariantLabel() in
   includes/content.php. Right-aligned opposite the platform name via
   .card-meta's own flex layout above. */
.card-variant-label {
  color: var(--accent);
  font-weight: 700;
}

.card-title {
  font-size: 15px;
  line-height: 1.35;
}

/* Bracket tags split out of the raw title by splitTitleTags() — this
   creator's own title convention leans on them heavily (genre/kink/POV
   markers), which reads better as a scannable row of small pills than as
   part of an already-long title. Capped to 2 rows (confirmed live: a
   heavily-tagged title could otherwise wrap to 4-5 rows and push the rest
   of the card's own content way down) — 36px = two 16px-tall pills
   (10px font, 2px top/bottom padding) plus the 4px gap between them;
   overflow:hidden rather than a third partial row peeking through. */
.card-tags {
  display: flex;
  flex-wrap: wrap;
  gap: 4px;
  max-height: 36px;
  overflow: hidden;
}

.card-tag {
  font-size: 10px;
  color: var(--ink-dim);
  background: rgba(255, 255, 255, 0.06);
  padding: 2px 7px;
  border-radius: 999px;
}

.card-sub {
  margin-top: auto;
  font-size: 12px;
  color: var(--ink-dim);
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.card-sub .price { color: var(--accent); font-weight: 600; }

/* ==========================================================================
   Right panel
   ========================================================================== */

.right-panel {
  flex: 0 0 0;
  width: 0;
  opacity: 0;
  transform: translateX(16px);
  overflow: hidden;
  pointer-events: none;
  background: var(--bg-raised);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  transition: flex-basis var(--transition-panel), width var(--transition-panel),
              opacity var(--transition-panel), transform var(--transition-panel);
}

.right-panel.is-open {
  flex: 0 0 var(--panel-width);
  width: var(--panel-width);
  opacity: 1;
  transform: translateX(0);
  pointer-events: auto;
  position: sticky;
  top: 24px;
  max-height: calc(100vh - 48px);
  overflow-y: auto;
}

.panel-inner {
  padding: 18px;
  display: flex;
  flex-direction: column;
  gap: 18px;
  min-width: 280px;
}

.panel-close {
  align-self: flex-start;
  width: 34px; height: 34px;
  border-radius: 50%;
  background: var(--bg-elevated);
  border: 1px solid var(--line);
  color: var(--ink);
  font-size: 18px;
  line-height: 1;
  cursor: pointer;
  display: flex; align-items: center; justify-content: center;
  transition: border-color var(--transition-fast), color var(--transition-fast);
}
.panel-close:hover { border-color: var(--accent); color: var(--accent); }

.panel-media {
  border-radius: var(--radius-sm);
  overflow: hidden;
  background: var(--bg-elevated);
  box-shadow: var(--shadow-glow);
}

.panel-media iframe {
  width: 100%;
  aspect-ratio: 16 / 9;
  border: 0;
  display: block;
}

/* Spotify's embed widget — not a video, so it doesn't want the 16:9 frame
   above; 352px is Spotify's own recommended height for a track/episode
   embed with cover art (see view.php's $embedIsAudioWidget). */
.panel-media-audio iframe {
  aspect-ratio: auto;
  height: 352px;
}

.panel-media-locked {
  position: relative;
  aspect-ratio: 16 / 9;
  background: var(--bg-elevated);
  display: flex;
  align-items: center;
  justify-content: center;
  overflow: hidden;
}
/* Separate element (not the parent's own `background`) so a paywalled
   post's blur filter only affects the image, never the lock icon/text
   drawn on top of it. inset is negative so the blur radius doesn't reveal
   a sharp, un-blurred fringe at the edges. */
.panel-media-locked-bg {
  position: absolute; inset: -20px;
  background: center / cover no-repeat;
  z-index: 0;
}
.panel-media-locked-bg--blurred {
  filter: blur(24px);
  transform: scale(1.1);
}
.panel-media-locked::before {
  content: '';
  position: absolute; inset: 0;
  background: rgba(0,0,0,0.55);
  z-index: 1;
}
.panel-media-locked .lock-copy {
  position: relative;
  z-index: 2;
  text-align: center;
  color: var(--ink);
  padding: 0 20px;
}
.panel-media-locked .lock-copy svg { width: 26px; height: 26px; color: var(--accent); margin-bottom: 8px; }

/* 'player' hero_style — a full 16:9 immersive canvas (same footprint as the
   iframe/locked states): full-bleed backdrop, art square + tall waveform +
   centered play button on one stage, "Listen on X" pill floating near the
   bottom. Matches the scale/composition of the user's waveform.jpg mockup
   rather than a small compact bar. */
.panel-player {
  position: relative;
  aspect-ratio: 16 / 9;
  border-radius: var(--radius-sm);
  overflow: hidden;
  background: var(--bg-elevated);
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: clamp(10px, 3vh, 22px);
  padding: 20px 0;
}
/* $heroPlayerBg in view.php: a second piece of art for this recording if
   one exists, else the same art blurred, else a plain gradient of site
   colors — inset is negative on the image case so the blur radius (when
   used) doesn't reveal a sharp, un-blurred fringe at the edges, matching
   .panel-media-locked-bg's own approach. The dark ::before overlay keeps
   the stage/pill readable regardless of how bright the backdrop itself
   happens to be. */
.panel-player-bg {
  position: absolute; inset: -10px;
  background: center / cover no-repeat;
  z-index: 0;
}
.panel-player-bg--blurred {
  filter: blur(6px);
  transform: scale(1.15);
}
.panel-player::before {
  content: '';
  position: absolute; inset: 0;
  background:
    radial-gradient(ellipse at center, rgba(10,8,16,0.15) 35%, rgba(10,8,16,0.55) 100%),
    rgba(10,8,16,0.3);
  z-index: 1;
}
.panel-player-tag {
  position: absolute; top: 14px; left: 14px; z-index: 3;
  background: rgba(10,8,16,0.55);
  border: 1px solid var(--accent);
  color: var(--accent);
  font-size: 10px; font-weight: 700; letter-spacing: 0.05em; text-transform: uppercase;
  padding: 4px 10px;
  border-radius: 999px;
}
.panel-player-stage {
  position: relative;
  z-index: 2;
  width: 100%;
  flex: 1;
  min-height: 0;
  display: flex;
  align-items: center;
}
/* Fills the entire stage (not just the space left of the art square) and
   sits behind it — see .panel-player-thumb's higher z-index below. */
.panel-player-waveform {
  position: absolute;
  inset: 0;
  z-index: 1;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 2px;
}
.panel-player-thumb {
  position: relative;
  z-index: 2;
  margin-left: clamp(20px, 6vw, 56px);
  flex-shrink: 0;
  width: clamp(90px, 22%, 160px);
  aspect-ratio: 1 / 1;
  border-radius: var(--radius-sm);
  background: var(--bg) center / cover no-repeat;
  box-shadow: 0 8px 24px rgba(0,0,0,0.5);
}
.panel-player-thumb-placeholder {
  display: flex;
  align-items: center;
  justify-content: center;
  color: var(--accent);
}
.panel-player-thumb-placeholder svg { width: 40px; height: 40px; }

/* Video variant of the player canvas — used when a VIDEO couldn't be
   embedded at all (age-restricted on YouTube, expired Twitch VOD) and falls
   back to this hero. The audio layout is wrong for it in three ways, so
   each is undone rather than a separate hero being built:

   - no waveform (hidden in the markup): there is no audio to draw, and fake
     bars over a video describe it as something it isn't
   - the artwork goes 16:9 and centred instead of a small square pushed to
     one side, because a video's own frame IS the artwork
   - the play button sits over that frame, which is where a video's play
     control belongs */
.panel-player--video .panel-player-stage { justify-content: center; }
.panel-player--video .panel-player-thumb {
  margin-left: 0;
  width: clamp(180px, 52%, 420px);
  aspect-ratio: 16 / 9;
  border-radius: var(--radius-sm);
  box-shadow: 0 10px 34px rgba(0,0,0,0.55);
}
/* Sits on the frame rather than beside it. */
.panel-player--video .panel-player-play {
  position: absolute;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
  z-index: 3;
}
/* Bars: each one's own solid background color (set inline, left-to-right
   across the whole set) is what produces the color transition. The mask
   fades each bar's own top/bottom tips to transparent (a small opacity
   taper, not a color-to-black gradient) so the horizontal color shift
   across bars stays the dominant gradient instead of every bar reading as
   "color fading to black" vertically. z-index:1 keeps them below the
   ::before/::after shine lines (z-index:2) — both are pseudo-elements of
   this same element, but without explicit z-index the flex-item bars and
   the positioned pseudo-elements don't reliably stack in front/behind
   order across browsers. */
.panel-player-waveform span {
  position: relative;
  z-index: 1;
  flex: 0 0 4px;
  width: 4px;
  border-radius: 2px;
  -webkit-mask-image: linear-gradient(to bottom, transparent 0%, #000000 22%, black 78%, transparent 100%);
  mask-image: linear-gradient(to bottom, transparent 0%, #000000 22%, black 78%, transparent 100%);
}
.panel-player-waveform::before {
  content: '';
  position: absolute; left: 0; right: 0; top: 48.75%;
  height: 14px;
  z-index: 2;
  background: linear-gradient(90deg, rgb(255 255 255 / 73%), rgba(255, 255, 255), rgba(255, 255, 255), rgb(255 255 255 / 73%));
  filter: blur(10px);
  mix-blend-mode: overlay;
  pointer-events: none;
}
.panel-player-waveform::after {
  content: '';
  position: absolute; left: 0; right: 0; top: 50%;
  height: 2px;
  z-index: 2;
  background: linear-gradient(90deg, #ffffff17, rgba(255,255,255), rgba(255,255,255), #ffffff17);
  filter: blur(1px);
  mix-blend-mode: overlay;
  pointer-events: none;
}
/* Purely decorative (see view.php) — centered over the whole stage, same
   spot as the mockup's play button relative to the art + waveform. Sized
   with vw instead of a %-based clamp() so width and height resolve
   against the same axis — a %-based clamp resolves width against the
   stage's width and height against the stage's height, which are very
   different numbers on a wide 16:9 box, producing an oval instead of a
   circle. */
.panel-player-play {
  position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
  width: clamp(48px, 6vw, 64px);
  height: clamp(48px, 6vw, 64px);
  border-radius: 50%;
  background: rgba(255,255,255,0.92);
  color: #1C1A17;
  display: flex; align-items: center; justify-content: center;
  box-shadow: 0 4px 18px rgba(0,0,0,0.5);
  z-index: 3;
}
.panel-player-play svg { width: 64%; height: 64%;}
/* Anchored just under the play button instead of flowing to the bottom of
   the hero box — the play button is a fixed clamp(48px,6vw,64px), so half
   its height plus a small gap lands consistently just past its edge. */
.panel-player-listen {
  position: absolute;
  top: calc(50% + clamp(24px, 3vw, 32px) + 14px);
  left: 50%;
  transform: translateX(-50%);
  z-index: 2;
  white-space: nowrap;
}

/* 'image' hero_style — just the image, no icon/text overlay. */
.panel-image-full {
  display: block;
  aspect-ratio: 16 / 9;
  background: var(--bg-elevated) center / cover no-repeat;
  border-radius: var(--radius-sm);
}

.access-options-row {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
}

.panel-product-media {
  aspect-ratio: 1 / 1;
  background: var(--bg-elevated) center / cover no-repeat;
}

.panel-title { font-size: 17px; line-height: 1.4; }
.panel-platform { font-size: 12px; color: var(--ink-dim); text-transform: uppercase; letter-spacing: 0.05em; }

.panel-source-link {
  display: inline-block;
  margin-top: 6px;
  font-size: 12.5px;
  color: var(--accent);
}
.panel-source-link:hover { text-decoration: underline; }

/* Script variant links (view.php) — a plain accent link for a same-explicit-
   level variant (different gender tag, alternate edit); a distinctly-colored,
   bordered one when it leads to something more explicit than the current
   page, so it reads as a deliberate choice rather than a normal link. */
.view-variants {
  margin-top: 14px;
  display: flex;
  flex-direction: column;
  gap: 8px;
  align-items: flex-start;
}

.view-variant-link {
  font-size: 13px;
  color: var(--accent);
}
.view-variant-link:hover { text-decoration: underline; }

/* Same treatment as .view-hero-social h3 ("On Social") — that rule is
   scoped to its own parent class, so this is a standalone copy rather than
   a shared selector, for the "Alternative links" heading above the merged-
   siblings list. */
.view-variants-heading {
  font-size: 15px;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  color: var(--ink-dim);
  margin: 0 0 10px;
}

/* "Alternative links" — merged-sibling postings (view.php). Each is a
   distinct bordered row (same card idea as .social-mention) rather than a
   bare inline text link, since a plain wall of accent-colored link text with
   no dividers made it impossible to tell where one link ended and the next
   began once rows carried a full title instead of just a platform name. */
.alt-links {
  margin-top: 14px;
}

.alt-links-list {
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.alt-link-row {
  display: flex;
  align-items: center;
  gap: 12px;
  background: var(--bg-raised);
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  padding: 10px 14px;
  transition: border-color var(--transition-fast);
}
.alt-link-row:hover { border-color: var(--accent); }

.alt-link-platform-icon {
  flex-shrink: 0;
  width: 32px;
  height: 32px;
  border-radius: 50%;
  background: var(--bg-elevated);
  color: var(--accent);
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 14px;
}

.alt-link-body {
  display: flex;
  flex-direction: column;
  gap: 2px;
  min-width: 0;
}

.alt-link-meta {
  font-size: 11.5px;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--ink-dim);
}
.alt-link-meta i { width: 12px; text-align: center; }

.alt-link-title {
  font-size: 13.5px;
  color: var(--ink);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.alt-link-external {
  flex-shrink: 0;
  margin-left: auto;
  font-size: 11px;
  color: var(--ink-dim);
}

.view-variant-gated {
  color: var(--rose);
  border: 1px solid var(--rose);
  border-radius: 999px;
  padding: 6px 14px;
}
.view-variant-gated:hover { background: var(--rose); color: var(--bg); text-decoration: none; }

.panel-external-link {
  display: inline-block;
  text-align: center;
  border: 1px solid var(--accent);
  color: var(--accent);
  border-radius: 999px;
  padding: 10px 18px;
  font-size: 13.5px;
  font-weight: 600;
  transition: all var(--transition-fast);
  background: var(--bg);
}
.panel-external-link:hover { background: var(--accent); color: var(--bg); }

/* Admin-only way in to a product's category/related boxes from a page it
   appears on. Deliberately quieter than .panel-external-link — the shop link
   is what a visit is actually for, and this sits next to it on every merch
   card an admin sees, so it can't compete for attention. */
.merch-manage-link {
  display: inline-block;
  margin-top: 8px;
  font-size: 12px;
  font-weight: 600;
  letter-spacing: 0.02em;
  color: var(--ink-dim);
  border-bottom: 1px dashed currentColor;
}
.merch-manage-link:hover { color: var(--accent); }
/* One grid cell holding a card plus its admin link, so the link doesn't
   become a cell of its own and shove the next card sideways. */
.card-grid-cell { display: flex; flex-direction: column; }

/* Patreon CTA box */

.panel-cta {
  background: linear-gradient(155deg, var(--accent-soft), transparent);
  border: 1px solid var(--accent);
  border-radius: var(--radius-sm);
  padding: 16px;
  text-align: center;
  display: flex;
  flex-direction: column;
  gap: 10px;
}

.panel-cta p { font-size: 13.5px; color: var(--ink); margin: 0; }

.btn-cta {
  display: inline-block;
  background: var(--accent);
  color: var(--bg);
  font-weight: 700;
  padding: 12px 20px;
  border-radius: 999px;
  font-size: 14px;
  transition: filter var(--transition-fast), transform var(--transition-fast);
}
.btn-cta:hover { filter: brightness(1.1); transform: translateY(-1px); }

/* Merch mini-grid */

.panel-merch h4 {
  font-size: 13px;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--ink-dim);
  margin-bottom: 10px;
  font-family: 'Inter', sans-serif;
  font-weight: 600;
}

.merch-mini-grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 8px;
}

.merch-mini-item {
  aspect-ratio: 1 / 1;
  border-radius: 10px;
  background: var(--bg-elevated) center / cover no-repeat;
  border: 1px solid var(--line);
  transition: border-color var(--transition-fast), transform var(--transition-fast);
}
.merch-mini-item:hover { border-color: var(--accent); transform: translateY(-2px); }

/* A design bundle's variant list in the right panel (RightPanel.renderDesign()
   in dashboard.js) — real small product cards (thumbnail + type + price),
   not a plain text link list (feedback: "expecting to see cards with all
   the products with that design"). */
.panel-title--in-media { margin: 0 0 12px; }

.panel-variant-grid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 10px;
}
.panel-variant-card {
  display: flex;
  flex-direction: column;
  border: 1px solid var(--line);
  border-radius: 10px;
  overflow: hidden;
  text-decoration: none;
  color: var(--ink);
  transition: border-color var(--transition-fast), transform var(--transition-fast);
}
.panel-variant-card:hover { border-color: var(--accent); transform: translateY(-2px); }
.panel-variant-thumb {
  aspect-ratio: 1 / 1;
  background: var(--bg-elevated) center / cover no-repeat;
}
.panel-variant-info {
  padding: 8px 10px;
  display: flex;
  flex-direction: column;
  gap: 2px;
}
.panel-variant-type { font-size: 12.5px; font-weight: 600; }
.panel-variant-price { font-size: 12px; color: var(--ink-dim); }

/* ==========================================================================
   view.php — content detail page (hero player + related grid)
   ========================================================================== */

.back-link {
  display: inline-block;
  color: var(--ink-dim);
  font-size: 13.5px;
  margin-bottom: 20px;
  transition: color var(--transition-fast);
}
.back-link:hover { color: var(--accent); }

.view-hero {
  display: flex;
  align-items: flex-start;
  flex-wrap: wrap;
  gap: 28px;
  margin-bottom: 40px;
}

.view-hero-main {
  flex: 1 1 600px;
  min-width: 0;
  max-width: 780px;
}

/* Reuses .panel-media / .panel-media-locked (same iframe + locked-state
   treatment as the merch panel used to share with content) — full-width here
   instead of a cramped sidebar, which is the whole point. */
.view-hero-media {
  width: 100%;
}

.view-title {
  font-size: 26px;
  line-height: 1.3;
  margin: 6px 0 10px;
}

.view-description {
  margin-top: 16px;
  color: var(--ink-dim);
  font-size: 14.5px;
  line-height: 1.6;
  /* Renders the text's own newlines as breaks — do not also run this through
     nl2br() in PHP, that double-applies every line break. */
  white-space: pre-line;
}

.view-description a {
  color: var(--accent);
  font-weight: 700;
}
.view-description a:hover { text-decoration: underline; }

/* "Show more"/"Show less" toggle folding the rest of a free item's own
   description (hashtags, chapter timestamps, affiliate links, etc.) — see
   splitDescriptionForCollapse() in includes/content.php. Plain <details>/
   <summary> (same native, no-JS pattern already used for the admin Stats
   box above) rather than a JS-driven collapse. */
.view-description-more { margin-top: 6px; }

.view-description-more summary {
  cursor: pointer;
  color: var(--accent);
  font-size: 13px;
  font-weight: 600;
  list-style: none;
}
.view-description-more summary::-webkit-details-marker { display: none; }
.view-description-more summary::after { content: ' \25BE'; }
.view-description-more[open] summary::after { content: ' \25B4'; }

.view-description-more:not([open]) .view-description-more-label-open { display: none; }
.view-description-more[open] .view-description-more-label-closed { display: none; }

.view-description-more-body { margin-top: 8px; }

.view-section {
  margin-bottom: 40px;
}
/* The sidebar's own "Related"/"On Social" headings — the full-page "Related"
   grid further down uses <h2> instead (styled by .hub-head h2, shared with
   the homepage), precisely so this doesn't also catch the h3s inside
   .card-title for each related card in that section. */
.view-hero-social h3 {
  font-size: 15px;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  color: var(--ink-dim);
  margin-bottom: 14px;
}

/* Groups the sidebar's "Related" and "On Social" lists so they read as two
   distinct stacks rather than one run-on list — "Related" comes first (see
   view.php's own comment on $sidebarRelated) since it's tailored to this
   visitor, unlike the generic social feed below it. */
.sidebar-section:not(:last-child) { margin-bottom: 28px; }

/* Actual social posts (Twitter/Bluesky/TikTok/Instagram) next to the hero on
   wide screens — never content platforms, see the comment in view.php. */
.view-hero-social {
  flex: 1 1 260px;
  min-width: 220px;
  max-width: 340px;
}

.social-mention-list {
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.social-mention {
  display: flex;
  align-items: center;
  gap: 10px;
  background: var(--bg-raised);
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  padding: 10px 14px;
  font-size: 13.5px;
  transition: border-color var(--transition-fast);
}
.social-mention:hover { border-color: var(--accent); }
.social-mention .card-badge { position: static; }

.social-mention-thumb {
  width: 36px; height: 36px;
  flex-shrink: 0;
  border-radius: 8px;
  background: var(--bg-elevated) center / cover no-repeat;
}

.social-mention-body {
  display: flex;
  flex-direction: column;
  gap: 4px;
  min-width: 0;
}

.social-mention-body .price {
  color: var(--accent);
  font-weight: 600;
  font-size: 12px;
}

/* Featured related-merch card (view.php sidebar, "Merch" section) — reuses
   .panel-media/.panel-product-media/.panel-external-link/.panel-platform/
   .panel-title as-is (the same "here's one product, here's how to buy it"
   pieces the homepage's right-panel single-product view already defines),
   so the two surfaces never visually drift apart. This block is only the
   small vertical stack around those reused parts, plus spacing for the
   smaller-card list right underneath for any OTHER related merch item that
   didn't win the feature slot this pageview (see pickFeaturedMerchItem() in
   includes/content.php) — those reuse .social-mention-list/.social-mention
   as-is too, same as "Related"/"On Social" right below. */
.merch-feature-body {
  display: flex;
  flex-direction: column;
  gap: 10px;
  margin-top: 14px;
}
.merch-feature-body .price {
  color: var(--accent);
  font-weight: 600;
  font-size: 15px;
}
.merch-feature-body .panel-title { margin: 0; }

.merch-feature-others {
  margin-top: 16px;
}

/* ==========================================================================
   Shorts — vertical short-form content (YouTube Shorts, vertical Twitch
   clips; see content.is_short's own comment in migrations.php). Everything
   here is portrait-first: a 9:16 card, and a player that keeps that shape
   instead of letterboxing a vertical video into a 16:9 box.
   ========================================================================== */

.panel-media-vertical {
  max-width: 420px;
  margin: 0 auto;
}
.panel-media-vertical iframe {
  aspect-ratio: 9 / 16;
  height: auto;
}

/* Instagram's own /embed/ page (see socialPostEmbedSrc()'s comment in
   content.php) always renders more than just the post: profile header,
   media, caption/action icons — then a "profile card" teaser with more of
   that account's posts, and an always-empty "Add a comment" box, neither of
   which is useful inside a small embed. There's no API call available here
   (Instagram's real dimensions/oEmbed data needs a Meta app + access
   token — deliberately not used, see that same comment) to know exactly
   where the useful part ends, so this is a fixed-height guess rather than a
   measurement: the iframe is given a generous natural height (taller than
   it needs), and the wrapper clips to a shorter window that should land
   right after the like/comment/share icons, before the teaser content
   starts.
   The base rule is tuned for portrait content (confirmed live against a
   real reel — lands right after the icon row, barely any leftover blank
   space) and doubles as the fallback when a post's shape is unknown (see
   resolveThumbnailAspect() in content.php) — biased toward "some leftover
   blank space" rather than "risk clipping the icon row", since the former
   looks mildly wasteful and the latter looks broken. The landscape/square
   modifiers below are shorter, matched to how much less vertical room
   Instagram's own media area actually needs for those shapes — but unlike
   the portrait number, these haven't been confirmed against a live
   screenshot taken AFTER .panel-media-vertical stopped being applied to
   them (see social.php's $igAspect override — a landscape/square post no
   longer gets the narrow reel-width box, so its real content height at the
   new, wider box width may not match these yet). Nudge if seen live. */
.panel-media-instagram {
  height: 560px;
  overflow: hidden;
}
.panel-media-instagram iframe {
  aspect-ratio: auto;
  width: 100%;
  height: 760px;
  border: 0;
  display: block;
}
.panel-media-instagram-landscape {
  height: 460px;
}
.panel-media-instagram-landscape iframe {
  height: 640px;
}
.panel-media-instagram-square {
  height: 510px;
}
.panel-media-instagram-square iframe {
  height: 700px;
}

/* Wraps onto a second row rather than scrolling sideways — a horizontal
   scroller hides whatever doesn't fit behind a gesture people often don't
   realise is there, and these lists are short enough (4 by default) that
   wrapping costs almost nothing. */
.shorts-row {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
  gap: 14px;
}

.short-card {
  color: inherit;
  display: block;
}
.short-card-thumb {
  position: relative;
  aspect-ratio: 9 / 16;
  border-radius: var(--radius-sm);
  background: var(--bg-elevated) center / cover no-repeat;
  border: 1px solid var(--line);
  display: flex;
  align-items: flex-end;
  padding: 8px;
  transition: border-color var(--transition-fast), transform var(--transition-fast);
}
.short-card:hover .short-card-thumb {
  border-color: var(--accent);
  transform: translateY(-2px);
}
.short-card-title {
  margin-top: 8px;
  font-size: 12.5px;
  line-height: 1.35;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}
.short-card:hover .short-card-title { color: var(--accent); }

/* Homepage side panel. .dashboard-wrap is max-width:1280px centred, so the
   free margin this sits in only exists once the viewport is meaningfully
   wider than that — below 1700px there's nowhere to put it without eating
   into the grid, so it's hidden and .shorts-feed-card takes over inline
   instead (and above that width, those inline cards hide, so the same
   shorts are never shown twice). Sticky rather than fixed so it scrolls
   with the page and stops at the footer. */
.shorts-panel { display: none; }
/* flex, not block — this class always rides along with .card-item, which is
   itself a flex column; overriding that here would break the card's own
   layout at exactly the widths where these cards are the only way to see a
   short at all. */
.shorts-feed-card { display: flex; }

@media (min-width: 1700px) {
  .shorts-panel {
    display: block;
    /* Fixed, anchored just outside the centred 1280px container (640px to
       its own centre + a 24px gutter) — it floats alongside the feed and
       stays put while scrolling, which is what the mockup shows. */
    position: fixed;
    top: 116px;
    left: calc(50% + 664px);
    width: 288px;
    max-height: calc(100vh - 150px);
    overflow-y: auto;
  }
  .shorts-feed-card { display: none; }
}

.shorts-panel h4 {
  font-size: 13px;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--ink-dim);
  margin: 0 0 10px;
}
.shorts-panel-grid {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: 12px;
}


/* Both live on the same right edge — a product the visitor just clicked
   wins over an ambient shorts list (see RightPanel in assets/js/dashboard.js,
   which toggles this class on <body>). */
body.product-panel-open .shorts-panel { display: none; }

/* Manually-paired related content (view.php sidebar) — deliberately the
   same visual weight as the featured-merch card right above it, since an
   admin pairing two items by hand is the same kind of "this specifically
   is worth your attention" signal a featured product is. Reuses
   .panel-media/.panel-product-media/.merch-feature-body/.panel-title as-is;
   the only thing this adds is making the whole card one clickable block
   (the merch version doesn't, since its own CTA button is the link). */
.related-feature {
  display: block;
  color: inherit;
}
.related-feature .panel-media {
  transition: box-shadow var(--transition-fast);
}
.related-feature:hover .panel-media {
  box-shadow: 0 0 0 1px var(--accent), var(--shadow-glow);
}
.related-feature:hover .panel-title {
  color: var(--accent);
}
.related-feature .card-tags {
  margin-top: 2px;
}

/* Backdrop (mobile bottom-sheet only) */

.panel-backdrop {
  display: none;
  position: fixed;
  inset: 0;
  background: rgba(0,0,0,0.6);
  z-index: 40;
  opacity: 0;
  pointer-events: none;
  transition: opacity var(--transition-fast);
}
.panel-backdrop.is-visible { opacity: 1; pointer-events: auto; }

/* ==========================================================================
   Responsive
   ========================================================================== */

@media (max-width: 900px) {
  .dashboard-layout { flex-direction: column; }

  .view-hero-main, .view-hero-social { max-width: none; width: 100%; }

  .right-panel {
    position: fixed;
    left: 0; right: 0; bottom: 0;
    top: auto;
    width: auto;
    max-height: 82vh;
    border-radius: 20px 20px 0 0;
    transform: translateY(100%);
    transition: transform var(--transition-panel), opacity var(--transition-panel);
    z-index: 50;
  }

  .right-panel.is-open {
    flex-basis: auto;
    transform: translateY(0);
  }

  .panel-backdrop { display: block; }
}

@media (max-width: 480px) {
  .card-grid { grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); }
  .dashboard-wrap { padding: 20px 16px 48px; }
}


/* A word swapped out by the site-wide masking rules, shown ONLY to an admin
   browsing the public site (see maskWordsHtml() in includes/content.php).
   Marks what's been changed without altering the layout, and the real word
   sits in the title attribute for hover. A visitor's markup contains none of
   this — no class, no tooltip, nothing revealing. */
.masked-word {
  border-bottom: 1px dashed rgba(228,120,90,0.75);
  cursor: help;
}
/* A word masked to nothing: the original is shown struck through instead, so
   a deletion is visible rather than silently absent. */
.masked-word--removed {
  border-bottom: 0;
  text-decoration: line-through;
  opacity: 0.55;
}
