/* Styles specific to the public pages (/browse, /tag/NAME, /bookmark/:id).
   Everything else — .toolbar, .count, .list, .row, .row-title-line,
   .row-url-line, .row-favicon, .row-sitename, .row-description, .row-thumb,
   .empty, .btn, .tag-pill, .tag-banner — comes from styles.css, which the
   dashboard already established these read-only rows (and its own tag
   filter banner) are visually a variant of. */

.row-tags-line {
  margin-top: 6px;
}

/* .model-pill itself lives in styles.css (shared with the bookmark
   permalink page and the dashboard) — this is just this row layout's own
   top margin, same shape as .row-tags-line just above, kept as a separate
   line above it rather than merged into the same one so a Model credit
   reads as a distinct kind of label, not just another tag. */
.row-models-line {
  margin-top: 6px;
}

/* ---- Trending tags/models/sites/profiles (/tags, /models, /sites, and
   /profiles pages, public/tags.js's, public/models.js's, public/sites.js's,
   and public/profiles.js's own loadTrending) ---- */

/* Plain flex-wrap row of .tag-pill (or .model-pill, .row-sitename, or
   .profile-pill) links (styles.css) — same gap/wrap treatment
   .row-tags-line's own pills already get, just not tied to a single row's
   own layout since this sits inside its own .panel section rather than a
   bookmark row. Shared by all four pages' trending sections — the layout is
   identical, only the pill class/destination differs (tagToPath/
   modelToPath/siteToPath/plain username, /tag//model//site//username). */
.trending-tags-list,
.trending-models-list,
.trending-sites-list,
.trending-profiles-list {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
}

/* ---- ad slots (public/ads.js) ---- */

/* Shared by every page with an ad slot — originally just #adSlot on
   /view/:id's video-embed mode (frame.html/frame.js), moved here now that
   /browse (and /tag/NAME, /:username), /tags, and the bookmark permalink
   page each got one of their own (lib/adSlots.js's browse_top/tags_top/
   bookmark_top) — all of them already load this file, so one shared rule
   covers every placement instead of repeating it per page. No border/
   background/min-height of its own on purpose: whatever the ad network's
   embed code renders supplies its own look, and an empty, unconfigured slot
   (the common case until an admin sets one up — see /admin) should take up
   no visible space at all rather than showing as a blank box. */
.ad-slot {
  /* Explicit width, not just max-width — on frame.html specifically, body
     is `display: flex; flex-direction: column` (see frame.css), and a flex
     item with an auto cross-axis margin (the "margin: ... auto" centering
     trick below) doesn't stretch to fill the container the way a normal
     block box would; it shrink-wraps to its own content instead, which
     silently broke every section using this exact pattern in video-embed
     mode (this ad slot, .bm-card/#videoCard, .comments-section, and
     frame.css's .video-stats-bar/.related-grid) — a comment box rendering
     at ~200px instead of the intended 640px column was the visible
     symptom that led here. width: 100% (capped by max-width, then
     centered by the auto margins) is a no-op everywhere else that loads
     this file, since normal block layout already computes width:auto the
     same way — it only matters inside this flex context. */
  width: 100%;
  max-width: 640px;
  margin: 0 auto;
  /* margin:auto above only ever centers the ad-slot BOX itself — the ad
     network's own markup rendered inside it (an <ins>, an <iframe>, a
     <div> the network's script builds) doesn't necessarily fill that box's
     full width, and needs its own centering. `text-align: center` was the
     first thing tried here, but it only affects INLINE content — most real
     ad markup (an <ins> with a script-assigned fixed pixel width, or any
     <div>/<iframe> a network's own JS ends up rendering, both block-level
     by default) sat flush left regardless, confirmed by actually testing a
     saved embed rather than assuming. Flex + align-items:center centers a
     child regardless of whether that child turns out to be inline or
     block, which is what actually fixes it for arbitrary third-party
     markup instead of only the inline case. */
  display: flex;
  flex-direction: column;
  align-items: center;
}

.ad-slot:not(:empty) {
  margin-bottom: 24px;
}

/* Per-slot opt-in (lib/adSlots.js's full_width column, toggled from /admin's
   "Full width" checkbox) — applied by public/ads.js's initAdSlot based on
   the fetched slot's own config, not something set in any page's static
   markup. Just removes the 640px cap above; .ad-slot's other rules (the
   flex centering, the :not(:empty) margins, the iframe/img/ins max-width
   fix below) all still apply unchanged, so a full-width slot still shrinks
   gracefully on a narrow screen and still takes no space when empty. How
   wide "full width" actually ends up being depends on this element's own
   parent in each page's markup (e.g. .container's 860px on a plain content
   page) — this doesn't reach past that, it just stops capping below it. */
.ad-slot.ad-slot-full {
  max-width: none;
}

/* ---- Ad slot spacing, consolidated to 4 positions (one number each,
   given directly, in px) ----

   Before this, every one of this app's ~20 ad slots (outside /view/:id,
   which is its own case — see this section's own closing note) was tuned
   independently, round over round, into a tangle of different formulas for
   what was really only ever 4 kinds of position: flush against the very
   top of a page, sitting under a toolbar/search box, embedded inline
   within a list or article, or sitting at the bottom of the page. Reported
   directly as "it seems to be different on every page" — it was: a plain
   .ad-slot alone (0px top / 24px bottom), .ad-slot-page-top (20px top,
   confusingly reused for BOTH true page-tops and every page's bottom
   slot), a Dashboard-only .dash-ad-slot-top plus a :has() selector just to
   force its own toolbar's margin down to collapse cleanly (15px/5px), and
   a bookmark-permalink-only .bm-ad-slot-bottom override (40px top) — four
   different formulas doing the same four jobs. All four are gone now,
   replaced by the one shared class each position actually needs below.
   Each slot's markup (every static .html file, lib/publicPages.js,
   public/browse.js's/public/dashboard.js's own JS-built .ad-slot-middle
   divs) now carries exactly one of these four instead.

   :not(:empty)-gated, same as every ad-slot margin rule in this app
   already is — an empty, unconfigured slot (the common case until an
   admin sets one up) still takes up no visible space at all, margin
   included, rather than leaving a gap where an ad would go. Each overrides
   the plain .ad-slot:not(:empty) 24px-bottom default above via the same
   later-in-source-wins convention this file already uses elsewhere
   (equal specificity, declared after). */

/* Flush against the very top of a page's own content, above even the H1 —
   every "_banner" slot (browse_banner/profile_banner/tags_banner/
   models_banner/dashboard_banner), plus the 3 pages whose own top slot
   IS that page's literal top edge with nothing else above it: the
   bookmark permalink page's own top slot, a Collection's own top slot,
   and the "Page not found" page's own top slot. */
.ad-pos-banner:not(:empty) {
  margin-top: 24px;
  margin-bottom: 0;
}

/* Just under a search/sort toolbar (or, on /collections, a plain search
   box) and above the results list/grid below it — every "_top"-keyed slot
   except Dashboard's own (dashboard_top rides this same class now too,
   despite its key, same reasoning the key's own SLOT_DEFINITIONS comment
   already gives for why the key itself was kept unchanged through the
   Round 32 banner relabeling). */
.ad-pos-below-toolbar:not(:empty) {
  margin-top: 24px;
  margin-bottom: 24px;
}

/* Embedded inline, part-way through real content — every "_middle" slot
   (a mid-list break in Browse/a profile/Dashboard's own bookmark list),
   the bookmark permalink page's own between-content-and-comments slot, and
   the "Page not found" page's own slot nested inside its card. Tighter on
   both sides than the page-level positions above/below, on purpose: this
   one sits between two pieces of real content that already have their own
   spacing, not against a page edge or an empty toolbar gap. */
.ad-pos-middle:not(:empty) {
  margin-top: 12px;
  margin-bottom: 4px;
}

/* The very bottom of a page's own content, below its list/pagination —
   every "_bottom"-keyed slot, full stop, on every page this pass covers. */
.ad-pos-bottom:not(:empty) {
  margin-top: 26px;
  margin-bottom: 0;
}

/* /view/:id's own 5 slots (view_below_toolbar/view_above_card/
   view_below_player/view_above_related/view_below_related) deliberately
   keep their existing, separately-tuned spacing (frame.css) rather than
   picking up one of the 4 classes above — that page's <body> is `display:
   flex; flex-direction: column` (frame.css's own comment on why), so
   margins there never collapse the way they do on every other page this
   section covers, and frame.css's own rules already compensate for that
   with adjacent-sibling selectors instead. Folding those 5 into this
   round's consolidation risked quietly breaking that compensation for a
   page nobody reported a problem on; left alone on purpose, not an
   oversight. */

/* Most ad networks hand back a fixed-pixel-width iframe/ins/img in their
   embed code (a 300x250 box, a 728x90 leaderboard, …) — fine on desktop,
   where .ad-slot's own 640px column is comfortably wider than any standard
   ad unit, but a leaderboard-width creative is wider than a phone's own
   viewport, and without this it forces the WHOLE PAGE to scroll
   horizontally to show it, not just the ad. This doesn't make the ad's
   actual creative reflow (most ad networks don't support that, and
   stretching a fixed-size creative's height to match a scaled-down width
   would just distort it) — it caps whatever the embed renders at the
   slot's own width and lets the browser scale it down, which is enough to
   stop it from breaking the rest of the page's layout on a small screen. */
.ad-slot iframe,
.ad-slot img,
.ad-slot ins {
  max-width: 100%;
  height: auto;
}

/* Round-32 follow-up #56 — a blocked/failed iframe (an ad blocker or the
   browser's own Tracking Protection stopping the request before it ever
   loads real content) still renders the <iframe> ELEMENT itself — an empty
   in-page document, not "nothing" — and the browser's own UA default for
   that is an opaque white background, same as a blank HTML page. Against
   this app's own dark theme especially, that reads as a jarring white
   rectangle sitting in an otherwise-empty slot, exactly where .ad-slot's
   own comment above says an unconfigured/empty slot should show nothing at
   all. `transparent` (not var(--bg)) so whatever's actually behind the
   slot always shows through correctly regardless of section/card
   background, in both themes, without this rule needing to track which
   background applies where. Harmless for a successfully-loaded ad too —
   the network's own creative supplies its own background from inside the
   iframe's own document, which this can't reach or affect either way (a
   different origin); this only ever changes what shows through when
   there's nothing (yet, or ever) actually there. */
.ad-slot iframe {
  background: transparent;
}

/* ---- tag pill expand/collapse (public/browse.js's buildTagsHtml) ---- */

/* .tags-extra is a plain inline wrapper around the overflow .tag-pill
   links — no layout rules of its own needed, its children already flow
   exactly like the always-visible pills before it; [hidden] (the native
   HTML attribute, toggled by initTagToggles) is what actually shows/hides
   it. */
.tag-toggle {
  display: inline-flex;
  align-items: center;
  background: none;
  border: 1px solid var(--border);
  color: var(--text-muted);
  border-radius: 999px;
  padding: 3px 10px;
  font-size: 12px;
  font-weight: 600;
  font-family: inherit;
  cursor: pointer;
  margin: 2px 4px 2px 0;
  vertical-align: middle;
}

.tag-toggle:hover {
  background: var(--accent-soft);
  color: var(--text);
  border-color: var(--accent-soft);
}

.row-meta {
  margin-top: 8px;
  font-size: 12px;
  color: var(--text-muted);
}

/* ---- thumbnail placeholder ---- */

/* Shown in a bookmark's .row-thumb slot instead of a real <img> whenever it
   has no captured image_url, or a captured one fails to load in the
   browser — public/browse.js's thumbPlaceholderHtml() and
   lib/publicPages.js's own copy for the permalink page. Same .row-thumb
   class as a real image, so it automatically picks up identical
   sizing/positioning in both list view (72px square, styles.css) and grid
   view (full-width bled card header, this file's .list.grid-view
   .row-thumb) — this rule only adds the placeholder's own look on top. */
