:root {
  --accent: #2e7d32;
  --accent-dark: #235e27;
  --accent-soft: #e7f3e8;
  --accent-contrast: #ffffff;
  --bg: #fafbfa;
  --surface: #ffffff;
  --text: #1a1f1b;
  --text-muted: #667066;
  --border: #e2e7e2;
  /* An approved Model credit's own gold/amber pair (.model-pill-approved,
     styles.css) — deliberately its own color, not --accent, so a starred
     Model chip reads as "reviewed identity" rather than blending into the
     same green/pink an ordinary tag pill already uses for both themes. */
  --model-gold: #7a5b00;
  --model-gold-soft: #fff3cd;
}

/* Dark palette — applies automatically when the visitor's OS/browser
   prefers dark (unless THEME=light pins it to light), and always when
   THEME=dark pins it explicitly. See lib/brand.js (getThemeAttr) and
   server.js, which set data-theme="light"/"dark" on <html> from the THEME
   env var, or leave it unset for "system" so this media query decides. */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --accent: #eb004e;
    --accent-dark: #bc003e;
    --accent-soft: #3a0f1e;
    --accent-contrast: #ffffff;
    --bg: #1a1216;
    --surface: #221a1e;
    --text: #f4ecee;
    --text-muted: #a89aa0;
    --border: #3a2d32;
    --model-gold: #ffd666;
    --model-gold-soft: #3d2f0a;
  }
}

:root[data-theme="dark"] {
  --accent: #eb004e;
  --accent-dark: #bc003e;
  --accent-soft: #3a0f1e;
  --accent-contrast: #ffffff;
  --bg: #1a1216;
  --surface: #221a1e;
  --text: #f4ecee;
  --text-muted: #a89aa0;
  --border: #3a2d32;
  --model-gold: #ffd666;
  --model-gold-soft: #3d2f0a;
}

* {
  box-sizing: border-box;
}

body {
  margin: 0;
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
  color: var(--text);
  background: var(--bg);
  line-height: 1.6;
}

a {
  color: var(--accent);
}

.container {
  max-width: 860px;
  margin: 0 auto;
  padding: 0 20px;
}

/* ---- site header ---- */

header.site-header {
  border-bottom: 1px solid var(--border);
  background: var(--surface);
}

header.site-header .container {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding-top: 16px;
  padding-bottom: 16px;
}

.brand {
  font-size: 17px;
  font-weight: 700;
  text-decoration: none;
  color: var(--text);
  display: flex;
  align-items: center;
  gap: 8px;
}

/* Home page only (index.html) — wraps .brand plus the optional BRAND_TAGLINE
   span so both stay one flex item within the header's own space-between
   layout (header.site-header .container), same as .brand alone was before.
   On every other page the tagline placeholder is never in the markup at
   all, so .brand-wrap there is just an unstyled, harmless wrapper. */
.brand-wrap {
  display: flex;
  align-items: baseline;
  gap: 10px;
  min-width: 0;
}

.brand-tagline {
  font-size: 13px;
  font-weight: 400;
  color: var(--text-muted);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

@media (max-width: 640px) {
  .brand-tagline {
    display: none;
  }
}

.site-nav {
  display: flex;
  align-items: center;
  gap: 18px;
  font-size: 14px;
}

.site-nav a {
  text-decoration: none;
  font-weight: 500;
}

/* :not(.btn) on both of these matters — a plain ".site-nav a" or
   ".site-nav a:hover" selector has higher specificity than ".btn-primary"/
   ".btn-primary:hover" (class+element beats class alone), so without this
   exclusion these would repaint a primary nav button's text back to the
   accent color — the same color as its own background (the "Create
   Account" button bug: accent-on-accent text, in both its default and
   hover states). Excluding .btn here lets button-classed nav links fall
   through entirely to the global .btn/.btn-primary rules below instead.
*/
.site-nav a:not(.btn) {
  color: var(--text-muted);
}

.site-nav a:not(.btn):hover {
  color: var(--accent);
}

/* The current page's own nav link — same accent color as the hover state
   above (so it reads as "you're already here" using the same visual
   language as "go here"), plus a heavier weight so it's still visibly
   distinct from an ordinary hover once the mouse moves away. Set by
   app.js's markActiveNavLink(), which runs on every page (part of
   initSharedNav). :not(.btn) for the same reason the hover rule above
   needs it — see that rule's own comment. */
.site-nav a.active:not(.btn) {
  color: var(--accent);
  font-weight: 600;
}

.site-nav a.btn:not(.btn-primary) {
  color: var(--accent);
}

/* .user-identity is an <a> even before a username is known (falls back to
   plain email text with its href removed — see dashboard.js renderIdentity)
   so it needs its own not-actually-a-link cursor, since :not(.btn) above
   already colors it like every other nav link regardless of href. */
.user-identity:not([href]) {
  cursor: default;
}

.user-identity:not([href]):hover {
  color: var(--text-muted);
}

/* The notification bell (public/app.js's hydrateNotifications()) — lives in
   every page's .site-nav (see the shared header markup each static page
   and lib/publicPages.js's own siteHeader() all carry), same nav-logged-in
   show/hide convention as the rest of that nav except on the handful of
   always-logged-in pages (account.html/dashboard.html/membership.html)
   where it's unconditionally visible, matching #userIdentity's own
   treatment on those same pages. position: relative anchors
   .notif-dropdown below to this wrapper, not to the page. */
.notif-bell-wrap {
  position: relative;
  display: flex;
  align-items: center;
}

.notif-bell {
  background: none;
  border: none;
  cursor: pointer;
  font-size: 16px;
  line-height: 1;
  color: var(--text-muted);
  padding: 4px;
  position: relative;
}

.notif-bell:hover {
  color: var(--accent);
}

/* Unread count — a small solid dot with the number in it, same
   "impossible to miss, doesn't need to be read to register" convention the
   Admin nav link's own "(n)" open-reports count uses, just a badge instead
   of inline text since this sits on an icon, not a text label. Hidden
   outright (not just showing "0") whenever there's nothing unread — see
   app.js's hydrateNotifications(). */
.notif-badge {
  position: absolute;
  top: -4px;
  right: -6px;
  min-width: 15px;
  height: 15px;
  padding: 0 3px;
  border-radius: 999px;
  background: var(--accent);
  color: var(--accent-contrast);
  font-size: 10px;
  font-weight: 700;
  line-height: 15px;
  text-align: center;
}

/* Right-aligned under the bell (not centered/left) so it never runs off the
   right edge of the viewport regardless of where the bell itself sits in a
   wide .site-nav — the same reasoning a browser's own address-bar menus
   right-align from whichever icon opened them. */
.notif-dropdown {
  position: absolute;
  top: calc(100% + 8px);
  right: 0;
  width: 320px;
  max-width: calc(100vw - 32px);
  max-height: 70vh;
  overflow-y: auto;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 10px;
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
  z-index: 30;
  padding: 6px 0;
}

.notif-dropdown-header {
  font-size: 13px;
  font-weight: 600;
  color: var(--text-muted);
  padding: 6px 14px 8px;
}

.notif-item {
  display: block;
  padding: 8px 14px;
  font-size: 13.5px;
  color: var(--text);
  text-decoration: none;
}

.notif-item:hover {
  background: var(--accent-soft);
}

/* An unread row's own left accent stripe — the badge count is already
   cleared the moment the dropdown opens (see app.js's own comment on why),
   so this is the one remaining "these are the ones you hadn't seen yet"
   signal once it has. */
.notif-item.unread {
  box-shadow: inset 3px 0 0 var(--accent);
}

.notif-item-time {
  display: block;
  font-size: 11.5px;
  color: var(--text-muted);
  margin-top: 2px;
}

.notif-empty {
  padding: 14px;
  font-size: 13px;
  color: var(--text-muted);
  text-align: center;
}

/* The user-icon nav dropdown (public/app.js's initUserMenu()) — Activity
   and Account, folded behind one icon the same way the bell above folds a
   whole notification list behind one icon. Same three-rule shape as
   .notif-bell-wrap/.notif-bell/.notif-dropdown just above (position:
   relative anchor, icon-only trigger button, absolutely-positioned
   right-aligned panel) — kept as its own small set of rules rather than
   reusing the .notif-* classes directly, since "this is the bell" and
   "this is the user menu" are different enough concepts that sharing a
   class name would just make each one's CSS harder to read in isolation,
   the same call every other pair of near-identical widgets in this
   stylesheet already makes (.view-toggle-btn vs .type-filter-btn, etc). */
.user-menu-wrap {
  position: relative;
  display: flex;
  align-items: center;
}

.user-menu-btn {
  background: none;
  border: none;
  cursor: pointer;
  font-size: 16px;
  line-height: 1;
  color: var(--text-muted);
  padding: 4px;
}

.user-menu-btn:hover {
  color: var(--accent);
}

/* Same "currently on this page" treatment .site-nav a.active gives a plain
   link — this is a <button>, not an <a>, so it needs its own rule rather
   than sharing that one (see app.js's markActiveNavLink() for when this
   gets added: visiting /activity or /account with the dropdown collapsed). */
.user-menu-btn.active {
  color: var(--accent);
}

.user-menu-dropdown {
  position: absolute;
  top: calc(100% + 8px);
  right: 0;
  min-width: 160px;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 10px;
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
  z-index: 30;
  padding: 6px 0;
  display: flex;
  flex-direction: column;
}

.user-menu-dropdown a {
  display: block;
  padding: 8px 14px;
  font-size: 13.5px;
  font-weight: 500;
  color: var(--text);
  text-decoration: none;
}

.user-menu-dropdown a:hover {
  background: var(--accent-soft);
}

/* Same reasoning as .site-nav a.active:not(.btn) above — highlights
   whichever of the two links inside this open dropdown matches the current
   page (app.js's markActiveNavLink()). No :not(.btn) needed here the way
   that rule needs it — neither link in this dropdown is ever styled
   .btn. */
.user-menu-dropdown a.active {
  color: var(--accent);
  font-weight: 600;
}

/* Hamburger toggle for .site-nav — hidden by default (desktop/tablet width,
   where .site-nav already lays out fine as an inline row) and only shown
   below the breakpoint in the media query further down, which is also where
   .site-nav itself switches from "always visible inline row" to "collapsed
   unless toggled open". app.js's initNavToggle() wires the click. */
.nav-toggle {
  display: none;
  align-items: center;
  justify-content: center;
  width: 36px;
  height: 36px;
  border-radius: 8px;
  border: 1px solid var(--border);
  background: var(--surface);
  color: var(--text);
  font-size: 18px;
  line-height: 1;
  cursor: pointer;
  padding: 0;
}

.nav-toggle:hover {
  border-color: var(--accent);
  color: var(--accent);
}

@media (max-width: 640px) {
  .nav-toggle {
    display: inline-flex;
  }

  /* flex-wrap lets .site-nav (100% width below) drop to its own row under
     the brand+toggle row instead of needing separate absolute positioning —
     there's no page content beside the header for it to overlap either
     way. */
  header.site-header .container {
    flex-wrap: wrap;
  }

  .site-nav {
    display: none;
    width: 100%;
    flex-direction: column;
    align-items: stretch;
    gap: 8px;
    margin-top: 14px;
    padding-top: 14px;
    border-top: 1px solid var(--border);
  }

  /* Toggled by app.js's initNavToggle(), not a CSS-only checkbox hack —
     keeping it a plain class match app.js's other nav-state classes
     (nav-logged-out/nav-logged-in) use for the same "[hidden] is the only
     other thing already fighting .btn's `display` here" reason documented
     below. */
  .site-nav.nav-open {
    display: flex;
  }

  /* :not(.btn) so this doesn't fight .btn's own (larger, pill-shaped)
     padding — same reasoning as the a:not(.btn) color rule above this
     media query. */
  .site-nav a:not(.btn) {
    padding: 8px 4px;
  }

  .site-nav a.btn {
    width: 100%;
  }
}

/* The [hidden] attribute is how hydrateSiteNav() (app.js) toggles
   .nav-logged-out/.nav-logged-in — it relies on the browser's default
   `[hidden] { display: none }` UA rule. That default loses to ANY author
   rule that sets `display` on the same element regardless of specificity
   (author styles beat UA styles at equal importance), so `.btn`'s own
   `display: inline-flex` below was silently overriding it — a button-styled
   nav link like "Create Account" (.btn.btn-primary.nav-logged-out) stayed
   visible even while actually hidden="", because the .btn rule, not the
   browser default, was what actually painted it. This reinstates `hidden`
   as authoritative for every element, button-styled or not. */
[hidden] {
  display: none !important;
}

/* ---- buttons ---- */

.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 6px;
  border-radius: 8px;
  border: 1px solid var(--border);
  background: var(--surface);
  color: var(--text);
  font-size: 14px;
  font-weight: 600;
  padding: 10px 18px;
  cursor: pointer;
  text-decoration: none;
  font-family: inherit;
}

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

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

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

