/* ==========================================================================
   digitaltool app layer — additions on top of style.css.

   The style system ships a top navbar (see .navbar in style.css), not a
   collapsible left sidebar, so the shell below is bespoke — built entirely
   from its design tokens so it re-themes for free if the palette ever
   changes. Everything else (buttons, cards, badges, tables, forms) should
   come straight from style.css — add here only what it genuinely doesn't
   provide.
   ========================================================================== */

/* --- App sidebar ---------------------------------------------------------
   Fixed left rail on desktop (lg: 1024px+), off-canvas drawer below that.
   Collapse + open/close state are toggled via classes from _sidebar.html's
   inline script and persisted to localStorage. */

.app-sidebar-toggle {
  position: fixed;
  top: calc(var(--space) * 3);
  left: calc(var(--space) * 3);
  z-index: 50;
  padding: calc(var(--space) * 2);
  border-radius: var(--radius-md);
  background: var(--color-card);
  border: 1px solid var(--color-border);
  color: var(--color-muted);
  box-shadow: var(--shadow-sm);
  cursor: pointer;
}
.app-sidebar-toggle:hover { color: var(--color-heading); }

.app-sidebar-backdrop {
  position: fixed;
  inset: 0;
  z-index: 40;
  background: rgb(0 0 0 / 0.4);
}

.app-sidebar {
  position: fixed;
  inset-block: 0;
  left: 0;
  z-index: 40;
  width: 16rem;
  display: flex;
  flex-direction: column;
  background: var(--color-card);
  border-right: 1px solid var(--color-border);
  transform: translateX(-100%);
  transition: width var(--speed) var(--ease-out), transform var(--speed) var(--ease-out);
}
.app-sidebar.is-open { transform: translateX(0); }

/* The collapse toggle alone. There was a brand card here — a "DT" badge and
   the product's name — and it is gone until the product has a name worth
   printing; an initial nobody chose is not an identity, it is a placeholder
   that looks like one. The toggle keeps the right edge it has always had, so
   the one thing this row still does is where the hand already goes. */
.app-sidebar-header {
  flex: none;
  display: flex;
  align-items: center;
  justify-content: flex-end;
  padding: calc(var(--space) * 3);
}

.app-sidebar-collapse {
  flex: none;
  padding: calc(var(--space) * 1.5);
  border: 0;
  border-radius: var(--radius-sm);
  background: none;
  color: var(--color-muted);
  cursor: pointer;
}
.app-sidebar-collapse:hover { color: var(--color-heading); background: var(--color-neutral-soft); }
.app-sidebar-collapse svg { transition: transform var(--speed) var(--ease-out); }

.app-sidebar-nav {
  flex: 1;
  overflow-y: auto;
  padding: calc(var(--space) * 1.5) calc(var(--space) * 2.5);
  display: flex;
  flex-direction: column;
  gap: calc(var(--space) * 0.25);
}
.app-sidebar-heading {
  padding: calc(var(--space) * 2) calc(var(--space) * 1.5) calc(var(--space) * 1);
  font-size: var(--text-xs);
  font-weight: var(--weight-ui);
  color: var(--color-faint);
}
.app-sidebar-link {
  display: flex;
  align-items: center;
  gap: calc(var(--space) * 2.5);
  padding: calc(var(--space) * 1.25) calc(var(--space) * 2);
  border-radius: var(--radius-sm);
  font-size: var(--text-sm);
  color: var(--color-text);
  transition: background-color var(--speed) var(--ease-out), color var(--speed) var(--ease-out);
}
.app-sidebar-link:hover { background: var(--color-neutral-soft); color: var(--color-heading); text-decoration: none; }
.app-sidebar-link.active { background: var(--color-inset); color: var(--color-heading); font-weight: var(--weight-ui); }
.app-sidebar-link svg { width: 1rem; height: 1rem; flex: none; color: var(--color-muted); }
.app-sidebar-link:hover svg, .app-sidebar-link.active svg { color: inherit; }

.app-sidebar-footer {
  flex: none;
  border-top: 1px solid var(--color-border);
  padding: calc(var(--space) * 2.5) calc(var(--space) * 2.5);
  display: flex;
  flex-direction: column;
  gap: calc(var(--space) * 0.25);
}

.app-sidebar-avatar {
  width: 1.5rem;
  height: 1.5rem;
  flex: none;
  border-radius: var(--radius-pill);
  background: var(--color-inset);
  border: 1px solid var(--color-border);
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 0.625rem;
  font-weight: var(--weight-heading);
  color: var(--color-heading);
  text-transform: uppercase;
}

/* Collapsed state: icon rail only.
   
   Keyed off the root, which is stamped by a blocking script in <head>, rather
   than off a class the sidebar's own script adds once the element is parsed.
   The difference is the width transition above: apply the class afterwards and
   the browser has already resolved 16rem, so it ANIMATES down to 4rem on every
   navigation — the sidebar visibly opens and closes. Styled from the root, the
   4rem is the element's first computed width and there is nothing to animate
   from.

   `:not(.is-open)` is what lets the mobile drawer still open full width: below
   1024px the toggle slides this in over the page, and an icon rail is not what
   that gesture asks for. */
html.sidebar-collapsed .app-sidebar:not(.is-open) { width: 4rem; }
html.sidebar-collapsed .app-sidebar:not(.is-open) .sidebar-label { display: none; }
html.sidebar-collapsed .app-sidebar:not(.is-open) .app-sidebar-collapse svg {
  transform: rotate(180deg);
}
/* Centred rather than right-aligned: on a 4rem rail, flush right reads as
   fallen over rather than placed. */
html.sidebar-collapsed .app-sidebar:not(.is-open) .app-sidebar-header {
  justify-content: center;
}
html.sidebar-collapsed .app-sidebar:not(.is-open) .app-sidebar-link,
html.sidebar-collapsed .app-sidebar:not(.is-open) .app-sidebar-footer a,
html.sidebar-collapsed .app-sidebar:not(.is-open) .app-sidebar-footer button {
  justify-content: center;
}

.app-main { min-height: 100vh; }

@media (min-width: 1024px) {
  .app-sidebar-toggle { display: none; }
  .app-sidebar-backdrop { display: none !important; }
  .app-sidebar { transform: translateX(0); }
  .app-sidebar-collapse { display: flex; align-items: center; justify-content: center; }
  .app-main { margin-left: 16rem; }
  /* Off the root rather than off <main> itself: the class is set in <head>,
     before <main> exists, which is what keeps the first paint from landing a
     sidebar's width to the right and snapping back. */
  html.sidebar-collapsed .app-main { margin-left: 4rem; }
}

/* --- Responsive table columns --------------------------------------------
   style.css's tables are dense-by-default and don't hide columns at
   narrower widths; several of digitaltool's data tables (insights, keyword
   results, talent list, video analysis) need that to stay usable on
   tablet/mobile. .col-sm/-md/-lg/-xl hide a <th>/<td> below the named
   breakpoint (Tailwind's defaults: 640/768/1024/1280), matching what these
   tables looked like pre-migration. */
.col-sm, .col-md, .col-lg, .col-xl { display: none; }
@media (min-width: 640px)  { .col-sm { display: table-cell; } }
@media (min-width: 768px)  { .col-md { display: table-cell; } }
@media (min-width: 1024px) { .col-lg { display: table-cell; } }
@media (min-width: 1280px) { .col-xl { display: table-cell; } }

/* --- Loading spinner ------------------------------------------------------
   Used by the fetch/infinite-scroll pages (insights, keyword results, video
   analysis) while a page of results is in flight. */
@keyframes app-spin { to { transform: rotate(360deg); } }
.spinner { width: 1.25rem; height: 1.25rem; color: var(--color-primary); animation: app-spin 0.7s linear infinite; }

/* --- Video result card ----------------------------------------------------
   Rendered client-side (keyword_results.html's renderCard()) into a
   .grid-cards grid — one card per TikTok video result. Kept as real classes
   rather than inline styles since it's built once per item, often dozens per
   page load. */
.video-card {
  background: var(--color-card);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-lg);
  overflow: hidden;
  display: flex;
  flex-direction: column;
  transition: border-color var(--speed) var(--ease-out);
}
.video-card:hover { border-color: var(--color-border-strong); }
.video-card-thumb {
  position: relative;
  aspect-ratio: 9 / 16;
  background: var(--color-inset);
  flex: none;
  max-height: 13rem;
  overflow: hidden;
}
.video-card-thumb-icon { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; color: var(--color-faint); }
/* Cover fetched from oEmbed at view time (keyword_results.html), which takes
   the placeholder icon's place in the box. The duration badge and click overlay
   follow it, so they keep painting above without needing a z-index. Covers
   rather than letterboxes: TikTok's stills are 9:16 like the frame, but a photo
   post's can be any shape. */
.video-card-img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
.video-card-duration {
  position: absolute;
  bottom: calc(var(--space) * 2);
  right: calc(var(--space) * 2);
  background: rgb(0 0 0 / 0.6);
  color: #fff;
  font-size: 0.625rem;
  font-family: var(--font-mono);
  padding: 0.1em 0.4em;
  border-radius: var(--radius-sm);
}
.video-card-link { position: absolute; inset: 0; }
.video-card-body { padding: calc(var(--space) * 3); display: flex; flex-direction: column; gap: calc(var(--space) * 2); flex: 1; }
.video-card-avatar {
  width: 1.75rem;
  height: 1.75rem;
  border-radius: var(--radius-pill);
  background: var(--color-primary-soft);
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 0.625rem;
  font-weight: var(--weight-heading);
  color: var(--color-primary);
  text-transform: uppercase;
  flex: none;
}
.video-card-stats { margin-top: auto; padding-top: calc(var(--space) * 1); display: grid; grid-template-columns: repeat(3, 1fr); gap: calc(var(--space) * 1); text-align: center; }
.video-card-music {
  display: flex;
  align-items: center;
  gap: calc(var(--space) * 1.5);
  font-size: 0.625rem;
  color: var(--color-muted);
  border-top: 1px solid var(--color-border);
  padding-top: calc(var(--space) * 2);
  margin-top: calc(var(--space) * 1);
  min-width: 0;
}

/* --- Line clamp -------------------------------------------------------- */
.line-clamp-2 { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }

/* --- Generic result field (video_analysis's schema-agnostic JSON render) -
   Used for arbitrary key/value output where the shape isn't known ahead of
   time (LLM extraction results). Distinct from style.css's .field, which is
   for actual form inputs. */