.row-thumb-placeholder {
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--accent-soft);
  color: var(--text-muted);
}

/* This element's own `display: flex` above is an author style, which wins
   the cascade over the browser's default `[hidden]{display:none}` (a
   user-agent style) at equal specificity — so without this rule, setting
   the `hidden` attribute (as the broken-image fallback in
   public/browse.js's renderList does) would do nothing and the
   placeholder would stay visibly flexed even while hidden. */
.row-thumb-placeholder[hidden] {
  display: none;
}

/* ---- bookmark-type badge ---- */

/* Wraps the thumbnail <a> (browse.js/dashboard.js's `thumb` markup,
   bookmarkHref or /view/:id link) so .row-type-badge below has a
   positioned ancestor to sit inside. `position: relative` is the only
   property this adds — the anchor is already a flex item of .row
   (styles.css), and a flex item's computed `display` is always blockified
   regardless of an <a>'s own default inline value, so this can't change
   how it participates in that flex layout, in either list view (styles.css
   .row) or grid view (.list.grid-view .row below) — both already worked
   with this exact element unstyled before the badge existed. */
.row-thumb-wrap {
  position: relative;
}

/* Small inset chip in the thumbnail's corner marking which of the three
   content types a bookmark is (browse.js/dashboard.js's shared
   bookmarkTypeBadgeHtml — 🎥 Video / 🖼️ Image gallery / 🔖 Page). Same
   small corner-chip look (size, padding, dark translucent background) as
   /view/:id's own .related-video-badge (frame.css) for sitewide
   consistency, given its own class here rather than reusing that one
   since this sits on .row-thumb-wrap (sometimes wrapping the placeholder
   <span> instead of always a real <img> — see .row-thumb-placeholder's own
   comment above on that same real-image/placeholder split). */
.row-type-badge {
  position: absolute;
  right: 6px;
  bottom: 6px;
  font-size: 12px;
  line-height: 1;
  padding: 4px 5px;
  border-radius: 5px;
  background: rgba(0, 0, 0, 0.65);
  pointer-events: none;
}

/* Dashboard-only 4th type (public/dashboard.js's own bookmarkTypeBadgeHtml
   — see its comment for why this exists as a type distinct from plain
   "page") — same fixed amber as styles.css's own .row-video-hidden pill in
   the row's title line, rather than the plain dark chip every other type
   above shares, so the one small always-visible corner badge is *also*
   already flagged as "something's different here" even before a viewer's
   eye reaches the fuller pill+tooltip in the title line below it. */
.row-type-badge-video-hidden {
  background: rgba(230, 81, 0, 0.85);
}

/* "Promoted" pill (public/browse.js's promotedBadgeHtml, bookmarks.is_promoted
   — migration 047), shown wherever a bookmark's own thumbnail renders
   (originally plain /browse only, unscoped across every mode that file's
   shared shell serves — see promotedBadgeHtml's own comment). Top-left
   corner, since .row-type-badge above already owns bottom-right on this
   same thumbnail.
   Same pill shape (font-size/weight/padding/radius) as .collection-badge
   (styles.css), but a solid, opaque amber fill rather than that class's own
   soft-tinted colors — same "legible sitting on top of an arbitrary photo"
   reasoning .dash-collection-tile-badge's own override already gives for
   the Dashboard's Public/Private pill, just its own color: amber/gold
   rather than green/muted, since this isn't a two-way public/private state,
   just a one-sided "notable" flag, the same convention a "Sponsored" chip
   commonly uses elsewhere on the web. */
.row-promoted-badge {
  position: absolute;
  left: 6px;
  top: 6px;
  font-size: 11.5px;
  font-weight: 600;
  line-height: 1;
  padding: 3px 7px;
  border-radius: 999px;
  color: #fff;
  background: rgba(180, 83, 9, 0.9);
  pointer-events: none;
}

.row-thumb-placeholder svg {
  width: 40%;
  height: 40%;
  max-width: 32px;
  max-height: 32px;
}

/* ---- profile follow bar (/:username only) ---- */

.profile-meta {
  display: flex;
  align-items: center;
  gap: 14px;
  margin-bottom: 14px;
}

/* Wraps memberSince + profileCounts so they stack as their own two-line
   column (X.com-style: join date above, follower/following counts below)
   instead of competing with the Follow button for the same row. */
.profile-meta-text {
  display: flex;
  flex-direction: column;
  gap: 3px;
  min-width: 0;
}

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

/* The follower/following counts (public/browse.js's renderProfileCounts)
   are links to /:username/followers and /:username/following now, not
   plain text — same muted-until-hovered treatment as this app's other
   chrome-colored links (e.g. .profile-collection-count's sibling
   .profile-collection-name is a link in the same "looks like the muted
   text around it, not a call to action" spirit) rather than the accent
   color a "real" link elsewhere on the page would get. */
.profile-counts a {
  color: inherit;
  text-decoration: none;
}

.profile-counts a:hover {
  text-decoration: underline;
}

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

.profile-joined:empty {
  display: none;
}

/* Round 47: #followBtn used the page's plain full-size .btn/.btn-primary
   (styles.css) — at the user's request, now the same small rounded pill
   shape as the Vote/Share/Bookmark/Report buttons elsewhere in this app
   (padding/radius/font-size match .report-pill exactly). Unfollowed state
   is solid accent by default (the inviting default action, same reasoning
   .btn-primary's own comment always gave for this button); the .following
   modifier below flips that to an outline once clicked, the common
   social-app convention for "you're already doing this, click to undo"
   rather than looking like a second equally-weighted call to action. This
   replaces the old .btn-primary/.following toggle (public/browse.js's
   render() now only ever toggles .following — .follow-pill itself is the
   permanent base class, never removed). */
.follow-pill {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  padding: 5px 12px;
  border: 1px solid var(--accent);
  border-radius: 999px;
  background: var(--accent);
  color: var(--accent-contrast);
  font: inherit;
  font-size: 13px;
  font-weight: 600;
  cursor: pointer;
  white-space: nowrap;
}

.follow-pill:hover {
  background: var(--accent-dark);
  border-color: var(--accent-dark);
}

.follow-pill.following {
  background: none;
  border: 1px solid var(--border);
  color: var(--text);
}

.follow-pill.following:hover {
  border-color: var(--accent);
  color: var(--accent);
}

/* Wrapper/heading for every "Collections" section that uses the plain
   thumbnail-tile grid below (.collection-tile-list/.collection-tile) —
   Browse's own "Featured collections", a profile's own public Collections
   row (browse.js's loadProfileCollections), and the Dashboard's own
   read-only preview all share this same heading look; only the grid
   underneath ever differs (a 3-across override for the bookmark permalink
   page's narrower column, see .bm-collections-list below — everyone else
   uses .collection-tile-list's own plain 4/2 default with no override at
   all). Wraps to multiple lines rather than scrolling horizontally, same as
   this app's other card rows. This design used to be a boxed link-card row
   (.profile-collection-card and friends) with its own 4-across/860px-floor
   grid — removed once every user of this section moved to the plainer tile
   design below. */
.profile-collections {
  margin-bottom: 18px;
}

/* The Dashboard's own read-only Collections preview (public/dashboard.html's
   #dashboardCollectionsPanel) shares this section's heading LOOK (.dash-
   header h2, styles.css) and tile grid (.collection-tile-list) with
   .profile-collections above, but never actually carried that class itself
   — its own wrapper is a bare <section>, not a `.profile-collections` div —
   so it never picked up this same margin-bottom, leaving the toolbar
   directly below it with no breathing room at all (Round 31 feedback: "the
   space between the Collections section and the toolbar area should match
   the Browse page"). Same 18px, scoped by id rather than adding the shared
   class to the markup itself, since this section also toggles its own
   `hidden` attribute independently (dashboard.js's loadCollections()) and
   doesn't need any of .profile-collections's other potential future
   meaning riding along. */
#dashboardCollectionsPanel {
  margin-bottom: 18px;
}

.profile-collections-heading {
  font-size: 15px;
  margin: 0 0 8px;
}

/* Bookmark permalink page's own "Public collections" section
   (lib/publicPages.js's bookmarkPublicCollectionCardHtml, .bm-collections —
   carries both .collection-tile-list AND .bm-collections-list, see that
   base class further down) — 3 across on desktop rather than the plain
   .collection-tile-list's 4, same "declared after the base class so source
   order wins on a shared-specificity override" convention as the
   .ad-pos-* classes elsewhere in this file, just its own class here. */
.bm-collections-list {
  grid-template-columns: repeat(3, 1fr);
}

@media (max-width: 860px) {
  .bm-collections-list {
    grid-template-columns: repeat(2, 1fr);
  }
}

@media (max-width: 640px) {
  .bm-collections-list {
    grid-template-columns: 1fr;
  }
}

/* Plain thumbnail-tile Collection design — Browse page's own "Featured
   collections" section (public/browse.js's loadFeaturedCollections/
   collectionTileHtml, #featuredCollectionsList) and the bookmark permalink
   page's own "Public collections" section (lib/publicPages.js's
   bookmarkPublicCollectionCardHtml, which additionally carries
   .bm-collections-list above to keep its own narrower 3-across count) both
   use this in place of the boxed .profile-collection-card design above — no
   border/background/padding and no bookmark count, just a big cover image
   with its title underneath, closer to a video/media tile than a
   settings-style card row. 4 across on desktop, dropping straight to 2 on a
   phone-width screen (no 3-column middle step — this is a simpler, plainer
   grid than the boxed one above, so it doesn't need one). */
.collection-tile-list {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: 16px;
}

@media (max-width: 640px) {
  .collection-tile-list {
    grid-template-columns: repeat(2, 1fr);
  }
}

.collection-tile {
  display: flex;
  flex-direction: column;
  gap: 8px;
  text-decoration: none;
  color: var(--text);
}

/* 16:9 — this app's bookmarks are frequently video links (see "Video
   embeds" elsewhere), so a landscape thumbnail reads as "a preview of what
   you'll watch," same spirit as the toolbar viewer's own video frame,
   rather than the small square icon .profile-collection-cover uses in the
   boxed card design above. */
.collection-tile-thumb {
  /* relative purely so the Dashboard's own .dash-collection-tile-badge
     (below) has something to pin itself to with position:absolute — a no-op
     everywhere else this class is used (Browse's Featured collections, the
     bookmark permalink page's Public collections), since neither of those
     ever has an absolutely-positioned child here. */
  position: relative;
  display: block;
  width: 100%;
  aspect-ratio: 16 / 9;
  border-radius: 10px;
  overflow: hidden;
  background: var(--accent-soft);
}

.collection-tile-thumb img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}

/* Collection-level parity to .row-promoted-badge above (a bookmark's own
   Promoted pill) — same amber, opaque-fill "Sponsored"-chip visual language,
   top-LEFT corner, sized/inset to match .collection-tile-thumb's own
   proportions the same way that badge matches a 72px .row-thumb. Reads
   c.is_promoted, computed live by lib/collections.js's HAS_PROMOTED_SQL
   (true the moment ANY member bookmark inside the Collection is Promoted —
   see that constant's own comment). Unlike the bookmark version's
   /browse-only scoping (showPromotedPill), this one is never gated: every
   collectionTileHtml/collectionCardHtml-shaped function sitewide that reads
   c.is_promoted renders it unconditionally, so a sponsor's reach through a
   Collection shows everywhere that Collection itself surfaces. .collection-
   tile-thumb already carries position:relative for .dash-collection-tile-
   badge's own sake (see that class's own comment just below), so this needs
   no extra positioning context of its own. Manage Collections' own much
   smaller 56x56 cover uses a separate, scaled-down
   .collection-card-promoted-badge instead (styles.css) — this full size
   would badly overflow that one corner. */