.btn-block {
  width: 100%;
}

/* ---- hero / landing ---- */

.hero {
  padding: 72px 0 56px;
}

.hero h1 {
  font-size: 38px;
  margin: 0 0 14px;
  max-width: 16ch;
}

.hero p.lede {
  font-size: 17px;
  color: var(--text-muted);
  max-width: 52ch;
  margin: 0 0 28px;
}

.hero-actions {
  display: flex;
  gap: 12px;
  margin-bottom: 8px;
}

.how-it-works {
  padding: 8px 0 64px;
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 20px;
}

@media (max-width: 680px) {
  .how-it-works { grid-template-columns: 1fr; }
}

.how-step {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 12px;
  padding: 20px;
}

.how-step .step-num {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 26px;
  height: 26px;
  border-radius: 999px;
  background: var(--accent-soft);
  color: var(--accent);
  font-size: 13px;
  font-weight: 700;
  margin-bottom: 12px;
}

.how-step h3 {
  font-size: 15px;
  margin: 0 0 6px;
}

.how-step p {
  font-size: 13.5px;
  color: var(--text-muted);
  margin: 0;
}

.footnote {
  color: var(--text-muted);
  font-size: 13.5px;
  padding-bottom: 60px;
}

/* ---- support page (/support) ---- */

.support-section {
  max-width: 680px;
  margin: 0 0 36px;
}

.support-section h2 {
  font-size: 18px;
  margin: 0 0 12px;
  padding-bottom: 8px;
  border-bottom: 1px solid var(--border);
}

.support-section h3 {
  font-size: 14.5px;
  margin: 20px 0 6px;
}

.support-section p,
.support-section li {
  font-size: 14px;
  color: var(--text-muted);
}

.support-section ul {
  padding-left: 20px;
  margin: 0;
}

.support-section li {
  margin-bottom: 8px;
}

/* ---- site footer ---- */

/* Shared across every regular-chrome page (see lib/brand.js's
   getFooterHtml(), spliced in via server.js's {{SITE_FOOTER}} and
   lib/publicPages.js's layout()) — NOT shown on privacy-policy.html or
   terms.html, which are self-contained documents with their own
   single-purpose footer already, or on frame.html, a full-viewport iframe
   viewer with no room for one. */
.site-footer {
  margin-top: 48px;
  border-top: 1px solid var(--border);
  padding: 28px 0 36px;
}

.site-footer-inner {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  /* flex-start, not space-between — space-between was spreading the row's
     two groups (nav links, copyright) to the far left and far right edges
     even with only 14px of actual content between them once the window was
     wide, which read as the copyright line floating off on its own rather
     than being part of the same footer. flex-start keeps everything
     clustered together, left-aligned, the same direction .footer-copyright
     itself already reads in. */
  justify-content: flex-start;
  gap: 14px;
}

/* Three stacked rows — social icon(s), then nav links, then copyright —
   each on its own line (flex: 1 0 100% forces a break both before and after
   a 100%-basis item), in that order both visually (order: 0/1/2) and in the
   markup itself (lib/brand.js's getFooterHtml). Used to be two rows (links
   + social sharing row 1, copyright alone on row 2) — split apart because a
   lone social icon sharing a row with the text links read as visually
   unbalanced/cluttered; three clean rows reads calmer. */
.footer-social {
  display: flex;
  gap: 12px;
  flex: 1 0 100%;
  order: 0;
}

.footer-social-link {
  display: inline-flex;
  align-items: center;
  color: var(--text-muted);
  text-decoration: none;
  font-size: 16px;
  line-height: 1;
}

.footer-social-link:hover {
  color: var(--accent);
}

/* Only Tumblr's own icon is an inline SVG (X's is a plain Unicode glyph,
   which needs none of this) — stroke=currentColor already makes it follow
   .footer-social-link's own color/hover color above, this just keeps its
   box the same 16px both link types otherwise share so the two sit on the
   same visual baseline in the row instead of the SVG's own default
   intrinsic sizing making it a slightly different size. */
.footer-social-link svg {
  width: 16px;
  height: 16px;
}

.footer-links {
  display: flex;
  flex-wrap: wrap;
  gap: 18px;
  flex: 1 0 100%;
  order: 1;
}

.footer-links a {
  color: var(--text-muted);
  text-decoration: none;
  font-size: 13.5px;
}

.footer-links a:hover {
  color: var(--text);
  text-decoration: underline;
}

.footer-copyright {
  color: var(--text-muted);
  font-size: 12.5px;
  margin: 0;
  flex: 1 0 100%;
  order: 2;
}

/* ---- auth forms ---- */

.auth-wrap {
  max-width: 380px;
  margin: 64px auto;
  padding: 0 20px;
}

.auth-card {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 14px;
  padding: 28px;
}

.auth-card h1 {
  font-size: 21px;
  margin: 0 0 6px;
}

.auth-card .auth-sub {
  color: var(--text-muted);
  font-size: 13.5px;
  margin: 0 0 22px;
}

/* ---- age gate ---- */
/* Reuses .auth-wrap/.auth-card's centered-card layout (public/age-gate.html)
   rather than a new one — same page shape as login/register, just with two
   stacked buttons instead of a form. */

.age-gate-brand {
  font-weight: 700;
  font-size: 16px;
  margin-bottom: 14px;
}

.age-gate-actions {
  display: flex;
  flex-direction: column;
  gap: 10px;
}

.field {
  margin-bottom: 14px;
}

.field label {
  display: block;
  font-size: 12.5px;
  font-weight: 600;
  color: var(--text-muted);
  margin-bottom: 5px;
}

.field input,
.field textarea {
  width: 100%;
  padding: 9px 11px;
  border: 1px solid var(--border);
  border-radius: 8px;
  font-size: 14px;
  font-family: inherit;
}

.field input:focus,
.field textarea:focus {
  outline: none;
  border-color: var(--accent);
}

/* claim-account.html's own read-only "Email" field (the only disabled
   .field input anywhere on this site) — .field input above never sets its
   own background/color, relying on the browser's native input rendering
   instead, which is normally fine (a real white box, real black text) but
   goes muted-grey-on-muted-grey for a DISABLED field once either this
   site's own dark theme or the browser/OS's own forced-dark rendering gets
   involved, since neither of those touches a native disabled input's
   colors the same predictable way they touch this page's own CSS. Explicit
   theme colors here read correctly in both themes regardless. */
.field input:disabled {
  background: var(--bg);
  color: var(--text-muted);
  cursor: not-allowed;
}

/* Wraps a password <input> once app.js's wirePasswordToggle() runs — plain
   .field's own margin/label styling above is untouched, this just gives
   the input a positioning context for the icon button below. */
.password-field {
  position: relative;
}

/* Same box as any other .field input (the selector below matches that
   rule's own specificity, class+type+class vs. class+type, so this wins
   without !important) — just reserves room on the right so typed text
   never renders under the icon button. */
.field input.password-field-input {
  padding-right: 40px;
}

/* Deliberately NOT var(--text-muted)/var(--border) here, unlike most icon
   buttons elsewhere in this app — .field input above has no background/
   color rules of its own, so every text field on this site (this one
   included) renders with the browser's plain native white box regardless
   of site theme; --text-muted flips to a PALE color in dark mode (meant
   for reading against dark surfaces, not a white input box), which made
   this icon nearly invisible sitting on top of one. Fixed, theme-independent
   colors instead — chosen to read clearly on the light input box this
   button always sits on, in either theme. */
.password-toggle-btn {
  position: absolute;
  top: 50%;
  right: 4px;
  transform: translateY(-50%);
  width: 30px;
  height: 30px;
  padding: 0;
  border: none;
  border-radius: 6px;
  background: none;
  color: #667066;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
}

.password-toggle-btn:hover {
  color: #1a1f1b;
  background: #e2e7e2;
}

.password-toggle-btn svg {
  width: 18px;
  height: 18px;
}

.field-checkbox {
  margin-bottom: 16px;
}

/* Scoped as ".field-checkbox .checkbox-label" rather than bare
   ".checkbox-label" — this label sits inside a ".field" div (for the
   auth-form's existing field spacing), and plain ".field label" (a class +
   an element = higher specificity than a single class alone) would
   otherwise win and silently override this rule's display/color/weight,
   same class of bug as the nav button fix above. */
.field-checkbox .checkbox-label {
  display: flex;
  align-items: flex-start;
  gap: 8px;
  font-size: 13px;
  font-weight: 500;
  color: var(--text);
  cursor: pointer;
}

.field-checkbox .checkbox-label input[type="checkbox"] {
  width: auto;
  margin-top: 2px;
  flex: 0 0 auto;
}