.result-label { font-size: var(--text-xs); text-transform: uppercase; letter-spacing: 0.05em; color: var(--color-muted); font-weight: var(--weight-heading); }
.result-value { font-size: var(--text-sm); color: var(--color-heading); margin-top: calc(var(--space) * 0.5); }
.section-card { background: var(--color-card); border: 1px solid var(--color-border); border-radius: var(--radius-lg); padding: calc(var(--space) * 5); box-shadow: var(--shadow-sm); }

/* --- Profile avatar -------------------------------------------------------
   The small round image beside a tracked profile's handle on the Accounts
   page. The <img> sits on top of the initial, so a broken or missing image
   (see social.services.thumbnails — mirrored copies can be absent, and CDN
   URLs expire) falls back to the letter underneath rather than an empty box
   or a browser's broken-image glyph. */
.profile-avatar {
  position: relative;
  width: 1.5rem;
  height: 1.5rem;
  border-radius: var(--radius-pill);
  background: var(--color-inset);
  color: var(--color-muted);
  display: inline-flex;
  align-items: center;
  justify-content: center;
  font-size: 0.5625rem;
  font-weight: var(--weight-heading);
  text-transform: uppercase;
  flex: none;
  overflow: hidden;
}
.profile-avatar img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  /* Removed by the onerror handler so the initial shows through. */
}

/* --- Inline field ---------------------------------------------------------
   A label sitting beside its control on a filter row, rather than above it.

   These were .cluster, which both wraps and shrinks. On a row whose search
   input takes flex:1, that let the pair be squeezed until the label dropped
   onto its own line — leaving the control ~13px below the input it was meant
   to line up with. Holding its natural width keeps label and control together;
   when the row genuinely runs out of room the outer .cluster wraps the whole
   group down instead, which is the behaviour you want. */
.field-inline {
  display: flex;
  align-items: center;
  flex-wrap: nowrap;
  flex: none;
  gap: calc(var(--space) * 2);
}

/* --- Accounts: split layout ----------------------------------------------
   The page has two states. Nothing selected, it is a single wide table
   (_account_table.html). With ?account= it becomes an index plus a detail pane
   — the table's job narrows to "which account", so it gives up its width
   rather than trying to hold both.

   A grid rather than flex so the index keeps its column when the pane's
   content is wide: a flex sibling with a long table inside will push a
   fixed-width neighbour narrower, which is exactly the drift this avoids. */
.account-pane {
  display: flex;
  flex-direction: column;
  gap: calc(var(--space) * 5);
  min-width: 0;
}
/* Bigger than the row avatars — this one identifies the thing you are looking
   at rather than labelling a line. */
.account-pane-avatar { width: 3rem; height: 3rem; font-size: var(--text-base); }
.account-pane-title {
  font-size: var(--text-2xl);
  font-weight: var(--weight-heading);
  letter-spacing: var(--tracking-heading);
  color: var(--color-heading);
}
.account-pane-stats {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr));
  gap: calc(var(--space) * 4);
}

/* The table rows are clickable as a whole (see talent_list.html); the name
   inside stays a real link so keyboard and middle-click still work. */
/* A row you can click. Two attributes rather than one because the roster's
   rows predate the generic handler; both mean the same thing to a reader. */
tr[data-account-row],
tr[data-row-href] { cursor: pointer; }

/* --- Filter pills ---------------------------------------------------------
   The list switcher on Accounts. These were underline tabs; the design gives
   filters a pill row instead — active is solid ink, inactive is an outline of
   the same ink, and a tertiary action (New list) uses the muted grey outline
   the system reserves for things that are not a current choice.

   They wrap rather than scroll. The reference calls for a horizontally
   scrolling bar, which suits a marketing page where the row is decorative;
   in a tool, a list scrolled out of sight is a list you forget you have. */
.filter-pill {
  display: inline-flex;
  align-items: center;
  gap: calc(var(--space) * 1.5);
  height: var(--control-h-sm);
  padding: 0 calc(var(--space) * 3);
  border: 1px solid var(--color-midnight-ink);
  border-radius: var(--radius-pill);
  background: transparent;
  font-size: var(--text-sm);
  font-weight: var(--weight-ui);
  color: var(--color-heading);
  white-space: nowrap;
  cursor: pointer;
  transition: background-color var(--speed) var(--ease-out), color var(--speed) var(--ease-out);
}
.filter-pill:hover { background: var(--color-inset); text-decoration: none; }
.filter-pill.active,
.filter-pill.active:hover {
  background: var(--color-primary);
  border-color: var(--color-primary);
  color: var(--color-on-primary);
}
/* Ash reads on both the white and the ink pill, so the count needs no variant. */
.filter-pill-count { font-size: var(--text-xs); color: var(--color-ash); }
/* Tertiary: present, but not one of the choices. */
.filter-pill-muted { border-color: var(--color-ash); color: var(--color-faint); }
.filter-pill-muted:hover { color: var(--color-heading); }

/* --- Roster: the name cell -----------------------------------------------
   Explicitly nowrap. The generic .cluster wraps, and in a table cell that is
   the wrong instinct: squeeze the table and the avatar drops onto its own line
   above the name, tripling the row height while the columns beside it stay
   30px tall. A data table would rather get wider and scroll — which the
   .table-scroll container already handles — than reflow into a card. */
.account-name-cell {
  display: flex;
  align-items: center;
  flex-wrap: nowrap;
  gap: calc(var(--space) * 2.5);
}
/* Bigger than the 1.5rem the avatar is elsewhere, and only here: this is the
   one place a reader scans a column of faces to find someone, where the
   milestone cards show one beside a sentence that already names them. The
   child selector keeps it off the nested .account-name-cell around the name,
   which holds no avatar. */
.account-name-cell > .profile-avatar { width: 2rem; height: 2rem; font-size: 0.6875rem; }

/* What type of profile a row's accounts are — "Alt", "HQ" — beside the
   platform badges. Outlined rather than filled: it qualifies the row rather
   than labelling it, and a filled badge here would out-shout the name. */
.badge-kind {
  background: none;
  border: 1px solid var(--color-border-strong);
  color: var(--color-muted);
}

/* The number on a filter pill. Dimmed rather than sized down: at this size a
   smaller figure stops being readable before it stops being loud. */
.pill-count { color: var(--color-faint); font-variant-numeric: tabular-nums; }
.pill-option input:checked + .pill-face .pill-count {
  color: color-mix(in oklab, var(--color-surface) 65%, transparent);
}

/* --- Roster: profile sources ---------------------------------------------
   The line under a talent's name, where the Profiles column ended up. Badges
   take the 8px radius rather than the pill: a platform tag is a static label,
   and pill radius is reserved for things you can click. Row weight is reset
   because the name cell is the table's first column, which carries the
   emphasis weight — inherited by the meta line it would compete with the name
   directly above it. */
.account-sources {
  display: flex;
  align-items: center;
  flex-wrap: nowrap;
  gap: calc(var(--space) * 1.5);
  margin-top: calc(var(--space) * 1);
  font-weight: var(--weight-body);
}
.account-sources .badge {
  font-size: 0.625rem;
  letter-spacing: 0.04em;
  padding: 0.1em 0.4em;
}

/* --- Info dot ------------------------------------------------------------
   Sits beside a column header carrying that column's definition. Faint by
   default so a row of them does not compete with the labels themselves, and
   inked on hover or keyboard focus. Sized in em so it tracks whatever header
   it is dropped into. */
.info-dot {
  display: inline-flex;
  align-items: center;
  margin-left: 0.35em;
  color: var(--color-faint);
  cursor: help;
  vertical-align: -0.1em;
}
.info-dot:hover, .info-dot:focus-visible { color: var(--color-muted); }


/* --- View transitions ----------------------------------------------------
   Two kinds, one set of timings.

   The roster's is SAME-document: it swaps #roster-view in place and wraps that
   in startViewTransition (see talent_list.html), which is why picking a row
   never looked like a page load. Everywhere else is a real navigation, and
   those get the CROSS-document form below — the opt-in is declarative, both
   documents have to carry it, and app.css is on every page that has a sidebar.

   Browsers without it navigate exactly as they did before; there is nothing
   here to fall back from. */
@view-transition { navigation: auto; }

/* The pairing that makes a navigation read as one thing moving rather than two
   pages swapping: the thumbnail of the post you clicked becomes the thumbnail
   at the top of its page.

   The name is assigned to the clicked element on the way out (see
   _view_transitions.html) rather than rendered onto every row. A
   view-transition-name has to be unique in its document, and a post can appear
   twice on one page — the timeline shows a post's own record and can also list
   it under the account record it drove — so a name-per-row would collide and
   the browser would drop the whole transition rather than one pair. */
.post-detail-thumb { view-transition-name: post-hero; }
[data-post-hero] { view-transition-name: post-hero; }

/* The cross-fade covering the roster's in-place swap (see talent_list.html),
   and the avatar morphs paired across the two states by view-transition-name.

   Wildcard rather than (root): the avatar groups are named per account at
   render time, so they cannot be enumerated here, and left alone they would
   morph at the browser's 250ms default while the cross-fade around them ran
   at 150ms — one motion visibly outlasting the other.

   Pinned to the app's own --speed and easing. The browser default is 250ms,
   which is long enough to read as waiting for a page rather than as the panel
   changing — and it left this as the only motion in the product not moving at
   the same rate as everything else.

   The group is included alongside old/new because the root group carries its
   own default-duration animation; setting only the two snapshots leaves half
   the effect still running long. */
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
  /* Literal fallbacks, and not for tidiness: these pseudo-elements live in
     their own tree, and if --speed did not reach it the declaration would be
     invalid at computed-value time and animation-duration would fall back to
     its initial 0s — silently deleting the cross-fade rather than shortening
     it. The fallback keeps the two values in step whichever way that goes. */
  animation-duration: var(--speed, 0.15s);
  animation-timing-function: var(--ease-out, cubic-bezier(0.22, 1, 0.36, 1));
}

/* Someone who has asked for less motion gets the swap with no cross-fade at
   all. The content still changes in place — it is the animation that goes,
   not the behaviour. */
@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(*),
  ::view-transition-old(*),
  ::view-transition-new(*) {
    animation: none;
  }
}