.collection-tile-promoted-badge {
  position: absolute;
  left: 8px;
  top: 8px;
  font-size: 11.5px;
  font-weight: 600;
  line-height: 1;
  padding: 3px 7px;
  border-radius: 999px;
  color: #fff;
  background: rgba(180, 83, 9, 0.9);
  pointer-events: none;
}

.collection-tile-thumb-placeholder {
  width: 100%;
  height: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
  color: var(--text-muted);
}

.collection-tile-thumb-placeholder[hidden] {
  display: none;
}

.collection-tile-thumb-placeholder svg {
  width: 22%;
  height: 22%;
  max-width: 32px;
  max-height: 32px;
}

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

.collection-tile-title {
  font-size: 14px;
  font-weight: 600;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* The bookmark-count-plus-page-views line under .collection-tile-title —
   shared by every plain .collection-tile user that now shows it (Browse's
   Featured collections and the /:username profile's own Collections
   section, both public/browse.js's own collectionTileHtml; the /collections
   gallery's Popular Collections row, public/collections.js's own copy; and
   the Dashboard's own dashCollectionCardHtml). Same muted small-print
   treatment as .row-views/.public-collection-card-count elsewhere on the
   site, so a plain tile's new stat line reads as the same kind of
   secondary metadata every other card's count already does. */
.collection-tile-meta {
  font-size: 12px;
  color: var(--text-muted);
}

/* /collections — the sitewide public gallery (public/collections.html/.js),
   not any one account's own private create/edit UI (that's
   manageCollections.html/.js now, at /collections/manage — see that file's
   own top comment for the split). This is the one place on the site a
   visitor is actually choosing AMONG many different Collections rather than
   already looking at/inside one, so its card carries three lines a plain
   .collection-tile never needs (curator, description, bookmark count) on
   top of the same cover-image-and-title look every other tile-based
   Collection listing already uses — reuses .collection-tile-thumb/
   .collection-tile-thumb-placeholder/.collection-tile-title verbatim
   (collections.js's own collectionCardHtml) rather than duplicating that
   markup/CSS a second time, so this also inherits their existing mask-mode
   coverage for free (see this file's own mask-mode section further down) —
   only the three NEW text lines below need their own mask rules added
   there. Bordered/backed card rather than a bare tile (unlike
   .collection-tile) since a plain thumbnail-and-title with no visual
   boundary starts looking sparse once three more lines of text sit under
   it — same "give it an actual card edge" instinct .panel already applies
   sitewide to a block of content greater than a single stat or label. */
.public-collection-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
  gap: 16px;
}

.public-collection-card {
  display: flex;
  flex-direction: column;
  text-decoration: none;
  color: var(--text);
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 10px;
  overflow: hidden;
  transition: border-color 0.15s ease;
}

.public-collection-card:hover {
  border-color: var(--accent);
}

.public-collection-card:hover .collection-tile-title {
  color: var(--accent);
}

/* .collection-tile-thumb is normally a standalone tile's own top-level
   element (position: relative purely as an absolute-positioning anchor for
   the Dashboard's own Public/Private pill — see its own comment above) —
   here it's the first flex child of a taller card instead, so the 16:9
   aspect-ratio box itself is untouched, just no longer the outermost
   element. flex: 0 0 auto keeps it from being squashed by
   .public-collection-card-body's own text below when a long
   name/description pushes the card taller than its neighbors in the same
   grid row. */
.public-collection-card .collection-tile-thumb {
  flex: 0 0 auto;
}

.public-collection-card-body {
  display: flex;
  flex-direction: column;
  gap: 4px;
  padding: 10px 12px 12px;
}

/* .collection-tile-title's own overflow:hidden/white-space:nowrap already
   clips a long name to one line, same as every other tile-based Collection
   listing — no override needed here, just inherited by nesting it inside
   this card's own padded body instead of a tile's bare bottom row. */

.public-collection-card-owner {
  font-size: 12px;
  color: var(--text-muted);
}

.public-collection-card-desc {
  font-size: 13px;
  color: var(--text-muted);
  /* Clamps to 2 lines rather than the single-line ellipsis
     .collection-tile-title uses — a description reads fine broken across a
     couple of lines the way a short title wouldn't, and clamping (not
     letting it run on) is what actually keeps every card in a row the same
     rough height despite wildly different description lengths. */
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

.public-collection-card-count {
  font-size: 12px;
  color: var(--text-muted);
  margin-top: 2px;
}

/* Dashboard's own Collections row (public/dashboard.js's
   dashCollectionCardHtml, dashboard.html's #dashboardCollectionsList) —
   reuses .collection-tile-list/.collection-tile/.collection-tile-thumb/
   .collection-tile-title exactly as-is (same 4-across-desktop/2-across-
   mobile grid and plain thumbnail-on-top/title-below look as Browse's own
   "Featured collections" section), with just this one addition: a
   Public/Private pill overlaid in the thumbnail's top-right corner, since
   this is the one place among this design's three users (Browse, the
   bookmark permalink page, here) that ever shows a mix of public AND
   private items needing a way to tell them apart at a glance — the other
   two only ever list already-known-public collections. Reuses
   .collection-badge/.collection-badge-public/.collection-badge-private's
   own pill shape (styles.css) for the text/padding/radius, just
   repositioned and recolored below for legibility sitting on top of an
   arbitrary photo instead of this app's own plain surface background. */
.dash-collection-tile-badge {
  position: absolute;
  top: 8px;
  right: 8px;
}

/* Overrides .collection-badge-public/.collection-badge-private's own
   tinted-on-plain-background colors (styles.css) — those read fine on
   .profile-collections' own card surface, but the same soft tint has poor
   contrast sitting directly on top of an arbitrary cover photo (or this
   design's own muted placeholder background) the way this overlay badge
   does. Solid, mostly-opaque fills plus white text keep it legible against
   any cover image. Declared after styles.css's own rules load (this file
   loads second — see frame.html/dashboard.html's own <link> order) and
   matches on the same two classes plus this one, so specificity/source
   order both already favor this without an extra qualifier needed. */
.dash-collection-tile-badge.collection-badge-public {
  color: #fff;
  background: rgba(46, 125, 50, 0.85);
}

.dash-collection-tile-badge.collection-badge-private {
  color: #fff;
  background: rgba(0, 0, 0, 0.6);
}

/* Dashboard's own bookmark thumbnails (public/dashboard.js's
   dashBookmarkBadgeHtml — round-32 punchlist) get the same Public/Private
   pill as .dash-collection-tile-badge above, same reasoning (this is the one
   place among this design's bookmark-thumbnail users — Browse, a profile
   page, here — that ever mixes public AND private rows, so it's the one
   place worth telling them apart at a glance). Top-left corner rather than
   top-right, since a bookmark thumbnail's bottom-right is already
   .row-type-badge's spot. Reuses .collection-badge/-public/-private's own
   pill shape/colors (styles.css) with the same solid-fill overrides just
   above for legibility over an arbitrary thumbnail image. */
.dash-bookmark-tile-badge {
  position: absolute;
  left: 6px;
  top: 6px;
}

.dash-bookmark-tile-badge.collection-badge-public {
  color: #fff;
  background: rgba(46, 125, 50, 0.85);
}

.dash-bookmark-tile-badge.collection-badge-private {
  color: #fff;
  background: rgba(0, 0, 0, 0.6);
}

/* Dashboard's own bookmark-tile Promoted pill (dashboard.js's
   dashBookmarkPromotedBadgeHtml) — the one thumbnail-badge context where
   .row-promoted-badge's own top-left corner is already taken, by
   .dash-bookmark-tile-badge (Public/Private) just above. Bottom-left is the
   one corner still free in both of this page's view modes: bottom-right is
   .row-type-badge's spot in both, and grid view alone also claims top-right
   for its own delete ✕ (.list.grid-view .row-delete below) — since this
   same badge markup is shared between list/grid view by one function, the
   corner has to be free in both, and bottom-left is the only one that is.
   Own class rather than reusing .row-promoted-badge directly, same
   "solid-fill + its own corner per context" convention every other
   thumbnail badge on this page already follows — but the same pill
   shape/color as that class, since it's the same amber "notable" flag,
   just relocated. */
.dash-bookmark-tile-promoted-badge {
  position: absolute;
  left: 6px;
  bottom: 6px;
  font-size: 11.5px;
  font-weight: 600;
  line-height: 1;
  padding: 3px 7px;
  border-radius: 999px;
  color: #fff;
  background: rgba(180, 83, 9, 0.9);
  pointer-events: none;
}

/* /:username/followers and /:username/following (public/followers.html+.js)
   — one row per listed account, same "small card, not a bare link" spirit
   as .profile-collection-card above, but a vertical list (one full-width
   row each) rather than a wrapping row of cards: a person's name is the
   one thing here, there's no cover image or description competing for
   width the way a Collection card has, so a list reads better than a grid
   of squarish cards would. */
.follow-user-list {
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.follow-user-card {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 10px;
  padding: 10px 14px;
}

.follow-user-link {
  display: flex;
  align-items: center;
  gap: 10px;
  min-width: 0;
  text-decoration: none;
  color: var(--text);
}

.follow-user-link:hover .follow-user-name {
  text-decoration: underline;
}

/* Generic person glyph — this app has no avatar/profile-picture concept at
   all, so every row gets the exact same placeholder rather than a per-row
   image that would need its own onerror/fallback handling for nothing
   (there's never a real image URL here to fail in the first place). Same
   circular-icon-in-a-tinted-square treatment as
   .profile-collection-cover-placeholder, just round instead of the
   Collection cover's rounded-square, so a glance tells "account" and
   "collection" rows apart even before reading either one's text. */
.follow-user-avatar {
  width: 36px;
  height: 36px;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--accent-soft);
  color: var(--text-muted);
  flex: 0 0 auto;
}

.follow-user-avatar svg {
  width: 55%;
  height: 55%;
}

.follow-user-name {
  font-size: 14px;
  font-weight: 600;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* Same solid-then-outline Follow/Unfollow convention as #followBtn.following
   in the profile-meta block above, just scoped to this list's own buttons
   (one per row, not a single #followBtn) instead of an id selector. */
.follow-row-btn.following {
  background: none;
  border: 1px solid var(--border);
  color: var(--text);
}

/* ---- sort toggle (Newest / Top / Popular / Following) + Random ---- */

.sort-toggle {
  display: flex;
  gap: 4px;
  flex: 0 0 auto;
}

.sort-btn {
  padding: 9px 14px;
  font-size: 13px;
  /* #randomLink shares this class (an <a>, not a <button>) so it matches
     the sizing/look of the four real sort buttons beside it — white-space
     keeps its "🎲 Random" text (and any sort button's own label) from
     wrapping mid-word if this row ever gets tight, since that would look
     far worse than the row just needing to scroll (see .toolbar's own
     mobile rule below). */
  white-space: nowrap;
}

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

/* ---- List/Grid layout toggle ---- */

/* Same active-state look as .sort-btn above, just narrower — these two
   buttons are icons, not words, so they don't need .sort-btn's wider
   horizontal padding. */
.view-toggle {
  display: flex;
  gap: 4px;
  flex: 0 0 auto;
}

.view-toggle-btn {
  padding: 9px 12px;
  font-size: 14px;
  line-height: 1;
}

.view-toggle-btn.active {
  background: var(--accent);
  border-color: var(--accent);
  color: var(--accent-contrast);
}

/* Collection detail page's own copy of this same toggle (public/
   collectionDetail.js — see that file's own comment on why it isn't just
   /browse's), sitting on the same line as the plain-text "n bookmarks"
   count rather than a whole toolbar of its own. .count-row is that shared
   line: flex + space-between puts the count on the left and the toggle
   (when hydrated in) on the right, replacing .count's own margin-bottom
   with one on the row itself so the two stay vertically aligned instead of
   the count's text baseline sitting lower than the toggle buttons next to
   it. */
.count-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  margin-bottom: 12px;
}

.count-row .count {
  margin-bottom: 0;
}

/* Wraps #collectionCount together with its own views badge (lib/
   publicPages.js's renderCollectionPage) as ONE flex child of .count-row —
   see that function's own comment on why the badge has to sit outside
   #collectionCount itself (collectionDetail.js's initTypeFilter overwrites
   that element's whole textContent on every filter change). Plain inline
   flex, not .count-row's own space-between — this pair reads as one
   left-hand cluster, same as it would if it were still a single element. */
.count-with-views {
  display: flex;
  align-items: center;
  gap: 8px;
}

/* Groups the type-filter icons and the Grid/List toggle together on the
   right side of .count-row, so .count-row itself still only has two flex
   children (the count, and this group) for its own space-between rule
   above to split between — flex-wrap here (not on .count-row itself) lets
   just this pair drop to its own line under a long count string on a
   narrow screen, rather than the count and the whole controls cluster
   fighting for the same line down to an awkward width. */
.count-row-controls {
  display: flex;
  align-items: center;
  gap: 10px;
  flex-wrap: wrap;
}

/* ---- Type filter icons (Video / Image gallery / Page) ---- */

/* Same button-group look as .view-toggle right beside it (flex + 4px gap) —
   sits to its left per the request that put these two groups on the same
   line. */
.type-filter {
  display: flex;
  gap: 4px;
  flex: 0 0 auto;
}

/* Same icon-button sizing as .view-toggle-btn (9px/12px padding, 14px
   font) — these two groups read as one continuous cluster of icon
   buttons, so they share a size rather than one looking like an
   afterthought next to the other. */
.type-filter-btn {
  padding: 9px 12px;
  font-size: 14px;
  line-height: 1;
}

/* Same active-state look as .view-toggle-btn.active/.sort-btn.active —
   "on" for a given type filter is the identical visual language this page
   already uses for "the currently selected" state everywhere else. */
.type-filter-btn.active {
  background: var(--accent);
  border-color: var(--accent);
  color: var(--accent-contrast);
}

/* A notch smaller than the plain .view-toggle-btn size (9px/12px padding,
   14px font, just above) — this sits next to a single line of small text
   ("n bookmarks") rather than a whole toolbar row, so full-size icon
   buttons would visually overpower it. Scopes down the padding/font on
   .view-toggle-btn specifically WITHIN a .view-toggle-compact wrapper,
   rather than touching .view-toggle-btn itself, in case a future full-size
   use ever needs the plain, unscoped size again. Originally the Collection
   detail page's own size only; /browse, /tag/:name, a /:username profile,
   and the Dashboard now carry this same modifier class on their own
   .view-toggle (and, alongside it, .type-filter-compact just below) so all
   five pages' copies of this row match exactly, rather than the larger
   size this row used when it first moved next to "n bookmarks" here. */
.view-toggle-compact {
  gap: 3px;
}

.view-toggle-compact .view-toggle-btn {
  padding: 5px 8px;
  font-size: 12px;
}

/* Same compact-size relationship to .type-filter as .view-toggle-compact
   has to .view-toggle just above — same wrapper-scoped override pattern,
   same exact padding/font values, so the two icon-button groups stay
   visually identical in size wherever they sit side by side. */
.type-filter-compact {
  gap: 3px;
}

.type-filter-compact .type-filter-btn {
  padding: 5px 8px;
  font-size: 12px;
}

/* .type-filter-wrap is the positioning anchor (position: relative) both the
   mobile popover trigger (#typeFilterMobileBtn) and the popover itself (the
   real .type-filter group, reused as-is rather than duplicated — see
   below) sit inside. Desktop: purely a pass-through wrapper, no visual
   effect of its own — the mobile button stays display:none (below) and
   the real group renders inline exactly as it already did before this
   wrap existed. */
.type-filter-wrap {
  position: relative;
  display: flex;
  align-items: center;
}

.type-filter-mobile-btn {
  display: none;
}

/* Below 640px, .count-row's three groups (count, type-filter, view-toggle)
   no longer fit "N bookmarks 🎥🖼️🔖 ▦☰" on one line without wrapping onto a
   second — with the type-filter icons now also in that row (this round's
   own addition), that wrap became the common case at phone width rather
   than a rare one. Collapsing the three individual type buttons behind one
   #typeFilterMobileBtn (showing all three glyphs small, so it still reads
   as "the type filter" at a glance) frees up exactly the width the row
   needs to stay on one line — same fix in spirit to .toolbar's own
   overflow-x:auto treatment just below, a different mechanism because
   there's no sane way to horizontally scroll a row that also has to keep
   "N bookmarks" readable at its start. */
@media (max-width: 640px) {
  .type-filter-mobile-btn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: 1px;
    font-size: 11px;
    padding: 5px 6px;
    line-height: 1;
  }

  /* Hidden by default at this width — public/browse.js's/
     public/dashboard.js's initTypeFilterPopover toggles the same `.open`
     class the notification bell's own dropdown pattern already uses
     (app.js's hydrateNotifications) on #typeFilterMobileBtn's click, and
     closes it the same "click anywhere outside" way. Reusing the real
     .type-filter group here (not a second copy of the three buttons) means
     there's only ever one #typeFilterVideo/Gallery/Page in the DOM either
     way — clicking one inside the open popover calls the exact same
     toggleType() handler it always did. */
  .type-filter {
    display: none;
  }

  .type-filter.open {
    display: flex;
    position: absolute;
    top: calc(100% + 6px);
    right: 0;
    z-index: 20;
    background: var(--surface);
    border: 1px solid var(--border);
    border-radius: 10px;
    padding: 6px;
    box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
  }
}

/* ---- Keeping the toolbar compact on a phone-width screen ----
   Search shares its row with the Grid/List toggle right beside it (both
   flex:0-ish already, search itself is flex:1 — styles.css), and the sort/
   Random button group drops to its own full-width row below rather than
   competing for space on the first one. That second row must NOT then wrap
   its five buttons onto a third (and fourth...) line — .sort-toggle's own
   default flex-wrap is already "nowrap" (browsers' flex default), so
   without a width constraint its buttons would instead silently overflow
   past the edge of the screen. overflow-x:auto turns that into a
   horizontal scroll confined to just this one row instead, so the row
   itself never grows the page wider than the viewport, and every button
   stays reachable even on the narrowest phone. min-width:0 on the search
   input overrides a text input's own intrinsic minimum width (a browser
   default wide enough to fight this layout on the narrowest screens),
   letting it actually shrink to share row one with the view toggle. */
@media (max-width: 640px) {
  .toolbar input[type="text"] {
    min-width: 0;
  }

  .toolbar .sort-toggle {
    flex: 1 1 100%;
    overflow-x: auto;
    -webkit-overflow-scrolling: touch;
    padding-bottom: 2px; /* keeps a scrollbar (where the platform shows one) from crowding the buttons above it */
  }
}

/* Grid view reuses the exact same .row markup list view renders
   (browse.js's renderList never branches on view mode) — everything below
   is pure reflow. flex `order` moves the thumbnail from its list-view spot
   on the right to the top of the card, and drops the vote widget out of
   the left margin into its own row underneath the text. A card with no
   thumbnail (item.image_url unset) just renders shorter, the same as a
   thumbnail-less row already does in list view. */
.list.grid-view {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
  gap: 16px;
}

.list.grid-view .row {
  flex-direction: column;
  align-items: stretch;
}

/* .empty (styles.css) is the "No bookmarks yet"/"No bookmarks tagged #X"
   message dashboard.js/browse.js drop straight into #list in place of any
   .row markup. #list.list.grid-view above turns EVERY direct child into a
   grid item, .empty included — without this it was implicitly sized to one
   220px-ish column like a card would be, which is why its one-line message
   was wrapping onto several lines in a narrow box instead of reading as a
   normal full-width line the way it does in list view. `grid-column: 1 / -1`
   spans it across every column the grid currently has, regardless of how
   many that ends up being at any given viewport width. */
.list.grid-view .empty {
  grid-column: 1 / -1;
}

/* "browse_middle" (lib/adSlots.js), dropped into #list part-way through the
   results by public/browse.js's renderList() — same "span every column
   regardless of how many the grid currently has" reasoning, and the same
   fix, as .list.grid-view .empty just above. List view (not .grid-view)
   needs nothing extra here — .ad-slot's own block-level layout already
   spans full width between two .row siblings there. */
.list.grid-view .ad-slot-middle {
  grid-column: 1 / -1;
}

/* "collections_middle" (lib/adSlots.js), dropped into #list part-way
   through the results by public/collections.js's own load() — same "span
   every column regardless of how many the grid currently has" reasoning as
   .list.grid-view .ad-slot-middle just above, but that rule doesn't apply
   here: /collections' own grid (public/collections.html) is always
   .public-collection-grid, with no Grid/List toggle (and so no .grid-view
   class) the way /browse's or a single Collection's own list has — needs
   its own copy of the same fix rather than being folded into that
   selector. */
.public-collection-grid .ad-slot-middle {
  grid-column: 1 / -1;
}

/* Same "span every column" fix as .list.grid-view .empty above, for this
   grid's own empty-state message (public/collections.js's own load(), the
   "You're not following anyone yet"/"No public collections yet" text) —
   missed when that fix originally landed, since .public-collection-grid is
   always a grid with no .grid-view class to key a selector off of (see the
   comment on .public-collection-grid .ad-slot-middle just above for why
   this needs its own copy rather than being folded into the shared rule).
   Without this, the message sat in a single ~220px grid cell like a card
   would, wrapping onto several lines instead of reading as one full-width
   line. */
.public-collection-grid .empty {
  grid-column: 1 / -1;
}

/* Bleeds the thumbnail to the card's edges using the row's own padding
   (12px 14px, from styles.css's .row) as the bleed amount, so it reads as
   a real image card header rather than an inset picture. */
.list.grid-view .row-thumb {
  order: -1;
  width: calc(100% + 28px);
  height: 180px;
  margin: -12px -14px 10px;
  border-radius: 10px 10px 0 0;
  border: none;
}

.list.grid-view .row-main {
  order: 1;
}

/* Round 48: same rounded-pill container as the Vote widget's own
   .horizontal variant on the Bookmark/Collection/View pages (browse.css's
   own comment on .vote-widget.horizontal) — the user's own "it should be
   consistent on its other appearances" request, and this grid card's own
   row-direction vote widget (▲ N ▼ side by side, same as .horizontal) is
   the natural match for that pill treatment: same orientation, just a
   different context. The narrow, vertical (column-direction) list-VIEW
   vote widget — /browse's List toggle, a plain 3-line ▲/count/▼ stack in a
   ~20px-wide sidebar column — is deliberately left alone here: wrapping
   that different, vertical shape in the same horizontal pill wouldn't read
   as "the same button," and doing so would need reworking that column's
   fixed width across every page that reuses it (browse/tag/model/site/
   profile/dashboard/activity rows). Flagged for the user rather than
   guessed at silently. */
.list.grid-view .vote-widget {
  order: 2;
  flex-direction: row;
  justify-content: flex-start;
  gap: 8px;
  margin-top: 10px;
  padding: 5px 12px;
  border: 1px solid var(--border);
  border-radius: 999px;
  background: var(--surface);
  width: fit-content;
}

/* Same reflow slot as .vote-widget just above (order: 2, after the
   thumbnail and the title/url block) — collectionDetail.js's own
   .row-private-marker takes that widget's place on a private member
   bookmark's row, so it needs the exact same repositioning in grid view or
   it'd default to sitting between the thumbnail and the title instead. */
.list.grid-view .row-private-marker {
  order: 2;
  margin-top: 10px;
}

/* .row-tags only ever exists on the Dashboard's own rows (public/
   dashboard.js) — a <textarea> now, not an <input> (see that file's own
   comment on the swap), so it already wraps a long tag list onto multiple
   lines instead of clipping/scrolling horizontally the way the old
   single-line input did. This just gives it more room to actually show
   those wrapped lines by default in the narrower grid card column, rather
   than starting at the same one-line height list view keeps (rows="1" in
   the markup) and relying on someone noticing the resize handle
   (.row-tags/.row-notes's shared resize: vertical, styles.css). List view
   is untouched — its rows are already wide enough that most tag lists fit
   on one line anyway. */
.list.grid-view .row-tags {
  min-height: 54px;
}

/* Same reasoning as .list.grid-view .row-tags just above, for the Models
   field's own textarea — typically shorter than a tag list, so a smaller
   minimum height is enough to avoid the same clipped/scrolled feel. */
.list.grid-view .row-models {
  min-height: 36px;
}

/* .row-delete only ever exists on the Dashboard's own rows (styles.css) —
   public/browse.js's rows have no delete action, so this never matches
   there. Without an explicit order, it would land between the thumbnail
   (order -1) and .row-main (order 1) in the column flow above — a full-
   width "✕" sliver wedged between the image and the text, which reads as
   broken rather than as a delete control. Overlaying it on the thumbnail's
   own top-right corner instead (the common "remove this card" convention)
   keeps it out of the card's text flow entirely. A thumbnail-less row still
   has something to overlay onto — thumbPlaceholderHtml's placeholder span
   carries the same .row-thumb class as a real image (dashboard.js), so it
   gets the exact same 180px card-header treatment above. */
.list.grid-view .row {
  position: relative;
}

.list.grid-view .row-delete {
  position: absolute;
  /* Round 50: was 8px, reported as not lining up with
     .dash-bookmark-tile-badge's own top:6px (styles.css) on the opposite
     corner of the same thumbnail header. The two aren't measured from the
     same edge, though, so simply copying "6px" doesn't actually align them:
     the badge's `top` is relative to .row-thumb-wrap's own padding edge,
     which itself sits `.row`'s own border (1px) + padding-top (12px) below
     THIS element's containing block — .row itself, whose padding edge is
     only 1px (border-width) below .row's outer edge. Matching the two
     absolute top edges means adding that 12px difference back in:
     1px + 18px (here) = 19px from .row's outer edge, same as
     1px + 12px + 6px (the badge's path) = 19px. Confirmed against real
     measured bounding boxes (Playwright), not just the arithmetic. */
  top: 18px;
  right: 8px;
  background: rgba(0, 0, 0, 0.55);
  color: #fff;
  z-index: 1;
}

.list.grid-view .row-delete:hover {
  background: #c62828;
}

/* ---- up/down vote widget ---- */

/* Shown wherever a public bookmark is listed — /browse, /tag/:name,
   /:username row lists (public/browse.js) and the /bookmark/:id permalink
   page (lib/publicPages.js's renderVoteWidget, with the .horizontal
   modifier below). Not shown on the dashboard — voting is a public/social
   action on someone ELSE's content; you can't vote on your own bookmark
   anyway (routes/votes.js rejects it, and app.js hides the widget for it).
   Colors are fixed hex, not var(--accent) — --accent flips to a pink/red in
   dark mode (see styles.css's :root[data-theme="dark"]), which would read
   oddly next to a red downvote, the same reason .row-delete's hover
   (styles.css) uses a fixed red rather than the theme accent. */
.vote-widget {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 2px;
  flex: 0 0 auto;
}

/* Round 47: the horizontal variant (the Bookmark permalink page, the
   Collection detail page, and — via this same class, since /view/:id loads
   browse.css too — the video-embed stats bar) now gets the same rounded
   pill container as the Share/Report/Bookmark buttons it sits next to on
   all three pages (padding/border/radius/background match .report-pill/
   frame.css's .video-report-btn/.video-bookmark-btn exactly), so Vote reads
   as the same family of button instead of being the one plain, unbordered
   item in the row. The plain (non-horizontal) .vote-widget above — list
   rows/cards everywhere else in the app — is untouched; this is scoped to
   the compound .horizontal class specifically. */
.vote-widget.horizontal {
  flex-direction: row;
  gap: 8px;
  margin-bottom: 14px;
  padding: 5px 12px;
  border: 1px solid var(--border);
  border-radius: 999px;
  background: var(--surface);
}

/* Collection detail page only (lib/publicPages.js's renderCollectionPage) —
   wraps the vote widget together with the Report button (round 46: moved
   here from the topbar above) so the two read as "actions on this
   collection" in one row, rather than Report sitting up among page
   navigation (Back/View →). Round 48: the 14px gap down to `.count-row`
   below now lives HERE instead of on `.vote-widget.horizontal`'s own
   margin-bottom (see the override just below) — that asymmetric bottom-only
   margin on just one of this row's two flex items meant `align-items:
   center` was centering each item's own MARGIN box, not just its visible
   pill, which put the actual Vote pill about 7px higher than the Report
   pill next to it (half the unmatched 14px). Moving the gap to the row's
   own margin instead of one child's fixes the misalignment without losing
   any spacing to `.count-row`. */
.collection-vote-row {
  display: flex;
  align-items: center;
  gap: 10px;
  flex-wrap: wrap;
  margin-bottom: 14px;
}

.collection-vote-row .vote-widget {
  margin-bottom: 0;
}

/* Small pill treatment for a Report button sitting inline next to a vote
   widget — a local copy of frame.css's `.video-report-btn` (the /view/:id
   video-embed stats bar's own Report control) under its own name, since
   this page (and the bookmark permalink page's own copy, below) never loads
   frame.css. Used by the Collection detail page's #collectionReportBtn
   (renderCollectionPage) and the Bookmark permalink page's own Report
   button (renderBookmarkPage, further down this file) — the same small
   pill either place a Report control sits next to a vote widget/action row
   rather than up in a page's topbar. */
.report-pill {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  padding: 5px 12px;
  border: 1px solid var(--border);
  border-radius: 999px;
  background: var(--surface);
  color: var(--text);
  font: inherit;
  font-size: 13px;
  font-weight: 600;
  cursor: pointer;
  white-space: nowrap;
}

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

/* public/collectionDetail.js's own stand-in for .vote-widget on a PRIVATE
   member bookmark, in the owner's own hydrated private-collection view —
   same fixed-width left-column slot in .row's flex layout, sized to roughly
   match .vote-widget's own natural width so public and private rows in the
   same list stay visually aligned instead of the private ones' first
   column jumping narrower. */
.row-private-marker {
  flex: 0 0 auto;
  width: 28px;
  text-align: center;
  font-size: 14px;
  color: var(--text-muted);
}

.vote-btn {
  background: none;
  border: none;
  padding: 2px 4px;
  cursor: pointer;
  color: var(--text-muted);
  font-size: 13px;
  line-height: 1;
  border-radius: 4px;
  font-family: inherit;
}

/* Round 50: reported — hovering Up vs Down used to give identical feedback
   (a single shared .vote-btn:hover rule, one neutral tint for both), so it
   wasn't clear which direction you were about to click. Split into a
   directional pair matching the green/red pairing .vote-up.vote-active/
   .vote-down.vote-active already use for the ACTIVE (already-voted) state
   just below — hovering now previews the same color the button locks into
   on click. rgba tints, not var(--accent), for the same reason those two
   active-state rules use fixed hex: --accent flips to pink/red in dark
   mode, which would clash with a red downvote. The green tint matches
   .collection-badge-public's own rgba(46, 125, 50, 0.12) background
   exactly, with a parallel red for Down, rather than inventing new values.
   .vote-up/.vote-down are the same classes across every appearance this
   widget has (plain list rows, the .horizontal pill, grid view, the
   Collection page's .collection-vote-row) with no local overrides
   anywhere, so this one change reaches all of them. */
.vote-up:hover:not(:disabled) {
  background: rgba(46, 125, 50, 0.12);
  color: #2e7d32;
}

.vote-down:hover:not(:disabled) {
  background: rgba(198, 40, 40, 0.12);
  color: #c62828;
}

.vote-btn:disabled {
  opacity: 0.5;
  cursor: default;
}

.vote-up.vote-active {
  color: #2e7d32;
}

.vote-down.vote-active {
  color: #c62828;
}

.vote-score {
  font-size: 12px;
  font-weight: 700;
  color: var(--text);
  min-width: 1.4em;
  text-align: center;
}

.pagination {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 16px;
  margin: 24px 0 8px;
}

.pagination button {
  cursor: pointer;
}

.pagination button:disabled {
  opacity: 0.4;
  cursor: default;
}

#pageInfo {
  font-size: 13px;
  color: var(--text-muted);
}

/* ---- tag directory (/tags) ---- */

/* CSS multi-column rather than a grid/flex-wrap — the tag list is a single
   alphabetical sequence (not a set of independent cards), and columns keep
   it reading top-to-bottom-then-across, like a phone book or an index,
   which is the order someone scanning for a specific letter expects.
   column-width (not column-count) lets the browser pick however many
   columns actually fit at the current window size — 2 on a narrow phone,
   4+ on a wide desktop — rather than a fixed count that's cramped or wastes
   space. */
.tag-index {
  column-width: 200px;
  column-gap: 28px;
}

.tag-index-item {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 8px;
  padding: 7px 0;
  border-bottom: 1px solid var(--border);
  text-decoration: none;
  color: var(--text);
  font-size: 13.5px;
  /* Keeps one tag's name+count together rather than splitting across the
     column break — the single biggest thing that makes a CSS-columns list
     look broken if left to the default. */
  break-inside: avoid;
}

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

.tag-index-name {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  min-width: 0;
}

/* Sites-only variant of the row above (public/sites.js) — this page adds an
   external-link icon (.site-external-link below) next to the name, which
   can't nest inside .tag-index-item's own <a> the way Tags'/Models' plain-
   link rows do (an <a> inside an <a> is invalid HTML and browsers reparent
   it unpredictably), so a Sites row is a <div class="tag-index-item">
   instead — every other .tag-index-item rule above (flex row, padding,
   border, break-inside) already works identically on a div, nothing there
   needed to change. .tag-index-name-group takes over "the left, space-
   between side" .tag-index-name alone used to be, now holding the name
   link AND the icon together as one flex item, so .tag-index-item's own
   justify-content: space-between still has exactly two children (this
   group, and .tag-index-count) to work with, same as before. */
.tag-index-name-group {
  display: flex;
  align-items: baseline;
  gap: 4px;
  overflow: hidden;
  min-width: 0;
}

.tag-index-link {
  text-decoration: none;
  color: inherit;
  overflow: hidden;
  min-width: 0;
}

.tag-index-link:hover .tag-index-name {
  text-decoration: underline;
}

/* The small "visit this site" icon itself — next to a site's name on
   /sites (public/sites.js) and in the "Bookmarks from X ↗" banner on
   /site/:name (public/browse.js) — a separate, sibling <a> from the
   name/filter link right next to it (never nested — see this file's own
   comment above), so clicking it goes straight to the site's own homepage
   instead of filtering this app's results. Deliberately plain/muted rather
   than styled like a pill — this is a small affordance, not another
   clickable chip competing with the name for attention. Absent entirely
   (not just hidden) for a site with no url set (lib/sites.js's own
   resolveSiteId/updateSiteDetails) — public/sites.js and browse.js each
   only render this markup when one exists. */
.site-external-link {
  flex: 0 0 auto;
  color: var(--text-muted);
  text-decoration: none;
  font-size: 12px;
  line-height: 1.4;
}

.site-external-link:hover {
  color: var(--accent);
}

.tag-index-count {
  flex: 0 0 auto;
  color: var(--text-muted);
  font-size: 12px;
  font-variant-numeric: tabular-nums;
}

/* ---- bookmark permalink page (/bookmark/:id) ---- */

/* Same 640px column and centering as .bm-card below, so the two read as
   stacked rows rather than a toolbar off on its own — "Back to browse" on
   the left (where you came from), "Random" on the right (where to go
   next). Deliberately `flex-wrap: nowrap` — both buttons together (even
   "← Back to browse", the longer of the two) comfortably fit side by side
   well below this app's own 400px mobile-width floor, so there's no real
   screen this needs to drop to two rows for; wrap used to be allowed here
   "for a narrow screen," but in practice it just meant a slightly-too-eager
   reflow left "🎲 Random" stranded alone on its own line under "← Back to
   browse" on an ordinary phone width, splitting what reads as one topbar
   into two. `gap` stays, in case a much longer future button label ever
   does need the wrap this specific pair no longer does. */
.bm-topbar {
  display: flex;
  flex-wrap: nowrap;
  justify-content: space-between;
  align-items: center;
  gap: 8px;
  max-width: 640px;
  margin: 16px auto 0;
}

/* .tag-banner is shared with /tag/NAME (browse.html), which is full-width
   within its own container — scoped to just after .bm-topbar so it picks
   up this page's narrower 640px column instead of widening unexpectedly. */
.bm-topbar + .tag-banner {
  max-width: 640px;
  margin: 12px auto 0;
}

/* ---- Collection detail page (/:username/collections/:slug) ---- */

/* Same .bm-topbar base as the bookmark permalink page above, but this
   page's own content below (.list, same class /browse's rows use) runs the
   full .container width, not the narrower 640px column .bm-topbar defaults
   to for that single-card page — overridden here to match instead of
   looking like a stray, off-width toolbar sitting above a wider body.
   Wrapping is allowed too, unlike that page's own deliberate nowrap: this
   topbar carries three links, not two, and the first one alone ("← Back to
   @username's profile") is already close to that page's own longer
   label — three of them together don't reliably fit one line down to this
   app's 400px mobile floor the way that page's two shorter buttons do. */
.collection-topbar {
  max-width: none;
  flex-wrap: wrap;
}

.bm-card {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 12px;
  padding: 24px;
  width: 100%; /* see .ad-slot's comment above — same flex-shrink-wrap fix, needed here too since #videoCard reuses this class. Safe with padding: styles.css's global `* { box-sizing: border-box }` already applies here. */
  margin: 16px auto 24px;
  max-width: 640px;
}

.bm-image {
  width: 100%;
  max-height: 320px;
  object-fit: cover;
  border-radius: 8px;
  border: 1px solid var(--border);
  margin-bottom: 16px;
}

/* Wraps .bm-image/.bm-image-placeholder (lib/publicPages.js's
   renderBookmarkPage) — display:block since the <a> it replaces was
   previously bare/unstyled and relied on its image child's own block-ish
   sizing; position:relative purely so .bm-play-badge below has something to
   center itself against with position:absolute. No margin/sizing of its own
   beyond that — everything visual still comes from the .bm-image/
   .bm-image-placeholder child exactly as before. */
.bm-image-wrap {
  position: relative;
  display: block;
}

/* Video-embed bookmarks only (lib/publicPages.js checks bookmark.video_embed_url
   before including this at all) — a plain filled play triangle centered over
   the big permalink thumbnail, signaling "this opens a video player" before
   the click the same way related.js's own small .related-video-badge corner
   chip does on a related-content tile, just as a real icon rather than an
   emoji, since this thumbnail is large/prominent enough to carry one.
   Pointer-events left alone (no `none` needed) — it's just a visual overlay
   inside the same <a> as everything else here, not stealing a click bound to
   anything else. */
.bm-play-badge {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  width: 56px;
  height: 56px;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 50%;
  background: rgba(0, 0, 0, 0.6);
  color: #fff;
  pointer-events: none;
}

.bm-play-badge svg {
  width: 26px;
  height: 26px;
  /* Optically re-center the triangle within its circle — a play glyph's own
     visual weight sits left of its true bounding-box center (the point is on
     the right edge), so a couple of px nudge right reads as centered where a
     mathematically centered placement would look off. */
  margin-left: 3px;
}

/* Image-gallery bookmarks only (lib/publicPages.js checks bookmark.images
   before including this at all, and only when there's no video embed —
   see that file's own comment on the priority order) — same badge shell as
   .bm-play-badge just above (size, circle, translucent background), a
   separate rule rather than reusing that one since these two are never
   shown together on the same thumbnail and a shared class would leave
   .bm-play-badge's own play-triangle-specific comment/margin misleadingly
   attached to a glyph it was never written for. */
.bm-gallery-badge {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  width: 56px;
  height: 56px;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 50%;
  background: rgba(0, 0, 0, 0.6);
  color: #fff;
  pointer-events: none;
}

.bm-gallery-badge svg {
  width: 26px;
  height: 26px;
}

/* The bookmark permalink page's own big thumbnail (lib/publicPages.js's
   renderBookmarkPage) gets the "Promoted" pill too, same
   bookmarks.is_promoted flag/amber "notable" language as .row-promoted-badge
   (public/browse.css just above) and every other thumbnail-badge context on
   the site. Top-left corner — free here since .bm-play-badge/.bm-gallery-badge
   above are centered, not corner-pinned, so this is the one context on the
   whole site with no existing corner badge to share a thumbnail with. A
   slightly larger pill than .row-promoted-badge's own (this page's own
   thumbnail is considerably bigger than a list/grid row's), same
   color/shape otherwise. */
.bm-promoted-badge {
  position: absolute;
  left: 10px;
  top: 10px;
  font-size: 13px;
  font-weight: 600;
  line-height: 1;
  padding: 4px 9px;
  border-radius: 999px;
  color: #fff;
  background: rgba(180, 83, 9, 0.9);
  pointer-events: none;
}

/* No real max-height to derive from (the placeholder isn't an <img>, so it
   has no natural aspect ratio) — a fixed height stands in for one, smaller
   than .bm-image's own max-height since a big empty icon reads as more
   "broken" than a smaller, quieter one would. */
.bm-image-placeholder {
  height: 160px;
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--accent-soft);
  color: var(--text-muted);
}