.form-error {
  color: #c62828;
  font-size: 13px;
  min-height: 18px;
  margin: -4px 0 10px;
}

.auth-switch {
  text-align: center;
  font-size: 13.5px;
  color: var(--text-muted);
  margin-top: 16px;
}

/* Right-aligned under the password field on login.html, and reused as-is
   by forgot-password.html/reset-password.html/verify-email.html for their
   own "back to log in" links — same muted, small-print treatment as
   .auth-switch above, just right-aligned instead of centered since this one
   sits directly under a field rather than below the whole form. */
.auth-forgot {
  text-align: right;
  font-size: 13px;
  margin: -8px 0 14px;
}

/* forgot-password.html/reset-password.html/verify-email.html's success
   state — same red-for-error convention .form-error already uses, just
   green for "this worked." Not reused for .form-error itself since an
   error and a success message are never shown at the same time on these
   pages (see each page's own script), so there's no need for one class
   that switches color. */
.auth-success {
  color: #2e7d32;
  font-size: 13.5px;
  margin: 0 0 14px;
}

/* claim-account.html's own small print under its (optional, pre-filled)
   username field — same size/color/spacing as .panel .panel-hint elsewhere,
   just scoped to .field since this page's fields aren't inside a .panel. */
.field-hint {
  font-size: 13px;
  color: var(--text-muted);
  margin: 4px 0 0;
}

/* claim-account.html's migration opt-in step — its two buttons
   ("Yes, bring them over" / "No, start fresh") side by side rather than
   stacked, since neither is a full-width primary-only action the way every
   other .auth-card's single submit button is. */
.auth-actions {
  display: flex;
  gap: 10px;
  margin-top: 4px;
}
.auth-actions .btn {
  flex: 1;
}

/* ---- dashboard ---- */

.dash-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 28px 0 20px;
}

.dash-header h1 {
  font-size: 22px;
  margin: 0;
}

/* Dashboard's own two section headers (public/dashboard.html — "Your
   collections" and "Your bookmarks") reuse this same flex/space-between
   wrapper with an <h2> + a small link instead of the page's own <h1>
   above — sized down a step since each is a section heading within the
   page, not the page's own title. */
.dash-header h2 {
  font-size: 18px;
  margin: 0;
}

/* The small "Manage →"/"Browse →" links sitting in a .dash-header next to
   an <h2> above (or, for the Collection detail page's own "Manage" link —
   lib/publicPages.js's renderCollectionPage — an <h1>) — plain text links
   rather than .btn's bordered pill, so a heading stays a single compact
   line instead of a heading-sized button competing with it for space. */
.dash-header-link {
  font-size: 13.5px;
  font-weight: 600;
  color: var(--accent);
  text-decoration: none;
  white-space: nowrap;
}

.dash-header-link:hover {
  text-decoration: underline;
}

/* Dashboard's own collapsible section headings ("Your collections"/"Your
   bookmarks" — public/dashboard.html/.js's wireSectionCollapse). The whole
   row is clickable (role="button"), not just the chevron, so it's a big
   enough target to reasonably click/tap — the nested .dash-header-link
   above stays its own separate click target underneath (dashboard.js's own
   click handler excludes it from toggling), so "Manage →"/"Browse →" still
   just navigates. */
.dash-section-header {
  cursor: pointer;
  user-select: none;
}

.dash-section-header:hover h2 {
  color: var(--accent);
}

.dash-section-chevron {
  display: inline-block;
  font-size: 12px;
  color: var(--text-muted);
  margin-left: 2px;
  transition: transform 0.15s ease;
}

/* Collapsed state points the chevron sideways (▾ rotated to ▸) rather than
   swapping the glyph itself — same "one element, animate a property" the
   Mask Content toggle's own eye icon (public/app.js) already keeps simpler
   than a text/emoji swap would be. */
.dash-section-header.collapsed .dash-section-chevron {
  transform: rotate(-90deg);
}

.dash-user {
  font-size: 13.5px;
  color: var(--text-muted);
  display: flex;
  align-items: center;
  gap: 12px;
}

.panel {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 12px;
  padding: 18px 20px;
  margin-bottom: 22px;
}

.panel h2 {
  font-size: 15px;
  margin: 0 0 6px;
}

.panel .panel-hint {
  font-size: 13px;
  color: var(--text-muted);
  margin: 0 0 14px;
}

.banner-panel {
  border-color: var(--accent);
  background: var(--accent-soft);
}

/* Dashboard's three headline stat boxes (Views/Bookmarks/Comments — see
   dashboard.html/.js). A plain 3-up flex row of .stat-box cards, each
   built from the same background/border/radius as .panel above rather than
   its own new surface treatment, so they read as part of the same page
   rather than a visually distinct new component. Equal-width via flex: 1
   so the row always fills the container edge-to-edge regardless of digit
   count in any one box. */
.stats-row {
  display: flex;
  gap: 14px;
  margin-bottom: 22px;
}

.stat-box {
  flex: 1;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 12px;
  padding: 16px 18px;
  display: flex;
  flex-direction: column;
  align-items: center;
  text-align: center;
  gap: 2px;
}

.stat-value {
  font-size: 26px;
  font-weight: 700;
  line-height: 1.2;
  color: var(--text);
}

.stat-label {
  font-size: 12.5px;
  color: var(--text-muted);
  text-transform: uppercase;
  letter-spacing: 0.04em;
}

/* Narrow phone screens: three boxes at the desktop's roomier padding get
   cramped fast, so this drops to a single column instead of shrinking each
   box's padding/font down to illegible sizes — same breakpoint the rest of
   this app's mobile rules already use (see browse.css's own 640px queries). */
@media (max-width: 640px) {
  .stats-row {
    flex-direction: column;
  }
}

/* Admin page — one .admin-slot-card (a .panel) per registered ad slot, see
   public/admin.js's slotCardHtml(). */
.admin-embed-input {
  width: 100%;
  box-sizing: border-box;
  padding: 10px 12px;
  border: 1px solid var(--border);
  border-radius: 8px;
  background: var(--surface);
  color: var(--text);
  font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  resize: vertical;
  margin-bottom: 10px;
}

.admin-embed-input:focus {
  outline: none;
  border-color: var(--accent);
}

.admin-slot-toggle {
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: 13.5px;
  font-weight: 500;
  cursor: pointer;
  margin-bottom: 14px;
}

.admin-slot-toggle input[type="checkbox"] {
  width: auto;
}

.admin-slot-row {
  display: flex;
  align-items: center;
  gap: 12px;
}

.admin-slot-status {
  font-size: 13px;
}

.admin-slot-status-ok {
  color: var(--accent);
}

.admin-slot-status-error {
  color: #c62828;
}

.admin-slot-updated {
  font-size: 12px;
  color: var(--text-muted);
  margin: 10px 0 0;
}

/* Admin page — the ad-slot card's Fallback URL field (public/admin.js's
   slotCardHtml, GET /api/public/fallback.js in routes/public.js). Sits
   below .admin-slot-updated with its own top border since it's a separate,
   read-only, nothing-to-save concern from the config fields above it in
   the same card. */
.admin-slot-fallback {
  margin-top: 14px;
  padding-top: 14px;
  border-top: 1px solid var(--border);
}

.admin-slot-fallback-label {
  margin: 0 0 8px;
  font-size: 13.5px;
  font-weight: 500;
}

.admin-slot-fallback-row {
  display: flex;
  gap: 8px;
}

.admin-slot-fallback-input {
  flex: 1;
  min-width: 0;
  box-sizing: border-box;
  padding: 8px 10px;
  border: 1px solid var(--border);
  border-radius: 8px;
  background: var(--surface);
  color: var(--text-muted);
  font: 12.5px/1.4 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}

.admin-slot-fallback-input:focus {
  outline: none;
  border-color: var(--accent);
  color: var(--text);
}

/* Admin page — the ad-slot card's "Custom code" vs "Ad Rotator" radio pair
   (public/admin.js's slotCardHtml) and the size dropdown that appears when
   Rotator is picked. The two radio labels reuse .admin-slot-toggle for
   their own icon+text+cursor styling (same class the plain Enabled/Full
   width checkboxes above use) — this wraps THEM in a row together instead
   of the vertical stack .admin-slot-toggle's own display:flex would give
   two of them back to back with nothing else grouping them. */
.admin-slot-mode {
  display: flex;
  flex-wrap: wrap;
  gap: 8px 20px;
  margin-bottom: 14px;
}

.admin-slot-mode .admin-slot-toggle {
  margin-bottom: 0;
}

.admin-slot-mode input[type="radio"] {
  width: auto;
}

.admin-slot-embed-field,
.admin-slot-rotator-field {
  margin-bottom: 0;
}

.admin-slot-rotator-field select {
  width: 100%;
  box-sizing: border-box;
  padding: 9px 12px;
  border: 1px solid var(--border);
  border-radius: 8px;
  background: var(--surface);
  color: var(--text);
  font-size: 14px;
  margin-bottom: 10px;
}

.admin-slot-rotator-field select:focus {
  outline: none;
  border-color: var(--accent);
}

/* Admin page — the Ad Rotator tab, one .admin-creative-card (a .panel) per
   creative in a size's pool (public/admin.js's creativeCardHtml). Reuses
   .admin-embed-input/.admin-slot-toggle/.admin-slot-row/.admin-slot-status
   from the ad-slot cards above rather than its own copies — same textarea,
   Enabled checkbox, and Save-button-plus-status-message shape, just one
   level smaller (a creative, not a whole slot). margin-bottom: 0 on the
   card itself hands all the spacing between cards to the list's own gap
   instead of doubling up with .panel's normal 22px. */
.admin-creatives-list {
  display: flex;
  flex-direction: column;
  gap: 14px;
  margin-bottom: 14px;
}

.admin-creative-card {
  margin-bottom: 0;
}

/* Admin page — the Users panel's search box, styled the same as every
   other page's own search input (.toolbar input[type="text"] above) even
   though this one isn't inside a .toolbar — the panel here only ever has
   this one control, so the flex row .toolbar exists for isn't needed. */
#userSearch {
  width: 100%;
  box-sizing: border-box;
  padding: 9px 12px;
  border: 1px solid var(--border);
  border-radius: 8px;
  background: var(--surface);
  color: var(--text);
  font-size: 14px;
}

#userSearch:focus {
  outline: none;
  border-color: var(--accent);
}

/* Admin page — the Featured Collections panel's search box (#featuredSearch)
   — same exact rule as #userSearch above (this app's established pattern
   for an admin panel's own single search input: match the main site's
   input styling by copying the rule under this control's own id, per the
   comment on #userSearch). Previously had no CSS at all, so it rendered as
   an unstyled native browser input, out of place next to the rest of the
   admin UI's styled controls. */