/* --- Pill choice ---------------------------------------------------------
   A radio group wearing the roster's filter-pill clothes, for choosing one of
   a handful of options. Used by the account-kind picker, where the whole set
   is three or four items and a <select> would hide all but one of them behind
   a click for no gain.

   The radio is visually hidden rather than display:none so it still takes
   focus and the group keeps native arrow-key navigation. */
.pill-choice {
  display: flex;
  flex-wrap: wrap;
  gap: calc(var(--space) * 2);
}
.pill-option { display: inline-flex; cursor: pointer; }
/* Checkboxes as well as radios: the filter modal's metric group composes, so
   it is boxes wearing the same clothes, and an unhidden native box sat beside
   the pill it was supposed to BE. */
.pill-option input[type="radio"],
.pill-option input[type="checkbox"] {
  position: absolute;
  width: 1px;
  height: 1px;
  opacity: 0;
  pointer-events: none;
}
.pill-face {
  display: inline-flex;
  align-items: center;
  gap: calc(var(--space) * 1.5);
  height: var(--control-h-sm);
  padding-inline: calc(var(--space) * 3);
  border: 1px solid var(--color-midnight-ink);
  border-radius: var(--radius-pill);
  background: transparent;
  color: var(--color-heading);
  font-size: var(--text-xs);
  font-weight: var(--weight-ui);
  white-space: nowrap;
  transition: background-color var(--speed) var(--ease-out), color var(--speed) var(--ease-out);
}
.pill-option:hover .pill-face { background: var(--color-inset); }
.pill-option input:checked + .pill-face {
  background: var(--color-midnight-ink);
  border-color: var(--color-midnight-ink);
  color: var(--color-surface);
}
.pill-option input:checked:hover + .pill-face { background: var(--color-midnight-ink); }
/* Keyboard focus has to be drawn on the face, since the radio itself is
   invisible — without this the group is navigable but you cannot see where
   you are. */
.pill-option input:focus-visible + .pill-face {
  outline: 2px solid var(--color-midnight-ink);
  outline-offset: 2px;
}

/* The "Other" pill types in place: a bare field sitting inside the pill,
   inheriting its ink so it reads as part of the pill rather than a control
   dropped into one. */
.pill-input {
  width: 7rem;
  min-width: 0;
  border: 0;
  padding: 0;
  background: transparent;
  color: inherit;
  font: inherit;
  outline: none;
}
.pill-input::placeholder { color: currentColor; opacity: 0.6; }

/* --- Links out to a post -------------------------------------------------
   Underlined and arrow-marked. These sit in numeric columns where nothing
   else is clickable, so an unmarked one reads as just another figure and the
   only real action in the table goes unnoticed. The underline is offset so it
   clears the digits' descenders rather than striking through them. */
.post-link {
  display: inline-flex;
  align-items: center;
  gap: 0.15em;
  color: var(--color-heading);
  font-weight: var(--weight-emphasis);
  text-decoration: underline;
  text-underline-offset: 0.2em;
  text-decoration-thickness: 1px;
  text-decoration-color: var(--color-faint);
}
.post-link:hover { text-decoration-color: currentColor; }
/* The mark is not underlined with the number — a rule running under an arrow
   glyph reads as a strikethrough. */
.post-link-mark {
  flex: none;
  text-decoration: none;
  vertical-align: text-top;
  opacity: 0.55;
}
.post-link:hover .post-link-mark { opacity: 1; }

/* --- Follower change windows --------------------------------------------
   The 1d / 7d / 30d row under the follower count. Windows without enough
   history are absent rather than dashed, so this wraps to whatever is
   actually available. */
.stat-windows {
  display: flex;
  flex-wrap: wrap;
  gap: calc(var(--space) * 3);
  margin-top: calc(var(--space) * 1);
}
.stat-window { display: inline-flex; align-items: baseline; gap: calc(var(--space) * 1); }
.stat-window-label {
  font-size: var(--text-xs);
  color: var(--color-faint);
  font-weight: var(--weight-body);
}

/* --- Daily-change line ---------------------------------------------------
   One colour for the whole window, taken from the net move across it — the
   line crosses zero, so per-segment colouring would strobe. The zero rule
   under it is what makes a point above or below it mean gained or lost. */
.change-line { stroke: var(--color-faint); }
.change-line.up { stroke: var(--color-trend-up); }
.change-line.down { stroke: var(--color-trend-down); }
.change-dot { fill: var(--color-faint); }
.change-dot.up { fill: var(--color-trend-up); }
.change-dot.down { fill: var(--color-trend-down); }
/* An invisible disc over each point. A 1.8px dot is not a hover target, and a
   line between them is not one either — this is what makes the chart readable
   a day at a time without pixel-hunting. */
.change-hit { cursor: help; }
.change-hit:hover { fill: var(--color-inset); fill-opacity: 0.6; }

/* The per-profile version, sized to sit in a table cell without changing the
   row height. */
.mini-chart { display: block; margin-left: auto; overflow: visible; }

/* A badge carrying only a platform mark. Squared up so the glyph is not
   floating in horizontal padding meant for text. */
.badge-platform {
  padding-inline: calc(var(--space) * 1);
  display: inline-flex;
  align-items: center;
}
.badge-platform .platform-mark { display: block; }

/* --- Engagement cell with its range --------------------------------------
   The median on top, the window's weakest and best beneath it. They sit here
   rather than in columns of their own because a cell is already scoped to one
   format: the two ends can only be posts of the same kind, measured against
   the same denominator. As columns they had to pool a profile's formats,
   which on Instagram ranked grid posts against reels. */
.eng-typical { display: block; }
.eng-range {
  display: flex;
  align-items: baseline;
  justify-content: flex-end;
  gap: 0.15em;
  margin-top: calc(var(--space) * 0.5);
  font-size: 0.6875rem;
  /* Reset the emphasis weight the first-column rule hands down, so the range
     reads as support for the number above it rather than a rival to it. */
  font-weight: var(--weight-body);
}
.eng-range .post-link { font-weight: var(--weight-body); color: var(--color-muted); }

/* How strong a post is, against every post in its own column across the page —
   see metrics.shade_by_column. The calendar shades a cell's background; these
   are figures inside a dense numeric column, so the ramp runs through the text
   instead. A green fill behind two 11px numbers per row, three columns wide,
   would read as a highlight and pull the eye off the medians above them.

   Level 1 is deliberately the ordinary muted colour rather than the palest
   green: the weakest post on the page should look like every other figure, so
   that any colour at all means something. Green is mixed INTO the muted grey
   rather than toward the card, because these are text — lightening toward the
   background is what you do to a fill, and to a letterform it just fades. */
.eng-range .post-link.level-1 { color: var(--color-muted); }
.eng-range .post-link.level-2 { color: color-mix(in oklab, var(--color-trend-up) 60%, var(--color-muted)); }
.eng-range .post-link.level-3 { color: color-mix(in oklab, var(--color-trend-up) 85%, var(--color-muted)); }
.eng-range .post-link.level-4 { color: var(--color-trend-up); font-weight: var(--weight-emphasis); }

/* After the ramp, so hovering a shaded figure still confirms it is a link. */
.eng-range .post-link:hover { color: var(--color-heading); }
.eng-range-sep { color: var(--color-faint); }

/* Roomier rows, for a table whose rows are things rather than records. The
   roster lists people, each with an avatar, a name, their profiles and two
   lines of figures — at the default --table-pad-y those stack into a block of
   text with no air between one account and the next. Set on the table so the
   header loosens with the body. */
.table-roomy { --table-pad-y: calc(var(--space) * 4); }
/* One step roomier again for the roster, whose rows carry a 2rem avatar and a
   second line under the name. At the shared padding those rows touched. */
.table-airy { --table-pad-y: calc(var(--space) * 5); }

/* A metric's total. Its movement lives in the next column along, so this cell
   holds one figure and nothing else. */
.metric-value { display: inline; }

/* The change columns. Narrower than the totals they follow — they hold "+318"
   or "+5.41M", never a seven-digit number — and tightened on the left so each
   change reads as belonging to the total on its left rather than floating
   between two columns. Without this they sit at even spacing and the eye has
   to work out which total each one belongs to. */
.table .cell-change {
  /* Tighter than --table-pad-x on both sides. A change column is narrow by
     nature — "+318", never a seven-digit total — and pulling it toward the
     figure it belongs to is what stops four of them reading as free-floating
     columns of their own. Four columns x the saving is also what brings the
     full three-group table inside the page's container. */
  padding-left: calc(var(--space) * 1);
  padding-right: calc(var(--space) * 2);
  /* Support for the total beside it, not a rival: the same reset .eng-range
     makes against the emphasis weight the first-column rule hands down. */
  font-weight: var(--weight-body);
}

/* --- Post feed -----------------------------------------------------------
   The paginated list of posts at the bottom of the account pane. Everything
   above it is a summary; this is the body of work being summarised, so it is
   built to be scanned — one row per post, the caption doing the identifying
   and every count in its own right-aligned column. */

/* The toolbar's selects size to their content. The base .select is full-width,
   which is right in a form column and wrong in a row of four filters. */
.toolbar-select { width: auto; min-width: 0; }

/* A sortable column header. It has to read as clickable while still looking
   like a table header — so it takes the header's own colour and case, and the
   caret is what marks it, not a link colour. */
.sort-link {
  display: inline-flex;
  align-items: center;
  gap: 0.25em;
  color: inherit;
  font: inherit;
  letter-spacing: inherit;
  text-transform: inherit;
  text-decoration: none;
  cursor: pointer;
}
.sort-link:hover { color: var(--color-heading); text-decoration: none; }
/* Drawn on every sortable column, faint until it is the live sort: a caret
   that appeared only on the active column would leave the rest looking like
   plain text, and nobody clicks plain text. */
.sort-mark {
  flex: none;
  opacity: 0.25;
  transition: opacity var(--speed) var(--ease-out), rotate var(--speed) var(--ease-out);
}
.sort-link:hover .sort-mark { opacity: 0.6; }
.sort-link.active { color: var(--color-heading); }
.sort-link.active .sort-mark { opacity: 1; }
/* One glyph, turned over. Two icons would be two things to keep consistent. */
.sort-mark.asc { rotate: 180deg; }

/* The post cell: thumbnail, caption, and the format badge where it matters. */
.post-cell {
  display: flex;
  align-items: center;
  gap: calc(var(--space) * 2.5);
  min-width: 0;
}
.post-thumb {
  flex: none;
  width: 2.25rem;
  height: 2.25rem;
  border-radius: var(--radius-sm);
  object-fit: cover;
  background: var(--color-inset);
}
/* The slot is rendered for every row and keeps its footprint whether anything
   fills it or not, so the captions stay on one left edge instead of stepping in
   and out as covers arrive, fail, or never existed. The cover is positioned
   inside it rather than being the box itself, which is what lets an image that
   fails to load be removed without the row collapsing. */