/* Same cascade gotcha and fix as .row-thumb-placeholder[hidden] above —
   this element's own `display: flex` would otherwise beat the browser's
   default `[hidden]{display:none}` and defeat the broken-image fallback
   toggle in lib/publicPages.js's renderBookmarkPage. */
.bm-image-placeholder[hidden] {
  display: none;
}

.bm-image-placeholder svg {
  width: 48px;
  height: 48px;
}

.bm-title {
  font-size: 22px;
  margin: 0 0 10px;
}

/* Bookmark permalink page: the title text itself is the link to /view/:id
   (lib/publicPages.js's renderBookmarkPage) — plain inherited color, no
   underline until hover, so it still reads as a heading first and a link
   second, same restraint .row-title (styles.css) shows on the dashboard. */
.bm-title a {
  color: inherit;
  text-decoration: none;
}

.bm-title a:hover {
  text-decoration: underline;
}

/* /view/:id's video-embed content card only (public/frame.js's
   buildVideoCardHtml) — the bookmark permalink page's own .bm-title above
   is never wrapped in this, since there the title itself is already the
   one link out. Puts the "🔗 View page" link (back to /bookmark/:id)
   alongside the title instead of below it, mirroring the dashboard's own
   .row-title-line (styles.css). */
.bm-title-line {
  display: flex;
  align-items: baseline;
  flex-wrap: wrap;
  gap: 10px;
  margin: 0 0 10px;
}

