/* The Observatory (/labs/observatory).
   Part of the /labs stylesheet, split out of one 3,400-line labs.css.

   THE NUMBER IN THE FILENAME IS LOAD-BEARING. stylesheet_link_tag :app
   collects every stylesheet under app/assets and serves them sorted by
   path, so the ordinal prefix is the only thing holding the cascade order
   these rules were written in — and several classes are shared across labs
   (.lp-head, .lab-entry, .lab-block, .dt-table). Renaming a file without
   renumbering it, or inserting one out of sequence, reorders the cascade
   silently.

   Nothing in this comment may contain the two characters that close a CSS
   comment. The first version of it wrote a shell glob here, which closed
   the comment early; the leaked text then parsed as a selector and ate the
   first real rule in all nine files. There is a test for that now.
   ============================================================ */

/* ============================================================
   Observatory — an instrument-panel treatment (bordered cards,
   one shared sticky toolbar driving both the WebGL map and the
   cluster-explorer lanes), inspired by product analytics UIs
   like Intercom's Topics Explorer, adapted to this site's own
   restraint. All white — the toolbar/legend strip pins under
   the site header (62px + 1px border = 63px) as you scroll.
   ============================================================ */
.obs-toolbar-sticky {
  /* Same pink accent already assigned to this page's /labs and homepage
     cards (see .lab-entry-pink / .labs-cell-pink) — declared here (and on
     .obs-card / .obs-drawer) since those classes aren't in this markup. */
  --accent-b: #EC4899;
  --accent-ink: #9D174D;
  position: sticky;
  top: 63px;              /* directly under header.cx-header (z-index 50) */
  z-index: 40;
  background: var(--bg);
  border-bottom: 1px solid var(--line);
}
.obs-card {
  --accent-b: #EC4899;
  --accent-ink: #9D174D;
  position: relative;
  border: 1px solid var(--line);
  border-radius: 4px;
  overflow: hidden;
}
.obs-hint {
  margin-top: 6px;
  margin-bottom: 18px;
  font-family: var(--font-sans);
  font-size: 13.5px;
  color: var(--ink-faint);
}
.obs-toolbar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 16px 28px;
  padding: 14px 0 10px;
}
.obs-control { display: flex; flex-direction: column; gap: 6px; }
.obs-control-label {
  font-family: var(--font-mono);
  font-size: 11px;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--ink-faint);
}
.obs-select {
  font-family: var(--font-mono);
  font-size: 13px;
  color: var(--ink);
  background: var(--bg);
  border: 1px solid var(--line);
  border-radius: 4px;
  padding: 8px 12px;
  cursor: pointer;
}
.obs-slider-row { display: flex; align-items: center; gap: 12px; }
.obs-slider { width: 200px; accent-color: var(--accent-ink, var(--ink)); }
.obs-slider-label {
  font-family: var(--font-mono);
  font-size: 12.5px;
  color: var(--ink-muted);
  white-space: nowrap;
  min-width: 9ch;
}
.obs-toolbar-count {
  margin-left: auto;
  font-family: var(--font-mono);
  font-size: 12.5px;
  color: var(--ink-faint);
  white-space: nowrap;
}
.obs-toolbar-count span { color: var(--ink); font-weight: 500; }

/* ── the cluster map: clusters positioned by semantic proximity (their
   UMAP centroid), reusing the exact box visual language from the Cluster
   Explorer below (.obs-box: same shading system, same shape, same type)
   rather than a separate visual system — a WebGL point-cloud scatterplot
   used to render here (colourful, ~300 individual points) but read as
   visually disconnected from the rest of the monochrome-pink page. Plain
   absolutely-positioned DOM nodes; no WebGL, no canvas. ── */
.obs-constellation {
  position: relative;
  width: 100%;
  height: clamp(480px, 52vw, 640px);
  background: var(--line-soft);
  overflow: hidden;
}
.obs-box.obs-node {
  position: absolute;
  transform: translate(-50%, -50%);
  padding: 8px 10px;
  gap: 0;
}
/* A smaller, distinct size scale from the box grid's: ~19 nodes scattered
   freely across one canvas (rather than flowing in a wrapped grid) need to
   stay compact to leave room for UMAP's actual spacing to read — the grid's
   up-to-336px boxes packed this densely would overlap regardless of how
   much separation the layout pass applies. Higher specificity (two classes)
   wins over the shared `.obs-box[data-box-size]` grid rules. */