.post-thumb-slot { display: block; position: relative; overflow: hidden; }
.post-thumb-img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
}
.post-cell-body {
  display: flex;
  align-items: baseline;
  gap: calc(var(--space) * 2);
  min-width: 0;
}
/* Captions run to paragraphs, so the template already cuts them to thirty
   characters. This is the backstop for the cases that survive that — thirty
   wide glyphs, or a single unbroken word — because one long caption widening
   this column pushes every figure off the right of the table. The full text is
   in the tooltip and the post itself is one click away. */
.post-caption {
  min-width: 0;
  max-width: 16rem;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-weight: var(--weight-body);
}
/* Only reels are badged. TikTok has one format and Instagram grid posts are
   the default, so labelling those two would be a badge on every row saying
   nothing. */
.post-fmt { flex: none; }

/* The Posts page's version of the cell. That page is the whole window rather
   than a panel beside a table, and its subject is the post itself, so the
   cover is worth actually looking at: 4rem against the pane's 2.25rem, which
   is the difference between confirming which post a row is and being able to
   tell what is in it.

   The caption stacks under the badges instead of sitting beside them, and runs
   to twice the pane's width — there is room here, and a page whose one job is
   listing posts should not identify them by thirty characters. */
.post-cell-lg { gap: calc(var(--space) * 3); }
.post-cell-lg .post-thumb { width: 4rem; height: 4rem; border-radius: var(--radius-sm); }
.post-cell-lg .post-cell-body {
  flex-direction: column;
  align-items: flex-start;
  gap: calc(var(--space) * 1);
}
.post-cell-lg .post-caption { max-width: 26rem; }

/* --- Post card -----------------------------------------------------------
   The sound page's grid view — see tiktok_insights/_sound_post_card.html. A
   creator tool's card layout (tall cover, handle, one stat per line, posted
   and updated) kept to this app's rules: a flat white card with the stats in
   its body, not white text over the photo on a dark gradient. The one thing
   laid over the cover is the platform mark, on a small solid chip like the
   duration badge beside it. */
.post-card-grid {
  display: grid;
  gap: calc(var(--space) * 4);
  grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr));
}
.post-card {
  display: flex;
  flex-direction: column;
  min-width: 0;
  background: var(--color-card);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-lg);
  overflow: hidden;
  transition: border-color var(--speed) var(--ease-out);
}
.post-card:hover { border-color: var(--color-border-strong); }
/* 3:4 rather than the video's own 9:16: tall enough to read as the cover it
   is, short enough that the stats are on screen without scrolling past it. */
.post-card-cover {
  position: relative;
  aspect-ratio: 3 / 4;
  background: var(--color-inset);
}
.post-card-mark {
  position: absolute;
  top: calc(var(--space) * 2);
  left: calc(var(--space) * 2);
  display: grid;
  place-items: center;
  width: 1.75rem;
  height: 1.75rem;
  border-radius: var(--radius-pill);
  background: var(--color-card);
  color: var(--color-heading);
  box-shadow: 0 0 0 1px var(--color-border);
}
.post-card-body {
  display: flex;
  flex-direction: column;
  gap: calc(var(--space) * 2);
  flex: 1;
  padding: calc(var(--space) * 3);
}
.post-card-who {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: calc(var(--space) * 2);
  min-width: 0;
}
.post-card-handle {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-size: var(--text-sm);
  font-weight: var(--weight-emphasis);
  color: var(--color-heading);
}
a.post-card-handle:hover { text-decoration: underline; }
.post-card-caption { margin: 0; font-size: var(--text-xs); color: var(--color-muted); }
.post-card-stats {
  display: flex;
  flex-direction: column;
  gap: calc(var(--space) * 1.25);
  margin: 0;
  padding: 0;
  list-style: none;
  font-size: var(--text-xs);
  color: var(--color-muted);
}
.post-card-stats li { display: flex; align-items: center; gap: calc(var(--space) * 2); }
.post-card-stats svg { flex: none; width: 0.875rem; height: 0.875rem; color: var(--color-faint); }
.post-card-stats .day-views { margin-left: auto; }
.post-card-value {
  color: var(--color-heading);
  font-weight: var(--weight-emphasis);
  font-variant-numeric: tabular-nums;
}
/* Each half stays whole; on a card too narrow for both, "Updated" drops to
   its own line rather than either phrase breaking in the middle. */
.post-card-foot {
  display: flex;
  flex-wrap: wrap;
  justify-content: space-between;
  gap: calc(var(--space) * 1) calc(var(--space) * 2);
  white-space: nowrap;
  margin-top: auto;
  padding-top: calc(var(--space) * 2);
  border-top: 1px solid var(--color-border);
  font-size: 0.6875rem;
  color: var(--color-faint);
}

/* --- Sharing -------------------------------------------------------------
   The share dialog on a page that can be shared, and the page a share link
   opens (see digital_tool.sharing). That page is the app's own page without
   the app around it — no sidebar — so its main column needs no offset. */
.share-main { min-height: 100vh; }
.share-banner {
  margin: 0 0 calc(var(--space) * 6);
  font-size: var(--text-xs);
  color: var(--color-faint);
}
.share-open { margin-left: auto; flex: none; }
/* A campaign's Add videos / Rename / Delete, at the header's far end like Share. */
.campaign-actions { margin-left: auto; flex: none; gap: calc(var(--space) * 2); }
.share-dialog { width: min(36rem, calc(100vw - var(--space) * 8)); }
.share-dialog .dialog-body {
  display: flex;
  flex-direction: column;
  gap: calc(var(--space) * 4);
  max-height: min(70vh, 40rem);
  overflow-y: auto;
}
.share-message {
  margin: 0;
  padding: calc(var(--space) * 2) calc(var(--space) * 3);
  border-radius: var(--radius-sm);
  background: var(--color-inset);
  font-size: var(--text-xs);
  color: var(--color-heading);
}
.share-message.is-error { color: var(--color-danger); }
.share-list {
  display: flex;
  flex-direction: column;
  gap: calc(var(--space) * 3);
  margin: 0;
  padding: 0;
  list-style: none;
}
.share-item {
  display: flex;
  flex-direction: column;
  gap: calc(var(--space) * 2);
  padding: calc(var(--space) * 3);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-lg);
}
.share-item.is-expired .share-url { opacity: 0.55; }
.share-item-label {
  margin: 0;
  font-size: var(--text-sm);
  font-weight: var(--weight-emphasis);
  color: var(--color-heading);
}
.share-url { display: flex; gap: calc(var(--space) * 2); }
.share-url .input { flex: 1; min-width: 0; font-family: var(--font-mono); font-size: var(--text-xs); }
.share-edit summary { cursor: pointer; color: var(--color-muted); }
.share-form {
  display: flex;
  flex-direction: column;
  gap: calc(var(--space) * 3);
  margin-top: calc(var(--space) * 3);
}
.share-new {
  margin-top: 0;
  padding-top: calc(var(--space) * 4);
  border-top: 1px solid var(--color-border);
}
.share-field { display: flex; flex-direction: column; gap: calc(var(--space) * 1.5); }

/* --- Region globe --------------------------------------------------------
   The sound page's "Where it's used" — see _region_globe_script.html. Land in
   the inset grey, countries with posts in the app's green at an opacity set
   by their share, the sea left white inside a hairline. */
.region-globe { position: relative; max-width: 18rem; margin: 0 auto; }
.region-globe svg { display: block; width: 100%; height: auto; touch-action: none; cursor: grab; }
.region-globe svg:active { cursor: grabbing; }
.globe-sphere { fill: var(--color-card); stroke: var(--color-border); stroke-width: 1; }
.globe-graticule { fill: none; stroke: var(--color-border); stroke-width: 0.5; opacity: 0.6; }
.globe-country { fill: var(--color-inset); stroke: var(--color-card); stroke-width: 0.5; }
.globe-country.has-posts { fill: var(--color-trend-up); cursor: pointer; }
.globe-country.has-posts:hover { stroke: var(--color-heading); stroke-width: 1; }
.globe-country.is-active { stroke: var(--color-heading); stroke-width: 1.5; }
.region-globe-tip {
  position: absolute;
  transform: translate(-50%, -100%);
  padding: 2px 8px;
  border-radius: var(--radius-md);
  background: var(--color-heading);
  color: var(--color-card);
  font-size: var(--text-xs);
  white-space: nowrap;
  pointer-events: none;
}

/* --- Sound page ----------------------------------------------------------
   Its header stats, its clickable total cards, the chart-and-globe row, and
   each total's histogram dialog. */
.sound-head-stats {
  display: flex;
  flex-wrap: wrap;
  gap: calc(var(--space) * 6);
  margin: calc(var(--space) * 3) 0 0;
}
.sound-head-stats dt {
  font-size: var(--text-xs);
  font-weight: var(--weight-heading);
  text-transform: uppercase;
  letter-spacing: 0.05em;
  color: var(--color-muted);
}
.sound-head-stats dd {
  margin: 0;
  font-family: var(--font-heading);
  font-size: var(--text-lg);
  color: var(--color-heading);
  font-variant-numeric: tabular-nums;
}

/* A stat card that opens something: the whole card is the button, with an
   arrow in its corner saying so. */
.stat-card-button {
  position: relative;
  display: block;
  width: 100%;
  padding: 0;
  text-align: left;
  font: inherit;
  color: inherit;
  cursor: pointer;
  transition: border-color var(--speed) var(--ease-out);
}
.stat-card-button:hover:not(:disabled) { border-color: var(--color-border-strong); }
.stat-card-button:disabled { cursor: default; }
.stat-card-button .stat-card > span { display: block; }
/* Room for the arrow, so a long label ("Total comments") never runs under it. */
.stat-card-button .stat-label { padding-right: calc(var(--space) * 5); }
/* The sound page's five totals: one row of five where there is room for a
   label like "Engagement" beside its arrow, three and two below that, and
   two to a row on a phone. */