.bm-title-line .bm-title {
  margin: 0;
}

/* Same small/muted-until-hover treatment as the dashboard's .row-permalink
   (styles.css) — a secondary path off the card, not its main heading. */
.bm-permalink-link {
  font-size: 12.5px;
  color: var(--text-muted);
  text-decoration: none;
  white-space: nowrap;
  flex: 0 0 auto;
}

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

.bm-url-line {
  display: flex;
  /* flex-start, not center — once .bm-url below wraps onto a second line
     (a long URL, a narrow card), center would push the favicon down to
     float against the middle of that two-line block instead of sitting
     next to the URL's own first line, which reads as misaligned. Harmless
     when the URL is short enough to stay on one line (favicon and text are
     the same line-height either way), so this is safe for both cases. */
  align-items: flex-start;
  gap: 8px;
  margin-bottom: 12px;
  flex-wrap: wrap;
}

.bm-favicon {
  width: 16px;
  height: 16px;
  border-radius: 3px;
  flex: 0 0 auto;
  /* Optically centers the 16px icon against the first line of .bm-url's
     text (13.5px, line-height ~1.4) now that align-items is flex-start
     instead of center above — without this the icon sits a couple pixels
     high relative to the text baseline next to it. */
  margin-top: 2px;
}

/* The bookmark permalink page's one deliberate way to open a new tab — a
   real link straight to the external page (see lib/publicPages.js's own
   comment on thumb, just above where this is used, for the full picture of
   why only this one thing on that page opens a new tab). Accent-colored
   like any other link, since it is one there. */