#featuredSearch {
  width: 100%;
  box-sizing: border-box;
  padding: 9px 12px;
  border: 1px solid var(--border);
  border-radius: 8px;
  background: var(--surface);
  color: var(--text);
  font-size: 14px;
}

#featuredSearch:focus {
  outline: none;
  border-color: var(--accent);
}

/* Admin page — the Tags panel's "block a new tag" form (#addBlockedTagForm)
   — a plain text input plus its Add-shaped submit button on one row, same
   general shape as #userSearch above just with a button attached rather
   than filtering as-you-type. Previously only had the flex-layout rule
   below (sizing within the row) with no visual box styling at all, so
   #addBlockedTagInput rendered as an unstyled native input — the second
   rule here gives it the same border/padding/radius/background/focus
   treatment as #userSearch and every other text input on the main site. */
.admin-inline-form {
  display: flex;
  gap: 8px;
  align-items: center;
}

.admin-inline-form input[type="text"] {
  flex: 1 1 auto;
  min-width: 0;
  box-sizing: border-box;
  padding: 9px 12px;
  border: 1px solid var(--border);
  border-radius: 8px;
  background: var(--surface);
  color: var(--text);
  font-size: 14px;
}

.admin-inline-form input[type="text"]:focus {
  outline: none;
  border-color: var(--accent);
}

/* One .admin-blocked-tag row per lib/blockedTags.js entry — same pill shape
   as .tag-pill below (background/radius/padding/font-size), so a blocked
   tag still reads visually as "this is a tag" the same way it does
   everywhere else on the site, just as its own plain <span> (not a link —
   nothing to click through to here) with a small × remove button riding
   along inside the same pill instead of a separate Delete button off to the
   side, which there's nothing else on this row to separate it from. */
.admin-blocked-tag {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  background: var(--accent-soft);
  color: var(--accent-dark);
  border-radius: 999px;
  padding: 3px 6px 3px 10px;
  font-size: 12px;
  font-weight: 600;
  margin: 2px 8px 8px 0;
}

.admin-blocked-tag-remove {
  background: none;
  border: none;
  cursor: pointer;
  color: var(--text-muted);
  font-size: 1.1em;
  line-height: 1;
  padding: 0;
}

.admin-blocked-tag-remove:hover {
  color: var(--text);
}

/* Admin page — the Users panel, one .admin-user-card (a .panel) per
   matching account — see public/admin.js's userCardHtml(). Same
   .admin-slot-toggle class as the ad-slot cards' own Enabled checkbox
   (above) for the "Ads enabled" one here, just laid out on the opposite
   end of the row instead of stacked underneath, since there's nothing else
   on this card to stack it under. */
.admin-user-card {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
  flex-wrap: wrap;
  padding: 14px 18px;
}

.admin-user-info {
  display: flex;
  flex-direction: column;
  gap: 2px;
}

.admin-user-info .text-muted {
  font-size: 13px;
}

/* A Partner's own optional `title` (lib/partners.js, migration 043) shown
   as a third muted line under its name/URL — italic distinguishes it at a
   glance from the plain URL line just above, which shares the same
   .text-muted base size/color. */
.admin-partner-title {
  font-style: italic;
}

/* Admin > Users' confirmed/unconfirmed marker next to each account's email
   (public/admin.js's emailStatusIconHtml) — same small circular
   Unicode-glyph badge shape as .member-check further down this file (plain
   circle + character, not an SVG, for the same "trivial to reproduce from
   plain template-string HTML" reason that one's own comment gives), just
   its own two-state color/glyph pair instead of that one's single pink
   checkmark. */
.email-status-icon {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 14px;
  height: 14px;
  margin-left: 5px;
  border-radius: 50%;
  color: #fff;
  font-size: 9px;
  line-height: 1;
  vertical-align: middle;
  flex-shrink: 0;
}

.email-status-icon.confirmed {
  background: #2e7d32;
}

.email-status-icon.unconfirmed {
  background: #9e9e9e;
}

/* Admin > Users' read-only "Admin" badge (public/admin.js's userCardHtml)
   — same pill shape as .collection-badge elsewhere on this page, its own
   class since neither of that one's public/private colors fit "this
   account itself is an admin." */
.admin-user-badge {
  display: inline-block;
  margin-left: 8px;
  font-size: 11px;
  font-weight: 600;
  padding: 2px 8px;
  border-radius: 999px;
  color: var(--accent);
  background: var(--accent-soft);
  vertical-align: middle;
}

.admin-user-card .admin-slot-toggle {
  margin-bottom: 0;
  white-space: nowrap;
}

/* Paid Membership's Grant/Revoke row (public/admin.js's membershipBlockHtml)
   — flex-basis: 100% drops it onto its own line below .admin-user-info/
   .admin-slot-toggle within the card's own flex-wrap row above, rather than
   fighting those two for space on the same line. */
.admin-user-membership {
  display: flex;
  align-items: center;
  gap: 10px;
  font-size: 13px;
  flex-basis: 100%;
}

/* Admin page — the Legacy URLs panel, one .admin-legacy-url-card (a .panel)
   per distinct path+query, built by public/admin.js's
   legacyUrlHitCardHtml(). Same flex-row shape as .admin-user-card above,
   just with a hit-count badge in place of that card's toggle/actions. */
.admin-legacy-url-card {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
  flex-wrap: wrap;
  padding: 14px 18px;
}

/* Admin > Partners, one .admin-partner-row (a .panel) per lib/partners.js
   entry — public/admin.js's partnerHtml(). Same flex-row shape as
   .admin-user-card/.admin-legacy-url-card above (name+URL grouped on the
   left via the shared .admin-user-info, a single action button on the
   right), just with a plain Remove button instead of a toggle. */
.admin-partner-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
  flex-wrap: wrap;
  padding: 14px 18px;
  margin-bottom: 10px;
}

/* Admin > Legacy URLs, one .admin-legacy-redirect-row (a .panel) per
   lib/legacyUrlRedirects.js entry — public/admin.js's legacyRedirectHtml().
   Same flex-row shape as .admin-legacy-url-card/.admin-partner-row above
   (old path + arrow + new path grouped on the left via the shared
   .admin-legacy-url-info, a single Remove button on the right). */
.admin-legacy-redirect-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
  flex-wrap: wrap;
  padding: 14px 18px;
}

/* Admin > Models, one .admin-model-row (a .panel) per lib/models.js entry —
   public/admin.js's modelCardHtml(). Same flex-row shape as
   .admin-legacy-redirect-row just above (reuses its own
   .admin-legacy-url-info for the name+meta grouping on the left), but with
   TWO actions on the right (Approve/Unapprove and Remove, not just one) —
   .admin-model-actions groups those so they wrap together as a pair rather
   than independently if the row gets too narrow. */
.admin-model-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
  flex-wrap: wrap;
  padding: 14px 18px;
}

.admin-model-actions {
  display: flex;
  gap: 8px;
  flex-shrink: 0;
}

/* Admin > Sites, one .admin-site-override-row (a .panel) per
   lib/siteNameOverrides.js entry — public/admin.js's siteOverrideHtml().
   Same flex-row shape as .admin-partner-row above (reuses its own
   .admin-user-info for the name+domain grouping on the left, a single
   Remove button on the right) — no dedicated rules needed beyond this
   selector alias, kept for the same "each tab names its own row class"
   convention every other Admin tab already follows. */
.admin-site-override-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
  flex-wrap: wrap;
  padding: 14px 18px;
  margin-bottom: 10px;
}

/* Admin > Legacy Users (Round 9) — the Import/Send-all buttons sit side by
   side above the row list, same reasoning as .auth-actions' two buttons on
   claim-account.html. */
.admin-legacy-users-actions {
  display: flex;
  gap: 10px;
  margin: 4px 0 10px;
}

/* The inline typed-confirmation step "Send to all pending" reveals —
   .admin-inline-form (above) supplies its input+button row; this just adds
   the same .panel-like soft inset the rest of this page uses to set an
   in-progress, not-yet-committed action visually apart from the page around
   it, without introducing an actual nested .panel (which would double up
   the card border already around the whole section). */
.admin-legacy-users-confirm {
  background: var(--bg);
  border: 1px solid var(--border);
  border-radius: 10px;
  padding: 14px 16px;
  margin: 4px 0 10px;
}

.admin-legacy-users-confirm p {
  margin: 0 0 10px;
  font-size: 13.5px;
}

/* One .admin-legacy-user-row (a .panel) per legacy_users row — same
   flex-row shape as .admin-site-override-row just above (reuses
   .admin-user-info for the email/username grouping), plus its own status
   badge and a small inline "send test" form that doesn't need its own
   dedicated row-width treatment since it's just one text input + one
   button, same size class as .admin-inline-form's. */
.admin-legacy-user-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
  flex-wrap: wrap;
  padding: 14px 18px;
  margin-bottom: 10px;
}

.admin-legacy-user-counts {
  flex-shrink: 0;
  font-size: 13px;
  color: var(--text-muted);
}

.admin-legacy-user-status {
  display: inline-block;
  font-size: 11px;
  font-weight: 600;
  padding: 2px 8px;
  border-radius: 999px;
  color: var(--text-muted);
  background: var(--bg);
  border: 1px solid var(--border);
  white-space: nowrap;
}

.admin-legacy-user-status-migrated {
  color: #2e7d32;
  background: rgba(46, 125, 50, 0.12);
  border-color: transparent;
}

.admin-legacy-user-status-claimed {
  color: var(--accent);
  background: var(--accent-soft);
  border-color: transparent;
}

.admin-legacy-user-send-test {
  display: flex;
  gap: 6px;
  align-items: center;
  flex-shrink: 0;
}

.admin-legacy-user-send-test input[type="email"] {
  width: 200px;
  padding: 7px 10px;
  border: 1px solid var(--border);
  border-radius: 8px;
  background: var(--surface);
  color: var(--text);
  font-size: 13px;
}

.admin-legacy-url-info {
  display: flex;
  flex-direction: column;
  gap: 2px;
  min-width: 0;
  word-break: break-all;
}

.admin-legacy-url-info .text-muted {
  font-size: 13px;
  word-break: break-word;
}

.admin-legacy-url-count {
  flex-shrink: 0;
  font-size: 13px;
  font-weight: 600;
  color: var(--text-muted);
  background: var(--bg);
  border: 1px solid var(--border);
  border-radius: 999px;
  padding: 4px 12px;
  white-space: nowrap;
}

.admin-user-membership .btn {
  padding: 4px 10px;
  font-size: 13px;
}

/* Account Settings page (public/account.html/js). .admin-slot-toggle
   (above) is reused as-is for the Public profile checkbox; these are the
   two pieces that page needs of its own. */