.post-detail-stats.sound-totals { grid-template-columns: repeat(5, minmax(0, 1fr)); }
@media (max-width: 80rem) {
  .post-detail-stats.sound-totals { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
@media (max-width: 40rem) {
  .post-detail-stats.sound-totals { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
.stat-card-arrow {
  position: absolute;
  top: calc(var(--space) * 4);
  right: calc(var(--space) * 4);
  color: var(--color-faint);
  transition: color var(--speed) var(--ease-out);
}
.stat-card-button:hover:not(:disabled) .stat-card-arrow { color: var(--color-heading); }
.stat-card-button:disabled .stat-card-arrow { display: none; }

/* The chart and the globe, half the page each. */
.sound-overview {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: calc(var(--space) * 5);
  margin-bottom: calc(var(--space) * 6);
}
@media (max-width: 60rem) {
  .sound-overview { grid-template-columns: minmax(0, 1fr); }
}

/* A SparkChart drawn with axes (spark_axes). Values up the left, beside the
   plot rather than in it — the plot's svg is stretched, and text inside it
   would stretch too — faint rules at the same heights, dates underneath. */
.spark-axes { display: flex; gap: calc(var(--space) * 2); }
.spark-y {
  position: relative;
  flex: none;
  width: 3rem;
  font-size: 0.6875rem;
  color: var(--color-faint);
  font-variant-numeric: tabular-nums;
}
.spark-y span { position: absolute; right: 0; transform: translateY(-50%); }
.spark-plot { flex: 1; min-width: 0; }
.spark-rules { position: absolute; inset: 0; pointer-events: none; }
.spark-rules span {
  position: absolute;
  left: 0;
  right: 0;
  border-top: 1px dashed var(--color-border);
}
.spark-x {
  position: relative;
  height: 1.25rem;
  margin-top: calc(var(--space) * 1.5);
  font-size: 0.6875rem;
  color: var(--color-faint);
  white-space: nowrap;
}
.spark-x span { position: absolute; top: 0; transform: translateX(-50%); }
.spark-x span.is-start { transform: none; }
.spark-x span.is-end { transform: translateX(-100%); }

/* A total's histogram. Bars are HTML rather than SVG so each one can carry
   the app's own hover tip and be a plain link. */
.hist-dialog { width: min(40rem, calc(100vw - var(--space) * 8)); }
.hist { display: flex; gap: calc(var(--space) * 2); }
.hist-y {
  display: flex;
  flex-direction: column;
  justify-content: space-between;
  flex: none;
  height: 12rem;
  font-size: 0.6875rem;
  color: var(--color-faint);
  text-align: right;
  font-variant-numeric: tabular-nums;
}
.hist-plot { flex: 1; min-width: 0; }
.hist-bars {
  display: flex;
  align-items: flex-end;
  gap: 3px;
  height: 12rem;
  border-bottom: 1px solid var(--color-border);
}
.hist-bar {
  display: flex;
  align-items: flex-end;
  flex: 1;
  height: 100%;
  min-width: 0;
}
.hist-bar > span {
  display: block;
  width: 100%;
  min-height: 2px;
  border-radius: 3px 3px 0 0;
  background: var(--color-trend-up);
  opacity: 0.75;
  transition: opacity var(--speed) var(--ease-out);
}
a.hist-bar:hover > span { opacity: 1; }
.hist-bar.is-empty > span { min-height: 0; }
.hist-x {
  display: flex;
  gap: 3px;
  margin-top: calc(var(--space) * 1.5);
  font-size: 0.625rem;
  color: var(--color-faint);
  font-variant-numeric: tabular-nums;
}
.hist-x span { flex: 1; min-width: 0; text-align: center; overflow: hidden; }

/* --- Pager ---------------------------------------------------------------
   Numbered pages with both ends always reachable. Shaped like the DataTables
   pagination the rest of the app uses, but hand-rolled: these are real links
   with real hrefs, because the feed's state lives in the URL. */
.pager { display: flex; align-items: center; gap: calc(var(--space) * 1); }
.pager-link {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: var(--control-h-sm);
  height: var(--control-h-sm);
  padding-inline: calc(var(--space) * 2);
  border-radius: var(--radius-pill);
  color: var(--color-heading);
  font-size: var(--text-sm);
  font-weight: var(--weight-ui);
  text-decoration: none;
  transition: background-color var(--speed) var(--ease-out), color var(--speed) var(--ease-out);
}
.pager-link:hover { background: var(--color-neutral-soft); text-decoration: none; }
.pager-link.current { background: var(--color-primary); color: var(--color-on-primary); }
.pager-link.disabled { opacity: 0.35; pointer-events: none; }
.pager-gap { color: var(--color-faint); padding-inline: calc(var(--space) * 1); }

/* Each filter row's name. Both rows carry one: with two rows of pills stacked,
   an unlabelled one reads as a continuation of the other. Fixed width so the
   two pill rows start on the same left edge rather than stepping. */
.filter-row-label {
  /* Wide enough for the longest label any filter row uses — "Platform" on the
     Posts page. The point of the fixed width is that every row's pills start
     on one left edge; sized to content they step in and out, and a stack of
     four rows reads as four unrelated controls rather than one panel. */
  min-width: 4.5rem;
  font-size: var(--text-xs);
  font-weight: var(--weight-ui);
  color: var(--color-faint);
  text-transform: uppercase;
  letter-spacing: 0.06em;
}

/* The way back out of a detail page. Its own line above the subject's header,
   which is where the post page already put it — the crumb is navigation, not
   part of the thing being read. */
.crumb-back { margin-bottom: calc(var(--space) * 4); }

/* Dialogs arrive and leave rather than appearing.
   
   Every other change of state in this app moves at --speed; a modal that
   simply existed one frame and not the next was the last thing that did not.
   Written here rather than in style.css so the design system keeps its own
   dialog primitive unopinionated about motion.

   `allow-discrete` on display and overlay is what makes the exit visible at
   all: without it the browser flips display:none the instant close() is
   called and there is nothing left to animate. @starting-style supplies the
   frame BEFORE open — a transition needs two ends, and an element that has
   just been displayed has only one. */
.dialog {
  opacity: 0;
  transform: translateY(0.5rem) scale(0.985);
  transition:
    opacity var(--speed) var(--ease-out),
    transform var(--speed) var(--ease-out),
    overlay var(--speed) var(--ease-out) allow-discrete,
    display var(--speed) var(--ease-out) allow-discrete;
}
.dialog[open] { opacity: 1; transform: none; }
@starting-style {
  .dialog[open] { opacity: 0; transform: translateY(0.5rem) scale(0.985); }
}
.dialog::backdrop {
  opacity: 0;
  transition:
    opacity var(--speed) var(--ease-out),
    overlay var(--speed) var(--ease-out) allow-discrete,
    display var(--speed) var(--ease-out) allow-discrete;
}
.dialog[open]::backdrop { opacity: 1; }
@starting-style { .dialog[open]::backdrop { opacity: 0; } }

/* Asked for less motion: the modal still opens and closes, it just does not
   travel to get there. */
@media (prefers-reduced-motion: reduce) {
  .dialog, .dialog[open] { transform: none; }
  .dialog, .dialog::backdrop { transition-duration: 1ms; }
}

/* --- The filter modal ----------------------------------------------------
   Every page's filters behind one button, rather than three or five rows of
   pills stacked above the table.

   The rows were honest but they grew: Posts carried five of them plus a range
   slider, which is more vertical space above the table than the first screen
   of the table itself. A button states how many filters are on and gets out of
   the way; the modal is where you set several at once and apply them in one
   navigation, instead of one reload per pill.

   Everything inside is a plain form control posting the same query parameters
   the links used to build, so the views did not have to learn anything new. */

/* Regions htmx swaps in place. It inserts the new content before removing the
   old, and if anything lays the page out in between — a clicked link that
   still holds focus is enough — scroll anchoring keeps the old content where
   it was: the page shifts down by the new content's height, then clamps to the
   bottom when the old content goes. Nothing in a swapped region is worth
   anchoring to, since all of it is about to be replaced. */
[data-filter-region] { overflow-anchor: none; }

.filter-trigger {
  display: inline-flex;
  align-items: center;
  gap: calc(var(--space) * 2);
}
/* How many filters are on, on the button itself. Without it the modal is a
   closed drawer: a page filtered to one list looks identical to an unfiltered
   one until you open it. */
.filter-count {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 1.25rem;
  height: 1.25rem;
  padding-inline: calc(var(--space) * 1);
  border-radius: var(--radius-pill-full);
  background: var(--color-heading);
  color: var(--color-card);
  font-size: 10px;
  font-weight: var(--weight-heading);
  font-variant-numeric: tabular-nums;
}

/* Wider than the app's other dialogs, which hold one form field at a time.
   This holds every axis a table has. */
.filter-dialog { width: min(40rem, calc(100vw - var(--space) * 8)); }
.filter-dialog .dialog-body {
  display: flex;
  flex-direction: column;
  gap: calc(var(--space) * 5);
  max-height: min(60vh, 32rem);
  overflow-y: auto;
}
/* Label above its options rather than beside them. The row form put them side
   by side to keep the toolbar short; here there is width to spare and a
   stacked label is easier to scan down. */
.filter-group { display: flex; flex-direction: column; gap: calc(var(--space) * 2); }
.filter-group > .filter-row-label { min-width: 0; }

/* The footer's two jobs pull opposite ways: Clear is destructive-ish and
   Apply is the point, so they sit at opposite ends rather than beside each
   other where a mis-click is one pixel away. */
.filter-dialog .dialog-footer { justify-content: space-between; }

/* A handle that links out to its platform. The arrow is the affordance —
   underlining every handle in the table would draw more attention to their
   being links than to which profile each one is. */
.profile-out {
  display: inline-flex;
  align-items: center;
  gap: calc(var(--space) * 1);
  text-decoration: none;
}
.profile-out svg { color: var(--color-faint); flex: none; }
.profile-out:hover { text-decoration: underline; }
.profile-out:hover svg { color: var(--color-muted); }

/* --- Plan usage ----------------------------------------------------------
   How much of a plan's profile allowance is spent, per platform.

   A bar rather than "10 of 20": the number answers the question and the bar
   answers the one after it, which is whether that is comfortable or nearly
   over. Colour only carries the warning, never the value — the count beside
   it says the same thing to someone who cannot see the difference. */
.usage-row { display: flex; flex-direction: column; gap: calc(var(--space) * 1.5); }
.usage-head { display: flex; align-items: baseline; justify-content: space-between; gap: calc(var(--space) * 3); }
.usage-count { font-variant-numeric: tabular-nums; color: var(--color-muted); font-size: var(--text-xs); }
.usage-track {
  height: 0.375rem;
  border-radius: var(--radius-pill-full);
  background: var(--color-inset);
  overflow: hidden;
}
.usage-fill {
  height: 100%;
  border-radius: inherit;
  background: var(--color-heading);
  transition: width var(--speed) var(--ease-out);
}
/* Four fifths spent is the point at which "plenty left" stops being true. */
.usage-fill.near { background: var(--color-trial); }
.usage-fill.full { background: var(--color-danger); }
.usage-note.full { color: var(--color-danger); }

/* --- The search box -----------------------------------------------------
   Beside the filter button, at the right-hand end of the toolbar.

   This was an expanding dock — a circle that opened into a field. It never
   opened: collapsed it was one control-height wide with overflow hidden, so
   the magnifier on screen was the decorative span that happens to come first
   in the markup, and the button that opened it sat clipped a few pixels to
   its right. A box that is always a box cannot be in the wrong state. */
.search-bar {
  display: flex;
  align-items: center;
  gap: calc(var(--space) * 2);
  width: min(20rem, 100%);
  margin-left: auto;
  height: var(--control-h-sm);
  padding-inline: calc(var(--space) * 3);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-pill-full);
  background: var(--color-card);
  transition: border-color var(--speed) var(--ease-out);
}
/* The ring goes on the pill, not on the input inside it.
   
   style.css draws :focus-visible as a box-shadow on the focused element and
   pins it to --radius-sm, which inside a capsule is a rounded rectangle in a
   pill — the shape the reader sees is not the shape of the control they are
   typing in. outline:none does nothing about it, because it is a shadow.

   So the field gives its ring up and the container takes one, inheriting the
   pill radius it already has. The colour follows .input:focus rather than
   inventing a third focus treatment. */
.search-bar:focus-within {
  border-color: var(--color-primary);
  box-shadow: var(--focus-ring);
}
.search-bar-field:focus,
.search-bar-field:focus-visible { box-shadow: none; }
/* Same rule, same problem: the clear button is round, and the global ring
   would square its corners off the moment it took focus. */
.search-bar-clear:focus-visible { border-radius: var(--radius-pill-full); }
.search-bar-icon { flex: none; display: inline-flex; color: var(--color-faint); }
.search-bar-field {
  flex: 1;
  min-width: 0;
  height: 100%;
  border: 0;
  background: none;
  color: var(--color-text);
  font-size: var(--text-sm);
  outline: none;
}
/* Safari draws its own clear button inside type=search, in a place and a
   shape this box does not choose. Ours is the one that stays. */
.search-bar-field::-webkit-search-cancel-button { display: none; }
.search-bar-clear {
  flex: none;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 1.25rem;
  height: 1.25rem;
  padding: 0;
  border: 0;
  border-radius: var(--radius-pill-full);
  background: none;
  color: var(--color-muted);
  cursor: pointer;
}
.search-bar-clear:hover { color: var(--color-heading); background: var(--color-inset); }

/* A stack of filter rows, on any page that has one.
   
   The rhythm is a token rather than a number repeated per page: the three
   pages carrying these rows had drifted to 0.5rem, 1rem and 1.5rem between
   them, which is invisible on any one page and obvious the moment you click
   between two. The Posts page's 1rem is the value the others were moved to. */
.filter-rows {
  display: flex;
  flex-direction: column;
  gap: var(--filter-row-gap);
  /* The same air under the block the Posts page's filters used to leave, so
     the gap to the table below matches on every page. */
  margin-bottom: calc(var(--space) * 8);
}

/* The Posts page's two-column filter block is gone — its filters live in the
   filter modal now. --filter-row-gap survives it: .filter-rows still uses it
   for the Sort row that stayed on the page. */


/* The count range: two typed bounds over a two-handled track. */
.range-filter {
  display: flex;
  flex-direction: column;
  gap: calc(var(--space) * 2);
  /* Lines up under the pills above it rather than under their label. */
  padding-left: calc(4.5rem + var(--space) * 2);
  min-width: 0;
}
@media (max-width: 1100px) {
  .range-filter { padding-left: calc(4.5rem + var(--space) * 2); }
}
.range-values {
  display: flex;
  align-items: center;
  gap: calc(var(--space) * 2);
  flex-wrap: wrap;
}
/* Pills, because --radius-md already is one and these are one line tall. Sized
   to hold eight digits, which is what a view count on a good post needs. */
.range-pill {
  width: 7.5rem;
  text-align: center;
  font-variant-numeric: tabular-nums;
}
.range-sep { color: var(--color-faint); }

/* The track. Two range inputs stacked in the same box: the platform has no
   two-handled control, and a pair is what lets each end be dragged, focused
   and keyed on its own. Both are transparent — the rail and the fill below
   are what you actually see — and pointer-events are handed back to the
   thumbs alone so the lower input cannot swallow clicks meant for the upper. */
.range-track {
  position: relative;
  height: 1.5rem;
  display: flex;
  align-items: center;
  min-width: 0;
}
.range-rail, .range-fill {
  position: absolute;
  height: 3px;
  border-radius: var(--radius-pill-full);
  pointer-events: none;
}
.range-rail { left: 0; right: 0; background: var(--color-border); }
.range-fill { background: var(--color-primary); }
.range-handle {
  position: absolute;
  left: 0;
  width: 100%;
  margin: 0;
  background: none;
  pointer-events: none;
  appearance: none;
  height: 1.5rem;
}
.range-handle:focus { outline: none; }
.range-handle::-webkit-slider-thumb {
  appearance: none;
  pointer-events: auto;
  width: 0.875rem;
  height: 0.875rem;
  border-radius: var(--radius-pill-full);
  background: var(--color-card);
  border: 2px solid var(--color-primary);
  box-shadow: var(--shadow-sm);
  cursor: grab;
}
.range-handle::-moz-range-thumb {
  pointer-events: auto;
  width: 0.875rem;
  height: 0.875rem;
  border-radius: var(--radius-pill-full);
  background: var(--color-card);
  border: 2px solid var(--color-primary);
  box-shadow: var(--shadow-sm);
  cursor: grab;
}
.range-handle:focus-visible::-webkit-slider-thumb { box-shadow: var(--focus-ring); }
.range-handle:focus-visible::-moz-range-thumb { box-shadow: var(--focus-ring); }
.range-note { margin: 0; }

[data-post-row] { cursor: pointer; }

/* --- One post's page ------------------------------------------------------ */
.post-detail-head {
  display: flex;
  align-items: flex-start;
  gap: calc(var(--space) * 5);
  margin-bottom: calc(var(--space) * 8);
}
.post-detail-thumb { width: 7.5rem; height: 7.5rem; border-radius: var(--radius-lg); }
/* The caption is the title here, and captions run long — so it wraps, unlike
   the one-line truncated version in the tables. */
.post-detail-title {
  white-space: normal;
  max-width: 46rem;
  margin-bottom: calc(var(--space) * 2);
}
.post-detail-meta {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: calc(var(--space) * 2);
  margin: 0 0 calc(var(--space) * 2);
  font-size: var(--text-sm);
  color: var(--color-muted);
}
/* Same auto-fit grid the account pane's cards use — one card per metric plus
   the rate, so the count varies with what the platform reports. */
.post-detail-stats {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));
  gap: calc(var(--space) * 4);
  margin-bottom: calc(var(--space) * 6);
}

/* --- Post calendar -------------------------------------------------------
   A profile's publishing history as a heat grid, under its row in the profiles
   table. Laid out like a contribution graph: a column per week, a row per
   weekday, months across the top and Mon/Wed/Fri down the side.

   Cells are a fixed square rather than stretching to the table's width. A year
   is ~53 columns, and stretching those across a wide table gives flat
   rectangles that stop reading as a calendar; keeping them square costs some
   trailing whitespace and is worth it. */
/* Declared on the row rather than on .calendar, because the legend sits up by
   the section heading, outside any grid, and still has to draw swatches the
   same size. Scoped there rather than at :root since nothing else wants them. */
.calendar,
.calendar-key,
.calendar-row {
  --calendar-cell: 0.9375rem;   /* 15px */
  --calendar-gap: 3px;
  --calendar-monthline: 0.875rem;
}

/* The grid spans the table, so its row gets no top border and no top padding:
   it reads as a continuation of the profile above it, not a row of its own. */
.calendar-row > td {
  border-top: 0;
  /* Matched top and bottom, so the grid sits in its own band of air rather
     than crowding the profile above it. */
  padding-top: calc(var(--space) * 4);
  padding-bottom: calc(var(--space) * 4);
}
.calendar-line {
  display: flex;
  align-items: flex-start;
  gap: calc(var(--space) * 4);
  flex-wrap: wrap;
}
.calendar-caption {
  flex: none;
  font-size: var(--text-xs);
  color: var(--color-faint);
  /* Dropped to sit beside the grid itself rather than the month labels. */
  padding-top: calc(var(--calendar-monthline) + var(--calendar-gap));
}

/* Spans the table. The columns are 1fr, so a year of weeks stretches to
   whatever width the table has; the rows stay a fixed 15px, which is what a
   column comes out to at the widths this page is actually used at, so the
   cells read square. On a much wider screen they flatten — capped so that
   never gets silly. */
.calendar {
  display: flex;
  gap: var(--calendar-gap);
  flex: 1;
  min-width: 0;
  max-width: 88rem;
}
.calendar-main { flex: 1; min-width: 0; }

/* Weekday names. The blank first cell is the height of the month row, which is
   what keeps Mon/Wed/Fri aligned with the rows they name. */
.calendar-gutter {
  display: grid;
  grid-template-rows: var(--calendar-monthline) repeat(7, var(--calendar-cell));
  gap: var(--calendar-gap);
  padding-right: calc(var(--space) * 1);
}
.calendar-monthline { height: var(--calendar-monthline); }
.calendar-weekday {
  font-size: 0.5625rem;
  line-height: var(--calendar-cell);
  color: var(--color-faint);
  text-align: right;
}

.calendar-main { display: flex; flex-direction: column; gap: var(--calendar-gap); }

/* Same track sizes as the weeks below, so a label sits over its own column. */
.calendar-months {
  display: grid;
  grid-template-columns: repeat(var(--calendar-weeks), minmax(0, 1fr));
  gap: var(--calendar-gap);
  height: var(--calendar-monthline);
}
.calendar-month {
  font-size: 0.5625rem;
  color: var(--color-faint);
  white-space: nowrap;
  /* Labels start at their column and run over the ones after it; those columns
     are empty, so nothing is covered. */
  grid-row: 1;
}

/* auto-flow: column fills top-to-bottom then rightwards, which is exactly the
   order post_calendar emits: a week at a time, Sunday first. */
.calendar-weeks {
  display: grid;
  grid-template-rows: repeat(7, var(--calendar-cell));
  grid-auto-flow: column;
  grid-auto-columns: minmax(0, 1fr);
  gap: var(--calendar-gap);
}
.calendar-cell {
  /* Width comes from the 1fr column; only the legend's loose swatches need to
     be told a size. */
  height: var(--calendar-cell);
  border-radius: 2px;
  background: var(--color-inset);
  outline: 1px solid transparent;
  transition: outline-color var(--speed) var(--ease-out);
}
/* Days outside the window hold their square and draw nothing, so the first and
   last columns stay ragged the way a real calendar is. */
.calendar-cell-void { background: transparent; }

/* Green, not the achromatic ink the rest of the page uses. This is the one
   place a ramp has to carry a quantity, and four greys between #ededed and
   near-black are not four steps anyone can tell apart at 11px. Green also
   already means "up" here — .stat-delta.up is the same hue.

   The lightest step is deliberately a definite green rather than a tint of
   grey: level 1 means "posted", and that has to be legible against level 0 at
   a glance. */
.calendar-cell.level-1 { background: color-mix(in oklab, var(--color-trend-up) 25%, var(--color-card)); }
.calendar-cell.level-2 { background: color-mix(in oklab, var(--color-trend-up) 50%, var(--color-card)); }
.calendar-cell.level-3 { background: color-mix(in oklab, var(--color-trend-up) 75%, var(--color-card)); }
.calendar-cell.level-4 { background: var(--color-trend-up); }

.calendar-cell[data-tip] { cursor: help; }
.calendar-cell[data-tip]:hover,
.calendar-cell[data-tip]:focus-visible {
  outline-color: var(--color-border-strong);
  outline-offset: 1px;
}

/* Four greens mean nothing without a legend — and here the words carry more
   than the swatches, because the scale is the profile's own posts. */
.calendar-key {
  display: flex;
  align-items: center;
  gap: var(--calendar-gap);
}
/* The words need more air than the swatches do, or "Weaker" and the first
   square read as one object. */
.calendar-key-label:first-child { margin-right: calc(var(--space) * 1); }
.calendar-key-label:last-child { margin-left: calc(var(--space) * 1); }
.calendar-key-label {
  font-size: 0.5625rem;
  color: var(--color-faint);
  white-space: nowrap;
}
.calendar-key .calendar-cell { flex: none; width: var(--calendar-cell); }

/* --- Stat cards ----------------------------------------------------------
   A total, its windows, and the shape of its movement — in one card. The
   sparkline used to be a card of its own, which put a number and its trend in
   different boxes and left that box with no label saying what it counted. */
.stat-card { display: flex; flex-direction: column; height: 100%; }
/* Pushed to the bottom so the lines sit on one baseline across the row of
   cards, however many windows each of them has room to show. */
.stat-spark { margin-top: auto; padding-top: calc(var(--space) * 3); }
.stat-spark svg { display: block; overflow: visible; }
.stat-spark-caption {
  margin-top: calc(var(--space) * 1);
  font-size: var(--text-xs);
  color: var(--color-faint);
}
/* The views caveat. Faint and small on purpose — it qualifies the figure above
   without competing with it — but present, because the daily number is a
   floor rather than a measurement. */
.stat-caveat {
  margin-top: calc(var(--space) * 1);
  font-size: 0.6875rem;
  color: var(--color-faint);
}

/* --- Spark chart ---------------------------------------------------------
   Adapted from ruixen's spark-chart — see talent/_spark_chart.html. One line
   and its gradient fill, in one colour throughout. The dot, scrub line and
   pill are HTML over the SVG, so the stretched viewBox cannot distort them,
   and they snap to readings rather than sliding along the curve.

   The app's green by default. Set --spark-color on the chart (spark_color in
   the include) to change it. */
.spark-chart {
  --spark-color: var(--color-trend-up);
  position: relative;
  user-select: none;
  touch-action: none;
  cursor: crosshair;
}
.spark-chart svg { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; }
.spark-stop { stop-color: var(--spark-color); }
.spark-line {
  fill: none;
  stroke: var(--spark-color);
  stroke-width: 2.2;
  stroke-linecap: round;
  stroke-linejoin: round;
  vector-effect: non-scaling-stroke;
  animation: spark-draw 0.8s ease-out;
}
/* Draw-in is a left-to-right wipe rather than ruixen's pathLength dash: with
   a non-scaling stroke the dash is measured on screen, so once the chart is
   stretched past its 400-unit viewBox the "full-length" dash stops short. */
@keyframes spark-draw { from { clip-path: inset(0 100% 0 0); } }

.spark-label,
.spark-scrub,
.spark-dot {
  position: absolute;
  pointer-events: none;
  opacity: 0;
  transition: opacity 0.1s;
}
.spark-chart.is-active .spark-label,
.spark-chart.is-active .spark-scrub,
.spark-chart.is-active .spark-dot { opacity: 1; }
.spark-label {
  top: 0;
  transform: translateX(-50%);
  padding: 2px 8px;
  border-radius: var(--radius-md);
  background: var(--color-inset);
  color: var(--color-heading);
  font-size: 13px;
  font-weight: var(--weight-emphasis);
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}
.spark-scrub {
  top: 0;
  bottom: 0;
  width: 1px;
  transform: translateX(-50%);
  background: var(--color-border);
}
.spark-dot {
  width: 14px;
  height: 14px;
  border-radius: 50%;
  transform: translate(-50%, -50%);
  background: var(--spark-color);
  box-shadow: 0 0 0 2px var(--color-card), 0 0 8px 2px rgba(0, 0, 0, 0.12);
}

@media (prefers-reduced-motion: reduce) {
  .spark-line { animation: none; }
}

/* --- Task run status -----------------------------------------------------
   Operator dashboard only. Four states, and the one that matters is "partial":
   a refresh where three of two hundred accounts failed is neither a success
   nor a failure, and it needs to look like neither. */
.badge.run-success { background: transparent; color: var(--color-heading); border: 1px solid var(--color-border-strong); }
.badge.run-partial { background: transparent; color: var(--color-trend-down); border: 1px solid var(--color-trend-down); }
.badge.run-failed { background: var(--color-danger-soft); color: var(--color-danger); border: 1px solid var(--color-danger); }
.badge.run-running { background: var(--color-inset); color: var(--color-muted); border: 1px solid var(--color-border); }
/* The failure column is the widest and least important thing to keep on one
   line, so it is the one allowed to wrap. */
.run-detail { max-width: 26rem; }

/* A reel Instagram is showing to non-followers only. Outlined rather than
   filled: it qualifies the Reel badge beside it rather than competing with it,
   and the state is temporary — most trials either graduate or disappear. */
/* Built like .badge-neutral beside it — a soft fill, no outline — rather than
   the outlined red it started as. An outline made it the loudest thing in a
   row whose subject is the post, and red reads as something being wrong; a
   trial is just a reel Instagram has not shown to followers yet. */
.badge-trial {
  background: var(--color-trial-soft);
  color: var(--color-trial);
}

/* Its best ever on this profile and format. Built the same way as the trial
   badge beside it and green rather than orange, because the two can appear in
   one row and used to be the same colour — a reader had to read both to learn
   that one was a distribution state and the other an achievement. */
.badge-record {
  background: var(--color-record-soft);
  color: var(--color-record);
}

/* Highlights ------------------------------------------------------------ */

/* A post inside a milestone row: cover, then caption over its date. Laid out
   as a link so the whole block is the target, not just the words. */
.milestone-post {
  display: flex;
  align-items: center;
  gap: calc(var(--space) * 3);
  min-width: 0;
  text-decoration: none;
  color: inherit;
}
.milestone-post:hover .milestone-post-caption { text-decoration: underline; }
.milestone-post-text {
  display: flex;
  flex-direction: column;
  gap: calc(var(--space) * 0.5);
  min-width: 0;
}
.milestone-post-caption {
  color: var(--color-heading);
  font-weight: var(--weight-heading);
  /* One line. These captions run to paragraphs, and a row that grows to fit
     one of them makes every other row in the table look empty. */
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.milestone-post-meta {
  display: flex;
  align-items: center;
  gap: calc(var(--space) * 2);
  font-size: var(--text-xs);
  color: var(--color-faint);
}

/* The posts behind a peak. Indented and lighter so the eye reads them as
   belonging to the row above rather than as more peaks — and with the top
   border dropped, since a peak and its posts are one thing. */
.table .peak-row td { border-bottom: none; }
.table .peak-contributor td {
  padding-top: calc(var(--space) * 1);
  padding-bottom: calc(var(--space) * 1);
  background: var(--color-card-hover);
}
.table .peak-contributor:last-child td { padding-bottom: calc(var(--space) * 4); }
.peak-contributor .milestone-post-caption { font-weight: var(--weight-body); }

/* Likes, comments and shares in one cell ---------------------------------- */

.reaction-stack {
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  gap: calc(var(--space) * 0.5);
  font-variant-numeric: tabular-nums;
}
.reaction {
  display: flex;
  align-items: center;
  gap: calc(var(--space) * 1.5);
  font-size: var(--text-xs);
  color: var(--color-muted);
  cursor: help;
}
/* The figure, not the row, carries the width. Right-aligning inside a fixed
   block is what keeps the icons in one vertical line and the digits in
   another — pinning the two ends of the row apart instead pushed them to
   opposite sides of the cell and read as two columns with a hole between. */
.reaction-value {
  min-width: 2.75rem;
  text-align: right;
  font-variant-numeric: tabular-nums;
}
.reaction svg {
  flex: none;
  width: 12px;
  height: 12px;
  opacity: 0.6;
}
/* The first line is the one people read; the other two are context. */
.reaction:first-child { color: var(--color-heading); font-size: var(--text-sm); }
.reaction:first-child svg { opacity: 0.8; }

/* Daily view change ------------------------------------------------------- */

.day-views {
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  gap: calc(var(--space) * 0.25);
  cursor: help;
}
.day-views-value {
  color: var(--color-heading);
  font-variant-numeric: tabular-nums;
}
/* Green for growth, matching every other rising figure in the app — the
   roster's change columns and the post calendar's shading both read up as
   green, and a column of movement that did not would be the odd one out. */
.day-views-value.is-up { color: var(--color-trend-up); }
/* A fall in a running total is not ordinary decline: it means a post was
   deleted from the account or the platform revised a count downwards. Worth
   its own colour precisely because it should be rare. */
.day-views-value.is-down { color: var(--color-trend-down); }
.day-views-day {
  font-size: var(--text-xs);
  color: var(--color-faint);
  white-space: nowrap;
}

/* The sort pills borrow the header caret, which is faint by default because
   it sits on every sortable column. On a pill it appears only on the active
   one, so it has nothing to distinguish itself from and wants full strength. */
.filter-pill .sort-mark { opacity: 1; margin-left: calc(var(--space) * 1); }

/* Highlights sections that scroll back through time --------------------- */

.highlight-scroll {
  /* Tall enough that a section is worth scrolling and short enough that three
     of them fit on a page. Roughly seven rows, which leaves the eighth cut off
     — the cheapest possible signal that there is more below. */
  max-height: 30rem;
  /* Both axes, and the ONLY scroll container in the panel: .table-scroll
     inside it is unset below. An inner element with overflow becomes the
     containing block for anything sticky within it, so leaving that in place
     pinned the header to a box that never scrolls vertically — the header
     simply left with the rows. */
  overflow: auto;
  position: relative;
}
.highlight-scroll .table-scroll { overflow: visible; }
/* The header has to stay put, or scrolling a section leaves its figures
   unlabelled — which on a table of bare numbers is the whole meaning gone. */
.highlight-scroll thead th {
  position: sticky;
  top: 0;
  z-index: 1;
  background: var(--color-card);
}
.highlight-more {
  padding: calc(var(--space) * 4) 0;
  text-align: center;
  font-size: var(--text-xs);
  color: var(--color-faint);
}
.highlight-more.is-done { color: var(--color-danger); }

/* The Highlights timeline ------------------------------------------------ */

.tl-scroll {
  /* Tall enough to be worth scrolling, short enough that the summary cards
     stay on screen with it. */
  max-height: 34rem;
  overflow: auto;
  position: relative;
  /* No frame, matching .table-bare: the outer border boxed in something that
     is already the width of the page, and drew a second rectangle just inside
     the browser's own. The entries sit on the page's left edge as a result,
     in line with the cards and the heading above them. */
  border: 0;
  padding: calc(var(--space) * 2) 0 0;
  /* Losing the border loses the only sign that the panel scrolls — macOS
     hides its scrollbar until you move — so the last rows fade out instead.
     Content that visibly continues past the edge is the affordance the frame
     used to be, and it costs no chrome. */
  --tl-fade: calc(var(--space) * 10);
  -webkit-mask-image: linear-gradient(
    to bottom, #000 calc(100% - var(--tl-fade)), transparent 100%
  );
  mask-image: linear-gradient(
    to bottom, #000 calc(100% - var(--tl-fade)), transparent 100%
  );
}
/* Gone at the end of the scroll, where a fade would read as the last row
   being clipped rather than as more to come. Set from the first frame, so a
   timeline short enough to fit never shows one. */
.tl-scroll[data-complete] {
  -webkit-mask-image: none;
  mask-image: none;
}
/* One continuous rail behind every entry. Drawn on the list rather than on
   each item, so it does not break between them. */
.tl {
  position: relative;
  padding-left: calc(var(--space) * 12);
}
.tl::before {
  content: "";
  position: absolute;
  left: 15px;
  top: calc(var(--space) * 2);
  bottom: calc(var(--space) * 2);
  width: 1px;
  background: var(--color-border);
}
.tl-day {
  position: relative;
  margin: calc(var(--space) * 7) 0 calc(var(--space) * 4);
  display: flex;
  align-items: baseline;
  gap: calc(var(--space) * 3);
}
.tl > .tl-day:first-child { margin-top: 0; }
/* The rail node for a date: filled, so a day reads as a stop on the line
   rather than as another entry. */
.tl-day::before {
  content: "";
  position: absolute;
  left: -33px;
  top: 7px;
  width: 7px;
  height: 7px;
  border-radius: var(--radius-md);
  background: var(--color-heading);
  box-shadow: 0 0 0 4px var(--color-card);
}
.tl-day-name { font-weight: var(--weight-heading); color: var(--color-heading); }
.tl-day-note { font-size: var(--text-xs); color: var(--color-faint); }

.tl-entry { position: relative; padding-bottom: calc(var(--space) * 6); }
/* The indicator IS the subject: a round avatar for a record, which is about an
   account, and a square cover for a milestone, which is about a post. The left
   edge says which kind of row this is before a word of it is read. */
.tl-mark {
  position: absolute;
  left: calc(var(--space) * -12);
  top: 0;
  width: 32px;
  height: 32px;
  border-radius: var(--radius-md);
  background: var(--color-inset);
  color: var(--color-muted);
  display: grid;
  place-items: center;
  font-size: var(--text-xs);
  font-weight: var(--weight-heading);
  box-shadow: 0 0 0 4px var(--color-card);
  overflow: hidden;
}
.tl-mark.is-post { border-radius: var(--radius-sm); }
.tl-mark img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }

.tl-line {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: calc(var(--space) * 1.5);
  margin: 0 0 calc(var(--space) * 1);
}
.tl-who { font-weight: var(--weight-heading); color: var(--color-heading); text-decoration: none; }
.tl-who:hover { text-decoration: underline; }
.tl-said { color: var(--color-muted); }
.tl-figure { font-weight: var(--weight-heading); color: var(--color-heading); font-variant-numeric: tabular-nums; }
/* Green only ever means a rise. A multiple is a ratio, not a gain, so it does
   not take the colour — which keeps the one chromatic signal on this page
   meaning exactly one thing. */
.tl-figure.is-up { color: var(--color-trend-up); }
.tl-caption {
  display: block;
  color: var(--color-heading);
  text-decoration: none;
  margin-bottom: calc(var(--space) * 1);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.tl-caption:hover { text-decoration: underline; }
.tl-meta {
  margin: 0;
  font-size: var(--text-xs);
  color: var(--color-faint);
  font-variant-numeric: tabular-nums;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: calc(var(--space) * 1.5);
}
.tl-meta .platform-mark { width: 12px; height: 12px; }
.tl-dot { color: var(--color-border-strong); }
.tl-tag {
  font-size: 10px;
  font-weight: var(--weight-heading);
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--color-muted);
  background: var(--color-inset);
  border-radius: var(--radius-sm);
  padding: 1px calc(var(--space) * 1.5);
}
/* Green only where a badge QUALIFIES the row rather than naming it. "Personal
   best" sits beside "Breakout" and says the breakout was also this post's best
   ever — a second, stronger claim, and worth the colour.

   "Record" and "Post record" stay grey with the other kind labels. They are
   what the row IS, not an extra fact about it, and every row in those two
   filters carries one: colouring them tinted whole stretches of the timeline
   green without telling a reader anything they could act on. */
.tl-tag.is-best {
  color: var(--color-record);
  background: var(--color-record-soft);
  cursor: help;
}

/* The posts behind a record. Indented once more and rule-marked, because they
   belong to the entry above rather than to the day. */
.tl-behind {
  margin-top: calc(var(--space) * 3);
  padding-left: calc(var(--space) * 3);
  border-left: 1px solid var(--color-border);
  display: flex;
  flex-direction: column;
  gap: calc(var(--space) * 2);
}
.tl-behind-row {
  display: flex;
  align-items: center;
  gap: calc(var(--space) * 3);
  text-decoration: none;
  color: inherit;
  min-width: 0;
}
.tl-behind-row:hover .tl-behind-cap { text-decoration: underline; }
.tl-behind-thumb { flex: none; width: 1.75rem; height: 1.75rem; border-radius: var(--radius-sm); }
.tl-behind-cap {
  flex: 1;
  min-width: 0;
  color: var(--color-muted);
  font-size: var(--text-sm);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.tl-behind-fig { color: var(--color-heading); font-variant-numeric: tabular-nums; font-size: var(--text-sm); }
.tl-behind-share { width: 3rem; text-align: right; color: var(--color-faint); font-size: var(--text-xs); font-variant-numeric: tabular-nums; }

.tl-more {
  padding: calc(var(--space) * 4) 0 0;
  text-align: center;
  font-size: var(--text-xs);
  color: var(--color-faint);
}
.tl-more.is-done { color: var(--color-danger); }

/* The post tables' next page, loaded as it scrolls into view — the timeline's
   "Loading earlier milestones…" in table and grid form (_feed_more.html). */
.feed-more td,
div.feed-more {
  padding: calc(var(--space) * 4);
  text-align: center;
  font-size: var(--text-xs);
}
div.feed-more { grid-column: 1 / -1; }
.feed-more a { color: var(--color-faint); text-decoration: none; }
.feed-more.htmx-request a { display: none; }
.feed-more.htmx-request td::after,
div.feed-more.htmx-request::after { content: "Loading more posts…"; color: var(--color-faint); }
/* Trial reels on the timeline take the same muted orange the post tables give
   them — a temporary distribution state is worth spotting without reading. */
.tl-tag.is-trial {
  color: var(--color-trial);
  background: var(--color-trial-soft);
  cursor: help;
}

/* A milestone's trajectory: where its figure started and whether it is still
   moving. Its own line, under the caption and above the badges, because it is
   a sentence about the number rather than another fact beside it. */
.tl-track {
  margin: 0 0 calc(var(--space) * 1);
  font-size: var(--text-xs);
  color: var(--color-muted);
  font-variant-numeric: tabular-nums;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: calc(var(--space) * 1.5);
}
.tl-track-now { color: var(--color-heading); }
.tl-track-gain,
.tl-climbing { color: var(--color-trend-up); }
.tl-climbing { cursor: help; }
.tl-arrow { color: var(--color-faint); }
/* The measurement date rides with the figure it qualifies, quieter than it. */
.tl-asof { color: var(--color-faint); }

/* Once a cover has loaded, the platform glyph behind it is dead weight — the
   image is absolutely positioned over the whole mark, so the glyph only shows
   through if a cover ever arrives with transparency. Degrades to the old
   behaviour where :has() is unsupported, which is an opaque image on top of a
   glyph nobody can see. */
.tl-mark:has(.post-thumb-img) .platform-mark { display: none; }