.bm-url {
  color: var(--accent);
  font-size: 13.5px;
  word-break: break-all;
  /* Round 48: the browser's own default anchor underline paints across an
     anchor's ENTIRE inline box, including nested child elements — setting
     text-decoration:none directly on .bm-url-external-icon (below) does
     stop that child from drawing its OWN underline, but does not stop this
     element's underline from being painted straight through it; that's a
     real, observed Chromium behavior (confirmed via computed styles: the
     icon itself correctly computes text-decoration-line: none, yet the
     line still visually renders under it), not a mistaken assumption round
     46/47 could have caught by testing text-decoration-line alone. Turning
     the default off HERE and re-adding it only on the new .bm-url-text
     wrapper below (the actual URL text, not including the icon) is what
     actually keeps the line from ever reaching the icon in the first
     place, rather than trying to suppress it after the fact on a
     descendant. */
  text-decoration: none;
  /* flex: 1 1 0% (a 0 BASIS, not flex:1 1 auto's content-based one) is what
     actually matters here, not just min-width:0 — flex-wrap's own decision
     to keep an item on the current line vs. bump it to a new one runs
     BEFORE flex-grow/shrink are applied, using each item's hypothetical
     size from its flex-basis. flex-basis:auto (what flex:1 1 auto's third
     value resolves to) makes that hypothetical size the URL text's own
     full unbroken content width — hundreds of pixels for a long URL — so
     the wrap algorithm saw it as "doesn't fit next to the favicon" and
     bumped the ENTIRE link to its own new line before shrinking ever came
     into it, leaving the favicon stranded alone above it (min-width:0
     alone doesn't fix this — it only affects shrinking on an already-kept
     line, never the line-placement decision itself). A 0 basis starts this
     item's hypothetical size at nothing, so it always fits the current
     line; flex-grow:1 then expands it into whatever space is actually left
     next to the favicon (and any .bm-sitename pill), at which point
     word-break:break-all above does what it was already there to do — wrap
     the URL's own text inside that box — so the favicon now reliably stays
     at the start of the first line instead of moving with it. min-width:0
     is still needed alongside it: a flex item's default min-width:auto
     would otherwise still floor its shrink at the content width even with
     a 0 basis. */
  flex: 1 1 0%;
  min-width: 0;
}

/* The actual URL text, wrapped separately from .bm-url-external-icon
   (below) specifically so the underline lives HERE instead of on .bm-url
   itself — see that class's own round-48 comment for why suppressing it on
   the icon alone doesn't work in practice. Visually this is unchanged from
   before: the URL text is still underlined exactly as a plain link would
   be, just via an explicit rule instead of browser default. */
.bm-url-text {
  text-decoration: underline;
}