.account-radio-group {
  display: flex;
  flex-direction: column;
  gap: 8px;
  margin-bottom: 6px;
}

.account-radio {
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: 13.5px;
  font-weight: 500;
  cursor: pointer;
}

.account-radio input[type="radio"] {
  width: auto;
}

.account-setting-status {
  font-size: 13px;
  min-height: 16px;
}

.account-setting-status-ok {
  color: var(--accent);
}

.account-setting-status-error {
  color: #c62828;
}

/* Account Settings' Email panel — the Verified/Not verified badge next to
   the address itself. Same warm-red-for-a-problem convention
   .row-dead-link already uses for a broken bookmark link, and the same
   accent-green .auth-success uses for "this worked" — reused colors, not
   new ones, so this doesn't introduce a third meaning for either hue. */
.verified-badge-yes {
  color: #2e7d32;
  font-weight: 600;
}

.verified-badge-no {
  color: #c62828;
  font-weight: 600;
}

/* Paid Membership's pink checkmark — rendered next to a username wherever
   it appears as a link to that account (profile page heading, bookmark
   listing bylines, the bookmark permalink page's byline, comment authors,
   and the signed-in visitor's own nav identity), backed by every public
   API response's `is_member`/`isMember` field (see lib/publicBookmarks.js's
   PUBLIC_FIELDS and lib/comments.js's IS_MEMBER_SQL, both intentionally
   mirroring lib/membership.js's isActive()). Deliberately a fixed pink,
   independent of --accent/THEME_COLOR — the whole point (X.com-style) is a
   consistent, recognizable "this account is verified/paying" color that
   doesn't shift with a white-labeled deployment's own branding. Built with
   a plain circle + Unicode checkmark rather than an SVG so this same tiny
   snippet is trivial to reproduce identically from both server-rendered
   HTML (lib/publicPages.js) and client-side script (public/app.js,
   public/browse.js, public/comments.js) without sharing a build step. */
.member-check {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 15px;
  height: 15px;
  margin-left: 3px;
  border-radius: 50%;
  background: #ec2899;
  color: #fff;
  font-size: 10px;
  line-height: 1;
  vertical-align: middle;
  flex-shrink: 0;
}

/* The profile page's own "Member" pill (public/browse.js's profileBadge) —
   distinct from .member-check above: this is a standalone, textual badge
   shown once in the profile-meta row (alongside "Joined <Month Year>" and
   follower/following counts), not a small mark repeated next to every
   occurrence of the name. Same pink as .member-check, so the two read as
   "the same feature" despite the different shape. */
.member-badge {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  padding: 2px 10px;
  border-radius: 999px;
  background: #ec2899;
  color: #fff;
  font-size: 12px;
  font-weight: 600;
  white-space: nowrap;
}

/* Account Settings' Cancel Account panel — the one destructive action on
   this page, so it gets a visually distinct treatment from every other
   .panel here (a muted red border instead of the default neutral one)
   purely so a skimming visitor's eye catches "this one's different" before
   reading a word of it, the same reason a browser's own "delete" dialogs
   are rarely styled identically to an "OK" one. */
.panel-danger {
  border-color: #c62828;
}

.panel-danger .field {
  max-width: 320px;
}

.btn-danger {
  background: #c62828;
  border-color: #c62828;
  color: #fff;
}

.btn-danger:hover {
  background: #a92121;
  border-color: #a92121;
  color: #fff;
}

/* Admin page — the two-column layout: a vertical tab list down the left
   (.admin-tabs) and the active panel on the right (.admin-panels), wrapping
   both (public/admin.html's #adminContent). Used to be a single horizontal
   row of tabs sitting above the panels, which read fine back when there
   were two or three of them, but at nine it wrapped onto a second line
   even on an ordinary desktop width — a sidebar list scales to more tabs
   without ever wrapping, and reads as a proper section list (like a
   settings page) rather than a toolbar. align-items: flex-start keeps the
   sidebar from stretching to the content panel's height when one panel is
   much taller than the tab list itself. */
.admin-layout {
  display: flex;
  align-items: flex-start;
  gap: 28px;
}

.admin-panels {
  flex: 1;
  min-width: 0; /* lets long content (e.g. a wide table) shrink instead of pushing the sidebar out */
}

/* The tab list itself (public/admin.js's tab-click listener, delegated on
   this container — unchanged by this vertical layout, it only cares about
   .admin-tabs/.admin-tab/data-tab, never their arrangement). Plain buttons,
   not real `<a href>` tabs — there's nothing to deep-link to, every panel's
   markup already loads together, this is purely a same-page show/hide
   toggle. */
.admin-tabs {
  display: flex;
  flex-direction: column;
  flex: 0 0 200px;
  gap: 2px;
  border-bottom: none;
  border-right: 1px solid var(--border);
  margin-bottom: 0;
  padding-right: 10px;
}

.admin-tab {
  padding: 10px 14px;
  border: none;
  background: none;
  font: inherit;
  font-size: 14px;
  font-weight: 600;
  color: var(--text-muted);
  cursor: pointer;
  text-align: left;
  border-radius: 6px;
  border-left: 2px solid transparent;
}

.admin-tab:hover {
  color: var(--text);
  background: var(--bg);
}

.admin-tab-active {
  color: var(--accent);
  background: var(--accent-soft);
  border-left-color: var(--accent);
}

/* Below 640px there isn't room for a sidebar next to a content panel — the
   same breakpoint every other phone-width layout change in this app uses
   (see browse.css's own .type-filter-wrap/.view-toggle rules). The tab list
   goes back to a single horizontal, side-scrolling row above the panel
   (rather than stacking nine full-width rows, which would push the actual
   panel below a very long list of buttons), and the active-tab highlight
   switches from a left border (meaningless once the list isn't vertical)
   to the same underline style the old horizontal tab bar used. */
@media (max-width: 640px) {
  .admin-layout {
    flex-direction: column;
    gap: 16px;
  }

  .admin-tabs {
    flex-direction: row;
    flex: 0 0 auto;
    width: 100%;
    overflow-x: auto;
    border-right: none;
    border-bottom: 1px solid var(--border);
    padding-right: 0;
    padding-bottom: 6px;
  }

  .admin-tab {
    flex: 0 0 auto;
    white-space: nowrap;
    border-left: none;
    border-radius: 6px 6px 0 0;
  }

  .admin-tab-active {
    border-left-color: transparent;
    border-bottom: 2px solid var(--accent);
  }
}

/* Admin page — the Reports panel, one .report-card (a .panel) per open (or,
   after the "Show resolved" toggle, resolved) report — see
   public/admin.js's reportCardHtml(). */
.report-card {
  padding: 14px 18px;
}

/* A report flagging content as involving a minor gets a visibly different
   treatment from the rest of the queue — a left accent bar in a distinct
   color, not just the same badge everything else gets — so it can't get
   lost between a run of "Broken or doesn't work" reports waiting their
   turn. This is a queue-ordering aid for an admin, not a moderation
   decision on its own — see lib/reports.js's own comment on why this
   reason exists. */
.report-card-urgent {
  border-left: 3px solid #c62828;
}

.report-card-header {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 10px;
  margin-bottom: 8px;
}

.report-reason-badge {
  display: inline-block;
  font-size: 12px;
  font-weight: 600;
  padding: 3px 10px;
  border-radius: 100px;
  background: var(--accent-soft);
  color: var(--accent);
  white-space: nowrap;
}

.report-card-urgent .report-reason-badge {
  background: #fdeaea;
  color: #c62828;
}

.report-meta {
  font-size: 13px;
  color: var(--text-muted);
}

.report-note {
  font-size: 13.5px;
  margin: 0 0 12px;
  padding: 10px 12px;
  background: var(--bg);
  border-radius: 8px;
}

.report-card-actions {
  display: flex;
  align-items: center;
}

.report-resolved-label {
  font-size: 13px;
  color: var(--text-muted);
}

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

.username-form {
  display: flex;
  gap: 10px;
  flex-wrap: wrap;
}

.username-form input {
  flex: 1 1 220px;
  padding: 9px 11px;
  border: 1px solid var(--border);
  border-radius: 8px;
  font-size: 14px;
  font-family: inherit;
}

.username-form input:focus {
  outline: none;
  border-color: var(--accent);
}

.bookmarklet-row {
  display: flex;
  align-items: center;
  gap: 14px;
  flex-wrap: wrap;
}

.bookmarklet-btn {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  background: var(--accent-soft);
  color: var(--accent-dark);
  border: 1px dashed var(--accent);
  border-radius: 8px;
  padding: 9px 16px;
  font-size: 13.5px;
  font-weight: 700;
  text-decoration: none;
  cursor: grab;
  user-select: none;
}

.bookmarklet-regenerate {
  font-size: 12.5px;
  color: var(--text-muted);
  background: none;
  border: none;
  cursor: pointer;
  text-decoration: underline;
  font-family: inherit;
  padding: 0;
}

.toolbar {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
  margin-bottom: 16px;
}

.toolbar input[type="text"] {
  flex: 1;
  padding: 9px 12px;
  border: 1px solid var(--border);
  border-radius: 8px;
  font-size: 14px;
}

.toolbar input[type="text"]:focus {
  outline: none;
  border-color: var(--accent);
}

/* Activity page's three modules (public/activity.html/js) — a plain button
   row, not a full tab widget: activity.js toggles which .activity-panel is
   hidden and which button carries aria-selected="true", styled here purely
   off that attribute rather than a separate "active" class so the two can
   never drift out of sync with each other. */
.activity-tabs {
  display: flex;
  gap: 8px;
  margin-bottom: 16px;
  border-bottom: 1px solid var(--border);
}

.activity-tab {
  padding: 10px 16px;
  border: none;
  border-bottom: 2px solid transparent;
  background: none;
  color: var(--text-muted);
  font-size: 14px;
  font-weight: 600;
  font-family: inherit;
  cursor: pointer;
}

.activity-tab:hover {
  color: var(--text);
}

.activity-tab[aria-selected="true"] {
  color: var(--text);
  border-bottom-color: var(--accent);
}

.count {
  color: var(--text-muted);
  font-size: 13px;
  margin-bottom: 12px;
}

.list {
  display: flex;
  flex-direction: column;
  gap: 10px;
}

.row {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 10px;
  padding: 12px 14px;
  display: flex;
  align-items: flex-start;
  gap: 10px;
}

.row-main {
  flex: 1;
  min-width: 0;
}

.row-title-line {
  display: flex;
  align-items: center;
  gap: 6px;
  min-width: 0;
}

.row-favicon {
  width: 16px;
  height: 16px;
  border-radius: 3px;
  flex: 0 0 auto;
}

.row-title {
  font-size: 14px;
  font-weight: 600;
  color: var(--text);
  text-decoration: none;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  min-width: 0;
}