.obs-box.obs-node[data-box-size="xl"] { width: 210px; min-height: 30px; }
.obs-box.obs-node[data-box-size="l"]  { width: 170px; min-height: 30px; }
.obs-box.obs-node[data-box-size="m"]  { width: 140px; min-height: 30px; }
.obs-box.obs-node[data-box-size="s"]  { width: 118px; min-height: 30px; }
.obs-box.obs-node[data-box-size="xs"] { width: 96px; min-height: 30px; }
/* Overrides the grid's 44px name reservation — nodes are far more compact
   (see widths above) and don't need it; their own name text is already
   pre-truncated to fit by truncateNames() in JS. */
.obs-box.obs-node .obs-box-name { min-height: 0; }

/* One drawer for both visualizations — fixed to the viewport's right edge,
   under the sticky header (63px) and above the sticky toolbar (z 40) but
   below the site header (z 50). Lives as a direct child of <main>: inside a
   card it would be clipped by overflow: hidden, and any transformed or
   filtered ancestor would re-anchor the fixed positioning. */
.obs-drawer {
  --accent-b: #EC4899;
  --accent-ink: #9D174D;
  position: fixed;
  top: 63px;
  right: 0;
  /* Stop above the fixed site footer (its measured height lands in --footer-h
     on body, set by application.js) instead of covering it. */
  bottom: var(--footer-h, 76px);
  z-index: 45;
  width: min(380px, 90vw);
  background: var(--bg);
  border-left: 1px solid var(--line);
  padding: 22px 22px 20px;
  transform: translateX(100%);
  transition: transform 0.25s cubic-bezier(0.22, 1, 0.36, 1);
  overflow-y: auto;
}
.obs-drawer.is-open { transform: translateX(0); }
.obs-drawer-close {
  position: absolute;
  top: 12px;
  right: 14px;
  font-size: 20px;
  line-height: 1;
  color: var(--ink-faint);
  background: none;
  border: none;
  cursor: pointer;
  padding: 4px;
}
.obs-drawer-close:hover { color: var(--ink); }
.obs-drawer-eyebrow {
  font-family: var(--font-mono);
  font-size: 11px;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--accent-ink, var(--ink));
  margin-top: 4px;
}
.obs-drawer-title {
  font-family: var(--font-sans);
  font-size: 18px;
  font-weight: 500;
  line-height: 1.3;
  color: var(--ink);
  margin-top: 8px;
  padding-right: 18px;
}
.obs-panel-customer {
  font-family: var(--font-mono);
  font-size: 12px;
  letter-spacing: 0.03em;
  color: var(--accent-ink, var(--ink));
  padding-right: 18px;
}
.obs-panel-customer[hidden] { display: none; }
.obs-panel-body {
  font-family: var(--font-sans);
  font-size: 15px;
  line-height: 1.6;
  color: var(--ink);
  margin-top: 8px;
  padding-right: 18px;
}
.obs-panel-meta {
  margin-top: 10px;
  font-family: var(--font-mono);
  font-size: 12px;
  color: var(--ink-faint);
}
/* Verbatim detail meta — one labelled pill per field (segment/theme/
   sentiment/source/date) instead of a flat "·"-joined sentence, so each
   value's meaning is legible at a glance rather than inferred from position. */
.obs-meta-pills {
  display: flex;
  flex-wrap: wrap;
  gap: 6px 8px;
  margin-top: 10px;
}
.obs-meta-pill {
  display: inline-flex;
  align-items: baseline;
  gap: 5px;
  font-family: var(--font-mono);
  font-size: 11.5px;
  color: var(--ink);
  border: 1px solid var(--line);
  border-radius: 999px;
  padding: 3px 10px;
}
.obs-meta-pill-label {
  font-size: 9.5px;
  letter-spacing: 0.07em;
  text-transform: uppercase;
  color: var(--ink-faint);
}
.obs-panel-ask { margin-top: 16px; }

/* Lives inside the sticky strip (which carries the background and bottom
   border) — swatches only, kept compact so the pinned bar stays slim. */
.obs-legend-swatches {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px 16px;
  padding: 0 0 12px;
  font-family: var(--font-sans);
  font-size: 13px;
  color: var(--ink-muted);
}
.obs-swatch { display: inline-flex; align-items: center; gap: 6px; }
.obs-swatch i { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }

/* This entry collapses to a single column (number stacked above content,
   same technique already used at the mobile breakpoint) so the Cluster
   Explorer gets the whole .wrap width instead of just the content column. */
.lab-entry.obs-full-width { grid-template-columns: 1fr; gap: 12px; }

/* ---- Cluster Explorer: one full-width lane per cluster, real verbatim dots ---- */
/* Mirrors .obs-lane's own grid exactly (same fixed column width, same gap,
   same horizontal padding) so the tick positions and each lane's dot
   positions share one coordinate space — they used to be laid out in two
   differently-sized containers (this spanned the full card; each lane's
   strip only got the width left over after its label column), so "20 July"
   lined up with the card's edge while the actual rightmost dots fell well
   short of it. */