/* Trailing external-link cue after the URL text (Bookmark permalink page —
   lib/publicPages.js — and the View page's video-embed content card —
   public/frame.js's buildVideoCardHtml) — re-added after going missing from
   both templates with no changelog entry (README had kept describing it
   after the markup itself lost it). Round 46: restyled to match the Sites
   page's own external-link cue (.site-external-link above) at the user's
   request — muted (`var(--text-muted)`) at rest, rather than the plain
   `var(--text)` this originally shipped with — same reasoning
   .site-external-link's own comment gives: a small, deliberately-muted
   affordance, not something competing for attention with the pink link
   text next to it. No underline here, same as .site-external-link — round
   46/47 tried to achieve that with text-decoration:none on this element
   alone, which computes correctly but doesn't stop .bm-url's own default
   underline from painting across it regardless (see .bm-url's own round-48
   comment); the real fix moved the underline onto .bm-url-text above
   instead, so this element was never given one to suppress in the first
   place. text-decoration:none stays here anyway, defensively, in case this
   ever ends up nested in a differently-styled ancestor. Still shifts to
   `var(--accent)` on hover of the whole link (not just the icon — a small
   arrow is a fiddly hover target on its own), same as .site-external-link's
   own hover, just triggered by the parent link since this icon isn't a
   link of its own. word-break:break-all (still declared on .bm-url itself —
   word-break is an inherited property, so .bm-url-text's content wraps the
   same way without needing its own copy) only ever applies to the actual
   URL text (it wraps mid-word, which would be a strange place for a single
   glyph to ever land), so the icon gets a hair of left margin instead and
   is otherwise left out of that rule's effect entirely — it isn't part of
   the text being wrapped. */
.bm-url-external-icon {
  margin-left: 3px;
  color: var(--text-muted);
  text-decoration: none;
}

.bm-url:hover .bm-url-external-icon {
  color: var(--accent);
}

/* Mask Content mode blurs .bm-url's own text via a solid ::after overlay
   (below) that sits on top of it — this icon is a separate child element,
   not covered by that overlay just by being visually nearby, so without
   this it would keep rendering (in its own un-masked color) right at the
   edge of the blur block instead of disappearing into it like the rest of
   the URL. */
body.mask-mode .bm-url-external-icon {
  color: transparent !important;
}

.bm-sitename {
  display: inline-block;
  font-size: 12px;
  color: var(--text-muted);
  background: var(--accent-soft);
  border-radius: 6px;
  padding: 2px 8px;
  text-decoration: none;
}

/* The Sites feature (migration 063) turned this into a real <a href="/site/
   NAME"> link (lib/publicPages.js's renderBookmarkPage, public/frame.js's
   toolbar-viewer card) — same hover treatment styles.css's own .tag-pill/
   .model-pill/.row-sitename already get. */
.bm-sitename:hover {
  background: var(--accent);
  color: var(--accent-contrast);
}

.bm-description {
  font-size: 14.5px;
  color: var(--text);
  line-height: 1.5;
  margin: 0 0 14px;
}

/* Round 50: the Collection page's own description (lib/publicPages.js's
   renderCollectionPage) — used to be appended onto the end of the "Curated
   by X" line below; moved to its own row directly under the title instead,
   so it reads as a description of the collection rather than an
   afterthought on the byline. Same size/color/line-height as .bm-description
   just above (the Bookmark permalink page's own description), just a
   tighter bottom margin since the "Curated by" line immediately follows
   this one rather than this being the last thing before a list. */
.collection-detail-description {
  font-size: 14.5px;
  color: var(--text);
  line-height: 1.5;
  margin: 0 0 6px;
}

.bm-tags {
  margin-bottom: 14px;
}

/* .model-pill itself lives in styles.css (shared across every surface that
   shows one) — this is just this card's own spacing, same shape as
   .bm-tags just above, kept as its own line rather than merged into
   .bm-tags so a Model credit reads as a distinct kind of label. Shared by
   the bookmark permalink page (lib/publicPages.js) and /view/:id's video
   card (public/frame.js) — both use the same .bm-models class name. */
.bm-models {
  margin-bottom: 8px;
}

.bm-meta {
  font-size: 13px;
  color: var(--text-muted);
  margin-bottom: 10px;
}

/* Round 46 introduced this rule scoped to the whole .bm-meta line rather
   than just the three new stat badges, which meant it also caught the
   byLine's own <a href="/username"><strong>username</strong></a> — the same
   markup every other page's own "by @username" byline already uses
   (browse.js/dashboard.js/activity.js/frame.js all build the identical
   `by <a><strong>...</strong></a>` shape). This rule's higher specificity
   than the plain `a { color: var(--accent) }` (styles.css) meant the
   username rendered in var(--text) instead of inheriting the link's own
   accent color — reported as "the username link turned white with an
   underline" (round 47). Fixed by scoping to the new `.bm-stat` class each
   stat badge carries (below) instead of every <strong> in .bm-meta, the
   same way frame.css's own .video-stat strong is scoped to just its own
   stat spans, not to /view/:id's own byLine (which sits in this same
   .bm-meta class on that page — browse.css is shared by both pages — but
   was never affected, precisely because its <strong> isn't inside a
   .video-stat). */
.bm-stat strong {
  color: var(--text);
  font-variant-numeric: tabular-nums;
}

/* Round 47 — Vote/Share/Bookmark form one left-aligned cluster, Report
   pinned to the far right, the same "actions on the left, Report standing
   apart on the right" shape /view/:id's own video-embed stats bar now uses
   (frame.css's .video-stats-bar/.video-report-btn). Round 46 had Vote on
   its own line above this row and Bookmark pinned right instead — moved
   here so the four read as one row of same-sized pill buttons rather than
   Vote looking like a leftover from a different design. */
.bm-action-row {
  display: flex;
  align-items: center;
  gap: 10px;
  flex-wrap: wrap;
  margin-top: 10px;
}

/* .vote-widget.horizontal (below) already supplies its own margin-bottom
   for when it stands alone as its own block (the Collection detail page's
   own .collection-vote-row still relies on that gap down to .count-row
   underneath it) — inside this row it's just one pill among several, so
   that bottom margin would only add unwanted space under the whole row.
   Same override frame.css's own .video-stats-bar .vote-widget already
   uses for the identical reason. */
.bm-action-row .vote-widget {
  margin-bottom: 0;
}

/* The bookmark permalink page's own "🔖 Bookmark" / "+ Collection" action —
   see lib/publicPages.js's renderBookmarkPage (the empty, data-needs-
   hydration container this fills) and public/bookmarkActions.js (the
   hydration/wiring). Round 46 gave this element its own margin-left:auto to
   pin it to the far right of .bm-action-row; round 47 moves that job onto
   #bmReportBtn instead (below), since Report — not Bookmark — is now the
   one item standing apart on the right. */
.bm-actions {
  display: flex;
  align-items: center;
  gap: 8px;
}

/* Round 47: Report is the one pill pinned to the far right of
   .bm-action-row, regardless of whether Share rendered (share domain
   configured or not) or which of Bookmark's three states .bm-actions is
   currently showing — same "stats/actions on the left, one thing standing
   apart on the right" shape frame.css's own .video-report-btn now uses in
   the equivalent row on /view/:id. Scoped to .bm-action-row specifically,
   not to .report-pill generally, since the Collection detail page's own
   .collection-vote-row (below) reuses .report-pill right next to its vote
   widget WITHOUT wanting it pushed to a far edge — the two pages want
   different placement for the same pill class. */
.bm-action-row .report-pill {
  margin-left: auto;
}

/* Own small copy of frame.css's .video-bookmark-btn/-done pill treatment —
   same visual language, but this page never loads frame.css (the permalink
   page and /view/:id load different, overlapping-but-not-identical
   stylesheet sets), so this is its own copy rather than a shared rule, same
   "small per-context copy" convention this app already follows for
   escapeHtml et al. */
.bm-action-btn {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  padding: 5px 12px;
  border: 1px solid var(--border);
  border-radius: 999px;
  background: var(--surface);
  color: var(--text);
  font: inherit;
  font-size: 13px;
  font-weight: 600;
  text-decoration: none;
  cursor: pointer;
  white-space: nowrap;
}

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

.bm-action-btn:disabled {
  opacity: 0.7;
  cursor: default;
}

.bm-action-btn-done {
  color: var(--text-muted);
  background: var(--accent-soft);
  cursor: default;
}

.bm-action-btn-done:hover {
  border-color: var(--border);
}

/* ---- Share control (round 46 — "parity with the View page") — a small
   local copy of frame.css's .share-widget/.share-widget-btn/etc. (that
   file's own comment has the full "why a popover" reasoning) for
   lib/publicPages.js's buildBookmarkShareWidgetHtml/public/bookmarkActions.js's
   wireBookmarkShareWidget. Same class names as frame.css's copy — safe to
   reuse since this page and /view/:id never load browse.css+frame.css
   together (see .report-pill's own comment on the same "why a copy, not a
   shared class" reasoning for this page's Report button). ---- */
.share-widget {
  position: relative;
}

.share-widget-btn {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  padding: 5px 12px;
  border: 1px solid var(--border);
  border-radius: 999px;
  background: var(--surface);
  color: var(--text);
  font: inherit;
  font-size: 13px;
  font-weight: 600;
  cursor: pointer;
  white-space: nowrap;
}

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

.share-widget-popover {
  position: absolute;
  top: calc(100% + 6px);
  left: 0;
  z-index: 2;
  width: 260px;
  padding: 10px;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 10px;
  box-shadow: 0 6px 20px rgba(0, 0, 0, 0.15);
}

.share-widget-popover[hidden] {
  display: none;
}

.share-widget-link-row {
  display: flex;
  gap: 6px;
  margin-bottom: 8px;
}

.share-widget-link {
  flex: 1;
  min-width: 0;
  padding: 5px 8px;
  border: 1px solid var(--border);
  border-radius: 6px;
  background: var(--bg);
  color: var(--text-muted);
  font: inherit;
  font-size: 12.5px;
}

.share-widget-copy-btn {
  flex-shrink: 0;
  padding: 5px 10px;
  border: 1px solid var(--border);
  border-radius: 6px;
  background: var(--surface);
  color: var(--text);
  font: inherit;
  font-size: 12.5px;
  font-weight: 600;
  cursor: pointer;
  white-space: nowrap;
}

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

.share-widget-channels {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
}

/* Deliberately plain links, not buttons — each one navigates straight to a
   channel's own share-intent URL (built in buildBookmarkShareWidgetHtml)
   rather than doing anything itself. */
.share-widget-channel {
  padding: 5px 10px;
  border: 1px solid var(--border);
  border-radius: 999px;
  background: var(--bg);
  color: var(--text);
  font-size: 12.5px;
  font-weight: 600;
  text-decoration: none;
  white-space: nowrap;
}

.share-widget-channel:hover {
  border-color: var(--accent);
}

/* ---- "+ Collection" picker (public/collectionPicker.js) — one shared
   component mounted from two places: the video-embed stats bar's
   #videoCollectionPicker (frame.js/frame.css's .video-collection-picker
   wrapper) and the bookmark permalink page's #collectionPickerMount
   (.bm-actions above). Lives here rather than in frame.css since this is
   the one stylesheet both mounting contexts load. */
.collection-picker {
  position: relative;
}

.collection-picker-btn {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  padding: 5px 12px;
  border: 1px solid var(--border);
  border-radius: 999px;
  background: var(--surface);
  color: var(--text);
  font: inherit;
  font-size: 13px;
  font-weight: 600;
  cursor: pointer;
  white-space: nowrap;
}

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

.collection-picker-popover {
  position: absolute;
  top: calc(100% + 6px);
  right: 0;
  z-index: 2;
  min-width: 200px;
  max-width: 280px;
  max-height: 260px;
  overflow-y: auto;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 10px;
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
  padding: 8px;
}

.collection-picker-popover[hidden] {
  display: none;
}

.collection-picker-row {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 6px 4px;
  font-size: 13.5px;
  cursor: pointer;
}

.collection-picker-row:hover {
  color: var(--accent);
}