.row-title:hover {
  text-decoration: underline;
}

.row-sitename {
  font-size: 11.5px;
  color: var(--text-muted);
  background: var(--accent-soft);
  padding: 1px 6px;
  border-radius: 999px;
  flex: 0 0 auto;
}

/* View counter — shown on the dashboard's own rows (next to the title,
   since there's no meta/date line there to fold into) and on the public
   /browse row list and /bookmark/:id permalink page (browse.css's
   .row-meta/.bm-meta, which this inherits its muted/small look from). See
   lib/views.js for why this is per-bookmark, not shared across every
   bookmark row for the same URL the way vote scores are. */
.row-views {
  font-size: 11.5px;
  color: var(--text-muted);
  flex: 0 0 auto;
  white-space: nowrap;
}

/* Dashboard-only click-through to a bookmark's public /bookmark/:id
   permalink (public/dashboard.js) — only ever rendered for a row that's
   currently public AND not flagged as a dead link (see .row-dead-link
   below), so there's no disabled/broken-link state to style here. Small and
   muted like .row-views right before it, not accent-colored like
   .row-title — this is a secondary path off the row, not its main action
   (that's still the title/thumbnail's own /view/:id link). */
.row-permalink {
  font-size: 11.5px;
  color: var(--text-muted);
  text-decoration: none;
  white-space: nowrap;
  flex: 0 0 auto;
}

.row-permalink:hover {
  color: var(--accent);
  text-decoration: underline;
}

/* Dashboard-only "this URL's last check came back 404" indicator
   (public/dashboard.js, lib/urls.js's is_dead_link) — a fixed warm-red tint
   regardless of theme (not var(--accent), which itself flips between green
   and pink across this app's own theme variants — a warning shouldn't ride
   along with a color that isn't reliably read as one) so it reads as a
   caution state at a glance rather than blending in with this row's other
   muted metadata. Never shown for a row that isn't currently flagged dead —
   see dashboard.js's own comment on why. */
.row-dead-link {
  font-size: 11.5px;
  font-weight: 600;
  color: #c62828;
  background: rgba(198, 40, 40, 0.12);
  padding: 1px 7px;
  border-radius: 999px;
  white-space: nowrap;
  flex: 0 0 auto;
}

/* Same small-text-button look as .row-field-edit right below, so this reads
   as "one more small inline action on the row" rather than a visually
   distinct new kind of control. */
.row-recheck-link {
  background: none;
  border: none;
  color: var(--accent);
  font-size: 11.5px;
  font-weight: 600;
  cursor: pointer;
  padding: 0;
  font-family: inherit;
  white-space: nowrap;
  flex: 0 0 auto;
}

.row-recheck-link:hover {
  text-decoration: underline;
}

.row-recheck-link:disabled {
  color: var(--text-muted);
  cursor: default;
  text-decoration: none;
}

.row-url-line {
  display: flex;
  align-items: center;
  gap: 6px;
  min-width: 0;
  margin: 2px 0 8px;
}

.row-url {
  font-size: 12px;
  color: var(--text-muted);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  min-width: 0;
}

.row-description {
  font-size: 12.5px;
  color: var(--text-muted);
  margin: 0 0 8px;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

.row-thumb {
  width: 72px;
  height: 72px;
  object-fit: cover;
  border-radius: 8px;
  flex: 0 0 auto;
  border: 1px solid var(--border);
}

.row-tags,
.row-models,
.row-notes {
  width: 100%;
  border: 1px solid var(--border);
  border-radius: 6px;
  padding: 5px 8px;
  font-size: 12.5px;
  font-family: inherit;
  margin-top: 4px;
  resize: vertical;
}

.row-tags:focus,
.row-models:focus,
.row-notes:focus {
  outline: none;
  border-color: var(--accent);
}

/* Wraps just the Models textarea (not the whole .row-field-models block) so
   .row-models-suggestions below can position itself directly under the
   textarea alone, regardless of where the field's label/display/Edit
   button above it sit. See public/dashboard.js's wireModelsAutocomplete. */
.row-models-input-wrap {
  position: relative;
}

/* Approved-name autocomplete dropdown for the Models field — same
   floating-panel shape as .notif-dropdown (surface/border/shadow), just
   anchored under a textarea instead of a nav icon. Only ever populated
   with APPROVED names (lib/models.js's listApprovedModelNames) — the ★
   prefix on each suggestion (dashboard.js's renderSuggestions) matches
   .model-pill-approved's own ★ elsewhere, so a suggestion visibly promises
   "picking this gets you the star immediately," not just "this exists." */
.row-models-suggestions {
  position: absolute;
  top: 100%;
  left: 0;
  right: 0;
  margin-top: 2px;
  max-height: 180px;
  overflow-y: auto;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 6px;
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
  z-index: 20;
}

.row-models-suggestion {
  display: block;
  width: 100%;
  text-align: left;
  background: none;
  border: none;
  padding: 6px 10px;
  font-size: 12.5px;
  font-family: inherit;
  color: var(--model-gold);
  cursor: pointer;
}

.row-models-suggestion:hover,
.row-models-suggestion-active {
  background: var(--model-gold-soft);
}

/* .tag-pill lives here (not browse.css) because the dashboard's read/edit
   tag display (server/public/dashboard.js) reuses the exact same look as
   the public pages' clickable tag pills — both are links now: the public
   ones go to /tag/NAME, the dashboard's own filter its own list in place
   (see dashboard.js's setActiveTag) since a dashboard row can be private
   and has no public /tag/NAME page to send you to. One visual style,
   shared by both contexts. */
.tag-pill {
  display: inline-block;
  background: var(--accent-soft);
  color: var(--accent-dark);
  border-radius: 999px;
  padding: 3px 10px;
  font-size: 12px;
  font-weight: 600;
  text-decoration: none;
  margin: 2px 4px 2px 0;
}

.tag-pill:hover {
  background: var(--accent);
  color: var(--accent-contrast);
}

/* .model-pill — a "Model" credit chip (lib/models.js), same base shape as
   .tag-pill just above, and now (as of /model/:name existing) the same
   real link too — public/browse.js's buildModelsHtml and
   lib/publicPages.js's buildRowModelsHtml both render this as an <a> to
   /model/NAME, not a <span>. Shown for BOTH an approved and a still-pending
   name (a brand-new Model credit is usable immediately, just unbadged —
   see migration 041's own comment on that choice) — .model-pill-approved
   is the only difference, adding the ★ and swapping in the gold/amber pair
   (--model-gold/--model-gold-soft) once an admin has approved the name
   from the Admin "Models" tab. Approved or not, the pill always links
   through — /model/:name itself doesn't gate on is_approved either (see
   getPublicModelCounts' own comment in lib/publicBookmarks.js for why only
   the /models index does). */
.model-pill {
  display: inline-block;
  background: var(--accent-soft);
  color: var(--accent-dark);
  border-radius: 999px;
  padding: 3px 10px;
  font-size: 12px;
  font-weight: 600;
  text-decoration: none;
  margin: 2px 4px 2px 0;
}

.model-pill:hover {
  background: var(--accent);
  color: var(--accent-contrast);
}

.model-pill-approved {
  background: var(--model-gold-soft);
  color: var(--model-gold);
}

/* .tag-banner also lives here (not browse.css) for the same reason as
   .tag-pill above — the dashboard's own tag filter (dashboard.js) reuses
   this exact "Filtered/Tagged #X ✕ clear" banner look, not just the public
   /tag/NAME and /bookmark/:id pages (browse.js / lib/publicPages.js). */
.tag-banner {
  display: flex;
  align-items: center;
  gap: 10px;
  background: var(--accent-soft);
  color: var(--accent-dark);
  border-radius: 8px;
  padding: 10px 14px;
  margin-bottom: 14px;
  font-size: 13.5px;
}

.tag-banner-clear {
  color: var(--accent-dark);
  font-weight: 600;
  text-decoration: none;
}

.tag-banner-clear:hover {
  text-decoration: underline;
}

/* Dashboard-only: each editable field (tags, notes) defaults to a compact
   read display (pills / plain text) with a small "Edit" toggle, rather than
   always showing the raw input/textarea the way this row used to. Clicking
   the toggle swaps that one field's display <-> input and flips the
   toggle's own label between "Edit"/"Done" — see dashboard.js's
   wireFieldToggle(). */
.row-field {
  margin-top: 6px;
}

.row-field-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
}

.row-field-label {
  font-size: 10.5px;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--text-muted);
  font-weight: 700;
}

.row-field-edit {
  background: none;
  border: none;
  color: var(--accent);
  font-size: 12px;
  font-weight: 600;
  cursor: pointer;
  padding: 0;
  font-family: inherit;
}

.row-field-edit:hover {
  text-decoration: underline;
}

.row-tags-display,
.row-models-display {
  margin-top: 3px;
  /* Matches .row-notes-display's own explicit font-size below. Tag/Model
     PILLS already set their own font-size (see .tag-pill/.model-pill in
     this file), so this only affects each one's "No tags"/"No models" empty
     state (plain .row-field-empty text, no pill) — without it, that text
     inherited a larger ambient size than "No notes" even though all three
     share the identical .row-field-empty span. */
  font-size: 12.5px;
}

.row-notes-display {
  margin: 3px 0 0;
  font-size: 12.5px;
  color: var(--text);
  white-space: pre-wrap;
  overflow-wrap: anywhere;
}

.row-field-empty {
  color: var(--text-muted);
  font-style: italic;
}

/* Featured-image picker (public/dashboard.js's renderImagePicker) — a grid
   of candidate thumbnails rather than a text display/input pair, so this
   doesn't reuse .row-tags-display/.row-notes-display, just the same
   .row-field/.row-field-header/.row-field-edit shell every other field
   already uses above it. */
.row-image-picker {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  margin-top: 6px;
}

.row-image-picker-status {
  color: var(--text-muted);
  font-style: italic;
  font-size: 12.5px;
  margin: 4px 0 0;
}

.row-image-option {
  padding: 0;
  border: 2px solid transparent;
  border-radius: 8px;
  background: none;
  cursor: pointer;
  line-height: 0;
  flex: 0 0 auto;
}

.row-image-option img {
  width: 56px;
  height: 56px;
  object-fit: cover;
  border-radius: 6px;
  display: block;
}

.row-image-option:hover {
  border-color: var(--border);
}

/* The candidate currently set as this URL's featured image (urls.image_url)
   — same accent color every other "this one's active" state in this app
   uses (.vote-active, .admin-tab-active), so it reads consistently. */
.row-image-option-active {
  border-color: var(--accent);
}

.row-image-option:disabled {
  opacity: 0.6;
  cursor: default;
}

.row-visibility {
  display: flex;
  align-items: center;
  gap: 6px;
  margin-top: 6px;
  font-size: 12px;
  font-weight: 500;
  color: var(--text-muted);
  cursor: pointer;
  width: fit-content;
}

