/* 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 (/tags page, public/tags.js's loadTrending) ---- */

/* Plain flex-wrap row of .tag-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. */
.trending-tags-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;
}

/* An ad slot that sits flush against something directly above it with no
   heading/toolbar row of its own to create natural breathing room —
   currently the bookmark permalink page's #adSlotTop (lib/publicPages.js,
   flush against the site header) and /view/:id's #adSlotToolbar
   (frame.html, flush against the sticky frame toolbar). /browse's,
   /tags's, and /:username's own top slots don't need this — each already
   has a search/sort toolbar or heading row above it providing that gap.
   Also :not(:empty)-gated, same reasoning as the bottom margin above: an
   empty, unconfigured slot should take up no visible space at all, margin
   included, not leave a gap where an ad would go. */
.ad-slot-page-top:not(:empty) {
  margin-top: 20px;
}

/* The Dashboard's own top slot (public/dashboard.html's #adSlotTop)
   specifically — doesn't qualify for .ad-slot-page-top above (it sits under
   the search/view-toggle toolbar rather than being flush against anything
   the way that class is meant for). Tuned for 10px of *visible* whitespace
   above and 5px below once populated — not just "10px"/"5px" as margin
   values, since plain margin arithmetic can't actually get there on either
   side:
     - Above: two adjacent block siblings' margins collapse to whichever is
       LARGER, not their sum — so as long as the toolbar's own 16px
       margin-bottom (styles.css) is still in play, margin-top: 10px here
       would collapse to 16px regardless, not 10px. :has() overrides the
       toolbar's own margin-bottom down to match (10px, same as this rule's
       own margin-top, so the two collapse to exactly 10px rather than one
       dominating the other), specifically when it's actually followed by a
       populated ad slot, leaving its ordinary 16px untouched for the (far
       more common) case of no ad to show at all. (Previously 10px/10px,
       then 5px/5px before that, tuned the same way each time — see git
       history if either's ever wanted back.)
     - Below: this sits directly above whichever section header comes next
       ("Your collections"/"Your bookmarks", both `.dash-header` — styles.css
       — with their own 28px top PADDING, which never collapses with a
       sibling's margin the way two margins do). A small positive
       margin-bottom would just add on top of that fixed 28px; a NEGATIVE
       one is what actually nets out to 5px of visible space (28 - 23 = 5)
       — this also overrides .ad-slot's own 24px margin-bottom (above) via
       the later-in-source-wins convention (both are :not(:empty)-gated
       single classes, so same (0,2,0) specificity), not stacking on top of
       it. Left at 5px this round — only the gap above was reported as too
       big. (Previously +40px/+12px, tuned the same way for a 40px gap —
       see git history if that's ever wanted back.) */
.toolbar:has(+ .dash-ad-slot-top:not(:empty)) {
  margin-bottom: 15px;
}

.dash-ad-slot-top:not(:empty) {
  margin-top: 15px;
  margin-bottom: -23px;
}

/* The bookmark permalink page's own bottom slot (lib/publicPages.js's
   renderBookmarkPage) specifically — sits right under the comment thread or
   the "Public collections" tile grid, either of which reads visually
   "busier"/denser than the plain single-column content most other bottom
   slots sit under, so it gets extra room instead of running into it. Same
   later-in-source-wins override convention as .bm-collections-list over
   .collection-tile-list elsewhere in this file — two same-specificity
   single-class selectors (:not(:empty) makes both (0,2,0)), this one
   declared after .ad-slot-page-top above so it wins on the one element that
   carries both classes, without touching every other page's own top/bottom
   slots that only carry .ad-slot-page-top alone. */
.bm-ad-slot-bottom:not(:empty) {
  margin-top: 40px;
}

/* 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;
}

/* ---- 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;
}

/* "Promoted" pill (public/browse.js's promotedBadgeHtml, bookmarks.is_promoted
   — migration 047) — plain /browse only, see showPromotedPill's own
   comment in that file for the scoping decision. 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;
}

/* Unfollowed state reuses .btn-primary (solid accent) so it reads as the
   inviting default action; 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. */
#followBtn.following {
  background: none;
  border: 1px solid var(--border);
  color: var(--text);
}

/* 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;
}

.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
   .bm-ad-slot-bottom 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-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;
}

/* /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);
}

/* /: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;
}

/* 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;
}

/* 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;
}

.list.grid-view .vote-widget {
  order: 2;
  flex-direction: row;
  justify-content: flex-start;
  gap: 8px;
  margin-top: 10px;
}

/* 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;
  top: 8px;
  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;
}

.vote-widget.horizontal {
  flex-direction: row;
  gap: 8px;
  margin-bottom: 14px;
}

/* 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;
}

.vote-btn:hover:not(:disabled) {
  background: var(--accent-soft);
}

.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;
}

.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;
}

/* 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;
  /* 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;
}


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

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

.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;
}

/* The bookmark permalink page's own "🔖 Bookmark" / "+ Collection" action
   row, directly under its vote widget — see lib/publicPages.js's
   renderBookmarkPage (the empty, data-needs-hydration container this fills)
   and public/bookmarkActions.js (the hydration/wiring). :empty keeps this
   from leaving a stray gap below the vote widget for the split second
   before hydration fills it in, and permanently for the one edge case that
   never fills it at all (bookmarkActions.js's hydration fetch failing
   outright leaves it empty rather than guessing a state). */
.bm-actions {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-top: 10px;
}

.bm-actions:empty {
  margin-top: 0;
}

/* 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);
}

/* ---- "+ 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;
}

.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;
}

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

.comment-author: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;
}