.collection-picker-status {
  font-size: 13px;
  color: var(--text-muted);
  padding: 4px;
  margin: 0;
}

.collection-picker-error {
  color: #c62828;
}

/* The "this bookmark is private" notice (public/collectionPicker.js's
   privacyNoticeHtml) — sits above the checkbox rows in the same popover,
   set off with its own bottom border/padding rather than blending into the
   list it's warning about. */
.collection-picker-notice {
  padding-bottom: 8px;
  margin-bottom: 8px;
  border-bottom: 1px solid var(--border);
}

.collection-picker-notice .collection-picker-status {
  padding: 4px 4px 8px;
}

.collection-picker-make-public {
  display: inline-flex;
  width: 100%;
  justify-content: center;
  padding: 5px 10px;
  border: 1px solid var(--border);
  border-radius: 999px;
  background: var(--surface);
  color: var(--text);
  font: inherit;
  font-size: 12.5px;
  font-weight: 600;
  cursor: pointer;
}

.collection-picker-make-public:hover {
  border-color: var(--accent);
}

.collection-picker-make-public:disabled {
  opacity: 0.7;
  cursor: default;
}

/* 🌐 tag next to a collection's name in the picker list (collectionPicker.js)
   — flags which of the caller's collections actually have a public page, so
   "which of these will actually show this bookmark if I check it" is
   visible without leaving the popover. */
.collection-picker-public-tag {
  font-size: 12px;
}

.bm-notfound {
  text-align: center;
  padding: 48px 24px;
}

.bm-notfound h1 {
  font-size: 20px;
  margin: 0 0 8px;
}

.bm-notfound p {
  color: var(--text-muted);
  margin: 0 0 20px;
}

/* ---- comments (public/comments.js) — the bookmark permalink page
   (.comments-section sits right after .bm-card, both server-rendered, see
   lib/publicPages.js) and the toolbar viewer's video-embed mode
   (.comments-section is built client-side after #videoCard, see frame.js).
   Same 640px column as .bm-card above in both places, so it reads as a
   continuation of the card rather than a separate, differently-proportioned
   block. */
.comments-section {
  width: 100%; /* see .ad-slot's comment above — same flex-shrink-wrap fix */
  max-width: 640px;
  margin: 0 auto 24px;
}

/* Round 49: a News item (public/news.js's newsItemHtml, and now the
   Dashboard's own preview cards — public/dashboard.js's newsCardHtml) is
   the one place .comments-section sits INSIDE the card it belongs to
   (.news-card's own last child), rather than as a separate block below one
   (.bm-card/#videoCard above) — so it never got the breathing room those
   other two get "for free" from being a distinct block with its own
   margin. `margin: 0 auto 24px` above has no top value at all (correct for
   the separate-block case, where the space comes from the card before it
   ending), which left this "Comments" heading sitting flush against the
   News item's own footer row (the vote widget + comment-count toggle)
   right above it — reported as looking cramped. Scoped to `.news-card
   .comments-section` specifically rather than raising the shared rule's
   own margin, since the separate-block case above doesn't need it and
   already reads correctly as-is. */
.news-card .comments-section {
  margin-top: 16px;
}

.comments-heading {
  font-size: 16px;
  margin: 0 0 12px;
}

/* "Public collections" section — the bookmark permalink page's own
   server-rendered version (lib/publicPages.js's renderBookmarkPage) and
   /view/:id's own client-rendered twin (public/frame.js's
   initPublicCollectionsSection, #publicCollectionsSection). Same 640px
   column/centering as .comments-section just above it, and the same
   heading size/margin as .comments-heading, so this reads as one more
   section continuing the same stack rather than a visually distinct block.
   :not(:empty)-gated bottom margin — the bookmark permalink page never
   renders this section at all when there's nothing to show (so it's
   effectively always non-empty there), but /view/:id's copy is a static,
   always-present element that frame.js just leaves empty when there's
   nothing to show, so it needs the same "take up no visible space" CSS
   treatment every other conditionally-empty section on this page already
   gets (.ad-slot, .related-grid). */
.bm-collections:not(:empty) {
  width: 100%; /* see .ad-slot's comment above — same flex-shrink-wrap fix */
  max-width: 640px;
  margin: 0 auto 24px;
}

.bm-collections-heading {
  font-size: 16px;
  margin: 0 0 12px;
}

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

.comments-login-prompt {
  color: var(--text-muted);
  font-size: 13.5px;
  margin: 0 0 16px;
}

.comment-form {
  margin-bottom: 20px;
}

.comment-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: inherit;
  font-size: 13.5px;
  resize: vertical;
}

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

.comment-form-row {
  display: flex;
  align-items: center;
  gap: 12px;
  margin-top: 8px;
}

.comment-form-error {
  color: #d33;
  font-size: 12.5px;
}

.comment-row {
  padding: 12px 0;
  border-top: 1px solid var(--border);
}

.comment-row:first-child {
  border-top: none;
  padding-top: 0;
}

.comment-meta {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 4px 8px;
  margin-bottom: 4px;
}

/* Round 49: .member-check (styles.css) carries its own margin-left: 3px,
   correct for every OTHER place it renders — a byline, the profile
   heading, a follower-list row, a notification sentence — since those are
   all plain inline text where that margin is the only spacing there is.
   This is the one place it sits inside a flex row that ALSO applies its
   own gap (4px 8px, above) between every child — so the two stacked,
   leaving 8px+3px=11px before the checkmark but a plain 8px after it,
   before .comment-date. Reset back to 0 here so both sides read as the
   same, single gap — same asymmetric-margin-vs-flex-gap fix already
   applied to .collection-vote-row's own Vote/Report row (round 48). */
.comment-meta .member-check {
  margin-left: 0;
}

.comment-author {
  font-size: 13px;
  font-weight: 600;
  color: var(--text);
  text-decoration: none;
}

.comment-author:hover {
  text-decoration: underline;
}

/* The #tag/@username/*model/^site mention-linking feature — each of
   comments.js/activity.js/dashboard.js/news.js's own copy of
   initMentionLinks builds these once a token is confirmed to resolve to
   something real (lib/mentionLinks.js). Colored like any other inline text
   link in this app rather than a pill (.tag-pill/.model-pill) — a mention
   sits inline inside a sentence, not alongside other standalone pills the
   way a bookmark's own tag/model list does. */
.mention-link {
  color: var(--accent);
  font-weight: 600;
  text-decoration: none;
}

.mention-link:hover {
  text-decoration: underline;
}

.comment-date {
  font-size: 12px;
  color: var(--text-muted);
}

.comment-delete {
  margin-left: auto;
  border: none;
  background: none;
  color: var(--text-muted);
  cursor: pointer;
  font-size: 13px;
  line-height: 1;
  padding: 2px 4px;
}

.comment-delete:hover {
  color: #d33;
}

.comment-body {
  font-size: 14px;
  color: var(--text);
  line-height: 1.5;
  margin: 0;
  white-space: pre-wrap;
  overflow-wrap: break-word;
}

/* Mask Content mode (public/app.js) — the two classes this file itself
   defines that also need masking (.bm-title/.bm-description/.bm-sitename,
   the big permalink/video-card versions of .row-title/.row-description/
   .row-sitename in styles.css), plus a CSS-level backstop for .ad-slot
   (also defined in this file). See styles.css's own copy of this same
   pattern, right after its Quick Hide block, for the full reasoning behind
   the transparent-text + ::after-bar technique and why the ad-slot rule
   here is a backstop, not the primary fix — ads.js's own initAdSlot/
   initGlobalAdSlot check isMaskModeOn() before ever fetching an embed at
   all, which is what actually stops a live tracking/impression request from
   firing; this rule only covers the (normally momentary) gap between
   mask-mode being switched on mid-session and app.js's own
   clearRenderedAdSlots() having already emptied every .ad-slot container it
   could find, plus anything a page reload's HTML/CSS applies before any
   script has run at all. */
body.mask-mode .ad-slot {
  display: none !important;
}

/* .bm-favicon — same masked-icon `content` swap as .row-thumb/.bm-image/
   .row-favicon in styles.css, via the shared --mask-thumb-icon custom
   property declared there (see that file's own comment, right where it's
   declared, for why it's a shared variable rather than a second copy of the
   same data URI — and for why this technique is safe here specifically:
   .bm-favicon, unlike .row-thumb, is always a real <img>, never a
   placeholder div sharing the class). */
body.mask-mode .bm-favicon {
  content: var(--mask-thumb-icon);
}

/* Public profile page's Collections row (public/browse.js's
   loadProfileCollections) — the cover thumbnail gets the same icon swap as
   every other thumbnail-shaped image, and the collection's own name gets
   the same bar-overlay treatment as .bm-title/.tag-index-name, since a
   Collection's name ("Weekend Reading", "Night Owl Picks") can be just as
   identifying as a bookmark's own title. .profile-collection-count/
   -description are left unmasked — a bare count and a possibly-empty
   description read as page chrome here, not as content revealing what was
   actually saved. */
body.mask-mode .profile-collection-cover {
  content: var(--mask-thumb-icon);
}

body.mask-mode .profile-collection-name {
  position: relative;
  color: transparent !important;
}

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

/* Same treatment for the plain thumbnail-tile Collection design
   (.collection-tile-list, above) — Browse's own "Featured collections" and
   the bookmark permalink page's "Public collections" section both use this,
   and a Collection's name/cover can be just as identifying here as it is in
   the boxed .profile-collection-card version just above. */
body.mask-mode .collection-tile-thumb img {
  content: var(--mask-thumb-icon);
}

body.mask-mode .collection-tile-title {
  position: relative;
  color: transparent !important;
}

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

/* /collections gallery card's own two extra text lines beyond the reused
   .collection-tile-thumb/.collection-tile-title above (which already
   inherit their masking from the two rules just above this one — see
   .public-collection-card's own comment near its non-mask-mode rules,
   further up this file). Same bar-overlay treatment as everywhere else on
   this page: the curator line gets it for the same reason a profile's own
   "By @username" banner does (.tag-banner strong, just below) — a username
   is exactly as identifying here, since this card is how a masked visitor
   would otherwise learn whose account curated this Collection — and the
   description gets it for the same reason a public bookmark's own
   .row-description does (styles.css) — unlike the private, owner-only
   .collection-card-description on /collections/manage (styles.css's own
   comment on that rule), this description is something a masked visitor is
   BROWSING, not their own already-known content, so it needs covering here
   even though its private-dashboard counterpart deliberately doesn't get
   this treatment. .public-collection-card-count is left unmasked — a bare
   "N bookmarks" reads as page chrome, same as every other plain count
   sitewide (.profile-collection-count and friends). */
body.mask-mode .public-collection-card-owner,
body.mask-mode .public-collection-card-desc {
  position: relative;
  color: transparent !important;
}

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

/* .tag-index-name (the /tags index page's own list, tags.js) and
   .tag-banner's own tag/username (the "Tagged #x"/"By @x" banner at the
   top of a filtered /tag/NAME, /:username, or /bookmark/:id page — browse.js
   and lib/publicPages.js each build one) — scoped to `.tag-banner strong`
   specifically, not the whole banner, since "Tagged"/"By"/the "✕ clear"
   link are page chrome, not identifying content; only the <strong> actually
   holds the tag/username text (see either caller's own markup). */
body.mask-mode .tag-index-name,
body.mask-mode .tag-banner strong {
  position: relative;
  color: transparent !important;
}

body.mask-mode .tag-index-name::after,
body.mask-mode .tag-banner strong::after {
  content: "";
  position: absolute;
  inset: 0;
  background: var(--text-muted);
  border-radius: 4px;
  opacity: 0.55;
}

body.mask-mode .bm-title,
body.mask-mode .bm-description,
body.mask-mode .bm-sitename,
body.mask-mode .bm-url {
  position: relative;
  color: transparent !important;
}

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