.row-visibility input[type="checkbox"] {
  width: auto;
  margin: 0;
}

.row-delete {
  background: none;
  border: none;
  color: #bbb;
  font-size: 14px;
  cursor: pointer;
  padding: 2px 6px;
  border-radius: 6px;
}

.row-delete:hover {
  color: #fff;
  background: #c62828;
}

/* "Set as collection cover" star (public/manageCollections.js's manageRowHtml,
   only offered on a row whose bookmark actually has an image) — outline
   star in the collection's normal unremarkable state, filled accent-colored
   star once it's this collection's current explicit cover_bookmark_id, same
   "outline vs. filled" convention #followBtn.following already uses
   elsewhere in this file for an active/toggled state. */
.row-cover-toggle {
  background: none;
  border: none;
  color: var(--text-muted);
  font-size: 16px;
  line-height: 1;
  cursor: pointer;
  padding: 2px 6px;
  border-radius: 6px;
  flex: 0 0 auto;
}

.row-cover-toggle:hover {
  color: var(--accent);
  background: var(--accent-soft);
}

.row-cover-toggle.active {
  color: var(--accent);
}

.empty {
  color: var(--text-muted);
  text-align: center;
  padding: 40px 0;
}

/* /partners — a plain link index, same multi-column layout as /tags' own
   .tag-index (browse.css) but kept here in the sitewide stylesheet instead,
   since partners.html doesn't load browse.css for anything else. No count
   badge (.tag-index-count's counterpart) — a partner link has nothing
   analogous to a tag's bookmark count to show. */
.partners-index {
  column-width: 220px;
  column-gap: 28px;
}

.partners-index-item {
  display: block;
  padding: 9px 0;
  border-bottom: 1px solid var(--border);
  text-decoration: none;
  color: var(--text);
  font-size: 14px;
  break-inside: avoid;
}

.partners-index-item:hover .partners-index-name {
  text-decoration: underline;
}

.partners-index-name {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  display: block;
}

/* Quick Hide (public/app.js's initQuickHide) — a floating button plus a
   full-viewport cover, injected on every page that loads app.js when
   QUICK_HIDE_ENABLED is on. The z-index on both is a deliberately extreme
   value (the common "nuclear option" max-int, same one cookie-consent
   banners/paywalls use) rather than just "higher than frame.css's own
   highest, 1000" — an ad creative this app has no control over the markup
   of (see ads.js's own comments on treating embed code as opaque) could set
   its own arbitrarily high z-index, and Quick Hide covering the page is the
   one thing here that has to win against literally everything else on it,
   including that. */
.quick-hide-btn {
  position: fixed;
  bottom: 16px;
  right: 16px;
  z-index: 2147483647;
  padding: 8px 14px;
  font-size: 13px;
  font-weight: 600;
  color: var(--text-muted);
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 999px;
  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15);
  cursor: pointer;
  opacity: 0.85;
}

.quick-hide-btn:hover,
.quick-hide-btn:focus-visible {
  opacity: 1;
  color: var(--text);
  border-color: var(--accent);
}

/* Kept visually low-key on purpose — muted colors, no alarming red/icon —
   so it doesn't itself draw a passerby's eye or read as "this button hides
   something sensitive" at a glance; the button still needs to be easy to
   FIND on purpose (fixed corner, always present, a title tooltip on hover),
   just not easy to notice for anyone who isn't the visitor themselves. */

.quick-hide-overlay {
  position: fixed;
  inset: 0;
  z-index: 2147483647;
  background: var(--bg);
  display: flex;
  align-items: center;
  justify-content: center;
}

.quick-hide-overlay[hidden] {
  display: none;
}

/* A plain spinner, nothing else — no text, no branding, nothing that reads
   as anything other than "this page is loading," which is both the most
   boring thing to glance at and, since Quick Hide's redirect really is in
   flight underneath it, not even inaccurate. */
.quick-hide-overlay-spinner {
  width: 28px;
  height: 28px;
  border-radius: 50%;
  border: 3px solid var(--border);
  border-top-color: var(--text-muted);
  animation: quick-hide-spin 0.8s linear infinite;
}

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

/* Mask Content mode (public/app.js's setMaskMode/hydrateMaskModeToggle) —
   Admins and Members only. Unlike Quick Hide just above, this ISN'T a
   floating always-there control: the icon button lives inline in the site
   nav, right before #userIdentity (built into #siteNav by
   buildMaskModeToggle), and only ever exists in the DOM at all once
   eligibility is confirmed. A plain icon button, not `.btn`'s usual
   padded/labeled pill (app.js swaps its whole innerHTML for
   PASSWORD_EYE_ICON/PASSWORD_EYE_OFF_ICON — the exact same eye-glyph
   markup wirePasswordToggle's own button already uses elsewhere on this
   site, same "this is hidden" visual language, not a second one invented
   just for this control) — sized/shaped like .nav-toggle just above (the
   hamburger button), the closest existing precedent for a small icon-only
   button sitting in this same header. */
.mask-mode-toggle {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 36px;
  height: 36px;
  padding: 0;
  border-radius: 8px;
  border: 1px solid var(--border);
  background: var(--surface);
  color: var(--text-muted);
  cursor: pointer;
}

.mask-mode-toggle svg {
  width: 20px;
  height: 20px;
}

.mask-mode-toggle:hover {
  border-color: var(--accent);
  color: var(--accent);
}

/* The "currently masked" state — filled in with --accent rather than left
   at the plain hover treatment above, so a visitor can tell at a glance
   that content IS masked right now without having to look closely at which
   of the two eye glyphs is showing. Same active-state color swap
   browse.css's own `.view-toggle-btn.active`/`.sort-btn.active` already use
   for "which of these is currently selected." */
.mask-mode-toggle.active {
  background: var(--accent);
  border-color: var(--accent);
  color: var(--accent-contrast);
}

.mask-mode-toggle.active:hover {
  color: var(--accent-contrast);
}

/* The masked-thumbnail placeholder — an inline SVG data URI, not a static
   image file (this app has no static image assets anywhere in public/ at
   all; every icon anywhere on the site is inline markup, favicon included —
   see lib/brand.js's own icon field, a plain emoji).
   Deliberately an eye-with-a-line-through-it glyph, the same "this is
   deliberately hidden" visual language app.js's own password-reveal icons
   (PASSWORD_EYE_OFF_ICON) already use elsewhere on this site — and
   deliberately NOT the picture-frame/dot/diagonal-line glyph
   .row-thumb-placeholder/.bm-image-placeholder already use for "this image
   failed to load" (see browse.js's thumbPlaceholderHtml): those two need to
   read as different situations to a visitor, not the same one twice. Colors
   are hardcoded (a data URI can't see this page's --accent/--text-muted
   custom properties — those only exist in the parent document, not inside
   an independently-fetched image resource) to a neutral gray that reads
   fine against either this app's light or dark palette, rather than
   matching either one exactly.
   Declared as a custom property (--mask-thumb-icon), not repeated inline
   wherever it's needed, since browse.css's own copy of this same rule (for
   .bm-favicon/.profile-collection-cover — see that file) needs the
   identical icon; a custom property set here on :root is visible there too,
   same as --accent and the rest of this file's own tokens already are
   across every stylesheet that loads after this one. */
:root {
  --mask-thumb-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 72 72'%3E%3Crect width='72' height='72' rx='8' fill='%23d7dbdb'/%3E%3Cg fill='none' stroke='%23666f6f' stroke-width='2.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M18 36s7-11 18-11 18 11 18 11-7 11-18 11S18 36 18 36Z'/%3E%3Ccircle cx='36' cy='36' r='5'/%3E%3Cpath d='M14 14l44 44'/%3E%3C/g%3E%3C/svg%3E");
}

/* .row-favicon (this file) masked the same way as the thumbnail above —
   small as it is, a favicon can still be identifying (a distinctive site
   logo), same reasoning as everything else in this block.
   `content: url(...)` reliably swaps a genuine replaced element's own
   rendering — an <img>, concretely — the same way in every current engine,
   which is what every selector below actually is.
   `.row-thumb` is the one exception worth calling out: in grid view, a
   bookmark with no captured image_url renders a plain <div>/<span>
   placeholder that ALSO carries the .row-thumb class (browse.js's
   thumbPlaceholderHtml — see .list.grid-view .row-delete's own comment
   below for why), so this selector matches both a real <img> and that
   placeholder div. An earlier version of this rule applied `content:url()`
   to BOTH — which briefly caused a real, reported bug: Chromium quietly
   ignores `content` on a non-replaced element like that div (`content` is
   only well-defined for genuinely replaced elements, an <img> among them),
   so nothing visibly broke there, but Firefox instead manufactures a
   brand-new replaced box sized off the swapped image's OWN intrinsic
   dimensions, ignoring the placeholder div's actual CSS width/height
   entirely — and a follow-up attempt to fix that by moving the swap onto a
   ::after overlay instead broke the OTHER, much more common case just as
   badly: ::before/::after generated content has no effect on a replaced
   element like <img> at all (also spec-defined, also true in every engine),
   so every REAL thumbnail across the whole site silently stopped masking.
   The actual fix is to keep `content: url(...)` for the real <img> case
   below (element-qualified as `img.row-thumb`, not bare `.row-thumb`, so
   this never touches the placeholder div) and handle that placeholder
   separately, with a technique that has no replaced-element ambiguity at
   all — see its own rule, right after this one. */
body.mask-mode img.row-thumb,
body.mask-mode .bm-image,
body.mask-mode .row-favicon,
body.mask-mode .collection-card-cover,
/* Featured-image picker thumbnails (public/dashboard.js's
   renderImagePicker) — the same scraped page content as .row-thumb above,
   just an alternate candidate rather than the currently-chosen one, so
   this needs the same cover-it-up treatment for the same shoulder-surfing
   reason. Only ever visible while a row's picker panel is actively open
   (row-image-picker starts [hidden]), but mask mode shouldn't leave a gap
   for the one deliberate interaction that opens it. */
body.mask-mode .row-image-option img {
  content: var(--mask-thumb-icon);
}