.obs-axis {
  display: grid;
  grid-template-columns: 460px 1fr 90px;
  gap: 20px;
  padding: 4px 20px 0;
  height: 20px;
}
.obs-axis-ticks { position: relative; }
.obs-axis-tick {
  position: absolute;
  top: 0;
  transform: translateX(-50%);
  font-family: var(--font-mono);
  font-size: 10.5px;
  color: var(--ink-faint);
  white-space: nowrap;
}

.obs-lanes { background: var(--bg); }
.obs-lane {
  display: grid;
  grid-template-columns: 460px 1fr 90px;
  align-items: stretch;
  gap: 20px;
  padding: 12px 20px;
  border-top: 1px solid var(--line-soft);
  cursor: pointer;
  transition: background 0.15s;
}
.obs-lane:hover { background: color-mix(in srgb, var(--ink) 3%, var(--bg)); }
/* The author display:grid above would beat the UA's [hidden] rule — make
   the empty-cluster hiding explicit. */
.obs-lane[hidden] { display: none; }
.obs-lane-label { display: flex; flex-direction: column; justify-content: center; gap: 4px; }
/* Coloured by the same size rank as its box in the Cluster Explorer's box
   grid above (--text-tint, set per-lane in JS by applySizeShading() — same
   function, same rank, a wider mix-with-ink range since this mixes into
   heading text rather than a background fill) — ties the two views'
   reading of "how big is this cluster" together at a glance. */