/* .bm-image on its own, right after the shared rule above — reported: with
   only that rule in place, masking an image bookmark's big permalink-page
   thumbnail didn't just swap in the icon, it blew it up to fill most of the
   viewport. Same underlying class of `content: url()` replaced-element
   sizing bug as the Firefox one already documented above (search this
   file for ".row-thumb-placeholder already use for"), just tripped a
   different way: `img.row-thumb`/`.row-favicon`/`.collection-card-cover`/
   `.row-image-option img` all give their <img> a DEFINITE pixel width AND
   height (see browse.css/this file's own rules for each), so swapping their
   content never has anywhere ambiguous to size itself into — `object-fit:
   cover` just crops the new (also square) icon to that fixed box, the same
   way it already crops a real photo. `.bm-image` (browse.css) is the one
   exception: `width: 100%; max-height: 320px;` — a PERCENTAGE width and
   only a max-height CAP, never a definite height — which is fine for a real
   photo (browsers resolve its own intrinsic ratio into a sane auto height
   that max-height only occasionally even needs to clamp) but left the
   swapped-in icon's own box height under-constrained enough that at least
   one engine rendered it far larger than 320px instead of clamping down to
   it. Giving `.bm-image` the same "definite pixel box, no ambiguity left
   for any engine to resolve differently" treatment as its four siblings —
   just scoped to mask mode, so the normal (unmasked) photo rendering, which
   was never actually broken, keeps its existing percentage-width/max-height
   sizing untouched. */
body.mask-mode .bm-image {
  height: 320px;
}

/* The no-captured-image placeholder (.row-thumb-placeholder — see the
   comment just above for why it needs its own rule, separate from the real
   <img> case). A plain <div>/<span> can take a background-image directly
   with zero replaced-element ambiguity in any engine, so that's what this
   uses instead of `content` — same "cover it with the mask icon" result,
   just via a technique that can't silently do the wrong thing depending on
   the browser. The placeholder's own inline "no image" SVG glyph
   (.row-thumb-placeholder svg, right below) is hidden underneath rather
   than removed, so nothing else about this element has to change; the mask
   icon's own opaque background rect (baked into the --mask-thumb-icon data
   URI — see its own comment above) fully covers the placeholder's normal
   --accent-soft background either way. */
body.mask-mode .row-thumb-placeholder {
  background-color: #d7dbdb;
  background-image: var(--mask-thumb-icon);
  background-repeat: no-repeat;
  background-size: 100% 100%;
}

body.mask-mode .row-thumb-placeholder svg {
  visibility: hidden;
}

/* Bookmark text — titles, descriptions, site-name pills, tags, the raw URL
   itself — covered by a solid bar rather than swapped for dots or blurred:
   a solid background-colored ::after positioned over the element's own box,
   so this never has to know how any given element's text was actually built
   (a plain text node here, an <a> there, a <span> pill elsewhere) — it
   works the same way regardless, by covering the box the text sits in
   rather than touching the text itself. `color: transparent` (not
   `visibility:hidden` or `opacity:0`) is what's doing the actual hiding —
   kept so the element keeps its normal layout size/line-wrapping exactly as
   before, which the overlay bar below then needs to still line up with.
   `!important` is needed for `.row-sitename`/`.tag-pill`'s own `color`
   declarations elsewhere in this file, which would otherwise win by sitting
   later in source order. .row-url is included here (not grouped with the
   thumbnail/favicon swap above) since it's plain text, not an image — the
   bookmark's actual target URL is at least as identifying as its title, so
   it needs the same bar rather than being left legible. */
body.mask-mode .row-title,
body.mask-mode .row-description,
body.mask-mode .row-sitename,
body.mask-mode .row-url,
body.mask-mode .tag-pill {
  position: relative;
  color: transparent !important;
}

body.mask-mode .row-title::after,
body.mask-mode .row-description::after,
body.mask-mode .row-sitename::after,
body.mask-mode .row-url::after,
body.mask-mode .tag-pill::after {
  content: "";
  position: absolute;
  inset: 0;
  background: var(--text-muted);
  border-radius: 4px;
  opacity: 0.55;
}

/* Collections dashboard, /collections/manage (public/manageCollections.js)
   — a Collection's own name,
   same bar-overlay treatment as a bookmark's .row-title/.bm-title above and
   browse.css's own .profile-collection-name (the public-facing version of
   this same card), since a Collection's name can be just as identifying as
   a bookmark's own title. .collection-card-description is left unmasked on
   purpose — same "chrome, not necessarily identifying" call
   .profile-collection-description's own comment makes, and an owner
   managing their own private dashboard already sees their card's bookmark
   count and Public/Private badge in the clear either way. */
body.mask-mode .collection-card-title h2 {
  position: relative;
  color: transparent !important;
}

body.mask-mode .collection-card-title h2::after {
  content: "";
  position: absolute;
  inset: 0;
  background: var(--text-muted);
  border-radius: 4px;
  opacity: 0.55;
}

/* Manage Collections page, /collections/manage
   (public/manageCollections.html/.js). One .panel (existing
   class, above) per collection, in a plain vertical stack — .collections-
   list, not .list, since these cards are much taller/denser than a bookmark
   .row and have no grid-view equivalent (unlike /browse and /dashboard,
   this page never offers a grid layout toggle). */
.collections-list {
  display: flex;
  flex-direction: column;
  gap: 14px;
}

.collection-card-header {
  display: flex;
  align-items: flex-start;
  gap: 12px;
  flex-wrap: wrap;
  margin-bottom: 4px;
}

/* Cover thumbnail (public/manageCollections.js's collectionCoverHtml) — the
   owner's explicit pick (setCollectionCover) or, absent one, the automatic
   "first member bookmark with an image" fallback both listMyCollections and
   getPublicCollectionsForUser compute server-side. Same 1px border/8px
   radius language .row-thumb already uses, just a little smaller since this
   sits inline in a card header rather than leading a list row. The
   placeholder variant (no image at all — an empty collection, or one whose
   every member is still imageless) reuses .row-thumb-placeholder's own
   icon/background treatment (browse.css) rather than inventing a second
   one, just sized to match this slot instead of a full .row-thumb. */
.collection-card-cover {
  width: 56px;
  height: 56px;
  object-fit: cover;
  border-radius: 8px;
  border: 1px solid var(--border);
  flex: 0 0 auto;
}

.collection-card-cover-placeholder {
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--accent-soft);
  color: var(--text-muted);
}

.collection-card-cover-placeholder[hidden] {
  display: none;
}

.collection-card-cover-placeholder svg {
  width: 40%;
  height: 40%;
  max-width: 24px;
  max-height: 24px;
}

.collection-card-title {
  display: flex;
  align-items: center;
  gap: 10px;
  flex-wrap: wrap;
}

.collection-card-title h2 {
  margin: 0;
}

.collection-card-actions {
  display: flex;
  align-items: center;
  gap: 8px;
  flex-wrap: wrap;
  /* Pushed to the far right now that .collection-card-header has a third
     child (the cover thumbnail) ahead of the title — .collection-card-header
     no longer uses justify-content: space-between for this, since with three
     flex children that would spread the cover away from the title instead of
     keeping them together. */
  margin-left: auto;
}

/* Same pill shape as .row-sitename (browse.css) — reused, not redefined —
   just with the two semantic colors this app already uses elsewhere for
   "this is out in the open" vs. "this is just for you": accent-green for
   public (.verified-badge-yes / .auth-success's own green) and a plain
   muted tone for private, since private is this page's default/unremarkable
   state and doesn't need to read as a warning the way .row-dead-link's red
   does. */
.collection-badge {
  font-size: 11.5px;
  font-weight: 600;
  padding: 2px 9px;
  border-radius: 999px;
  flex: 0 0 auto;
}

.collection-badge-public {
  color: #2e7d32;
  background: rgba(46, 125, 50, 0.12);
}

.collection-badge-private {
  color: var(--text-muted);
  background: var(--accent-soft);
}

.collection-card-count {
  margin-bottom: 6px;
}

/* Same muted, small-print treatment as .row-views' own 👁 count elsewhere
   (dashboard.js/browse.js rows) — a secondary stat riding along after the
   count's own " · " separator, not competing with it for attention. */
.collection-card-views {
  color: var(--text-muted);
  font-size: 0.9em;
}

.collection-card-description {
  margin-bottom: 10px;
}

.collection-public-link {
  display: inline-block;
  font-size: 13px;
  color: var(--accent);
  text-decoration: none;
  margin-bottom: 10px;
}

.collection-public-link:hover {
  text-decoration: underline;
}

.collection-edit-form {
  border-top: 1px solid var(--border);
  padding-top: 14px;
  margin-top: 4px;
}

.collection-manage {
  border-top: 1px solid var(--border);
  padding-top: 14px;
  margin-top: 4px;
}

.manage-count {
  margin-bottom: 10px;
}

/* One collection-manage-row per bookmark — .row (styles.css) plus a drag
   handle prepended and .row-delete (existing "✕" button) appended, same
   markup shape /dashboard's own rows already use for their own trailing
   delete button. */
.collection-manage-row {
  cursor: default;
}

.drag-handle {
  cursor: grab;
  color: var(--text-muted);
  font-size: 16px;
  line-height: 1;
  padding: 2px 4px;
  flex: 0 0 auto;
  user-select: none;
}

.drag-handle:active {
  cursor: grabbing;
}

/* Applied to the one row actually being dragged (public/manageCollections.js's
   wireDragAndDrop) — dimmed and slightly scaled down so it reads as "lifted
   out of the list" while the other rows visually reflow around it. */
.collection-manage-row.dragging {
  opacity: 0.4;
}

/* Admin page — Featured Collections tab, the "Currently featured" list.
   Same .row/.drag-handle/.dragging shapes as .collection-manage-row above
   (public/manageCollections.js), just for collections instead of a collection's
   own bookmarks — see public/admin.js's featuredCurrentRowHtml/
   wireFeaturedDragAndDrop. */
.featured-collection-row {
  cursor: default;
}

.featured-collection-row.dragging {
  opacity: 0.4;
}

/* Flags a currently-featured collection that's gone private since being
   featured (public/admin.js's featuredCurrentRowHtml) — still listed here
   so an admin can un-feature it or wait for it to go public again, but
   it's silently absent from the Browse page's own featured section in the
   meantime (see lib/collections.js's getFeaturedCollections, which filters
   on is_public unlike this admin listing). Amber rather than the report
   card's red — this is a "heads up," not an error. */
.featured-collection-warning {
  display: inline-block;
  font-size: 11px;
  font-weight: 600;
  padding: 2px 8px;
  border-radius: 100px;
  background: #fff3e0;
  color: #e65100;
  flex: 0 0 auto;
}

.collection-add-bookmark {
  margin-top: 14px;
}

.add-bookmark-search {
  width: 100%;
  padding: 9px 11px;
  border: 1px solid var(--border);
  border-radius: 8px;
  font-size: 14px;
  font-family: inherit;
  margin-bottom: 8px;
}

.add-bookmark-search:focus {
  outline: none;
  border-color: var(--accent);
}

.collection-add-results {
  display: flex;
  flex-direction: column;
  gap: 6px;
}

.collection-add-result-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  padding: 8px 10px;
  border: 1px solid var(--border);
  border-radius: 8px;
}

.collection-add-result-row .row-title {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  min-width: 0;
}