.obs-lane-name {
  font-family: var(--font-sans);
  font-size: 14px;
  font-weight: 500;
  line-height: 1.35;
  color: color-mix(in srgb, #9D174D var(--text-tint, 45%), var(--ink));
  white-space: nowrap;   /* the label column is sized to fit the longest real cluster name on one line */
}
.obs-lane-count {
  font-family: var(--font-mono);
  font-size: 11px;
  color: var(--ink-faint);
}
.obs-lane-dominant {
  font-family: var(--font-mono);
  font-size: 11px;
  color: var(--ink-faint);
  margin-top: 2px;
}
.obs-lane-dominant[hidden] { display: none; }
/* Trend-in-words + a truncated pull from the cluster's existing AI summary —
   set via updateTrendLabels() in JS, empty (and so invisible, :empty above)
   until the controller connects. */
.obs-lane-summary {
  font-family: var(--font-sans);
  font-size: 12px;
  line-height: 1.5;
  color: var(--ink-faint);
  margin: 4px 0 0;
}
.obs-lane-summary:empty { display: none; }
.obs-lane-strip {
  position: relative;
  min-height: 56px;
}
/* The visible mark stays a small 8px dot, but the clickable/hoverable hit
   area is a larger 22px invisible box around it (skill floor: ~24px) — an
   8px-only hit target is unreliable to click precisely. */
.obs-dot {
  position: absolute;
  width: 22px;
  height: 22px;
  transform: translate(-50%, -50%);
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
}
.obs-dot::before {
  content: "";
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: var(--dot-colour, #999);
  opacity: 0.85;
  transition: transform 0.12s, opacity 0.12s;
}
.obs-dot:hover::before { transform: scale(1.6); opacity: 1; }
.obs-lane-trend { display: flex; align-items: center; justify-content: flex-end; }
@media (max-width: 640px) {
  .obs-axis { grid-template-columns: 1fr; }
  .obs-axis-ticks { display: none; }   /* no fixed label column to align against once lanes stack */
  .obs-lane { grid-template-columns: 1fr; gap: 6px; }
  .obs-lane-name { white-space: normal; }   /* a 460px single-line name doesn't fit once the column drops to full-width-stacked */
  .obs-lane-strip { min-height: 44px; }
  .obs-lane-trend { justify-content: flex-start; }
}

/* ── cluster size boxes: an "at a glance" companion to the swimlanes below,
   area proportional to verbatim count (sqrt-scaled, not a packed treemap —
   see renderClusterBoxes()/boxSizeClass() in observatory_controller.js),
   shaded — one hue, the same pink used for this page's eyebrow — from
   darkest (the biggest cluster) to lightest (the smallest), a relative rank
   set per-box via the --box-tint custom property in JS rather than a fixed
   sentiment-coloured fill, so the boxes read as one size-ranked family
   rather than competing with the page's actual sentiment encoding
   (negative/neutral/positive, used everywhere else — map, legend, dots). ── */
.obs-boxes-hint { margin-top: 14px; margin-bottom: 0; padding: 0 20px; }
.obs-boxes {
  display: flex;
  flex-wrap: wrap;
  align-content: flex-start;
  gap: 4px;
  padding: 20px;
  background: var(--line-soft);
}
.obs-box {
  --box-hue: #9D174D;   /* matches .eyebrow-pink */
  position: relative;
  display: flex;
  flex-direction: column;
  justify-content: flex-start;
  gap: 10px;
  background: color-mix(in srgb, var(--box-hue) var(--box-tint, 10%), var(--bg));
  border: none;
  border-radius: 0;
  padding: 14px 16px;
  cursor: pointer;
  text-align: left;
  font: inherit;
  color: inherit;
  transition: filter 0.12s;
}
.obs-box:hover { filter: brightness(0.97); }
.obs-box[hidden] { display: none; }
/* Fixed to exactly 2 lines' worth of height so the stats row below always
   lands at the same vertical offset across every box, regardless of bucket
   — a 1-line name and a 2-line name otherwise pushed the count/negative%/
   trend chip to different heights. The text itself is pre-truncated to fit
   in JS (truncateNames() in the controller, per-bucket character budgets) —
   CSS -webkit-line-clamp was tried here first but proved unreliable at
   narrow bucket widths (a long name would still render a 3rd line instead
   of clipping, growing that one box taller than same-bucket siblings —
   exactly this misalignment bug). overflow:hidden stays only as a safety
   net. A fixed pixel value (not em) deliberately, sized for the largest
   bucket's font (xl, 16px: 16 × 1.35 × 2 ≈ 43px) — an em value scales with
   each bucket's own font-size, so two *different*-size boxes sharing a
   flex-wrap row (common: boxes pack by width, not by bucket) would still
   end up with different-height name blocks. */
.obs-box-name {
  font-family: var(--font-sans);
  font-weight: 500;
  line-height: 1.35;
  color: var(--ink);
  min-height: 44px;
  overflow: hidden;
}
/* Always three stacked lines (count, negative%, trend pill) rather than a
   flex-wrap row — at narrower bucket widths the row wrapped inconsistently
   (sometimes the pill shared a line with "X% negative", sometimes it wrapped
   onto its own 3rd line), so the pill landed at a different vertical offset
   in different boxes even within the same visual row. A fixed one-item-per-
   line structure makes that offset identical for every box regardless of
   its bucket. */
.obs-box-stats { display: flex; flex-direction: column; align-items: flex-start; gap: 4px; }
.obs-box-count, .obs-box-neg {
  font-family: var(--font-mono);
  font-size: 11px;
  color: var(--ink-faint);
  white-space: nowrap;
}
/* width communicates rank/size; min-height (not height) gives longer names
   or a wrapped stats row room to grow rather than clipping — a fixed height
   here previously cut off the negative% and trend chip on smaller boxes. */
.obs-box[data-box-size="xl"] { width: 336px; min-height: 136px; }
.obs-box[data-box-size="l"]  { width: 232px; min-height: 130px; }
.obs-box[data-box-size="m"]  { width: 180px; min-height: 116px; }
.obs-box[data-box-size="s"]  { width: 148px; min-height: 100px; }
.obs-box[data-box-size="xs"] { width: 116px; min-height: 84px; }
.obs-box[data-box-size="xl"] .obs-box-name { font-size: 16px; }
.obs-box[data-box-size="l"]  .obs-box-name { font-size: 15px; }
.obs-box[data-box-size="m"]  .obs-box-name { font-size: 13.5px; }
.obs-box[data-box-size="s"]  .obs-box-name { font-size: 12.5px; }
.obs-box[data-box-size="xs"] .obs-box-name { font-size: 11.5px; }
.obs-box[data-box-size="xs"] .obs-box-stats { display: none; }
@media (max-width: 640px) {
  .obs-boxes { gap: 3px; padding: 14px; }
  .obs-box[data-box-size] { width: calc(50% - 2px) !important; }
}

/* ── trend chip: shared by boxes, lanes, and the drawer. Volume direction,
   not sentiment — deliberately neutral ink tones rather than red/green so
   it never reads as a second, competing sentiment encoding. ── */
.obs-trend {
  display: inline-flex;
  align-items: center;
  gap: 3px;
  font-family: var(--font-mono);
  font-size: 10.5px;
  padding: 1px 7px;
  border-radius: 10px;
  border: 1px solid var(--line);
  white-space: nowrap;
  color: var(--ink-faint);
}
/* visibility (not display: none) — an empty chip still reserves its line's
   height, so a cluster with no computable trend doesn't end up one line
   shorter than its row siblings and throw off the same alignment this
   whole trend-chip/stats layout exists to guarantee. */
.obs-trend:empty { visibility: hidden; }
.obs-trend[data-direction="up"] { color: var(--ink); border-color: var(--ink-faint); }
.obs-trend[data-direction="new"] { font-style: italic; }

