@font-face {
  font-family: "JVMIYO PRO";
  src: url("/static/fonts/JVMIYOPROsemibold.ttf") format("truetype");
  font-weight: 600;
  font-style: normal;
  font-display: swap;
}

:root {
  /* --font-mono is for titles, headings, short UI labels, badges, and
     genuinely code/data-shaped text (ids, wire-format request/response
     panels, DNS/file verification values) -- never for anything meant to
     be read at length. A nerd-font monospace at body-text sizes reads as
     visually thick/dense (real user feedback: a run's target URL and a
     finding's title/description were hard to read at the weight this
     face renders prose). "JetBrainsMono Nerd Font" is how the nerd-fonts
     patcher names it; the unpatched "JetBrains Mono" and generic
     monospace cover machines without the Nerd Font variant installed. */
  --font-mono: "JetBrainsMono Nerd Font", "JetBrainsMono NF", "JetBrains Mono", ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;

  /* --font-sans is the base body font -- prose, descriptions, evidence
     text, list rows, free-text inputs, anything a person actually reads
     rather than scans as a label. The platform system-font stack: no
     extra asset to ship/license, and it already carries the OS's own
     optical sizing/tracking/legibility tuning (see the apple-design
     skill's typography section) rather than this project guessing at it. */
  --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;

  /* JVMIYO PRO is used ONLY for the "lannerva" brand-mark text, nowhere else --
     a display wordmark choice, not a body font. Only the semibold weight is
     loaded (see its @font-face above), matched by this rule's own
     font-weight: 600 below so the browser never faux-bolds it. */
  --font-brand: "JVMIYO PRO", var(--font-mono);

  /* True neutral grey/black -- was previously a deliberately violet-leaning
     dark neutral (see git history), changed on request: these six tokens
     are the background/surface/border/text colors used by literally every
     element in the app, so that violet tint was what read as "the whole
     site has a purple hue." Each value below preserves the OLD color's
     relative lightness step (same luminance, per ITU-R BT.709 weighting)
     so nothing about contrast/hierarchy shifts, only the hue -- R=G=B
     exactly, no tint at all. */
  /* Dark grey, not literal black, on request -- every page EXCEPT
     /get-started (landing.html), which deliberately keeps the original
     near-black #0b0b0b/#111111 (see its own hardcoded overrides further
     down: body.landing-page's background and .landing-page .card's
     background-image both have pixel-tuned seams -- the hero gradient's
     own bottom pixels, the wave canvas's fade-in gap -- matched to that
     exact old color, not to whatever --bg-0/--surface currently resolve
     to, so this token change can't silently drag landing.html along with
     it). --bg-1 (the findings donut's center hole, dashboard-only, no
     landing.html equivalent) folds into the same value as --bg-0 instead
     of keeping its own separate shade -- the request named two greys for
     "black boxes and bento boxes" collectively, not three. */
  /* Darkened from the dark-grey #1b1b1b this project deliberately chose
     earlier (see the comment above) to a true near-black, on request --
     the app shell (this dashboard, the new-assessment wizard, code review)
     should read with the same stark, high-contrast darkness /get-started
     already has, so saturated accent colors actually pop against it the
     way a near-black background makes them pop in a reference image given
     for this change. This does NOT touch /get-started: body.landing-page's
     own hardcoded `background: #0b0b0b` below already overrides --bg-0
     entirely (see its own comment), so landing.html renders identically
     either way -- confirmed by inspecting every landing-page-scoped rule
     before making this change, not assumed. Contrast against --text-0/
     --text-1/--text-2 can only IMPROVE from darkening the background behind
     lighter text, never regress, so none of those were re-tuned. */
  --bg-0: #0d0d0d;
  --bg-1: #0d0d0d;
  /* Darkened in step with --bg-0 above, same reasoning -- a card surface
     needs to stay visibly a step up from the new near-black page behind
     it (still a real, distinguishable surface, not literal black-on-black)
     while reading as part of the same darker, more vibrant-by-contrast
     shell. Still a flat solid fill plus a solid hairline border, not a
     translucent "glass" edge -- unchanged reasoning from the original
     choice below. */
  --surface: #171717;
  --surface-strong: #1f1f1f;
  --panel-border: #272727;
  --panel-border-strong: #383838;
  --grid-line: rgba(255, 255, 255, 0.025);
  --text-0: #f2f2f2;
  /* #999999 against --surface (#222222) was already ~6:1, comfortably past
     AA -- raised again anyway, on request, for the same "easier to read"
     reason --text-2 below has already been raised for twice: a value that
     only clears the legal minimum isn't the same as a value that's
     genuinely comfortable to read at length. #a8a8a8 brings this to
     ~7.3:1 (AAA territory for body text), still clearly a muted secondary
     tone next to --text-0's near-white. */
  --text-1: #a8a8a8;
  /* #808080 gave .hint-text/.wizard-step-dot's not-done label ~4.78:1
     against the old --surface (#111111) -- AA-passing for body text
     (4.5:1). Raised when --surface went from #111111 to #222222 (dark
     grey, not literal black, on request): #808080 against #222222 is only
     ~4.03:1, a real regression (new-assessment.html's "Pick a starting
     point..."/check-description hint text, and the wizard breadcrumb's
     own background IS --surface with this as its text color -- about as
     direct a collision as it gets). #8c8c8c restored ~4.73:1 against the
     new --surface -- technically AA-passing, but only barely. #969696
     brought that to ~5.3:1 on a first follow-up request; still not
     "genuinely easy to read" by the operator's own repeated word, so
     raised once more here. #9e9e9e lands around ~5.9:1 -- clearly past
     AA with real margin, while staying a deliberately more muted gray than
     --text-1 (#a8a8a8) rather than converging on it: still a distinct,
     quieter tier, just no longer the hardest color on the page to read.
     Contrast only improves, never regresses, on /get-started's own darker,
     unchanged surface color either. */
  --text-2: #9e9e9e;

  /* brand/interactive accent: sky blue, picked explicitly. */
  --accent: #8aecff;
  --accent-rgb: 138, 236, 255;
  --accent-ink: #072a33;

  /* every .primary-button site-wide uses this dark blue (sampled from the
     landing-page hero gradient, ~rgb(20,80,140)) rather than --accent --
     kept as its own token instead of repurposing --accent itself, since
     --accent still drives unrelated things (borders, focus spotlights, the
     tier-list hover glow) that were never asked to change color too. */
  --btn-blue-rgb: 20, 80, 140;

  /* The pale pill button fill (.primary-button, .badge-scope-tag) -- was a
     near-pure-white #f4f4f1, dimmed to a softer, nearly-desaturated
     off-white (#d9d6cb) since the original read as too bright/high-contrast
     against the near-black page. That left it reading flat/inert at rest --
     raised in saturation and warmth here, on a later "make buttons pop
     more, not too vibrant" request: still the same pale-pill family (not a
     hue change to the brand's own cyan --accent, which would read as a
     disconnected, colder choice for what's meant to be a warm, inviting
     primary action), just with enough chroma to read as a deliberate warm
     champagne tone instead of desaturated putty. Dark ink text (#141414,
     set on .primary-button itself) keeps excellent contrast regardless of
     exactly which pale color sits under it. */
  --btn-pale: #ead9a3;

  --green: #03c300;
  --green-rgb: 3, 195, 0;
  --green-ink: #052e1c;
  --amber: #f5b14b;
  --amber-rgb: 245, 177, 75;
  --amber-ink: #3a2305;
  --red: #ef4444;
  --red-rgb: 239, 68, 68;
  --red-ink: #2a0a0a;
  --purple: #b08af0;
  --purple-rgb: 176, 138, 240;
  --purple-ink: #1f1335;

  /* A new saturated accent, additive alongside --accent (general
     interactive: links, focus rings, primary selection) rather than
     replacing any existing semantic color (--green/--amber/--red/--purple
     above are all already deliberate, already-documented choices for
     specific meanings -- completed/pending/error/etc. -- and this change
     isn't a re-theme of those). Reserved for ONE specific, new "this is
     happening right now" role, not used decoratively: --vibe-lime on the
     run-detail spinner ring and the "running" badge (a run actually in
     flight), same "color on a solid layer, reserved for genuine feedback"
     discipline as every other color in this file. Explicitly NOT applied
     anywhere near the severity donut/finding badges -- those colors
     (--sev-*) carry their own fixed risk-heatmap meaning and were asked to
     stay untouched by this change. */
  --vibe-lime: #baff3d;
  --vibe-lime-rgb: 186, 255, 61;
  --vibe-lime-ink: #182b02;

  /* Exact colors given for status LABELS specifically (badges + the
     landing hero's "Confirmed"/"Possible" words) -- distinct from
     --green/--amber/--purple above, which stay as-is for everything else
     that happens to use that same hue family (the approve button, success
     result text, the approvals banner) and wasn't asked to change. */
  --label-green: #e3fe8d;
  --label-orange: #ffc802;
  --label-purple: #c888c8;

  /* severity scale: a single red (most severe) -> orange -> gold -> grey
     (least severe) risk gradient. Every value here is sampled directly from
     the operator's own reference palette image (a Critical/High/Medium/
     Low/Info donut), not picked independently -- this is the second such
     reference image this scale has been matched to; two earlier palettes
     (a six-band image, then a critical/high hue swap within that one) are
     no longer live and aren't detailed here.
     --sev-low is the one tier that doesn't also work as literal TEXT: its
     exact sampled grey (#535353) is only ~2.5:1 against --bg-0 (#0d0d0d),
     well under the ~4.4:1+ floor every other tier here clears, because the
     severity filter chip renders its active tier's own color directly as
     small 11.5px text (.severity-chip.sev-low[aria-pressed]). Lightening
     --sev-low itself to pass would push it up near --sev-info's own grey
     and erase the distinction between the two tiers everywhere else (ring,
     legend dot, border) that the image's exact value is what was actually
     asked for.
     --sev-low-text (that chip-only override) first tried a lightened grey
     (#797979, ~4.5:1) to stay in the same family as --sev-low itself, but
     real feedback after shipping that was it still read as "hard to see"
     next to the vividly-colored CRITICAL/HIGH/MEDIUM chips either side of
     it -- technically legible isn't the same bar as comfortably readable
     at a glance. Replaced with a light blue (#9ec5e7, ~10.7:1, a callback
     to this project's own PRE-reference-image --sev-info value, not a new
     color invented from nothing) -- chosen specifically because it's
     distinct from every other hue already in play here (the warm red/
     orange/yellow tiers, --sev-info's own neutral grey, and --accent's own
     sky-blue used for brand/interactive elements elsewhere), so the LOW
     chip can't be confused for any of those.
     Same request asked for a pass over every filter chip, not just LOW --
     --sev-info itself (#868686, ~5.3:1) technically clears the text floor
     but is visually the same dull grey complaint for the same reason (next
     to four now-vivid siblings). --sev-info-text (#c9a9e8, a lavender,
     ~9.6:1) gets the identical chip-only treatment, picked to be distinct
     from --sev-low-text's blue too so the two lightest-severity chips
     don't start looking like the same color. Each -text(-rgb) pair drives
     ALL THREE of its chip's active properties (border/background/color),
     not just color -- see those two rules below -- so the whole pill reads
     as one deliberate treatment instead of a grey outline/tint around
     colored text. Every other tier's plain --sev-* token already clears
     the text bar on its own at full saturation and needs no separate
     chip-only variant. */
  --sev-critical: #ed5053;
  --sev-critical-rgb: 237, 80, 83;
  --sev-high: #f58b30;
  --sev-high-rgb: 245, 139, 48;
  --sev-medium: #fed45b;
  --sev-medium-rgb: 254, 212, 91;
  --sev-low: #535353;
  --sev-low-rgb: 83, 83, 83;
  --sev-low-text: #9ec5e7;
  --sev-low-text-rgb: 158, 197, 231;
  --sev-info-text: #c9a9e8;
  --sev-info-text-rgb: 201, 169, 232;
  --sev-info: #868686;
  --sev-info-rgb: 134, 134, 134;

  /* true right angles, no rounding at all -- explicitly requested */
  --radius-sm: 0;
  --radius-md: 0;
  --radius-lg: 0;
  /* Real buttons only (.primary-button, .secondary-button, .danger-button,
     .run-delete-btn, .oauth-button) -- on request, matching a reference
     image of a moderately-rounded rectangular button, not the full 999px
     stadium/pill shape those all used before. Deliberately its own token
     rather than reusing --radius-lg/md/sm above: those are 0 by design for
     panels/cards, a separate decision this doesn't touch. Status badges,
     filter chips, the dev-glasses switch, and the search input keep their
     own pill shape -- none of those read as a "button" the way these do,
     and the request was specifically about buttons. */
  --radius-button: 10px;
  --ease: cubic-bezier(0.2, 0.7, 0.3, 1);
  /* --ease is a strong ease-out shape (correct for things entering/exiting
     or moving -- lifts, reveals, presses). Pure hover/color-only feedback
     (a border or text color swap, nothing moving) calls for a plain `ease`
     per the decision tree, not the same strong curve used for motion --
     kept as its own token rather than reusing --ease everywhere, so the
     two kinds of transition stay visually distinct. */
  --ease-hover: ease;
}

* {
  box-sizing: border-box;
}

/* buttons/inputs/selects don't inherit font-family from the page by default
   in every browser's UA stylesheet -- this guarantees every control, current
   or future, actually picks up --font-mono instead of a silent system-font
   fallback */
button,
input,
select,
textarea {
  font-family: inherit;
}

@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.001ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.001ms !important;
  }

  /* Reduced motion means fewer/gentler animations, not zero -- the blanket
     rule above correctly kills movement (lifts, slides, scale), but it was
     also flattening every pure color/border/filter transition (the
     `var(--ease-hover)` rules) to instant, losing feedback that has no
     motion to object to in the first place. Restoring their own already-
     short, already-authored duration here keeps that feedback smooth
     without reintroducing any movement. */
  input[type="checkbox"],
  .brand-mark,
  /* NOT a bare `.card` -- that also matches `.landing-page .card`, whose
     OWN more specific `transition` list includes `transform` (the hover
     lift), and `!important` on transition-duration would apply to
     whichever transition-property list wins regardless of selector
     specificity, reintroducing that movement under reduced motion. */
  body:not(.landing-page) .card,
  .tool-tab,
  form input,
  form select,
  form textarea,
  .list-item.clickable,
  .run-delete-btn,
  .triage-select,
  .approve-button,
  .deny-button,
  .timeline-bar-volume,
  .timeline-bar-unit,
  .tier-word,
  .wizard-step-dot.is-done,
  .assessment-type-card {
    transition-duration: 0.15s !important;
  }
}

::selection {
  background: rgba(var(--red-rgb), 0.4);
  color: var(--text-0);
}

::-webkit-scrollbar {
  width: 10px;
  height: 10px;
}

::-webkit-scrollbar-track {
  background: transparent;
}

::-webkit-scrollbar-thumb {
  background: rgba(255, 255, 255, 0.12);
  border-radius: 999px;
  border: 2px solid transparent;
  background-clip: padding-box;
}

::-webkit-scrollbar-thumb:hover {
  background: rgba(255, 255, 255, 0.2);
  background-clip: padding-box;
}

body {
  margin: 0;
  min-height: 100vh;
  font-family: var(--font-sans);
  font-size: 16px;
  line-height: 1.5;
  color: var(--text-0);
  /* a faint fixed grid instead of a decorative color wash -- reads as an
     instrument-panel backdrop for a recon/scanning tool rather than a
     generic SaaS gradient blob */
  background-image:
    linear-gradient(var(--grid-line) 1px, transparent 1px),
    linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
  background-size: 34px 34px, 34px 34px;
  background-color: var(--bg-0);
  background-attachment: fixed;
  -webkit-font-smoothing: antialiased;
}

/* Drops the base `body` rule's instrument-panel grid lines for a plain flat
   background, on request -- "make the dashboard cleaner." Same technique
   `body.landing-page` already uses below for the same reason, just a
   separate class: the control panel (index.html), the assessment wizard
   (new-assessment.html), and account settings (settings.html) are dense,
   information-heavy pages where the grid was competing with real content
   rather than reading as atmosphere, which is a different call from the
   landing page's own reasoning. Every other page (wave.html,
   code-review.html) keeps the grid -- not asked for. */
.no-grid-bg {
  background-image: none;
  background-color: var(--bg-0);
}

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

:focus-visible {
  outline: 2px solid rgba(var(--accent-rgb), 0.6);
  outline-offset: 2px;
  border-radius: 0;
}

/* Custom appearance, not just `accent-color` -- `accent-color` only paints
   the CHECKED state, so the unchecked box was still the browser's own
   near-invisible thin grey outline on this dark UI. Built from scratch with
   the same blue as .primary-button so a checkbox reads as part of the same
   "blue = interactive/selected" language as the buttons around it. */
input[type="checkbox"] {
  appearance: none;
  -webkit-appearance: none;
  width: 16px;
  height: 16px;
  margin: 0;
  flex-shrink: 0;
  border-radius: 4px;
  border: 1.5px solid rgba(var(--btn-blue-rgb), 0.9);
  background: rgba(var(--btn-blue-rgb), 0.15);
  position: relative;
  cursor: pointer;
  transition: background 0.15s var(--ease-hover), border-color 0.15s var(--ease-hover);
}

input[type="checkbox"]:hover {
  border-color: rgb(var(--btn-blue-rgb));
  background: rgba(var(--btn-blue-rgb), 0.3);
}

input[type="checkbox"]:checked {
  background: rgb(var(--btn-blue-rgb));
  border-color: rgb(var(--btn-blue-rgb));
}

input[type="checkbox"]:checked::after {
  content: "";
  position: absolute;
  left: 4px;
  top: 1px;
  width: 5px;
  height: 9px;
  border: solid #fff;
  border-width: 0 2px 2px 0;
  transform: rotate(45deg);
}

input[type="checkbox"]:disabled {
  opacity: 0.4;
  cursor: not-allowed;
}

.topbar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 18px 32px;
  border-bottom: 1px solid var(--panel-border);
  background: rgba(11, 11, 11, 0.65);
  backdrop-filter: blur(10px);
  position: sticky;
  top: 0;
  z-index: 10;
}

.brand {
  display: flex;
  align-items: baseline;
  gap: 10px;
}

.brand-mark {
  font-family: var(--font-brand);
  font-size: 17px;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: -0.01em;
  color: var(--text-0);
  text-decoration: none;
  cursor: pointer;
  transition: color 0.15s var(--ease-hover);
  /* was never needed for a single-word wordmark -- "lannerva" is two
     words, and a wordmark breaking across lines reads as a layout bug,
     not a brand choice, so it stays on one line even where the topbar's
     own row has little width to spare (e.g. next to the tagline on a
     narrow phone, before the topbar's own wrap breakpoint kicks in). */
  white-space: nowrap;
}

.brand-mark:hover {
  color: var(--accent);
}

.brand-sub {
  font-size: 14px;
  color: var(--text-2);
  letter-spacing: 0.02em;
}

/* Control panel only (index.html), sidebar-nav comparison branch -- a
   persistent left sidebar replacing the breadcrumb-style topbar on
   control-panel-tabs. Scoped to only the product's REAL sections
   (Dashboard, Assessments, Domains, Code Review, Settings), not the full
   reference item list -- see index.html's own <body> comment for the
   full reasoning. On request, the SAME sidebar now also wraps
   code-review.html and settings.html (`class="sidebar-layout"` on their
   `<body>` too) rather than those pages reverting to a plain `.topbar` --
   Dashboard/Assessments/Domains are real links there (`/`, `/#assessments`,
   `/#domains`, read by index.html's own hash-routing on load) since those
   pages have no in-page panes of their own. landing.html/new-assessment.html
   still keep their own plain `.topbar`/`.brand` layout -- not asked for. */
.sidebar-layout .app-shell {
  display: flex;
  min-height: 100vh;
  align-items: stretch;
}

.sidebar {
  flex: 0 0 240px;
  display: flex;
  flex-direction: column;
  gap: 4px;
  padding: 20px 14px;
  background: var(--surface);
  border-right: 1px solid var(--panel-border);
  /* Sticks the sidebar to the viewport while .app-main scrolls past it --
     same "persistent map of the product" reasoning a sidebar earns its
     screen space for in the first place. */
  position: sticky;
  top: 0;
  /* `100vh` on mobile Safari is the LARGEST possible viewport height, as
     if the address bar were already hidden -- while it's actually showing
     (the common case while scrolling), the real visible area is shorter,
     so `.sidebar-footer` (`margin-top: auto`, pinned to this box's own
     bottom edge) ended up positioned behind the address bar, out of
     reach. `100dvh` (dynamic viewport height) tracks the real visible
     area instead, shrinking/growing as that bar shows/hides -- falls back
     to the `100vh` line above on a browser that doesn't support `dvh`. */
  height: 100vh;
  height: 100dvh;
  padding-bottom: env(safe-area-inset-bottom);
  box-sizing: border-box;
  /* Width transitions smoothly between expanded/collapsed (the manual
     toggle, see .sidebar-collapse-toggle below) -- the auto narrow-
     viewport collapse (the 900px media query further down) deliberately
     does NOT transition the same way (a viewport resize is already a
     discontinuous event, nothing to animate smoothly between). */
  transition: flex-basis 0.18s var(--ease);
}

.sidebar-brand-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 6px;
  padding-bottom: 18px;
}

.sidebar-brand {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 6px 8px;
  margin: -6px 0 -6px -8px;
  text-decoration: none;
  min-width: 0;
}

/* The little collapse/expand control, on request -- next to the wordmark
   rather than buried at the sidebar's bottom, so it's discoverable the
   moment someone looks at the sidebar at all (a real consideration for a
   non-technical reader who may not go looking for it). A single chevron
   that flips direction via `.sidebar.is-collapsed`, not two separate
   icons swapped in/out -- same "one element, state communicated by
   transform" idiom this file already uses for the old context-menu
   chevron. */
.sidebar-collapse-toggle {
  flex-shrink: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 26px;
  height: 26px;
  background: transparent;
  border: 1px solid transparent;
  border-radius: var(--radius-sm);
  color: var(--text-2);
  cursor: pointer;
}

@media (hover: hover) and (pointer: fine) {
  .sidebar-collapse-toggle:hover {
    color: var(--text-0);
    background: rgba(255, 255, 255, 0.06);
    border-color: var(--panel-border);
  }
}

.sidebar-collapse-icon {
  width: 15px;
  height: 15px;
  transition: transform 0.18s var(--ease);
}

.sidebar.is-collapsed .sidebar-collapse-icon {
  transform: rotate(180deg);
}

.sidebar-brand-icon {
  width: 22px;
  height: 22px;
  object-fit: contain;
  flex-shrink: 0;
}

.sidebar-brand-label {
  font-family: var(--font-brand);
  font-size: 16px;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: -0.01em;
  color: var(--text-0);
}

.sidebar-nav-group {
  display: flex;
  flex-direction: column;
  gap: 1px;
  padding-bottom: 14px;
  margin-bottom: 14px;
  border-bottom: 1px solid var(--panel-border);
}

.sidebar-nav-group:last-of-type {
  border-bottom: none;
  margin-bottom: 0;
  padding-bottom: 0;
}

/* Shared by the three in-page pane triggers (<button data-pane>) and the
   two real page links (Code Review/Settings, plain <a>) -- reset so a
   button reads identically to a link, since from the reader's point of
   view all five are just "sidebar destinations." */
.sidebar-nav-item {
  display: flex;
  align-items: center;
  gap: 10px;
  width: 100%;
  background: transparent;
  border: none;
  border-radius: var(--radius-sm);
  padding: 8px 10px;
  font-family: var(--font-mono);
  font-size: 14px;
  color: var(--text-1);
  text-decoration: none;
  text-align: left;
  cursor: pointer;
  transition: background 0.15s var(--ease-hover), color 0.15s var(--ease-hover);
}

@media (hover: hover) and (pointer: fine) {
  .sidebar-nav-item:hover {
    color: var(--text-0);
    background: rgba(255, 255, 255, 0.05);
  }
}

/* Solid fill, not a border/accent-text treatment -- reads as "the current
   screen" at a glance even scanning quickly, the same strong active-state
   signal the reference screenshot's own sidebar uses. */
.sidebar-nav-item.is-active {
  color: var(--text-0);
  background: rgba(255, 255, 255, 0.08);
  font-weight: 600;
}

.sidebar-nav-icon {
  width: 17px;
  height: 17px;
  flex-shrink: 0;
  color: inherit;
}

.sidebar-footer {
  margin-top: auto;
  padding-top: 14px;
  border-top: 1px solid var(--panel-border);
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
}

.sidebar-footer[hidden] {
  display: none;
}

.sidebar-account-email {
  font-size: 12px;
  color: var(--text-2);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.sidebar-logout-button {
  flex-shrink: 0;
  background: transparent;
  border: none;
  padding: 4px 6px;
  font-family: inherit;
  font-size: 12px;
  color: var(--text-1);
  cursor: pointer;
  border-radius: var(--radius-sm);
}

.sidebar-logout-button:hover {
  color: var(--accent);
}

.app-main {
  flex: 1;
  min-width: 0;
  display: flex;
  flex-direction: column;
}

.app-topbar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 18px 32px;
  border-bottom: 1px solid var(--panel-border);
  position: sticky;
  top: 0;
  background: rgba(11, 11, 11, 0.85);
  backdrop-filter: blur(10px);
  z-index: 10;
}

.app-pane-title {
  margin: 0;
  font-family: var(--font-mono);
  font-size: 18px;
  font-weight: 600;
  color: var(--text-0);
}

/* Global find (see index.html's own comment on #global-find for what it
   does and doesn't search). `position: relative` anchors the results
   dropdown to this box specifically, not the topbar/page -- it must stay
   pinned under the input regardless of where that sits in the (flexible,
   space-between) topbar row. */
.global-find {
  position: relative;
  flex: 1 1 auto;
  max-width: 420px;
  margin: 0 24px;
}

/* Reparented here by app.js's setupGlobalFindMobilePlacement below ~900px
   (see that function's own comment) -- a plain block in a vertical column
   instead of a flex-squeezed pill in a horizontal row, so it reads like
   the sidebar's other rows rather than a leftover topbar control. Hidden
   entirely while the sidebar itself is collapsed to its icon rail, same
   as the nav labels/footer right below it -- there's no room to type a
   query into an icon-only column anyway. */
.sidebar .global-find {
  flex: 0 0 auto;
  max-width: none;
  margin: 0 0 14px;
}

.sidebar .global-find-input {
  border-radius: var(--radius-sm);
}

.sidebar.is-collapsed .global-find {
  display: none;
}

.global-find-input {
  width: 100%;
  padding: 7px 14px;
  border-radius: 999px;
  border: 1px solid var(--panel-border-strong);
  background-color: var(--surface-strong);
  color: var(--text-0);
  font-family: var(--font-sans);
  font-size: 13px;
  transition: border-color 0.15s var(--ease-hover);
}

.global-find-input::placeholder {
  color: var(--text-2);
}

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

.global-find-results {
  position: absolute;
  top: calc(100% + 6px);
  left: 0;
  right: 0;
  max-height: 360px;
  overflow-y: auto;
  background: var(--surface-strong);
  border: 1px solid var(--panel-border-strong);
  border-radius: var(--radius-md);
  box-shadow: 0 16px 32px rgba(0, 0, 0, 0.5);
  z-index: 20;
}

.global-find-results[hidden] {
  display: none;
}

.global-find-group-label {
  padding: 8px 12px 4px;
  font-family: var(--font-mono);
  font-size: 10.5px;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--text-2);
}

/* A real <button>, not a link -- results can mean "switch pane" (no real
   URL) as easily as "open this run," so a plain click handler in app.js
   drives navigation uniformly either way rather than half the rows being
   <a href> and half being JS-only. */
.global-find-result {
  display: flex;
  flex-direction: column;
  gap: 2px;
  width: 100%;
  padding: 8px 12px;
  border: none;
  background: transparent;
  color: var(--text-0);
  font-family: var(--font-sans);
  font-size: 13px;
  text-align: left;
  cursor: pointer;
}

.global-find-result:hover,
.global-find-result.is-active {
  background: rgba(255, 255, 255, 0.07);
}

.global-find-result-sub {
  font-family: var(--font-mono);
  font-size: 11px;
  color: var(--text-2);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.global-find-empty {
  padding: 14px 12px;
  color: var(--text-2);
  font-size: 12.5px;
}

.content-pane[hidden] {
  display: none;
}

.dashboard-stats-row {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
  gap: 16px;
  margin-bottom: 20px;
}

.dashboard-stat {
  padding: 16px 18px;
  background: var(--surface);
  border: 1px solid var(--panel-border);
  border-radius: var(--radius-lg);
}

.dashboard-stat[hidden] {
  display: none;
}

.dashboard-stat-value {
  display: block;
  font-family: var(--font-mono);
  font-size: 28px;
  font-weight: 700;
  color: var(--text-0);
}

.dashboard-stat-label {
  font-size: 13px;
  color: var(--text-2);
}

/* Four cards -- Testing activity, Recent issues, Opened PRs, Recent
   assessments -- as a real 2x2 grid, row-major order (the HTML's own
   source order IS the grid position: row 1 = Testing activity + Opened
   PRs, row 2 = Recent issues + Recent assessments; see index.html's own
   comment for why this replaced an earlier two-independent-flex-columns
   version that left a large page-level gap under whichever column's
   cards were naturally shorter). align-items defaults to `stretch` here
   (not overridden, on request) so Opened PRs is always exactly as tall
   as Testing activity's own timeline widget next to it, never shorter --
   #dashboard-opened-prs' own max-height (see its own rule) keeps that
   from working the other way around, so Testing activity's own compact
   height is still what the row is sized to. grid-template-columns is
   3fr/2fr (3/5 + 2/5 of the row), not an even split -- the left column
   (Testing activity stacked above Recent issues) carries the wider
   content (a many-week heatmap, long finding titles) and is
   deliberately the wider column; Opened PRs/Recent assessments on the
   right are both narrower, mostly-fixed-width content (a repo name + a
   URL, a short PR link) that doesn't need the extra room. */
.dashboard-quad-grid {
  display: grid;
  grid-template-columns: 3fr 2fr;
  gap: 20px;
}

/* Recent issues rows (renderDashboardRecentIssues) reuse .finding-item/
   .cve-feed-item's existing look, but need their own click affordance --
   scoped to this card's own id rather than added to .finding-item itself,
   since that class is also the real Issues tab's <details> row (its own,
   different disclosure-driven interaction already exists there and
   shouldn't be touched for this one glance card). Opened PRs
   (renderDashboardOpenedPRs) shares this exact hover/active treatment --
   same click-the-whole-row-to-open-the-run affordance -- so it's grouped
   into the same selector rather than duplicated. */
#dashboard-recent-issues .finding-item,
#dashboard-opened-prs .finding-item {
  cursor: pointer;
  transition: border-color 0.15s var(--ease-hover), background 0.15s var(--ease-hover);
}

#dashboard-recent-issues .finding-item:hover,
#dashboard-opened-prs .finding-item:hover {
  border-color: rgba(var(--accent-rgb), 0.4);
  background: rgba(0, 0, 0, 0.32);
}

#dashboard-recent-issues .finding-item:active,
#dashboard-opened-prs .finding-item:active {
  background: rgba(0, 0, 0, 0.45);
  border-color: rgba(var(--accent-rgb), 0.55);
  transition-duration: 0.05s;
}

/* Row 2's two lists (Recent issues/Recent assessments, see
   .dashboard-quad-grid's own comment) show 3.5 rows at a time, not a
   full 5 -- the fractional row is deliberate, a visible "cut off, keep
   scrolling" affordance rather than a clean edge that could read as
   "that's everything." Measured live from the real rendered rows (.list
   -item height ~71px/8px gap is the taller of the two item shapes this
   page uses), not guessed: height = 3.5 * itemHeight + 3 * gap (3 full
   gaps between the 3 whole rows and the half row) = 3.5*71 + 3*8 =
   272.5px. #dashboard-recent-issues' own .finding-item rows are
   slightly shorter (~68.5px), so the same 272px cap there shows a hair
   over 3.5 -- close enough that the two cards still read as the same
   height, which matters more here since they sit side by side. */
#dashboard-recent-issues,
#dashboard-recent-runs {
  max-height: 272px;
  overflow-y: auto;
  overflow-x: hidden;
}

/* Opened PRs sits next to Testing activity (row 1, see
   .dashboard-quad-grid's own comment) and is always stretched to
   exactly its height -- this cap is what keeps that relationship
   pointed the right way: without it, an operator with a long PR
   history could make THIS card the tallest in the row, stretching
   Testing activity's own fixed-size timeline widget to match (wasted
   space around a heatmap that never grows). 360px keeps the list within
   Testing activity's own ~446px card height (44px padding + 24px header
   + 16px margin leaves ~362px available), so Testing activity's own
   compact, content-driven height is always what defines the row. */
#dashboard-opened-prs {
  max-height: 360px;
  overflow-y: auto;
  overflow-x: hidden;
}

/* The one link inside an Opened PRs row that must NOT trigger the row's
   own click-to-open-run handler (see renderDashboardOpenedPRs's
   stopPropagation) -- styled as a real, visibly-external link (underline
   on hover) rather than looking identical to the plain summary text every
   other .cve-feed-item-summary renders. */
.pr-link {
  color: var(--accent);
  text-decoration: none;
}

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

@media (max-width: 860px) {
  .dashboard-quad-grid {
    grid-template-columns: 1fr;
  }
}

/* GitHub-contribution-graph shape, built entirely from this account's own
   real run history (renderActivityHeatmap in app.js, bucketed from the
   same /api/runs data the stats row above and the Recent assessments list
   already fetch) -- never synthetic activity. Columns = weeks, rows = the
   7 weekdays Sun..Sat (only Mon/Wed/Fri get a text label, same restraint
   GitHub's own graph uses rather than labeling every row). */
.activity-heatmap {
  position: relative;
  display: flex;
  gap: 6px;
}

/* A separate fixed column, not grid cells inside .activity-heatmap-grid
   itself -- that grid is grid-auto-flow:column (one column per week, see
   below), so a label meant to span every week's row 2/4/6 can't live
   inside it as a grid item without becoming its own repeated column.
   auto-sized rows here mirror that grid's own `auto repeat(7, 11px)`
   track sizes exactly, row for row, so the two columns line up. */
.activity-weekday-labels {
  display: grid;
  grid-template-rows: auto repeat(7, 11px);
  gap: 3px;
  flex: 0 0 auto;
}

.activity-weekday-label {
  font-size: 10px;
  color: var(--text-2);
  font-family: var(--font-mono);
  display: flex;
  align-items: center;
}

.activity-heatmap-grid {
  display: grid;
  grid-auto-flow: column;
  grid-template-rows: auto repeat(7, 11px);
  gap: 3px;
  overflow-x: auto;
  padding-bottom: 10px;
  scrollbar-width: thin; /* Firefox -- same thumb/track pair as the webkit rules below */
  scrollbar-color: rgba(var(--accent-rgb), 0.55) var(--panel-border);
}

/* The page's one bare horizontal scrollbar (every other scrollable area
   here is a vertical list, already covered by the plain, neutral global
   ::-webkit-scrollbar rule near the top of this file) -- a ~53-week-wide
   grid squeezed into one card is always going to need scrolling, so
   unlike the global rule this one is deliberately a visible, on-brand
   control rather than a barely-there one: a slim accent-colored thumb
   (the same cyan this file already uses for focus/hover states) on a
   visibly bounded track, closer to a scrubber than a default OS
   scrollbar. Scoped to this one grid, not promoted to the global rule --
   a loud accent thumb on every vertical list in the app (findings,
   runs, ...) would be a lot more color than this design system's own
   "one accent, used rarely" rule allows. */
.activity-heatmap-grid::-webkit-scrollbar {
  height: 5px;
}

.activity-heatmap-grid::-webkit-scrollbar-track {
  background: var(--panel-border);
  border-radius: 999px;
}

.activity-heatmap-grid::-webkit-scrollbar-thumb {
  background: rgba(var(--accent-rgb), 0.55);
  border-radius: 999px;
}

.activity-heatmap-grid::-webkit-scrollbar-thumb:hover {
  background: var(--accent);
}

.activity-month-label {
  grid-row: 1;
  font-size: 11px;
  color: var(--text-2);
  font-family: var(--font-mono);
  white-space: nowrap;
}

/* Rounded, not the site's usual true-right-angle --radius-sm:0 -- a
   deliberate, scoped exception on request ("rounded grey squares, like
   github's"), not a general softening of the design system. Kept to this
   one element and its legend swatches below. */
.activity-cell {
  width: 11px;
  height: 11px;
  border-radius: 2px;
  background: var(--panel-border);
  transition: outline-color 0.1s var(--ease-hover);
}

.activity-cell:hover {
  outline: 1px solid rgba(255, 255, 255, 0.4);
  outline-offset: -1px;
}

/* Positioned entirely by JS (top/left computed per-cell from live rects
   in renderActivityHeatmap's hover handlers, same rect-diffing approach
   .scan-timeline-tooltip's showTimelineTooltip already uses) rather than
   a CSS bottom:100% anchor -- that only works for a single row of bars;
   this tooltip has to track an arbitrary cell across 7 rows x ~53 weeks,
   and .activity-heatmap-grid's own overflow-x:auto would clip it if it
   lived inside that scrolling element, so it's a sibling positioned
   against .activity-heatmap (the shared, non-scrolling, position:relative
   ancestor) instead. Same visual language as .scan-timeline-tooltip. */
.activity-tooltip {
  position: absolute;
  padding: 7px 10px;
  border-radius: var(--radius-sm);
  background: var(--surface-strong);
  border: 1px solid var(--panel-border-strong);
  box-shadow: 0 4px 14px rgba(0, 0, 0, 0.5);
  font-family: var(--font-mono);
  font-size: 12.5px;
  color: var(--text-0);
  white-space: nowrap;
  pointer-events: none;
  z-index: 2;
  transform: translateX(-50%);
}

.activity-tooltip .tt-count {
  color: var(--text-1);
  font-size: 11.5px;
  display: block;
  margin-top: 2px;
}

.activity-cell.level-1 {
  background: rgba(var(--btn-blue-rgb), 0.35);
}

.activity-cell.level-2 {
  background: rgba(var(--btn-blue-rgb), 0.55);
}

.activity-cell.level-3 {
  background: rgba(var(--btn-blue-rgb), 0.78);
}

.activity-cell.level-4 {
  background: rgb(var(--btn-blue-rgb));
}

.activity-heatmap-legend {
  display: flex;
  align-items: center;
  gap: 5px;
  margin-top: 10px;
  font-size: 11px;
  color: var(--text-2);
  font-family: var(--font-mono);
}

.activity-legend-swatch {
  width: 11px;
  height: 11px;
  border-radius: 2px;
  background: var(--panel-border);
}

.activity-legend-swatch.level-1 {
  background: rgba(var(--btn-blue-rgb), 0.35);
}

.activity-legend-swatch.level-2 {
  background: rgba(var(--btn-blue-rgb), 0.55);
}

.activity-legend-swatch.level-3 {
  background: rgba(var(--btn-blue-rgb), 0.78);
}

.activity-legend-swatch.level-4 {
  background: rgb(var(--btn-blue-rgb));
}

/* What's new in CVEs: real NVD data only (lannerva.cve_feed, cached by
   /api/cve-feed) -- deliberately no map/globe visual here, see index.html's
   own comment above this card for why. */
.dashboard-cve-card {
  margin-top: 20px;
}

.cve-feed-layout {
  display: flex;
  gap: 24px;
  margin-top: 4px;
}

.cve-feed-counts {
  display: flex;
  flex-direction: column;
  gap: 20px;
  flex: 0 0 180px;
}

.cve-feed-count-value {
  display: block;
  font-family: var(--font-mono);
  font-size: 30px;
  font-weight: 700;
  color: var(--text-0);
}

.cve-feed-count-label {
  font-size: 13px;
  color: var(--text-2);
}

.cve-feed-list {
  flex: 1 1 auto;
  min-width: 0;
}

.cve-feed-item {
  display: flex;
  flex-direction: column;
  gap: 4px;
}

.cve-feed-item-head {
  display: flex;
  flex-wrap: wrap;
  justify-content: space-between;
  align-items: baseline;
  gap: 4px 12px;
}

.cve-feed-item-id {
  font-family: var(--font-mono);
  font-weight: 700;
  color: var(--text-0);
  font-size: 13.5px;
  /* The CVE id is one unbroken token -- on a narrow viewport that can't
     also fit the meta text, the ROW should wrap (flex-wrap above), never
     the id itself mid-digit (confirmed at 430px: without this it broke as
     "CVE-" / "2026-119371"). */
  white-space: nowrap;
}

.cve-feed-item-meta {
  font-family: var(--font-mono);
  font-size: 12px;
  color: var(--text-2);
  white-space: nowrap;
}

.cve-feed-item-summary {
  margin: 0;
  font-size: 13.5px;
  line-height: 1.5;
  color: var(--text-1);
  /* Same "one clean line, not a ragged wrapped paragraph" truncation the
     run list's own target-URL row uses elsewhere in this file. */
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

@media (max-width: 760px) {
  .cve-feed-layout {
    flex-direction: column;
  }
  .cve-feed-counts {
    flex-direction: row;
    flex: 0 0 auto;
  }
}

.run-detail-back {
  margin-bottom: 14px;
}

/* Below ~900px the sidebar DEFAULTS to a slim icon rail (labels hidden)
   rather than a full off-canvas drawer -- keeps every destination
   reachable with one tap, no hamburger menu to discover first, which
   matters specifically for a non-technical reader who may not know that
   pattern. That default used to be forced here unconditionally via this
   same media query, with completely duplicated declarations from
   `.sidebar.is-collapsed` below -- which meant the manual toggle's own
   click handler could never actually EXPAND the sidebar below 900px: the
   media query's styles always won regardless of whether `.is-collapsed`
   was present or not, so clicking "expand" visibly did nothing. Fixed by
   dropping the unconditional CSS override and moving the default into
   JS instead (see app.js's own setupSidebarCollapseToggle: collapsed-by-
   default below 900px only when no explicit choice is stored yet) --
   `.sidebar.is-collapsed` is now the ONLY thing that ever applies the
   collapsed look, at any width, so toggling it always has a visible
   effect, and an explicit expand choice sticks even on a narrow phone. */
@media (max-width: 900px) {
  .app-topbar {
    padding: 16px 20px;
  }
}

/* The manual toggle's own collapsed state -- same declarations as the
   900px media query above, applied unconditionally via a class
   app.js/code-review.js/settings.js each add to #sidebar-collapse-toggle's
   click handler instead of a viewport check. Kept as an exact duplicate on
   purpose (see the media query's own comment) rather than trying to share
   it through a mixin-like trick plain CSS doesn't have. */
.sidebar.is-collapsed {
  flex-basis: 64px;
  min-width: 0;
  padding: 16px 8px;
}

.sidebar.is-collapsed .sidebar-brand-label,
.sidebar.is-collapsed .sidebar-nav-label,
.sidebar.is-collapsed .sidebar-account-email,
.sidebar.is-collapsed .sidebar-logout-button {
  display: none;
}

.sidebar.is-collapsed .sidebar-brand-row {
  flex-direction: column;
  gap: 10px;
  padding-bottom: 18px;
}

.sidebar.is-collapsed .sidebar-brand {
  justify-content: center;
  padding: 6px 0;
  margin: 0;
}

.sidebar.is-collapsed .sidebar-nav-item {
  justify-content: center;
  padding: 10px;
}

.topbar-status {
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: 14px;
  color: var(--text-1);
}

/* Dev Glasses: injected by dev-glasses.js as the first child of
   .topbar-status on every static page, so it sits at the very top of every
   page inline with whatever other nav controls that page already has,
   without needing its own fixed-position overlay (which would risk
   colliding with the brand mark on the left or the account menu/CTA on the
   right -- different on each of the three pages this loads on).

   A real iOS-style switch -- pill track, a round knob that slides between
   its two ends, a symbol sitting in the track on the side the knob just
   vacated -- matching a reference image of exactly this shape, with the
   glasses/glasses-with-a-slash icon (see dev-glasses.js) standing in for
   that reference's literal "ON"/"OFF" text. Colored with this project's own
   accent (cyan) rather than the reference's purple, same "designed for
   this product, not a pasted-in stock widget" reasoning as every other
   control here -- purple appears nowhere else in this design system. */
.dev-glasses-switch {
  position: relative;
  display: inline-flex;
  align-items: center;
  flex-shrink: 0;
  width: 46px;
  height: 25px;
  padding: 0;
  border: none;
  border-radius: 999px;
  background: rgba(255, 255, 255, 0.14);
  cursor: pointer;
  transition: background 0.2s var(--ease-hover);
}

.dev-glasses-switch.is-on {
  background: var(--accent);
}

@media (hover: hover) and (pointer: fine) {
  .dev-glasses-switch:not(.is-on):hover {
    background: rgba(255, 255, 255, 0.22);
  }
}

.dev-glasses-switch:active .dev-glasses-switch-knob {
  width: 20px; /* a brief iOS-style "squish" on press, same affordance a real switch gives */
}

.dev-glasses-switch-knob {
  position: absolute;
  top: 2.5px;
  left: 2.5px;
  width: 20px;
  height: 20px;
  border-radius: 50%;
  background: #fff;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
  transition: transform 0.2s var(--ease), width 0.1s var(--ease-hover);
}

.dev-glasses-switch.is-on .dev-glasses-switch-knob {
  transform: translateX(21px);
}

/* The icon sits in the track space the knob ISN'T currently covering --
   off: knob parked left, icon shown on the right; on: knob slides right,
   icon shown on the left -- same "label fills the revealed track" layout
   the reference image uses for its ON/OFF text. */
.dev-glasses-switch-icon {
  position: absolute;
  top: 50%;
  right: 5px;
  width: 13px;
  height: 13px;
  transform: translateY(-50%);
  color: var(--text-1);
  pointer-events: none;
  transition: color 0.2s var(--ease-hover);
}

.dev-glasses-switch.is-on .dev-glasses-switch-icon {
  right: auto;
  left: 5px;
  color: var(--accent-ink);
}

.dev-glasses-switch-icon svg {
  display: block;
  width: 100%;
  height: 100%;
}

/* Plain-English mode: every [data-dev-only]/[data-plain-only] pair in the
   static markup swaps visibility off a single class on <html>, applied by
   dev-glasses.js as early as possible (that file runs from <head>, before
   <body> parses) so there's no flash of the wrong copy. Declarative pairs
   in the markup, not a runtime text swap in JS, for anything that's part
   of a page's own fixed copy -- dynamically-rendered content (app.js)
   branches on window.LannerDevGlasses.isDevMode() directly instead, since
   there's no static markup for it to pair against. */
html.plain-mode [data-dev-only] {
  display: none !important;
}
html:not(.plain-mode) [data-plain-only] {
  display: none !important;
}

/* UA stylesheet's `[hidden] { display: none }` loses to this rule's own
   unconditional `display: inline-flex` on specificity-tie + source order
   otherwise (same gotcha `.layout[hidden]`/`.field-row[hidden]` already
   document elsewhere in this file) -- without this, landing.html's
   `dashboard-link-button.hidden = true` would do nothing and both it and
   the "Login" button would show at once. */
.primary-button[hidden] {
  display: none;
}

.layout {
  padding: 28px 32px 56px;
  max-width: 1280px;
  margin: 0 auto;
  display: flex;
  flex-direction: column;
  gap: 20px;
}

/* index.html (the main dashboard: the Saved-scopes/Runs bento grid, run
   detail, findings list) only -- NOT the landing page's own marketing
   prose/hero copy and NOT new-assessment.html's step-by-step form wizard,
   both of which still want .layout's narrower base 1280px (prose and form
   fields read worse wide; a narrower column is the correct call there, not
   an oversight). `.layout`'s own `max-width` already makes the box fill
   100% of the viewport (minus this rule's own padding) up to whatever cap
   is set -- margin:auto only centers it once the viewport exceeds that cap
   -- so raising the number alone is the whole fix: at a normal ~1300-1700px
   laptop window the grid was already pinned at the old 1280px ceiling,
   leaving the bento boxes themselves cramped while real screen space sat
   unused outside them. 1760px keeps a deliberate ceiling of its own for a
   27"+/ultrawide monitor, where a 3-column bento grid any wider starts
   reading as oversized cards with too much empty interior space rather
   than "using the screen well". */
.layout-wide {
  max-width: 1760px;
}

/* Sidebar pages only: `.layout`'s own `margin: 0 auto` centers it inside
   whatever width `.app-main` leaves after the sidebar -- at a normal wide
   laptop/desktop window that put an equal gutter on BOTH sides of the
   content (one right next to the sidebar, one before the right edge),
   reading as the whole dashboard "packed in the middle" instead of
   anchored under the nav the way every other sidebar app lays out
   (Linear/Vercel/Stripe-style: content starts flush left under the nav,
   extra width just trails off on the right). `.app-topbar` already pads
   18px 32px with no centering, so this also brings the page body back
   into left-edge alignment with its own topbar above it. Landing/
   new-assessment/code-review's (non-sidebar) own plain `.layout` usage is
   untouched -- this is scoped to `.app-main`, which only the sidebar
   shell has. */
.app-main .layout {
  margin: 0;
}

.layout[hidden] {
  /* .layout's own `display: flex` above otherwise wins over the `hidden`
     attribute's UA-stylesheet `display: none` (same specificity, author
     styles win) -- this is what actually hides the dashboard while the
     login gate is showing */
  display: none;
}

.grid {
  display: grid;
  grid-template-columns: 1.15fr 1fr 1fr;
  gap: 20px;
  align-items: start;
}

.grid-2 {
  grid-template-columns: 1fr 1fr;
}

/* Hosted mode always hides `#scopes-card` (scope files are a local/CLI-mode
   concept, see app.js's `showMainLayout`) -- `display:none` drops it as a
   grid item entirely, so `#runs-card` alone was left sitting in the FIRST
   `1fr` track, half the row's width, with the second track just empty
   space next to it: narrower than the Domains card/Host-Method-Add row
   above, and visibly out of line with it. `showMainLayout` adds this class
   to the grid whenever it hides scopes-card, collapsing it to one full-
   width column so Runs actually lines up with everything above it. */
.grid-single {
  grid-template-columns: 1fr;
}

@media (max-width: 900px) {
  .grid {
    grid-template-columns: 1fr;
  }
  .layout {
    padding: 20px 16px 48px;
  }
  .topbar {
    padding: 16px 16px;
  }
}

/* At phone width `.topbar`'s three groups (brand, the dev-glasses switch,
   Code Review + New Assessment) no longer fit one row -- New Assessment
   was clipping off the right edge of the viewport entirely. Wrapping the
   row (rather than shrinking type/padding further, which was already
   fairly tight) puts brand on its own line and status/actions on a second
   full-width line below it, same two-group idea as the single-row layout,
   just stacked. `.brand-sub`'s three-line wrap next to the wordmark goes
   away too, since the brand row now has the full viewport width to itself
   instead of sharing it with the actions. */
@media (max-width: 520px) {
  .topbar {
    flex-wrap: wrap;
    row-gap: 10px;
  }
  .topbar-status {
    flex-wrap: wrap;
    width: 100%;
    justify-content: flex-end;
  }

  /* .context-menu-dropdown's `left: 0` anchors to the TRIGGER, which
     itself sits well right of the viewport's own left edge (after
     "LANNERVA" + the topbar's own padding) -- a fixed 320px positioned
     that way runs past a narrow phone's right edge, confirmed directly at
     390px. `position: fixed` + explicit `left`/`right` anchors it to the
     viewport instead, the same "stop anchoring to the trigger, anchor to
     the screen" move any popover needs once the screen gets narrower than
     the popover itself. */
  .context-menu-dropdown {
    position: fixed;
    top: 64px;
    left: 12px;
    right: 12px;
    width: auto;
  }
}

.card {
  background: var(--surface);
  border: 1px solid var(--panel-border);
  border-radius: var(--radius-lg);
  padding: 22px;
  transition: border-color 0.15s var(--ease-hover);
  /* Grid/flex items default to `min-width: auto`, i.e. "don't shrink below
     your content's own intrinsic size" -- and a column flex container like
     `.list` resolves ITS cross-axis intrinsic size to roughly its
     max-content width rather than true min-content (a well-known
     flex/grid quirk, confirmed empirically here: "Runs" blew out to
     ~1600px, consuming nearly all of `.grid-2`'s 1fr/1fr space and pushing
     "Saved scopes" down to its own min-content width, well past the
     viewport). min-width: 0 removes that content-based floor so `.grid`'s
     `1fr`/`1fr` tracks actually split evenly and the card's own content
     (the `.list`'s overflow-y: auto, `.row-sub`'s overflow-wrap: anywhere)
     handles anything that doesn't fit, instead of the grid track itself
     stretching to fit it. */
  min-width: 0;
}

.card h2,
.section-heading {
  display: flex;
  align-items: center;
  gap: 8px;
  margin: 0 0 16px;
  font-family: var(--font-mono);
  font-size: 14.5px;
  font-weight: 600;
  letter-spacing: 0.01em;
  color: var(--text-1);
}

.card h2::before,
.section-heading::before {
  content: "";
  width: 5px;
  height: 5px;
  flex-shrink: 0;
  background: var(--accent);
}

.card h3 {
  margin: 18px 0 10px;
  font-family: var(--font-mono);
  font-size: 13px;
  font-weight: 600;
  color: var(--text-2);
  letter-spacing: 0.02em;
}

.form-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
  gap: 0 16px;
}

.form-grid .span-all {
  grid-column: 1 / -1;
}

.tool-tabs {
  display: flex;
  flex-wrap: wrap;
  gap: 2px;
  border-bottom: 1px solid var(--panel-border);
  margin-bottom: 18px;
}

.tool-tab {
  position: relative;
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 9px 13px;
  background: transparent;
  border: none;
  border-bottom: 2px solid transparent;
  color: var(--text-1);
  font-size: 14px;
  font-weight: 600;
  font-family: inherit;
  cursor: pointer;
  transition: color 0.15s var(--ease-hover), border-color 0.15s var(--ease-hover);
}

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

.tool-tab.active {
  color: var(--accent);
  border-bottom-color: var(--accent);
}

.tab-dot {
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: var(--green);
  box-shadow: 0 0 6px rgba(var(--green-rgb), 0.8);
}

.tool-panel {
  animation: panel-in 0.12s var(--ease);
}

@keyframes panel-in {
  from {
    opacity: 0;
    transform: translateY(2px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

form label {
  display: block;
  font-size: 14px;
  color: var(--text-1);
  margin-bottom: 14px;
  font-weight: 500;
}

form label[hidden] {
  /* same `.layout[hidden]` gotcha as above: `form label`'s own `display:
     block` otherwise wins over the `hidden` attribute's UA-stylesheet
     `display: none` (author styles win regardless of specificity) --
     without this, a conditionally-hidden form label (e.g. new-assessment's
     wordlist-path row) stays visible even while JS has set `hidden` on it */
  display: none;
}

form input,
form select,
form textarea {
  display: block;
  width: 100%;
  margin-top: 7px;
  padding: 9px 11px;
  border-radius: var(--radius-sm);
  border: 1px solid var(--panel-border);
  background: rgba(0, 0, 0, 0.3);
  color: var(--text-0);
  font-size: 15px;
  font-family: inherit;
  transition: border-color 0.15s var(--ease-hover), background 0.15s var(--ease-hover);
}

/* a monospace font is wider per character than a proportional one, so long
   placeholder/value text that used to just fit can now run past the edge --
   an ellipsis reads as intentional, a hard mid-word clip reads as broken */
form input:not([type="checkbox"]),
form select {
  text-overflow: ellipsis;
  overflow: hidden;
  white-space: nowrap;
}

form input::placeholder,
form textarea::placeholder {
  color: var(--text-2);
}

form input:hover,
form select:hover,
form textarea:hover {
  border-color: var(--panel-border-strong);
}

form input:focus,
form select:focus,
form textarea:focus {
  outline: none;
  border-color: rgba(var(--accent-rgb), 0.55);
  background: rgba(0, 0, 0, 0.4);
}

form textarea {
  resize: vertical;
  font-family: var(--font-sans);
  font-size: 14px;
}

/* A plain caption for a field whose interactive control isn't itself a
   `<label>` (new-mobile-scan.html's APK file field -- the drop zone below
   already IS the `<label for="apk-file-input">`, so its own name can't
   also be one without nesting two <label> elements) -- same typography as
   `form label` above, just not the element that triggers the control. */
.form-label-text {
  display: block;
  font-size: 14px;
  color: var(--text-1);
  margin-bottom: 14px;
  font-weight: 500;
}

/* The file input's own native control (a "Choose File" button rendered by
   the OS/browser) can't be restyled directly -- same unavoidable
   cross-browser limit every file-upload UI runs into. Standard fix: the
   real <input type="file"> sits inside this <label>, stretched to fill it
   and invisible (.file-drop-input below), so a click anywhere on the
   drop zone opens the real file picker; this element is what actually
   carries the square-corner/dark-theme look. */
.file-drop {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  margin-top: 7px;
  padding: 26px 16px;
  border: 1px dashed var(--panel-border-strong);
  border-radius: var(--radius-sm);
  background: rgba(0, 0, 0, 0.3);
  color: var(--text-1);
  font-size: 14px;
  text-align: center;
  cursor: pointer;
  transition: border-color 0.15s var(--ease-hover), background 0.15s var(--ease-hover), color 0.15s var(--ease-hover);
}

.file-drop:hover {
  border-color: rgba(var(--accent-rgb), 0.5);
  color: var(--text-0);
}

/* Real file chosen: switches from the "nothing selected yet" dashed/muted
   state to a solid, accent-tinted one -- same affordance language
   .assessment-type-card.is-selected/.size-picker-card.is-selected already
   use elsewhere in this file for "this is the thing that's active now". */
.file-drop.has-file {
  border-style: solid;
  border-color: rgba(var(--accent-rgb), 0.45);
  background: rgba(var(--accent-rgb), 0.06);
  color: var(--text-0);
}

.file-drop-icon {
  width: 20px;
  height: 20px;
  flex-shrink: 0;
}

/* Overrides `form input`'s own display/width/padding/border/background
   (a single-class selector beats that two-element one on specificity,
   see this rule's own comment in new-mobile-scan.html) -- the real input
   is fully transparent and stretched over the whole drop zone so a click
   anywhere on it opens the file picker, never a small native control
   sitting visibly inside a second, redundant box. */
.file-drop-input {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  margin: 0;
  padding: 0;
  border: none;
  background: transparent;
  opacity: 0;
  cursor: pointer;
}

.field-row {
  display: flex;
  align-items: flex-end;
  gap: 12px;
}

.field-row[hidden] {
  /* same `.layout[hidden]` gotcha as above, hitting a second component this
     time: `#retest-picker` is a `<form class="field-row" hidden>`, and
     `.field-row`'s own `display: flex` otherwise wins over the `hidden`
     attribute's UA-stylesheet `display: none` -- without this, the "Prior
     run to retest" picker stayed visible for every assessment type, not
     just Retest */
  display: none;
}

.field-row label {
  flex: 1;
  /* `align-items: flex-end` above aligns each item's MARGIN box, not its
     content box -- `form label`'s own margin-bottom:14px (meant to space
     out stacked form rows) was otherwise still counted, making the label's
     effective flex-item box 14px taller than the button sitting next to it
     and pushing the actual input/select box out of line with the button's
     bottom edge. Zeroed here since a .field-row's own `gap` already handles
     spacing after the row. */
  margin-bottom: 0;
}

.checkbox-row {
  display: flex;
  align-items: flex-start;
  gap: 8px 18px;
  margin-bottom: 12px;
  font-size: 14px;
  font-weight: 500;
  color: var(--text-0);
  line-height: 1.5;
}

.checkbox-row label {
  display: flex;
  align-items: center;
  gap: 6px;
  margin-bottom: 0;
  font-size: 14px;
  font-weight: 500;
  white-space: nowrap;
  color: var(--text-0);
}

/* A nested confirmation checkbox inside a .state-mutating-warning box (the
   "I understand..." row under the POST-forms warning) needs tighter margins
   than a normal top-level .checkbox-row -- used to be a `style="..."`
   attribute on that one label, moved here so the dashboard's CSP doesn't
   need 'unsafe-inline' in style-src (scanner-engineering skill, section 6). */
.checkbox-row.nested-confirm-row {
  margin-top: 10px;
  margin-bottom: 0;
}

.checkbox-row input {
  /* no width/margin override here -- input[type="checkbox"]'s own fixed
     16x16 box (needed for its custom appearance:none rendering) would
     otherwise be squashed back to width:auto by this rule's equal CSS
     specificity and later position in the stylesheet */
  flex-shrink: 0;
}

label.checkbox-row input {
  margin-top: 2px;
}

/* White pill, dark text, trailing arrow -- the "Check my site" hero CTA's
   look, generalized to every primary button site-wide on request (was a
   filled navy rectangle using --btn-blue-rgb; that token still exists but
   nothing reads from it anymore, left in place rather than deleted in case
   a future button wants the old treatment back). */
/* Smaller and paler on an earlier request -- was a near-pure-white
   (#f4f4f1) fill at generous padding, read as too large/too high-contrast
   against the dark page, so it moved to --btn-pale's dimmer cream. Back to
   a flat white fill on a later, explicit request (matching a reference
   button image), with a solid dark border rather than a border matching
   the fill -- on a near-black page a pure white button needs no border for
   contrast, but a crisp dark edge is what the reference button actually
   has. --btn-pale itself is untouched: .badge-scope-tag still reads from
   it and was never asked to change. Weight dropped from 600 to 500 at the
   same time, on request ("thinner, less bold") -- still the heaviest
   weight on the page (matching this project's own "600 at most for rare
   emphasis" rule) but a visibly lighter mark on a page of mostly 400/500
   mono text. */
.primary-button {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 6px;
  box-sizing: border-box;
  width: 100%;
  padding: 8px 16px;
  border-radius: var(--radius-button);
  border: 1px solid #141414;
  background: #ffffff;
  color: #141414;
  font-family: var(--font-mono);
  font-weight: 500;
  font-size: 14px;
  letter-spacing: 0.01em;
  text-align: center;
  text-decoration: none;
  cursor: pointer;
  box-shadow: 0 3px 10px rgba(0, 0, 0, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.4);
  transition: filter 0.15s var(--ease), transform 0.1s var(--ease), border-color 0.15s var(--ease), box-shadow 0.15s var(--ease);
}

/* .primary-button defaults to width:100% (fine inside a form) and a
   fairly chunky 8px/16px padding -- override back to an intrinsic width
   and a slimmer pill for every place a .primary-button sits inline next to
   other nav/topbar controls rather than filling a form of its own: the
   dashboard's own topbar ("New Assessment") and the landing page's
   post-login "Control panel" link both use this. Defined right after
   the base rule (not earlier in the file, e.g. alongside .topbar-status)
   so it actually wins the width/padding tie -- two same-specificity
   single-class rules resolve by source order, and an earlier definition
   would lose to .primary-button's own width:100% below it. */
.compact-primary-button {
  width: auto;
  padding: 5px 14px;
  margin-right: 4px;
}

.primary-button::after {
  content: "\203A"; /* › */
  font-size: 15px;
  line-height: 1;
  transition: transform 0.15s var(--ease);
}

/* Gated: touch has no real hover, so an untapped rule here would stick
   after a tap until the next one elsewhere (the classic sticky-hover bug).
   .primary-button is shared across the landing page and every dashboard
   page, so this covers the one surface (landing.html) real touch visitors
   actually reach. */
@media (hover: hover) and (pointer: fine) {
  .primary-button:hover {
    filter: brightness(0.97);
    border-color: #141414;
    transform: translateY(-2px);
  }

  .primary-button:hover::after {
    transform: translateX(3px);
  }
}

.primary-button:active {
  /* A scale-down, not a translate -- this is the actual Apple press
     signature (see apple-design skill: "Respond on pointer-down... a
     button to feel pressed" via transform: scale, not position/shadow
     alone), paired with flattening the shadow + a brightness dim so the
     press reads through multiple signals at once. */
  transform: scale(0.97);
  filter: brightness(0.9);
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
}

.error-text {
  color: var(--red);
  font-size: 14px;
  margin-top: 10px;
}

/* Same pill shape/sizing as .secondary-button, red instead of neutral --
   for a genuinely destructive confirm action (delete-run-dialog's own
   "Delete run" button), never .primary-button: that class always renders
   a trailing "›" chevron and goes full-width, both wrong for a compact
   dialog action. */
.danger-button {
  display: inline-block;
  padding: 7px 14px;
  border-radius: var(--radius-button);
  border: 1px solid rgba(var(--red-rgb), 0.5);
  background: var(--red);
  color: var(--red-ink);
  font-family: var(--font-mono);
  font-weight: 600;
  font-size: 14px;
  cursor: pointer;
  transition: filter 0.15s var(--ease), transform 0.1s var(--ease);
}

@media (hover: hover) and (pointer: fine) {
  .danger-button:hover {
    filter: brightness(1.08);
  }
}

.danger-button:active {
  transform: scale(0.97);
  filter: brightness(0.92);
}

/* Native <dialog>, not a hand-rolled overlay div: focus trapping, Esc-to-
   close, and the ::backdrop dimming all come from the browser for free. */
.confirm-dialog {
  background: var(--surface);
  color: var(--text-0);
  border: 1px solid var(--panel-border);
  border-radius: var(--radius-lg);
  padding: 24px;
  max-width: 420px;
  width: calc(100% - 48px);
  /* same neutral "popped forward" elevation convention used elsewhere on
     request (.landing-page .card, .wizard-step-dot.is-active) -- a plain
     dark drop shadow plus a faint top highlight line, never a colored glow */
  box-shadow: 0 20px 50px -20px rgba(0, 0, 0, 0.7), inset 0 1px 0 rgba(255, 255, 255, 0.05);
}

.confirm-dialog::backdrop {
  background: rgba(0, 0, 0, 0.6);
}

.confirm-dialog h3 {
  margin: 0 0 10px;
  font-family: var(--font-mono);
  font-size: 19px;
  font-weight: 700;
  color: var(--text-0);
}

.confirm-dialog .hint-text {
  margin: 0 0 20px;
}

.dialog-actions {
  display: flex;
  justify-content: flex-end;
  gap: 10px;
}

.list {
  display: flex;
  flex-direction: column;
  gap: 8px;
  max-height: 360px;
  overflow-y: auto;
  /* explicit, not left to default -- per spec, setting only `overflow-y`
     makes the UA compute `overflow-x` as `auto` too (not `visible`), which
     is what let this scroll horizontally whenever a row's content ran wide
     (see `.run-item-scope` above for the actual cause). This list only
     ever scrolls vertically. */
  overflow-x: hidden;
  padding-right: 2px;
}

.list-item {
  padding: 11px 13px;
  border-radius: var(--radius-sm);
  background: rgba(0, 0, 0, 0.22);
  border: 1px solid transparent;
  font-size: 15px;
  cursor: default;
  transition: border-color 0.15s var(--ease-hover), background 0.15s var(--ease-hover);
}

.list-item.clickable {
  cursor: pointer;
}

.list-item.clickable:hover {
  border-color: rgba(var(--accent-rgb), 0.4);
  background: rgba(0, 0, 0, 0.32);
}

/* A full-width row reads wrong with a scale-down press (its straight edges
   pulling in from the container looks like a layout glitch, not a squeeze)
   -- a full-width list row's real Apple-pattern press feedback is a deeper
   background highlight, applied as close to instantly as this site's own
   `--ease-hover` short-transition convention allows, so the press still
   reads immediately rather than waiting for the normal hover-speed fade. */
.list-item.clickable:active {
  background: rgba(0, 0, 0, 0.45);
  border-color: rgba(var(--accent-rgb), 0.55);
  transition-duration: 0.05s;
}

.list-item .row-title {
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: 10px;
  font-weight: 600;
  font-size: 14.5px;
}

.list-item .row-sub {
  margin-top: 5px;
  color: var(--text-1);
  font-size: 13.5px;
  font-family: var(--font-sans);
  overflow-wrap: anywhere;
}

/* Run list items specifically: row-sub carries two pieces of info (scope/
   mode, and a relative time), so it's a space-between flex row here rather
   than the plain block every other .row-sub is. A third line (event count
   + run id on the left, the delete control on the right) carries whatever
   doesn't fit the title/subtitle, same space-between shape. */
.run-item .row-sub {
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: 10px;
}

/* The run URL itself (app.js's `.run-item-target`, a bare span with no
   width constraint of its own) is the one piece of `.row-title` that was
   never given the same `min-width: 0` + ellipsis treatment
   `.run-item-scope` below already has -- on a long target URL at phone
   width, that span's intrinsic content width forced `.row-title` (and
   everything under it, `.list`, the whole `.card`) wider than the
   viewport, which is what made the entire page scroll horizontally.
   `.list-item .row-title`'s `space-between` already assumes its first
   child can shrink; this is the general fix for every row-title (run
   items, the domain list's bare host span) rather than a run-item-only
   patch, since both render the exact same "bare first span + a badge"
   shape. */
.list-item .row-title > :first-child {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.list-item .row-title > :last-child {
  flex-shrink: 0;
}

.run-item-scope {
  /* Flex items default to `min-width: auto` (don't shrink below your own
     content's width) -- same gotcha documented on `.card`/`.grid` above.
     `.run-delete-btn` next to this is a fixed `min-width: 154px;
     flex-shrink: 0`, so without this, a long scope/mode label (e.g.
     "example-target · agent") forced the row wider than `.list`'s own
     width, which is what made the Runs card scroll horizontally -- `.list`
     only ever meant to scroll vertically. Truncate instead of overflowing. */
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.run-item-time {
  flex-shrink: 0;
  color: var(--text-2);
}

.run-item-meta {
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: 10px;
  margin-top: 8px;
}

.run-item-meta-left {
  color: var(--text-2);
  font-size: 13px;
  font-family: var(--font-mono);
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* Two-layer hold-to-confirm button: .run-delete-btn-label is always visible
   in the idle pale-pill style (light grey background, dark text/icon);
   .run-delete-btn-fill is an absolutely-positioned copy of the same
   label on a solid pink background with red text/icon, revealed
   left-to-right via clip-path while holding -- which is also what makes
   a single word read as two colors mid-reveal (both copies sit in the
   exact same spot; clip-path just cuts the top one off partway through).
   Pure CSS/JS -- no animation library -- clip-path is driven by the
   .is-holding class below plus a per-instance --hold-duration custom
   property JS sets inline, the same "set the moving part as a real
   value, transition picks it up" technique the severity donut/timeline
   charts elsewhere on this page already use. Colors are hardcoded, not
   tokens: a dedicated pale pink/grey pair sampled directly from the
   reference image on request, with no equivalent already in this dark
   theme's palette to reuse. */
.run-delete-btn {
  position: relative;
  isolation: isolate;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  height: 32px;
  min-width: 154px;
  padding: 0 16px;
  overflow: hidden;
  flex-shrink: 0;
  border: none;
  border-radius: var(--radius-button);
  background: #f7f5f5;
  color: #232017;
  font-family: var(--font-mono);
  font-size: 12.5px;
  font-weight: 600;
  cursor: pointer;
  touch-action: none;
  user-select: none;
  transition: filter 0.15s var(--ease-hover);
}

.run-delete-btn:hover {
  filter: brightness(0.97);
}

.run-delete-btn:focus-visible {
  outline: 2px solid var(--red);
  outline-offset: 2px;
}

.run-delete-btn-label,
.run-delete-btn-fill {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 6px;
  white-space: nowrap;
}

.run-delete-btn-icon {
  width: 14px;
  height: 14px;
  flex-shrink: 0;
  fill: none;
  stroke: currentColor;
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
}

.run-delete-btn-fill {
  position: absolute;
  inset: 0;
  background: #fddbdd;
  color: var(--red);
  clip-path: inset(0 100% 0 0);
  transition: clip-path 0.18s cubic-bezier(0.23, 1, 0.32, 1);
}

.run-delete-btn.is-holding .run-delete-btn-fill {
  clip-path: inset(0 0 0 0);
  transition: clip-path var(--hold-duration, 1100ms) linear;
}

.run-delete-btn.is-confirmed .run-delete-btn-fill {
  clip-path: inset(0 0 0 0);
  transition: none;
}

@media (prefers-reduced-motion: reduce) {
  .run-delete-btn-fill,
  .run-delete-btn.is-holding .run-delete-btn-fill {
    transition-duration: 0.01ms;
  }
}

.hint-text {
  color: var(--text-2);
  font-size: 13.5px;
  line-height: 1.5;
  margin-top: -6px;
  margin-bottom: 14px;
}

.empty-note {
  color: var(--text-2);
  font-size: 14px;
  font-style: normal;
  padding: 8px 2px;
}

.badge {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  padding: 4px 11px;
  /* pill, matching the button/badge-scope-tag language elsewhere on the
     site, with a touch of lift instead of sitting perfectly flat on the
     card background -- was var(--radius-sm)/no shadow, read as plain and
     cramped next to the rest of the now-pill-shaped UI. */
  border-radius: 999px;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.35);
  font-family: var(--font-mono);
  font-size: 12.5px;
  font-weight: 700;
  letter-spacing: 0.02em;
}

/* Green/orange/purple ones use the exact colors given, as a direct fill
   (not blended toward white like the earlier pass -- these already ARE the
   intended color). One shared dark ink works across every hue here since
   the background is always light. */
.badge-starting,
.badge-crawling {
  background: var(--label-purple);
  color: #141414;
}

/* Used to be the diluted site accent (color-mix(--accent, 35%, white)) --
   the same "washed-out pale" problem .badge-error's own history below
   already documents for a diluted mix next to a full-saturation sibling
   badge, just never revisited for this one until now. Direct --vibe-lime
   fill instead: a run that's actually in flight is exactly the kind of
   "something is happening right now" state this project's new vibrant
   accent is reserved for (see --vibe-lime's own definition above), and a
   flat pale-blue badge next to .badge-completed's full-saturation green
   read as the least vivid status in the list, backwards for the one that
   means "watch this." --vibe-lime-ink (not the shared #141414) since lime
   is light enough that the shared dark ink needs to stay a near-black
   tinted toward the hue, same reasoning --green-ink/--amber-ink/etc. exist
   as their own tokens elsewhere rather than everything using one literal. */
.badge-running {
  background: var(--vibe-lime);
  color: var(--vibe-lime-ink);
}

.badge-completed {
  background: var(--label-green);
  color: #141414;
}

/* Used to be the same color-mix(--red, 35%, white) dilution as .badge-running
   above -- that reads as a washed-out pale salmon next to .badge-completed's
   full-saturation green, exactly backwards for the badge that's supposed to
   be the alarming one. Direct --red fill on request ("make the error more
   vibrant"), same "exact color given, not blended toward white" treatment
   .badge-completed/.badge-starting already got. #141414 ink still clears
   AA here (~4.9:1) -- checked, not assumed: --red's own mid luminance means
   white text would actually read WORSE (~3.8:1, fails AA) on this
   background, so the shared dark-ink convention stays correct rather than
   needing a per-badge exception. */
.badge-error {
  background: var(--red);
  color: #141414;
}

/* Neutral gray, not red/green/purple -- a cancelled run isn't a failure
   (error) or a success (completed), it's just stopped on request. */
.badge-cancelled {
  background: var(--text-2);
  color: #141414;
}

/* .badge-invalid flags a real problem (the scope file failed to load),
   which is still worth keeping visually alarming -- the other saved-scope
   badge (what the scope ALLOWS, e.g. "read+scan") was removed from the UI,
   this one stayed. */
.badge-invalid {
  background: color-mix(in srgb, var(--red) 35%, white);
  color: #141414;
}

.badge-unapproved {
  background: var(--label-orange);
  color: #141414;
}

.badge-confirmed {
  background: var(--label-green);
  color: #141414;
  flex-shrink: 0;
}

/* Plain yellow text, not a pill -- replaces the old .badge-possible fill
   on request ("remove the yellow box ... use yellow text instead"). */
.confidence-text {
  color: var(--label-orange);
  font-family: var(--font-mono);
  font-size: 12.5px;
  font-weight: 700;
  letter-spacing: 0.02em;
  flex-shrink: 0;
  cursor: help;
}

/* A plain green check glyph in place of the old "completed" text pill --
   same tooltip mechanism as .confidence-text, via data-tooltip. */
.status-check {
  display: inline-flex;
  flex-shrink: 0;
  color: var(--green);
  cursor: help;
}

.status-check svg {
  width: 17px;
  height: 17px;
}

/* Shared generic tooltip for any [data-tooltip] element (see
   showInfoTooltip/hideInfoTooltip in app.js) -- same visual language as
   .scan-timeline-tooltip/.activity-tooltip, positioned off the live
   viewport rect of whatever triggered it rather than a fixed anchor. */
.info-tooltip {
  position: fixed;
  transform: translateX(-50%);
  max-width: 220px;
  padding: 6px 10px;
  border-radius: var(--radius-sm);
  background: var(--surface-strong);
  border: 1px solid var(--panel-border-strong);
  box-shadow: 0 4px 14px rgba(0, 0, 0, 0.5);
  font-family: var(--font-sans);
  font-size: 12px;
  line-height: 1.4;
  color: var(--text-0);
  pointer-events: none;
  z-index: 50;
}


/* groups the confidence/new badges + triage select as ONE flex child on
   the right of .finding-title, so .finding-title's own space-between
   still just splits "title text" vs "everything else" the way it always
   has, regardless of how many badges/controls end up inside this group */
.finding-title-right {
  display: flex;
  align-items: center;
  gap: 8px;
  flex-shrink: 0;
}

/* `.finding-title-right` staying `flex-shrink: 0` is correct at any width
   wider than this (a badge/select squishing into illegibility is worse
   than a narrower title column) -- but at phone width `.finding-title`'s
   two fixed-ish-width halves leave `.finding-title-text` only a sliver to
   wrap in, one or two words per line, immediately beside a control group
   just as tall. Wrapping the row gives the title the full line width and
   drops the badge/select to their own line underneath instead. */
@media (max-width: 520px) {
  .finding-item .finding-title {
    flex-wrap: wrap;
  }
  .finding-title-right {
    width: 100%;
    justify-content: flex-end;
  }
}

/* NOT inside a <form> (it's injected into a plain finding-item div), so the
   `form select` theming above never actually reached this -- it was
   rendering as a bare, unstyled native dropdown. Fully custom now: pill
   shape to match the badges it sits next to, a hand-drawn chevron since
   `appearance: none` drops the native one, dark fill consistent with the
   rest of the UI's inputs. */
.triage-select {
  appearance: none;
  -webkit-appearance: none;
  display: inline-flex;
  align-items: center;
  padding: 5px 26px 5px 12px;
  border-radius: 999px;
  border: 1px solid var(--panel-border-strong);
  background-color: var(--surface-strong);
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' fill='none'%3E%3Cpath d='M3 4.5L6 7.5L9 4.5' stroke='%23999999' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
  background-repeat: no-repeat;
  background-position: right 10px center;
  background-size: 10px;
  color: var(--text-0);
  font-family: var(--font-mono);
  font-size: 12.5px;
  font-weight: 600;
  cursor: pointer;
  transition: border-color 0.15s var(--ease-hover), background-color 0.15s var(--ease-hover);
}

.triage-select:hover {
  border-color: rgba(var(--accent-rgb), 0.45);
}

.triage-select:focus {
  outline: none;
  border-color: var(--accent);
}

.approvals-banner {
  border: 1px solid rgba(var(--amber-rgb), 0.3);
  background: rgba(var(--amber-rgb), 0.06);
  border-radius: var(--radius-lg);
  padding: 18px 22px;
}

.approvals-banner h2 {
  margin: 0 0 12px;
  color: var(--amber);
  font-family: var(--font-mono);
  font-size: 14px;
  font-weight: 600;
  letter-spacing: 0.01em;
  display: flex;
  align-items: center;
  gap: 8px;
}

.approvals-banner h2::before {
  content: "";
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: var(--amber);
  box-shadow: 0 0 8px rgba(var(--amber-rgb), 0.8);
}

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

.approval-item {
  background: rgba(0, 0, 0, 0.28);
  border: 1px solid var(--panel-border);
  border-radius: var(--radius-md);
  padding: 13px 15px;
}

.approval-item .approval-head {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  font-weight: 700;
  font-size: 15px;
}

.approval-item .approval-head span:last-child {
  font-weight: 500;
  font-size: 13px;
  color: var(--text-2);
  font-family: var(--font-mono);
}

.approval-item .approval-desc {
  margin: 7px 0;
  font-size: 14px;
  color: var(--text-1);
  line-height: 1.5;
}

.approval-item pre {
  margin: 8px 0;
  padding: 10px;
  border-radius: var(--radius-sm);
  background: rgba(0, 0, 0, 0.35);
  border: 1px solid var(--panel-border);
  font-size: 13px;
  overflow-x: auto;
  font-family: var(--font-mono);
}

.approval-actions {
  display: flex;
  gap: 8px;
  align-items: center;
  margin-top: 10px;
}

.approval-actions input {
  flex: 1;
  padding: 7px 9px;
  border-radius: var(--radius-sm);
  border: 1px solid var(--panel-border);
  background: rgba(0, 0, 0, 0.3);
  color: var(--text-0);
  font-size: 14px;
}

.approve-button,
.deny-button {
  border: 1px solid transparent;
  border-radius: var(--radius-sm);
  padding: 7px 16px;
  font-size: 14px;
  font-weight: 700;
  cursor: pointer;
  transition: filter 0.15s var(--ease-hover);
  white-space: nowrap;
}

.approve-button:hover,
.deny-button:hover {
  filter: brightness(1.1);
}

.approve-button {
  background: var(--green);
  color: var(--green-ink);
}

.deny-button {
  background: transparent;
  border-color: rgba(var(--red-rgb), 0.5);
  color: var(--red);
}

.run-detail-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
}

.run-detail-header h2 {
  margin: 0;
}

.run-detail-header-right {
  display: flex;
  align-items: center;
  gap: 10px;
}

/* The Assessments popup's own "Runs" header: "Runs" plus a right-aligned
   "Select" toggle into bulk-selection mode -- same space-between row shape
   as .run-detail-header above. */
.card-head-row {
  display: flex;
  justify-content: space-between;
  align-items: center;
}

/* A plain text/link-style control for an action that toggles a mode rather
   than carrying out a primary or destructive one -- doesn't earn
   .secondary-button's bordered pill treatment. */
/* Resting color is a muted neutral, not the bright accent cyan -- on
   request, after real feedback that every "View all"/"NVD database" link
   across the whole dashboard reading as a loud, constantly-lit accent
   blob (bold 600 weight compounding it) was shiny and distracting rather
   than clean. Accent now shows up only ON hover (swapped from the old
   hover:text-0), so it reads as "this is interactive, something just
   responded to you" instead of permanently shouting for attention --
   closer to how this file's own "one accent, used rarely" rule already
   treats every other UI surface (see e.g. the dataviz/scanner-engineering
   skills' own design doctrine). */
.text-button {
  background: none;
  border: none;
  padding: 0;
  color: var(--text-1);
  font-family: var(--font-mono);
  font-size: 13px;
  font-weight: 600;
  cursor: pointer;
  transition: color 0.15s var(--ease-hover);
}

.text-button:hover {
  color: var(--accent);
}

/* Same [hidden]-vs-author-`display` gotcha this file documents repeatedly
   (.secondary-button[hidden], .primary-button[hidden], .layout[hidden]) --
   this element has no unconditional `display` rule of its own here, but one
   is added defensively since this button IS toggled via the `hidden`
   property (runs-select-toggle). */
.text-button[hidden] {
  display: none;
}

.runs-select-toolbar {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 10px 14px;
  margin: 12px 0;
  padding: 10px 12px;
  background: rgba(var(--accent-rgb), 0.06);
  border: 1px solid rgba(var(--accent-rgb), 0.2);
  border-radius: var(--radius-sm);
}

.runs-select-toolbar[hidden] {
  display: none;
}

.runs-select-all-row {
  margin-bottom: 0;
}

.runs-select-count {
  color: var(--text-2);
  font-size: 13px;
  font-family: var(--font-mono);
  margin-right: auto;
}

.runs-select-actions {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 8px;
}

/* Keeps each button at its own natural single-line width -- flex's default
   flex-shrink:1 would otherwise squeeze "Invert selection"/"Delete
   selected" narrower than their own text at the Assessments popup's
   narrowest (a phone viewport, see its own 520px override), wrapping
   mid-word inside the button instead of the ROW wrapping to a second line
   as whole buttons. */
.runs-select-actions > button {
  flex-shrink: 0;
}

/* .run-item is now a two-column flex row: an optional select checkbox on
   the left (.run-item-select, hidden unless #runs-list carries
   .select-mode) and everything the row always had (.run-item-body, the
   existing title/sub/meta stack) filling the rest. */
.run-item {
  display: flex;
  align-items: flex-start;
  gap: 10px;
}

.run-item-body {
  flex: 1;
  min-width: 0;
}

.run-item-select {
  display: none;
  flex-shrink: 0;
  padding-top: 3px;
}

#runs-list.select-mode .run-item-select {
  display: flex;
}

.run-item-select input:disabled {
  opacity: 0.35;
  cursor: not-allowed;
}

/* Small, inline spinning-ring loader shown next to the status badge only
   while a run is actively in progress (see refreshRunDetail's own status
   check) -- a plain CSS conic-gradient ring, not an animated logo trace. */
.run-detail-spinner {
  display: inline-flex;
  align-items: center;
}

/* `.run-detail-spinner`'s own `display: inline-flex` is an author rule, so
   it otherwise wins over the `[hidden]` attribute's user-agent-stylesheet
   `display: none` default (same specificity, author beats UA) -- the same
   gotcha this project already hit once with `.layout[hidden]`. Without
   this, the ring stays visible after a run finishes even though
   refreshRunDetail sets the `hidden` attribute correctly. */
.run-detail-spinner[hidden] {
  display: none;
}

.run-detail-spinner-ring {
  --run-detail-spinner-size: 20px;
  --run-detail-spinner-thickness: 3px;
  display: inline-block;
  width: var(--run-detail-spinner-size);
  height: var(--run-detail-spinner-size);
  border-radius: 50%;
  /* Was a flat --text-1 grey -- recolored to --vibe-lime (the same "this
     is actually happening right now" accent .badge-running now uses) plus
     a soft drop-shadow glow, on request for a more vibrant dark theme.
     filter: drop-shadow, not box-shadow: this element is masked into a
     ring below, and box-shadow draws from the element's own un-masked
     box, which would paint a solid glowing DISC, not a glowing ring --
     drop-shadow operates on the already-masked, rendered alpha shape, so
     the glow correctly follows the ring itself. */
  background: conic-gradient(from 0deg, var(--vibe-lime), rgba(var(--vibe-lime-rgb), 0.08) 80%, transparent 100%);
  filter: drop-shadow(0 0 4px rgba(var(--vibe-lime-rgb), 0.55));
  -webkit-mask: radial-gradient(
    farthest-side,
    transparent calc(100% - var(--run-detail-spinner-thickness)),
    #000 calc(100% - var(--run-detail-spinner-thickness))
  );
  mask: radial-gradient(
    farthest-side,
    transparent calc(100% - var(--run-detail-spinner-thickness)),
    #000 calc(100% - var(--run-detail-spinner-thickness))
  );
  animation: run-detail-spinner-spin 0.85s linear infinite;
}

@keyframes run-detail-spinner-spin {
  to {
    transform: rotate(360deg);
  }
}

@media (prefers-reduced-motion: reduce) {
  .run-detail-spinner-ring {
    animation: none;
  }
}

#run-detail-id {
  font-family: var(--font-mono);
  color: var(--text-1);
  font-weight: 400;
  letter-spacing: 0;
}

.run-detail-meta {
  margin-top: 10px;
  color: var(--text-1);
  font-size: 14px;
  font-family: var(--font-sans);
  /* The target URL in this line (especially a path-fuzzed target like
     Google Gruyere's own long numeric session-id path segment) is one
     unbroken token with no space for the browser to wrap on -- without
     this, it overflowed the card's own width and dragged the whole page
     into horizontal scroll instead of staying inside it. */
  overflow-wrap: anywhere;
}

.run-detail-final {
  margin-top: 16px;
  padding: 14px;
  border-radius: var(--radius-md);
  background: rgba(var(--accent-rgb), 0.06);
  border: 1px solid rgba(var(--accent-rgb), 0.2);
  font-size: 15px;
  white-space: pre-wrap;
  line-height: 1.6;
}

/* `.hint-text`'s own -6px top margin is tuned for its usual job (pulling a
   caption line up against a heading right above it) -- here it sits right
   after .run-detail-final's bordered box instead, where that same negative
   margin pulled it up ENOUGH to visibly overlap the box's bottom border. */
#run-detail-diff {
  margin-top: 10px;
}

/* Donut + scan-timeline side by side, same shape as a dashboard with a
   narrower "breakdown" card next to a wider "activity over time" card --
   on request. Each keeps its own card-like padding/background/border
   (already had that individually); the row below just places them next to
   each other and sizes the donut card to its content instead of letting it
   stretch full-width. */
.run-detail-charts-row {
  display: flex;
  align-items: stretch;
  gap: 16px;
  margin-top: 16px;
}

.run-detail-charts-row .severity-chart,
.run-detail-charts-row .scan-timeline {
  margin-top: 0;
}

@media (max-width: 860px) {
  .run-detail-charts-row {
    flex-direction: column;
  }
}

.severity-chart {
  display: flex;
  align-items: center;
  justify-content: center;
  flex: 0 0 auto;
  margin-top: 16px;
  padding: 26px 20px;
  border-radius: var(--radius-lg);
  background: rgba(0, 0, 0, 0.22);
  border: 1px solid var(--panel-border);
}

/* same `.layout[hidden]`/`.run-detail-spinner[hidden]` gotcha: this class's
   own `display: flex` otherwise wins over `[hidden]`'s UA default, so a run
   with zero findings -- renderSeverityChart sets `hidden` and returns early
   -- rendered an empty donut instead of nothing. */
.severity-chart[hidden] {
  display: none;
}

.donut-wrap {
  position: relative;
  width: clamp(180px, 20vw, 220px);
  height: clamp(180px, 20vw, 220px);
  flex-shrink: 0;
}

.donut-chart {
  width: 100%;
  height: 100%;
  border-radius: 50%;
  /* no fill behind the ring -- the gaps between segments should show the real
     card background underneath, not a separate grey "track" disc */
  background: transparent;
  box-shadow: 0 10px 28px -16px rgba(0, 0, 0, 0.6);
}

.donut-hole {
  position: absolute;
  inset: 16%;
  border-radius: 50%;
  background: var(--bg-1);
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  padding: 5% 9%;
  text-align: center;
}

.donut-total {
  font-family: var(--font-mono);
  font-size: clamp(26px, 5vw, 38px);
  font-weight: 700;
  color: var(--text-0);
  line-height: 1;
  font-variant-numeric: tabular-nums;
}

.donut-hole-label {
  font-size: 11.5px;
  font-weight: 600;
  color: var(--text-1);
  margin-top: 4px;
}

.donut-breakdown {
  /* a fixed 2-column grid, not flex-wrap: wrapping based on available
     width collapses to a single tall column on a narrow viewport, and a
     single column of 5 rows is taller than a small hole can hold. Sized
     down further (font/dot/gaps) now that the donut itself is smaller --
     this content used to have a ~320px-diameter hole to work with, now
     closer to 150px. */
  display: grid;
  grid-template-columns: repeat(2, auto);
  justify-content: center;
  gap: 2px 8px;
  margin-top: 6px;
}

.donut-breakdown .legend-row {
  display: flex;
  align-items: center;
  gap: 3px;
  font-family: var(--font-mono);
  font-size: 10.5px;
  font-weight: 600;
  color: var(--text-1);
  white-space: nowrap;
}

.donut-breakdown .legend-dot {
  width: 6px;
  height: 6px;
  border-radius: 50%;
  flex-shrink: 0;
}

.donut-breakdown .legend-label {
  letter-spacing: 0.02em;
}

.donut-breakdown .legend-count {
  color: var(--text-0);
  font-variant-numeric: tabular-nums;
}

/* --- Scan timeline -------------------------------------------------------
   A bar per time bucket, its height proportional to real requests sent
   against the target during that interval (from real audit-log event
   timestamps -- see app.js's eventRequestWeight), with a colored stripe per
   finding embedded in the bar it came from, positioned by how far through
   that bucket's own window the finding was found. Deliberately a plain bar
   chart rather than a GitHub-style stacked-square calendar grid: that style
   is built for many parallel days of data, where this is one run's own
   wall-clock span (usually seconds to a few minutes) -- a bar per bucket is
   the honest adaptation of "request volume over time, findings marked along
   it" to that shape of data, not a cosmetic choice. */
.scan-timeline {
  display: flex;
  flex-direction: column;
  flex: 1 1 auto;
  min-width: 0; /* lets it shrink below its bars' content width inside the flex row */
  margin-top: 16px;
  padding: 18px 20px 16px;
  border-radius: var(--radius-lg);
  background: rgba(0, 0, 0, 0.22);
  border: 1px solid var(--panel-border);
}

/* same `[hidden]` gotcha as `.severity-chart` above: `renderScanTimeline`
   hides this container (fewer than 2 timestamps, or zero requests and zero
   findings) and returns early, but this class's own `display: flex` wins
   over `[hidden]`'s UA default without this override. */
.scan-timeline[hidden] {
  display: none;
}

.scan-timeline-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 12px;
  margin-bottom: 14px;
  flex-wrap: wrap;
}

.scan-timeline-head h3 {
  margin: 0;
}

.scan-timeline-stats {
  display: flex;
  gap: 18px;
  font-family: var(--font-mono);
  font-size: 13px;
  color: var(--text-1);
}

.scan-timeline-stats strong {
  color: var(--text-0);
  font-weight: 700;
  margin-right: 4px;
  font-variant-numeric: tabular-nums;
}

.scan-timeline-chart-wrap {
  position: relative;
  /* Was a fixed 108px pinned to the bottom of .scan-timeline via
     `margin-top: auto` -- that card is stretched to match the donut
     card's height (`.run-detail-charts-row`'s `align-items: stretch`),
     which left a tall empty gap ABOVE a small, fixed-height chart instead
     of the chart itself actually using that space, on request ("too
     small", the bars were visibly tiny above a wide dead zone). `flex: 1 1
     auto` makes this grow to fill whatever room `.scan-timeline-head`
     doesn't use in that stretched column, the same way the donut's own
     content fills ITS card, with `min-height` as a floor for the stacked
     single-column mobile layout (`.run-detail-charts-row`'s `max-width:
     860px` media query) where nothing stretches this taller than its own
     content needs. */
  flex: 1 1 auto;
  min-height: 108px;
}

.scan-timeline-bars {
  position: relative;
  z-index: 0;
  display: flex;
  align-items: flex-end;
  /* wider gap than before -- narrower-reading bars, now that this chart
     shares a row with the donut instead of spanning the full card width */
  gap: 4px;
  height: 100%;
  padding-top: 14px;
}

/* Each bar is a positioning context, not just a colored div: the
   request-volume fill sits at the bottom (height proportional to that
   bucket's real request count), and a thin colored stripe sits EMBEDDED
   within that fill for each finding from that time bucket -- at the height
   corresponding to how far through the bucket's own time window the
   finding's own timestamp falls, not stacked on top of the bar. A finding
   found early in a bucket sits low in the bar; one found roughly halfway
   through that interval's requests sits roughly halfway up it; same idea as
   "the 5th of 10 requests in this interval" landing in the middle. */
/* This dashboard's own CSP is `style-src 'self'` with no 'unsafe-inline',
   nonce, or hash (scanner-engineering skill, section 6 -- this product
   holds its own surface to the same bar its checks judge other sites by).
   That blocks an inline `style="..."` attribute AND a JS `el.style.x = ...`
   assignment identically -- both are "inline style" as far as CSP is
   concerned. A bar chart's per-bucket height and a finding-marker's
   per-finding vertical offset are both genuinely dynamic per-element
   percentages, so neither can live in a fixed, handwritten class the way
   `.nested-confirm-row` and friends (see this file's own CSP-hardening
   history) could for a static value. Fixed with a bounded, generated set of
   101 classes (0-100, matching the already-integer `Math.round()`ed
   percentages this chart computes) that each set ONE custom property from a
   real external stylesheet rule -- CSP only restricts style ORIGIN, not
   custom properties, so `--pct` set this way is unaffected by style-src.
   Consumers below read it via `var(--pct, 0%)`, picking whichever of the
   two actual properties (bar height, marker bottom-offset) that specific
   element needs -- one generated class set, reused for both. */
.pct-0 { --pct: 0%; }
.pct-1 { --pct: 1%; }
.pct-2 { --pct: 2%; }
.pct-3 { --pct: 3%; }
.pct-4 { --pct: 4%; }
.pct-5 { --pct: 5%; }
.pct-6 { --pct: 6%; }
.pct-7 { --pct: 7%; }
.pct-8 { --pct: 8%; }
.pct-9 { --pct: 9%; }
.pct-10 { --pct: 10%; }
.pct-11 { --pct: 11%; }
.pct-12 { --pct: 12%; }
.pct-13 { --pct: 13%; }
.pct-14 { --pct: 14%; }
.pct-15 { --pct: 15%; }
.pct-16 { --pct: 16%; }
.pct-17 { --pct: 17%; }
.pct-18 { --pct: 18%; }
.pct-19 { --pct: 19%; }
.pct-20 { --pct: 20%; }
.pct-21 { --pct: 21%; }
.pct-22 { --pct: 22%; }
.pct-23 { --pct: 23%; }
.pct-24 { --pct: 24%; }
.pct-25 { --pct: 25%; }
.pct-26 { --pct: 26%; }
.pct-27 { --pct: 27%; }
.pct-28 { --pct: 28%; }
.pct-29 { --pct: 29%; }
.pct-30 { --pct: 30%; }
.pct-31 { --pct: 31%; }
.pct-32 { --pct: 32%; }
.pct-33 { --pct: 33%; }
.pct-34 { --pct: 34%; }
.pct-35 { --pct: 35%; }
.pct-36 { --pct: 36%; }
.pct-37 { --pct: 37%; }
.pct-38 { --pct: 38%; }
.pct-39 { --pct: 39%; }
.pct-40 { --pct: 40%; }
.pct-41 { --pct: 41%; }
.pct-42 { --pct: 42%; }
.pct-43 { --pct: 43%; }
.pct-44 { --pct: 44%; }
.pct-45 { --pct: 45%; }
.pct-46 { --pct: 46%; }
.pct-47 { --pct: 47%; }
.pct-48 { --pct: 48%; }
.pct-49 { --pct: 49%; }
.pct-50 { --pct: 50%; }
.pct-51 { --pct: 51%; }
.pct-52 { --pct: 52%; }
.pct-53 { --pct: 53%; }
.pct-54 { --pct: 54%; }
.pct-55 { --pct: 55%; }
.pct-56 { --pct: 56%; }
.pct-57 { --pct: 57%; }
.pct-58 { --pct: 58%; }
.pct-59 { --pct: 59%; }
.pct-60 { --pct: 60%; }
.pct-61 { --pct: 61%; }
.pct-62 { --pct: 62%; }
.pct-63 { --pct: 63%; }
.pct-64 { --pct: 64%; }
.pct-65 { --pct: 65%; }
.pct-66 { --pct: 66%; }
.pct-67 { --pct: 67%; }
.pct-68 { --pct: 68%; }
.pct-69 { --pct: 69%; }
.pct-70 { --pct: 70%; }
.pct-71 { --pct: 71%; }
.pct-72 { --pct: 72%; }
.pct-73 { --pct: 73%; }
.pct-74 { --pct: 74%; }
.pct-75 { --pct: 75%; }
.pct-76 { --pct: 76%; }
.pct-77 { --pct: 77%; }
.pct-78 { --pct: 78%; }
.pct-79 { --pct: 79%; }
.pct-80 { --pct: 80%; }
.pct-81 { --pct: 81%; }
.pct-82 { --pct: 82%; }
.pct-83 { --pct: 83%; }
.pct-84 { --pct: 84%; }
.pct-85 { --pct: 85%; }
.pct-86 { --pct: 86%; }
.pct-87 { --pct: 87%; }
.pct-88 { --pct: 88%; }
.pct-89 { --pct: 89%; }
.pct-90 { --pct: 90%; }
.pct-91 { --pct: 91%; }
.pct-92 { --pct: 92%; }
.pct-93 { --pct: 93%; }
.pct-94 { --pct: 94%; }
.pct-95 { --pct: 95%; }
.pct-96 { --pct: 96%; }
.pct-97 { --pct: 97%; }
.pct-98 { --pct: 98%; }
.pct-99 { --pct: 99%; }
.pct-100 { --pct: 100%; }

.timeline-bar {
  position: relative;
  flex: 1 1 0;
  min-width: 2px;
  height: 100%;
  /* A faint full-height track behind the real value-bar below -- without
     it, a short bar just floats with nothing to show how much taller it
     COULD have been; this gives the eye a scale to read every bar
     against, the same reason a premium chart library always draws one.
     Deliberately almost invisible (barely lighter than the page itself)
     so it reads as structure, not as its own competing bar. */
  background: linear-gradient(180deg, rgba(255, 255, 255, 0.05) 0%, rgba(255, 255, 255, 0.015) 100%);
}

.timeline-bar-volume {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  /* Falls back to 0% so a bar that (for whatever reason) never got a
     .pct-N class -- e.g. the explicit "no bar at all" case for a bucket
     that rounds to 0 requests -- still renders at zero height rather than
     an unstyled auto height. */
  height: var(--pct, 0%);
  background: var(--panel-border);
  cursor: pointer;
  /* border-box, not the default content-box -- .has-activity's own
     border-top below needs to draw WITHIN this percentage-computed
     height, not add 1px on top of it. */
  box-sizing: border-box;
  transition: background 0.15s var(--ease-hover), filter 0.1s var(--ease-hover), height 0.2s var(--ease-hover);
}

/* Only a bar with real (if small) activity gets the 3px floor -- app.js
   already sets height:0 inline for a bucket that rounds to 0 requests, and
   this class is only added once count > 0.05, so a true-zero bucket is
   never a `.has-activity` element in the first place; this rule just makes
   sure a genuinely tiny-but-nonzero bar still has somewhere to be seen,
   without resurrecting a stub bar for buckets with nothing in them.
   Was a flat `rgb(var(--btn-blue-rgb))` fill -- a dull, muted navy with no
   relationship to this product's own actual accent hue, reading as an
   unstyled default (on request: "looks like default pandas bars"). First
   moved to this product's own `--accent` cyan, then moved again on
   request: cyan sat too close to `--sev-info-text`'s own light blue (the
   INFORMATIONAL severity chip color), reading as "this looks like
   informational severity" at a glance. Settled on a dedicated rich royal
   blue instead -- solid RGB stops, not `--accent-rgb` faded through
   alpha, since fading an already-dark color toward transparent over this
   page's own near-black background just looked murky/undefined at a
   bar's base rather than like real depth; two fully opaque stops (a
   brighter highlight top, a deep navy base) read as a deliberate, premium
   gradient instead. Compared directly against `--sev-info-text`'s own
   swatch before settling on this (three real candidate hues built and
   screenshotted side by side, not picked blind) -- clearly reads as its
   own distinct dark blue, not a lighter/pastel tier that could be
   mistaken for a severity color. Deliberately no color/blur shadow
   anywhere on this -- a glowing accent bar on a dark background is
   exactly the "AI-generated UI" tell this project's own design doctrine
   already flags elsewhere (dark-glow); the premium read here comes from
   the gradient and the crisp top edge alone. */
.timeline-bar-volume.has-activity {
  min-height: 3px;
  background: linear-gradient(180deg, rgb(78, 124, 240) 0%, rgb(50, 88, 196) 30%, rgb(28, 54, 128) 100%);
  border-top: 1px solid rgb(98, 142, 248);
}

.timeline-bar-volume:hover {
  filter: brightness(1.2);
}

.timeline-bar-unit {
  position: absolute;
  left: 0;
  right: 0;
  /* Same .pct-N / --pct mechanism as .timeline-bar-volume's height above,
     consumed here as a bottom offset instead -- one generated class set,
     two different properties depending on which element it's applied to. */
  bottom: var(--pct, 0%);
  height: 4px;
  margin-bottom: -2px;
  cursor: pointer;
  transition: filter 0.1s var(--ease-hover);
}

.timeline-bar-unit:hover {
  filter: brightness(1.35);
}

/* One class per severity (SEVERITY_UNIT_CLASS in app.js), real stylesheet
   rules instead of an inline `background: var(...)` style attribute -- same
   CSP reasoning as the `.pct-N` block above, just for color instead of
   position: a bounded 5-value set, not a dynamic value, so it doesn't need
   the generated-class treatment, only to not be inline. */
.timeline-bar-unit-sev-critical {
  background: var(--sev-critical);
}

.timeline-bar-unit-sev-high {
  background: var(--sev-high);
}

.timeline-bar-unit-sev-medium {
  background: var(--sev-medium);
}

.timeline-bar-unit-sev-low {
  background: var(--sev-low);
}

.timeline-bar-unit-sev-info {
  background: var(--sev-info);
}

.timeline-bar-unit-sev-default {
  background: var(--text-1);
}

.scan-timeline-axis {
  display: flex;
  justify-content: space-between;
  margin-top: 8px;
  font-family: var(--font-mono);
  font-size: 12px;
  color: var(--text-2);
}

/* `pickTimelineAxisStepMs` (app.js) picks a tick count that reads well
   against a desktop-width chart -- at phone width the same tick count's
   timestamps (HH:MM:SS, ~8 characters each) have no room left between
   them and run together into one unreadable string. Thinning density in
   CSS rather than re-deriving `TIMELINE_AXIS_TARGET_TICKS` for narrow
   viewports keeps that tick-spacing algorithm single-purpose; dropping
   every other label is the same "fewer ticks on a narrow axis" practice
   any chart library applies responsively, and always keeps the first
   label (`:nth-child(1)` is odd) so the run's start time stays anchored. */
@media (max-width: 520px) {
  .scan-timeline-axis span:nth-child(even) {
    display: none;
  }
}

.scan-timeline-tooltip {
  position: absolute;
  bottom: 100%;
  transform: translate(-50%, -8px);
  padding: 7px 10px;
  border-radius: var(--radius-sm);
  background: var(--surface-strong);
  border: 1px solid var(--panel-border-strong);
  box-shadow: 0 4px 14px rgba(0, 0, 0, 0.5);
  font-family: var(--font-mono);
  font-size: 13px;
  color: var(--text-0);
  white-space: nowrap;
  pointer-events: none;
  z-index: 2;
}

.scan-timeline-tooltip .tt-time {
  color: var(--text-1);
  font-size: 12px;
  display: block;
  margin-bottom: 2px;
}

.result-text {
  margin-top: 10px;
  font-size: 14px;
  color: var(--green);
}

.result-text.is-error {
  color: var(--red);
}

.hint-text.is-error {
  color: var(--red);
}

/* Same pill shape/font as .primary-button, but outlined rather than
   filled and with no trailing arrow -- keeps it visually a step below the
   primary action (arrows read as "this is the forward/main action", which
   isn't true for Back/Log out/Add domain) while still matching the site's
   new pill language. A fully transparent fill at rest (the original
   version of this rule) read as a ghost/ambiguously-clickable outline next
   to the now-richer .primary-button -- a faint accent tint at rest (not
   just on :hover, which already had one) gives it real presence without
   making it compete with the primary action: still a visibly lower-key
   surface than .primary-button's filled pill, following Operate-mode's own
   "accent for primary actions and state indicators, not decoration" rule
   by keeping this tint subtle rather than a full saturated fill. */
.secondary-button {
  display: inline-block;
  padding: 7px 14px;
  border-radius: var(--radius-button);
  border: 1px solid rgba(var(--accent-rgb), 0.3);
  background: rgba(var(--accent-rgb), 0.05);
  color: var(--text-0);
  font-family: var(--font-mono);
  font-weight: 600;
  font-size: 14px;
  text-decoration: none;
  cursor: pointer;
  transition: border-color 0.15s var(--ease), background 0.15s var(--ease), color 0.15s var(--ease), transform 0.1s var(--ease);
}

/* Same gotcha `.layout[hidden]`/`.primary-button[hidden]`/`.field-row[hidden]`
   already document elsewhere in this file: `.secondary-button`'s own
   unconditional `display: inline-block` wins over the UA stylesheet's
   `[hidden] { display: none }` regardless of specificity, so the `hidden`
   ATTRIBUTE alone does nothing without this. Found by actually
   screenshotting the run-detail view: #run-detail-cancel (a .secondary-
   button) stayed visible on a completed run even though
   `el("run-detail-cancel").hidden = true` was being set correctly -- the
   JS was right, the CSS silently ignored it. */
.secondary-button[hidden] {
  display: none;
}

/* Copy-an-AI-fix-prompt icon button: one on the run-detail header (the
   whole run) and one per finding (.finding-title-right), both built from
   the same createCopyPromptButton() factory in app.js. Icon-only by
   design (a finding row already has a title/severity/confidence/triage
   control crowding .finding-title-right -- a labeled button would be the
   widest thing in the row for what's a secondary action); data-tooltip
   carries the accessible label the icon alone can't. */
.icon-copy-button {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 28px;
  height: 28px;
  padding: 0;
  border-radius: var(--radius-sm);
  border: 1px solid rgba(var(--accent-rgb), 0.3);
  background: rgba(var(--accent-rgb), 0.05);
  color: var(--text-1);
  cursor: pointer;
  flex-shrink: 0;
  transition: border-color 0.15s var(--ease), background 0.15s var(--ease), color 0.15s var(--ease);
}

.icon-copy-button[hidden] {
  display: none;
}

.icon-copy-button svg {
  width: 15px;
  height: 15px;
  fill: none;
  stroke: currentColor;
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
}

@media (hover: hover) and (pointer: fine) {
  .icon-copy-button:hover {
    border-color: rgba(var(--accent-rgb), 0.45);
    background: rgba(var(--accent-rgb), 0.08);
    color: var(--accent);
  }
}

/* Success state after a real clipboard write -- a brief, self-contained
   state change on the button itself (same "the control shows its own
   result" discipline .run-delete-btn's own is-holding/is-confirmed
   states already use), not a separate toast/snackbar component. */
.icon-copy-button.is-copied {
  border-color: rgba(var(--green-rgb), 0.5);
  background: rgba(var(--green-rgb), 0.1);
  color: var(--green);
}

.icon-copy-button.is-copy-error {
  border-color: rgba(var(--sev-critical-rgb), 0.5);
  background: rgba(var(--sev-critical-rgb), 0.1);
  color: var(--sev-critical);
}

@media (hover: hover) and (pointer: fine) {
  .secondary-button:hover {
    border-color: rgba(var(--accent-rgb), 0.45);
    background: rgba(var(--accent-rgb), 0.08);
    color: var(--accent);
    transform: translateY(-2px);
  }
}

/* .secondary-button previously had no :active state at all -- pressing it
   just transitioned straight back to its resting transform, with nothing
   in between to read as an actual press. Same scale signature as
   .primary-button/.danger-button for the same reason. */
.secondary-button:active {
  transform: scale(0.97);
  border-color: rgba(var(--accent-rgb), 0.45);
  background: rgba(var(--accent-rgb), 0.12);
}

.findings-toolbar {
  margin-top: 16px;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 10px;
}

/* same `[hidden]` gotcha as `.severity-chart`/`.scan-timeline`/`.findings-
   list` elsewhere in this file: this class's own `display: flex` above
   wins over the `hidden` attribute's UA default without this override. */
.findings-toolbar[hidden] {
  display: none;
}

/* Not inside a <form>, same reason `.triage-select` below isn't either --
   `form input` theming never reaches it. Deliberately narrow/compact
   rather than full-width: this sits in a toolbar row next to the severity
   chips and sort select, not alone on its own line. */
.findings-search {
  flex: 1 1 180px;
  min-width: 140px;
  max-width: 280px;
  padding: 6px 12px;
  border-radius: 999px;
  border: 1px solid var(--panel-border-strong);
  background-color: var(--surface-strong);
  color: var(--text-0);
  font-family: var(--font-sans);
  font-size: 12.5px;
  transition: border-color 0.15s var(--ease-hover);
}

.findings-search::placeholder {
  color: var(--text-2);
}

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

.findings-severity-filter {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
}

/* A toggle, not a button that does something on click -- aria-pressed is
   the correct native semantics for "this filter is currently active",
   same reasoning the rest of this project already applies to genuine
   toggle controls. Inactive (aria-pressed="false") dims to the muted
   border/text pair every other inactive chip-like control here uses;
   active picks up this severity's own --sev-* accent, same color language
   as .finding-item's left border and .finding-new-dot. */
.severity-chip {
  appearance: none;
  padding: 4px 11px;
  border-radius: 999px;
  border: 1px solid var(--panel-border-strong);
  background: transparent;
  color: var(--text-2);
  font-family: var(--font-mono);
  font-size: 11.5px;
  font-weight: 600;
  letter-spacing: 0.02em;
  cursor: pointer;
  transition: border-color 0.15s var(--ease-hover), background-color 0.15s var(--ease-hover), color 0.15s var(--ease-hover);
}

.severity-chip.sev-critical[aria-pressed="true"] {
  border-color: rgba(var(--sev-critical-rgb), 0.6);
  background: rgba(var(--sev-critical-rgb), 0.16);
  color: var(--sev-critical);
}

.severity-chip.sev-high[aria-pressed="true"] {
  border-color: rgba(var(--sev-high-rgb), 0.6);
  background: rgba(var(--sev-high-rgb), 0.16);
  color: var(--sev-high);
}

.severity-chip.sev-medium[aria-pressed="true"] {
  border-color: rgba(var(--sev-medium-rgb), 0.6);
  background: rgba(var(--sev-medium-rgb), 0.16);
  color: var(--sev-medium);
}

/* --sev-low-text(-rgb) throughout, not --sev-low(-rgb) -- see that token's
   own comment. All three properties (not just color) switch together so
   the whole pill reads as one deliberate light-blue treatment instead of a
   grey outline/tint around blue text. */
.severity-chip.sev-low[aria-pressed="true"] {
  border-color: rgba(var(--sev-low-text-rgb), 0.6);
  background: rgba(var(--sev-low-text-rgb), 0.16);
  color: var(--sev-low-text);
}

/* --sev-info-text(-rgb), not --sev-info(-rgb) -- see that token's own
   comment (a dull grey next to four now-vivid chip colors, same complaint
   as --sev-low-text's own). All three properties switch together, same
   reasoning as the LOW chip's own rule just above. */
.severity-chip.sev-informational[aria-pressed="true"] {
  border-color: rgba(var(--sev-info-text-rgb), 0.6);
  background: rgba(var(--sev-info-text-rgb), 0.16);
  color: var(--sev-info-text);
}

@media (hover: hover) and (pointer: fine) {
  .severity-chip:hover {
    border-color: rgba(var(--accent-rgb), 0.45);
  }
}

.findings-list {
  margin-top: 16px;
  display: flex;
  flex-direction: column;
  gap: 8px;
}

/* same `[hidden]` gotcha as `.severity-chart`/`.scan-timeline` above: a run
   with no findings hides this container, but this class's own `display:
   flex` wins over `[hidden]`'s UA default without this override. */
.findings-list[hidden] {
  display: none;
}

.issues-load-more-bar {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 12px;
  margin-top: 14px;
}

.issues-load-more-bar[hidden] {
  display: none;
}

.issues-load-more-count {
  font-family: var(--font-mono);
  font-size: 12px;
  color: var(--text-2);
}

.finding-item {
  position: relative; /* anchors .finding-new-dot to this box's own corner, not the page */
  padding: 11px 14px;
  border-radius: var(--radius-sm);
  background: rgba(0, 0, 0, 0.25);
  border: 1px solid var(--panel-border);
  border-left: 3px solid var(--text-2);
  font-size: 14px;
  line-height: 1.5;
  /* --finding-accent: this severity's own color, set once per sev-X rule
     below and reused by .finding-new-dot -- one source of truth instead of
     a second parallel set of sev-X color rules just for the dot. */
  --finding-accent: var(--text-2);
}

.finding-item.sev-critical {
  border-left-color: var(--sev-critical);
  --finding-accent: var(--sev-critical);
}

.finding-item.sev-high {
  border-left-color: var(--sev-high);
  --finding-accent: var(--sev-high);
}

.finding-item.sev-medium {
  border-left-color: var(--sev-medium);
  --finding-accent: var(--sev-medium);
}

.finding-item.sev-low {
  border-left-color: var(--sev-low);
  --finding-accent: var(--sev-low);
}

.finding-item.sev-informational {
  border-left-color: var(--sev-info);
  --finding-accent: var(--sev-info);
}

/* Notification dot for a finding that's new since the last run against
   this target -- sits at the box's own top-right corner, half outside it,
   the same "unread badge on a corner" shape as a notification count on an
   app icon. Colored by severity (--finding-accent, set above), not a fixed
   color, so it reads as "new AND how bad" at a glance instead of
   competing with the confidence badge inside the row for attention.
   Positioned relative to .finding-item (this element's own nearest
   `position: relative` ancestor), but the element itself must live INSIDE
   <summary> in the markup (see buildFindingItem in app.js) -- a closed
   <details> only renders <summary> and its descendants; any other direct
   child of <details> is suppressed by the browser's own content model, not
   by CSS, so this rule's own styles would be computed correctly but never
   actually paint if the dot lived outside <summary>. */
.finding-new-dot {
  position: absolute;
  top: -5px;
  right: -5px;
  width: 11px;
  height: 11px;
  border-radius: 50%;
  background: var(--finding-accent);
  border: 2px solid var(--bg-0);
  box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.4);
  pointer-events: none;
}

.finding-new-dot[hidden] {
  display: none;
}

/* .finding-item is a <details>, .finding-title its <summary> -- collapsed
   by default, click anywhere on the title row to expand the description.
   Browsers draw their own default disclosure marker on <summary> (a
   triangle in Chrome/Firefox, nothing visible but still spacing-affecting
   in Safari via ::-webkit-details-marker) -- suppressed in favor of the
   hand-drawn .finding-chevron below, which can sit wherever this flex
   layout puts it instead of being pinned to the marker's fixed position. */
.finding-item .finding-title {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  font-weight: 700;
  color: var(--text-0);
  cursor: pointer;
  list-style: none;
}

.finding-item .finding-title::-webkit-details-marker {
  display: none;
}

.finding-chevron {
  flex-shrink: 0;
  width: 0;
  height: 0;
  border-left: 4px solid transparent;
  border-right: 4px solid transparent;
  border-top: 5px solid var(--text-1);
  transition: transform 0.15s var(--ease);
}

.finding-item[open] .finding-chevron {
  transform: rotate(-180deg);
}

.finding-title-text {
  flex: 1 1 auto;
  min-width: 0;
}

/* Issues-tab-only wrapper (see app.js's buildIssueItem): a finding here
   comes from many different runs, so the title alone isn't enough to place
   it -- a second, smaller line underneath names which target/run it's
   from. .finding-title-text itself (used by both the run-detail findings
   list AND this) stays untouched, so this is purely additive -- the
   run-detail list never wraps it in this stack and never sees the extra
   line. */
.finding-title-stack {
  display: flex;
  flex-direction: column;
  gap: 2px;
  flex: 1 1 auto;
  min-width: 0;
}

.finding-origin {
  font-family: var(--font-mono);
  font-size: 11px;
  font-weight: 500;
  color: var(--text-2);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.finding-body {
  margin-top: 8px;
  padding-top: 8px;
  border-top: 1px solid var(--panel-border);
}

.finding-detail {
  display: flex;
  flex-direction: column;
  gap: 12px;
  margin-top: 10px;
}

.finding-section-label {
  margin-bottom: 4px;
  font-size: 11px;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--text-2);
}

.finding-poc-list {
  margin: 0;
  padding-left: 18px;
  color: var(--text-1);
}

.finding-poc-list li + li {
  margin-top: 4px;
}

.finding-remediation-text {
  margin: 0;
  color: var(--text-1);
}

/* The Burp-active-scan-style request/response viewer: a real wire-format
   request next to its real response, the exact payload highlighted in
   both. Side by side on a wide finding row (this box can legitimately run
   the full content width, unlike the narrower chat-style .event-reqresp
   cards elsewhere), stacking only once there isn't room for two readable
   columns. */
.exchange-columns {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 12px;
}

@media (max-width: 760px) {
  .exchange-columns {
    grid-template-columns: 1fr;
  }
}

/* Grid's own default align-items:stretch already makes .exchange-col
   (the grid item) match the row's tallest sibling -- but that alone
   doesn't make the VISIBLE box (the .exchange-pre inside it, which is
   what actually has a background/border) match: with no rule of its own,
   .exchange-col just sat at whatever height its own content needed,
   leaving .exchange-pre floating at ITS OWN shorter natural height inside
   a taller, invisible wrapper (confirmed: a short Request next to a long
   Response rendered as two visibly different-sized boxes, exactly the
   reported bug). A flex column makes the real, visible box itself grow to
   fill that stretched space. */
.exchange-col {
  display: flex;
  flex-direction: column;
}

.exchange-pre {
  font-family: var(--font-mono);
  font-size: 12.5px;
  max-height: 340px;
  /* Grows to fill the stretched column, capped at max-height same as
     before for genuinely long content (still scrolls internally via
     .event-pre's own overflow: auto). min-height: 0 overrides flexbox's
     own default min-height: auto, the standard gotcha that otherwise
     stops a flex child from shrinking below its content size at all. */
  flex: 1 1 auto;
  min-height: 0;
}

/* The payload itself, picked out of the surrounding wire text -- the
   entire point of this viewer over the plain evidence text block above
   it. Warm/alarm-toned (not --accent, which this UI already uses for
   ordinary interactive/brand elements) since this marks the dangerous
   bit, not something clickable. */
.exchange-highlight {
  background: rgba(var(--sev-high-rgb), 0.32);
  color: var(--text-0);
  border-bottom: 2px solid var(--sev-high);
  border-radius: 2px;
  padding: 0 1px;
}

.exchange-tabs {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  margin-bottom: 10px;
}

/* Same pill shape/sizing as .severity-chip (this project's one existing
   toggle-chip pattern), reused rather than invented fresh -- active picks
   up --accent since which EXCHANGE is showing isn't a severity, it's a
   plain "which pair am I looking at" selection. */
.exchange-tab {
  appearance: none;
  padding: 4px 11px;
  border-radius: 999px;
  border: 1px solid var(--panel-border-strong);
  background: transparent;
  color: var(--text-2);
  font-family: var(--font-mono);
  font-size: 11.5px;
  font-weight: 600;
  letter-spacing: 0.02em;
  cursor: pointer;
  transition: border-color 0.15s var(--ease-hover), background-color 0.15s var(--ease-hover), color 0.15s var(--ease-hover);
}

.exchange-tab.active {
  border-color: rgba(var(--accent-rgb), 0.6);
  background: rgba(var(--accent-rgb), 0.16);
  color: var(--accent);
}

/* Repeater: the Request column's own label row gains an "Edit & resend"
   link on its trailing edge, same .text-button treatment as "View full
   run" elsewhere in this file -- a label row the Response column doesn't
   need, so this is scoped to the Request side only, not a shared
   .event-reqresp-section-label variant. */
.event-reqresp-section-label-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
}

.exchange-edit-btn {
  font-size: 11px;
  letter-spacing: 0.02em;
}

/* Same visible box as .exchange-pre (border-free, same dark fill, same
   mono type) so swapping <pre> for this mid-row never jumps size/style --
   only `resize: vertical` and the focus ring are genuinely new, since
   this one's actually editable. */
.exchange-edit-textarea {
  font-family: var(--font-mono);
  font-size: 12.5px;
  width: 100%;
  min-height: 160px;
  max-height: 340px;
  flex: 1 1 auto;
  resize: vertical;
  box-sizing: border-box;
  margin: 0;
  padding: 9px 11px;
  border: 1px solid transparent;
  border-radius: var(--radius-sm);
  background: rgba(0, 0, 0, 0.3);
  color: var(--text-0);
  white-space: pre;
  overflow: auto;
}

.exchange-edit-textarea:focus-visible {
  border-color: rgba(var(--accent-rgb), 0.5);
  outline: none;
}

.exchange-edit-actions {
  display: flex;
  align-items: center;
  gap: 10px;
  margin-top: 8px;
}

.exchange-send-btn {
  padding: 5px 13px;
  font-size: 12.5px;
}

.exchange-send-btn[disabled] {
  opacity: 0.6;
  cursor: default;
}

.exchange-edit-error {
  font-size: 12px;
  color: var(--red);
}

.exchange-edit-error[hidden] {
  display: none;
}

/* Set once a resend actually lands, so the response panel's own label
   makes plain that what's showing is a fresh response to the EDITED
   request, not the original capture it replaced. */
.exchange-response-label.is-resent::after {
  content: " (resent)";
  color: var(--text-2);
  text-transform: none;
  font-weight: 500;
  letter-spacing: 0;
}

.events-feed {
  display: flex;
  flex-direction: column-reverse;
  gap: 5px;
  max-height: 420px;
  overflow-y: auto;
  font-family: var(--font-mono);
  font-size: 13.5px;
  padding-right: 2px;
}

/* .event-item is a <details>, same collapsed-by-default disclosure as
   .finding-item -- a long agent-mode audit trail reads as a scannable list
   of one-liners until an operator actually wants to see a specific
   request/response pair, not a wall of pre-expanded JSON. */
.event-item {
  padding: 7px 11px;
  border-radius: var(--radius-sm);
  background: rgba(0, 0, 0, 0.22);
  border: 1px solid transparent;
  border-left: 3px solid var(--text-2);
  overflow-wrap: anywhere;
  --event-accent: var(--text-2);
}

.event-item.event-cat-danger {
  border-left-color: var(--red);
  --event-accent: var(--red);
}

.event-item.event-cat-warning {
  border-left-color: var(--amber);
  --event-accent: var(--amber);
}

.event-item.event-cat-success {
  border-left-color: var(--green);
  --event-accent: var(--green);
}

.event-summary {
  display: flex;
  align-items: center;
  gap: 8px;
  cursor: pointer;
  list-style: none;
}

.event-summary::-webkit-details-marker {
  display: none;
}

.event-chevron {
  flex-shrink: 0;
  width: 0;
  height: 0;
  border-left: 4px solid transparent;
  border-right: 4px solid transparent;
  border-top: 5px solid var(--text-1);
  transition: transform 0.15s var(--ease);
}

.event-item[open] .event-chevron {
  transform: rotate(-180deg);
}

.event-kind-badge {
  flex-shrink: 0;
  color: var(--event-accent);
  font-weight: 600;
}

.event-synopsis {
  flex: 1 1 auto;
  min-width: 0;
  color: var(--text-1);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.event-body {
  margin-top: 8px;
  padding-top: 8px;
  border-top: 1px solid var(--panel-border);
  display: flex;
  flex-direction: column;
  gap: 8px;
}

/* The structured request/response card -- used both for a single
   replay_request result and for each entry of a list_captured_requests
   result -- is the main payoff of expanding an event: method + status +
   URL on one line, headers as aligned key/value rows, body in its own
   scrollable block, instead of re-reading a JSON blob by eye. */
.event-reqresp-line {
  display: flex;
  align-items: center;
  gap: 8px;
  flex-wrap: wrap;
}

/* A captured_request event (crawl.py) shows both a request AND a response
   body, unlike every other .event-reqresp use (one body slot) -- this
   labels which section is which. */
.event-reqresp-section-label {
  margin-top: 10px;
  margin-bottom: 4px;
  font-size: 11px;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--text-2);
}

.event-method {
  flex-shrink: 0;
  padding: 1px 7px;
  border-radius: 999px;
  background: rgba(255, 255, 255, 0.08);
  color: var(--text-0);
  font-weight: 700;
  font-size: 12px;
}

.event-status {
  flex-shrink: 0;
  padding: 1px 7px;
  border-radius: 999px;
  font-weight: 700;
  font-size: 12px;
  color: #141414;
  background: var(--text-2);
}

.event-status-2xx {
  background: var(--label-green);
}

.event-status-3xx {
  background: var(--label-purple);
}

.event-status-4xx,
.event-status-5xx {
  background: color-mix(in srgb, var(--red) 35%, white);
}

.event-url {
  min-width: 0;
  overflow-wrap: anywhere;
  color: var(--text-1);
}

.event-kv {
  display: flex;
  flex-direction: column;
  gap: 2px;
  padding: 7px 9px;
  border-radius: var(--radius-sm);
  background: rgba(0, 0, 0, 0.25);
}

.event-kv-row {
  display: flex;
  gap: 8px;
}

.event-kv-key {
  flex-shrink: 0;
  color: var(--text-2);
}

.event-kv-value {
  min-width: 0;
  overflow-wrap: anywhere;
  color: var(--text-1);
}

.event-pre {
  margin: 0;
  padding: 9px 11px;
  border-radius: var(--radius-sm);
  background: rgba(0, 0, 0, 0.3);
  color: var(--text-1);
  max-height: 260px;
  overflow: auto;
  white-space: pre-wrap;
  overflow-wrap: anywhere;
}

.event-error-text {
  color: var(--red);
}

.event-message-text {
  color: var(--text-1);
  white-space: pre-wrap;
  overflow-wrap: anywhere;
}

.login-gate {
  max-width: 420px;
  margin: 80px auto;
}

.login-gate form {
  display: flex;
  flex-direction: column;
  gap: 14px;
  margin-top: 16px;
}

/* GitHub/Google "sign in with" buttons -- same pill language as
   .secondary-button (outlined, no trailing chevron, since these aren't
   "forward" actions in the .primary-button sense) but full-width with an
   icon+label row, used both inside #login-gate and inside #signin-dialog. */
.oauth-buttons {
  display: flex;
  flex-direction: column;
  gap: 10px;
}

.oauth-button {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 10px;
  width: 100%;
  box-sizing: border-box;
  padding: 9px 16px;
  border-radius: var(--radius-button);
  border: 1px solid var(--panel-border-strong);
  background: var(--surface);
  color: var(--text-0);
  font-family: var(--font-mono);
  font-weight: 600;
  font-size: 14px;
  text-decoration: none;
  cursor: pointer;
  transition: border-color 0.15s var(--ease), background 0.15s var(--ease), transform 0.1s var(--ease);
}

.oauth-button svg {
  width: 18px;
  height: 18px;
  flex: none;
}

@media (hover: hover) and (pointer: fine) {
  .oauth-button:hover {
    border-color: rgba(var(--accent-rgb), 0.45);
    background: rgba(var(--accent-rgb), 0.08);
    transform: translateY(-2px);
  }
}

.oauth-button:active {
  transform: scale(0.97);
}

/* "or" divider between the OAuth buttons and the email form -- a plain
   centered line with the word floating on top via an opaque background
   patch, the standard cheap way to do this without an extra wrapper. */
.login-divider {
  display: flex;
  align-items: center;
  gap: 10px;
  margin: 18px 0;
  color: var(--text-2);
  font-size: 12px;
  text-transform: uppercase;
  letter-spacing: 0.08em;
}

.login-divider::before,
.login-divider::after {
  content: "";
  flex: 1;
  height: 1px;
  background: var(--panel-border);
}

#signin-dialog {
  max-width: 360px;
}

/* code-review.html's "Connected as <login>" row -- a plain flex row, same
   gap/alignment convention as every other inline status-plus-action row in
   this file (e.g. .checkbox-row), nothing new needed beyond that. */
.github-connected-row {
  display: flex;
  align-items: center;
  gap: 12px;
}

#signin-dialog .oauth-buttons {
  margin-top: 16px;
}

#signin-dialog .dialog-actions {
  margin-top: 18px;
  justify-content: space-between;
  align-items: center;
}

/* overrides .hint-text's -6px/14px top/bottom margins (tuned for pulling
   hint text up under a heading elsewhere) -- those throw off the
   align-items:center alignment against .compact-primary-button next to it.
   font-size pinned to 14px (an id selector beats .landing-page .hint-text's
   16px page-wide bump regardless of source order) so the topbar instance on
   landing.html matches the 14px "Login" button sitting next to it instead
   of inheriting that page-wide bump meant for body copy. */
#account-email {
  margin: 0 4px 0 0;
}

/* #account-email is a compact circular avatar (one uppercase initial, same
   idea as Google's account menu) instead of the full address -- the
   address itself still reaches the user via the title tooltip and the
   dropdown line below. A fixed circle keeps the topbar's width stable
   regardless of email length, which is what was crowding the mobile row. */
/* `[hidden]` is only a UA-stylesheet default (`display: none`) -- an
   author rule of equal or higher specificity always wins over it
   regardless of source order, so a plain `.account-avatar-button {
   display: flex }` was overriding `hidden` and showing an empty avatar
   circle while logged out. `:not([hidden])` keeps the flex layout but
   lets the attribute win when it's actually set. */
.account-avatar-button:not([hidden]) {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 30px;
  height: 30px;
  flex: 0 0 auto;
  background: var(--accent);
  color: var(--accent-ink);
  border: none;
  border-radius: 50%;
  font-family: inherit;
  font-size: 13px;
  font-weight: 600;
  letter-spacing: 0;
  cursor: pointer;
  transition: filter 0.15s var(--ease-hover);
}

@media (hover: hover) and (pointer: fine) {
  .account-avatar-button:hover {
    filter: brightness(1.08);
  }
}

.account-avatar-button:focus-visible {
  outline: 1px solid var(--accent);
  outline-offset: 2px;
}

/* The dropdown's own first line carries the full address -- the only place
   it's now shown in full near the topbar. */
.account-menu-email {
  margin: 2px 10px 6px;
  padding-bottom: 6px;
  border-bottom: 1px solid var(--panel-border-strong);
  font-size: 12px;
  color: var(--text-2);
  word-break: break-all;
}

.account-menu {
  position: relative;
}

.account-menu-dropdown {
  position: absolute;
  top: calc(100% + 6px);
  right: 0;
  z-index: 20;
  min-width: 190px;
  background: var(--surface);
  border: 1px solid var(--panel-border-strong);
  border-radius: var(--radius-sm);
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
  padding: 4px;
}

.account-menu-dropdown button {
  display: block;
  width: 100%;
  text-align: left;
  background: transparent;
  border: none;
  padding: 7px 10px;
  color: var(--text-0);
  font-family: inherit;
  font-size: 13px;
  cursor: pointer;
  border-radius: var(--radius-sm);
}

@media (hover: hover) and (pointer: fine) {
  .account-menu-dropdown button:hover {
    background: rgba(255, 255, 255, 0.08);
  }
}

/* .credential-item (settings.html's stored-credentials list) shares this
   exact row shape -- title/meta on the left, one action button on the
   right -- rather than duplicating it under a second name. */
.domain-item,
.credential-item {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
}

.domain-item-meta {
  flex: 1;
  min-width: 0;
}

.domain-instructions {
  margin-top: 8px;
  padding: 10px 12px;
  border-radius: var(--radius-sm);
  background: rgba(0, 0, 0, 0.25);
  font-family: var(--font-mono);
  font-size: 14px;
  word-break: break-all;
}

/* Plan-and-billing rows (settings.html #billing-card): the same left-meta,
   right-actions row shape as .domain-item above, plus a compact
   extra-domains number field beside each plan's choose button. */
#billing-card .list-item {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  margin-bottom: 10px;
}

.billing-plan-actions {
  display: flex;
  align-items: flex-end;
  gap: 10px;
  flex-shrink: 0;
}

.billing-extra-label {
  display: flex;
  flex-direction: column;
  gap: 4px;
  font-size: 12px;
  color: var(--text-2);
}

.billing-extra-label input {
  width: 72px;
}

/* -------------------------------------------------------------------- */
/* /get-started: public, client-facing page (landing.html). Reuses the   */
/* same tokens/.card/.layout/.primary-button etc. as the operator        */
/* dashboard on purpose, so it reads as the front door of the same tool  */
/* rather than a bolted-on marketing template.                           */
/* -------------------------------------------------------------------- */

body.landing-page {
  /* No instrument-panel grid on this page anymore (removed on request --
     it's still the default for the rest of the dashboard, via the base
     `body` rule this selector overrides).

     The static background photo (previously a fixed image here, see
     styles.css.before-bg-image.bak and get-started-bg*.jpg on disk for the
     prior versions) is replaced by the same animated hex-dot wave used by
     /wave, rendered into `.landing-wave-canvas` by wave.js and layered in
     by `position: relative` here so that canvas/fade below -- both
     `position: absolute` with negative z-index -- use THIS element as
     their containing block instead of the initial containing block
     (viewport), which is what makes `.landing-wave-fade`'s `top:0;
     bottom:0` span the page's real total height rather than just one
     screen. */
  position: relative;
  /* Hardcoded, not var(--bg-0): this page's wave seam is pixel-matched to
     the OLD near-black value specifically (see .landing-wave-canvas's own
     comment below), so it stays #0b0b0b regardless of what --bg-0 resolves
     to elsewhere. The hero itself used to carry its own gradient image on
     top of this (see git history); now that it's gone the hero shows this
     solid color directly too. */
  background: #0b0b0b;
}

/* The topbar's own base rule only ever carries a border-bottom (see
   `.topbar` near the top of this file) -- enough to separate it from
   whatever sits below on every other page, but on THIS page the hero
   directly behind it is now plain solid black (the gradient image that
   used to sit there is gone, see `body.landing-page` above), so the bar
   had nothing at all marking its own top edge against the page. A very
   slight grey border-top, same `--panel-border` token the bottom one
   already uses, gives it a complete, deliberate outline instead of
   reading as an edgeless strip floating on black. Scoped to this page only
   -- every other page's topbar sits on its own dashboard chrome already
   and doesn't need this. */
body.landing-page .topbar {
  border-top: 1px solid var(--panel-border);
}

/* Phone-width header: the generic `.topbar` rule at `@media (max-width:
   520px)` wraps brand onto its own row and status/actions onto a second
   row below it, sized for the full dashboard chrome's three button groups
   (Code Review + New Assessment etc.). This page only ever has the
   dev-glasses toggle plus Login-or-(avatar), which fits one row on its
   own once the tagline's width is out of the way -- screenshotted at
   390px confirmed it fits with room to spare. Dropping the tagline and
   un-wrapping keeps this a single short bar instead of the two-line
   stack a phone was rendering before. */
@media (max-width: 600px) {
  body.landing-page .brand-sub {
    display: none;
  }

  body.landing-page .topbar {
    flex-wrap: nowrap;
    padding: 12px 16px;
  }

  body.landing-page .topbar-status {
    flex-wrap: nowrap;
    width: auto;
  }
}

.landing-wave-canvas {
  /* top: 407px is a measured, hardcoded value, not a formula -- on request,
     the FAINTEST dots in the ripple (the ones wave.js's own vertical
     falloff keeps dimmest, right at this canvas's own top edge -- see
     wave.js's `topFadeHeight`/`falloff`) should begin level with
     `.landing-sub` ("lannerva crawls your site...", confirmed via
     getBoundingClientRect: top 406.89px at the 1280px reference width) --
     not the "Check your site" heading or "Your site's URL" label earlier
     versions aligned to instead. That text's own position comes from
     `.landing-hero`'s own layout (padding-top, the heading, `.landing-sub`'s
     own margin-top), a chain already documented on `.landing-hero` and
     `.landing-sub` themselves -- so this is pinned to the measured result
     directly, same as several other hardcoded positions in this file; if
     that chain changes, re-measure and update this value rather than
     assuming it still lines up.

     The hero itself no longer paints anything above this canvas's own top
     edge (its old gradient image is gone, see `.landing-hero::before`'s own
     comment above) -- nothing (no wave dots) shows through it regardless.
     This canvas fades ITSELF in over its own first 150px (see the mask
     below) -- so the gap in between renders nothing from either layer and
     is simply `body.landing-page`'s own solid `--bg-0`, i.e. actual black,
     not a guessed-similar color. Untouched by the gradient's removal: this
     canvas's own position/size/mask are exactly as they were. */
  position: absolute;
  top: 407px;
  left: 0;
  width: 100%;
  height: 100vh;
  z-index: -2;
  pointer-events: none;
  display: block;

  /* Fades the wave IN from its own top edge (transparent) to fully visible
     by 150px down, instead of the dots starting at full brightness right
     at the edge of the black gap above. Straight gradient, not curved --
     the curve was removed on request; this is "a gradient change, no bold
     line" purely via the fade distance, no shaped mask involved. */
  -webkit-mask-image: linear-gradient(to bottom, transparent 0, black 150px, black 100%);
  mask-image: linear-gradient(to bottom, transparent 0, black 150px, black 100%);
}

.landing-hero {
  /* The background gradient itself lives on `::before` now (see below), a
     repeating 900x323px TILE (not sized to the viewport width at all
     anymore) -- each tile stays genuinely near-black (not mask-forced) by
     about 85-97% of its own 323px, i.e. by roughly 275-313px down.

     480px here is sized for THAT: padding-top (90) + the heading (144.89)
     + the gap down to the subtitle (105px margin, see `.landing-sub`,
     landing it comfortably past where the tile's gone dark) + the subtitle
     itself (74.25) + padding-bottom (56) = ~470px, rounded up to 480 --
     shorter than a previous 600px version, sized back down now that the
     gradient reaches black within a 323px tile instead of needing ~460px+
     of a viewport-width-scaled single image to get there. */
  min-height: 480px;
  padding: 90px 32px 56px;
  box-sizing: border-box;
  /* Containing block for `::before` below, same reason `body.landing-page`
     has its own `position: relative` for the wave canvas/fade -- without
     it the pseudo-element's `inset: 0` would resolve against the initial
     containing block (the viewport) instead of this element's own box. */
  position: relative;
}

/* The hero previously carried a tiled blue-to-black gradient image on this
   pseudo-element (see git history on this branch for that version). Removed
   on request: the hero is now just `body.landing-page`'s own solid
   `background: var(--bg-0)` showing straight through, no image/mask of its
   own. `.landing-hero` stays `position: relative` for `.landing-hero-inner`/
   `.hero-target-icon`'s own positioning below, nothing else depends on this
   rule still existing. The wave canvas/fade below (`.landing-wave-canvas`)
   is unrelated to this and intentionally untouched. */

.landing-hero-inner {
  width: 100%;
  max-width: 1280px;
  margin: 0 auto;
  /* containing block for .hero-target-icon's absolute positioning below --
     this alone can't change this element's own box (or anything else's),
     since it only matters for where an ALREADY-absolutely-positioned
     descendant resolves its offsets against */
  position: relative;
}

/* Decorative bird-mark icon to the right of the heading. Absolutely
   positioned so it's out of normal flow -- it cannot push, resize, or
   reflow the heading/subtitle/anything else in the hero, which is the
   whole point (see the HTML comment above this element in landing.html
   for why that matters here specifically). Sized well inside
   `.landing-hero-inner`'s own 1280px max-width past the heading's 19ch
   cap, so at any viewport wide enough to show it (see the media query
   below) there's already empty space here, not text it would cover. */
.hero-target-icon {
  position: absolute;
  /* -9px was originally measured to center the icon within the old
     323px-tall gradient band (now removed, see `.landing-hero::before`'s
     own comment above); left at the same value on purpose since the icon's
     actual on-page position looks right in the same spot regardless of
     whether a gradient is there to center it against, and nothing asked
     for it to move. Re-measure only if this ever visibly needs to. */
  top: -9px;
  right: 32px;
  width: 160px;
  height: 160px;
  /* bird-mark.png is a plain resize of the source file (500x500), nothing
     cropped or edited out of it -- on request, it keeps the source's own
     white badge shapes behind the bird, not just the red silhouette alone.
     `contain`, not `cover`: keeps the whole image visible undistorted
     inside this square box regardless of the source's own proportions. */
  object-fit: contain;
  /* transform/opacity are driven every scroll event by the script at the
     bottom of this page -- hints the browser to put this on its own
     compositor layer up front instead of promoting it mid-scroll */
  will-change: transform, opacity;
}

@media (max-width: 1100px) {
  .hero-target-icon {
    display: none;
  }
}

/* Scoped to the landing page specifically (`main.layout` is the shared
   dashboard shell used elsewhere too, see the plain `.layout` rule near the
   top of this file) -- pulls the "Check your site" card and tier grid up
   a bit, on request, entirely via this sibling's own margin rather than by
   resizing `.landing-hero` itself, which would have restretched its
   background gradient against a shorter box (explicitly ruled out). The
   hero's own fade/mask and the wave canvas below it are unchanged by this;
   it only affects how much of that transition is visible before the
   (opaque) card covers it. */
body.landing-page main.layout {
  margin-top: -50px;
}

.landing-nav-link {
  color: var(--text-1);
  font-size: 14px;
  font-family: inherit;
  text-decoration: none;
  padding: 5px 10px;
  border-radius: var(--radius-sm);
  border: 1px solid var(--panel-border);
  /* this is a <button> now (opens the sign-in dialog), not an <a> --
     reset the button chrome so it still reads identically to the plain
     link it replaced */
  background: transparent;
  cursor: pointer;
}

@media (hover: hover) and (pointer: fine) {
  .landing-nav-link:hover {
    color: var(--accent);
    border-color: rgba(var(--accent-rgb), 0.5);
  }
}

.landing-hero h1 {
  max-width: 19ch;
  margin: 0 0 18px;
  font-family: var(--font-mono);
  font-size: clamp(30px, 4vw, 44px);
  font-weight: 700;
  line-height: 1.15;
  letter-spacing: -0.01em;
  /* `--text-0`, not `#000` -- the hero's own light/medium-blue gradient
     image is gone (see `.landing-hero::before`'s own comment above), so the
     heading now sits on plain `--bg-0` black like the rest of the page and
     needs the same light-on-dark treatment as everything else, not the
     dark-on-light override that gradient used to require. */
  color: var(--text-0);
}

/* "Encrypted text" reveal effect on page load (see the script at the
   bottom of landing.html): every character of the heading starts
   scrambled to a random glyph, then resolves left-to-right into its real
   character. `.et-char` is per-character so a monospace glyph swap never
   reflows anything. On request, the scrambled state is full-strength
   color throughout (NOT dimmed) -- it reads as "the real heading,
   actively glitching," rather than a faded placeholder waiting to
   resolve -- so `.et-encrypted` carries no color override at all here;
   every character just inherits the same color it always would (plain
   black heading text, or `.landing-hero-highlight`'s red for
   "attacker"), scrambled or not. The class still exists purely for the
   script's own bookkeeping (which characters are still actively
   scrambling vs. already resolved). */
.et-char {
  display: inline-block;
}

/* Groups one word's .et-char letters so the line only ever wraps at a real
   space between words, not between two adjacent per-letter inline-blocks
   (which otherwise carry their own implicit soft-wrap opportunity, even
   with no whitespace between them in the markup -- without this, a word
   could visibly break mid-letter, e.g. "at" / "tacker"). */
.et-word {
  display: inline-block;
  white-space: nowrap;
}

.landing-hero-highlight {
  color: var(--red);
}

.landing-hero-confirmed {
  color: var(--label-green);
}

.landing-hero-possible {
  color: var(--label-orange);
}

.landing-hero .landing-sub {
  max-width: 64ch;
  /* 105px top margin (collapses with h1's own 18px margin-bottom -- the
     LARGER of the two wins in normal block flow, so this is effectively
     the gap, not 105+18). Originally sized to clear the old gradient tile's
     own fade-to-dark point; the gradient is gone now (see
     `.landing-hero::before`'s own comment above) but this value is left
     exactly as-is on purpose -- `.landing-wave-canvas`'s hardcoded
     `top: 407px` below was measured against THIS element's current
     position, so changing this margin would silently break that alignment
     too. Re-measure both together if this ever needs to move. */
  margin: 105px 0 0;
  font-size: 17px;
  line-height: 1.65;
  /* White -- same light-on-dark treatment as the heading above it now that
     the hero's gradient image is gone (see `.landing-hero h1`'s own
     comment above); both sit on plain `--bg-0` black. */
  color: #fff;
}

/* Everything else (pill shape, color, arrow) now comes straight from the
   base `.primary-button` rule, which carries this same look site-wide --
   only the hero-specific auto-width + positioning stays here. */
.landing-hero-cta {
  width: auto;
  margin-top: 28px;
}

.landing-page .hint-text {
  /* this page is read by a prospective customer deciding whether to trust
     the product, not the operator working their own dense console -- the
     11.5px hint-text default used throughout the dashboard is too small here.
     Scoped to the whole page (not per-section) so it covers every hint-text
     here now and any added later, not just the ones present when this was
     written. */
  font-size: 16px;
}

/* Tier picker: a GSAP ScrollTrigger-scrubbed drum of the four tier names
   (vendored annnimate-starter-pack "rotating-words" component, see
   static/vendor/gsap/) turns as this section scrolls past; tier-rotating.js
   watches which word is centred and toggles the matching .tier-panel below.
   Replaced the previous hover-word-list/click-to-pin version (see git
   history on this branch for that implementation) on request, to make the
   tier section scroll-driven instead. --rw-* are the vendored component's
   own theming hook (see rotating-words.css) -- overridden here, scoped to
   this wrapper, rather than edited in the vendored file itself, so a future
   update to that file never has to be re-applied against local changes. */
.tier-rotating {
  --rw-bg: transparent;
  --rw-fg: var(--text-0);
  --rw-sans: var(--font-mono);
  --rw-margin: 0px;
  /* Scroll distance mapped to the drum's full turn (item 0 through item 3).
     Shorter than the vendored demo's 320vh -- that was tuned for ten words,
     four need proportionately less room to read as a deliberate turn
     rather than a stuck page. Cut further, from 190vh to 150vh, on request:
     the stretch between this section settling on its last word (Retest)
     and "Why this matters" actually coming into view read as an empty,
     stuck scroll -- `.tier-rotating-stage` stays pinned for exactly
     `(this height) - (its own 100vh)`, and the GSAP scrub driving the
     drum (`rotating-words.js`'s `shape()`) reads its 0..1 progress as a
     plain FRACTION of this same height, so shrinking it compresses the
     whole pinned+scrub range together without changing the rotation's
     relative pacing (hold/snap/settle are all fractions of the total, not
     fixed pixel amounts) -- confirmed by scrolling through all four words
     after this change, each still gets a clear, readable dwell. 150vh
     (not lower) keeps that dwell comfortable; much below this risked
     words turning too fast to read. */
  /* 180vh: the turn still uses 50vh of scroll; the extra 30vh is a hold on
     the last word (data-anm-tail in landing.html) before the page scrolls on. */
  height: 180vh;
  /* That 150vh cut above still wasn't enough on a tall monitor: the blank
     stretch between the drum settling on its last word and "Why this
     matters" arriving scales with viewport height, because it's bounded
     by `.tier-rotating-stage`'s own height (100vh) -- once the sticky
     stage releases, the page needs exactly that much MORE scrolling for
     it to fully clear the top before the next section is reachable,
     regardless of how much of that space the (centred, lifted) wheel and
     nav dots actually occupy on screen. Measured the real gap this
     leaves (from `.rw_nav`'s own bottom, the lowest real content in the
     stage, down to `.landing-stats`'s top, both in document coordinates
     so the number is scroll-position-independent) at two real viewport
     heights: 483px at 1080px tall, 643px at 1400px tall -- a 160px
     difference for a 320px taller viewport, i.e. roughly half the extra
     height becomes extra dead gap. `margin-bottom: calc(200px - 50vh)`
     cancels exactly that slope (50vh matches the measured ~0.5 * vh
     growth), landing on a roughly CONSTANT ~140px final gap regardless of
     viewport height instead of one that keeps growing on taller screens;
     `max()` with a flat -620px floor stops it from pulling arbitrarily
     hard on an extreme-height display, which would otherwise risk
     dragging "Why this matters" up into content that's still visible.
     Doesn't touch `.tier-rotating-stage`'s own height, so the word's
     on-screen position (`--tier-wheel-lift`, tuned separately and
     confirmed already correct) is completely unaffected by this. */
  margin-bottom: max(calc(380px - 50vh), -440px); /* +180px vs before: the wheel no longer sits 180px higher, so the stage-release gap needs that back */
}

.tier-rotating-stage {
  position: sticky;
  top: 0;
  height: 100vh;
  height: 100svh;
  min-height: 560px;
  box-sizing: border-box;
  display: flex;
  align-items: center;
  gap: 48px;
  padding: 40px 0;
  /* Transparent on purpose -- no background of its own. A pinned second
     ripple canvas living inside this sticky stage was tried (so the
     ripple wouldn't visibly scroll past the pinned drum) and reverted:
     two independently-animated dot fields -- this one and the page's own
     #landing-wave-canvas -- never line up at the seam where they meet,
     which read as visibly broken on a real screenshot, not just
     imperfect. Letting the one real page background show through plainly
     (its normal, normal-flow scroll behavior and all) is the simpler,
     correct version of this until/unless there's a single-canvas way to
     keep it pinned here too. */
  /* 0 on request: the drum starts turning while it is centred in the
     middle of the viewport (it used to be lifted 180px, so the turn began
     near the top of the screen). tier-rotating.js reads this back. */
  --tier-wheel-lift: 0px;
  transform: translateY(calc(-1 * var(--tier-wheel-lift)));
  /* On request: the gap between the sticky "Tiers" heading and the
     current word read as far too large. The word's SCREEN position is
     pinned to this box's vertical centre (`align-items: center` above,
     fixed), and that centre sits `.tier-rotating-wheel`'s own half-height
     away from the stage's top -- `rotating-words.js`'s `layout()` sizes
     that box to `2 * radius` (~640px here) purely so neighbour words have
     room to curve away above/below on the drum; shrinking that box
     directly (e.g. overriding `.rw_list`'s computed height) would
     decentre the drum's own `top: 50%` item positioning, which is derived
     from that exact same radius -- not a safe lever to pull here.
     `translateY` sidesteps that: it shifts the whole centred assembly
     (wheel + detail box) up as a block, AFTER centring is resolved, so
     the drum's internal geometry (and the GSAP scrub's own sticky-pin
     timing, which transform never touches) are both untouched. 220px was
     chosen against `rotating-words.css`'s own `.rw_is-built .rw_list`
     mask (`transparent, #000 24%, #000 76%, transparent`): the top 24%
     of this ~640px box is already faded to fully transparent, so shifting
     by up to roughly that same ~150px-plus-clearance keeps the heading
     only ever overlapping space that was already invisible, never the
     real (unfaded) word text. tier-rotating.js's own ripple-canvas pin
     reads this custom property back (see its own comment) so the ripple
     stays centred on the word's new, higher on-screen position instead
     of the old plain viewport-centre assumption. */
}

.tier-rotating-wheel {
  position: relative;
  flex: 0 1 46%;
  min-width: 220px;
  font-family: var(--rw-sans);
  font-size: clamp(32px, 4.4vw, 68px);
  line-height: 1.1;
  color: var(--rw-fg);
  -webkit-font-smoothing: antialiased;
}

/* Focus outline for the vendored nav dots: the vendored default is a dark,
   near-invisible outline tuned for a light page background (see
   rotating-words.css's own .rw_dot:focus-visible) -- this page is dark, so
   it needs this project's own accent outline instead, scoped to this
   wrapper rather than changed in the vendored file. */
.tier-rotating .rw_dot:focus-visible {
  outline: 1px solid rgba(var(--accent-rgb), 0.6);
  outline-offset: 2px;
}

.tier-detail-box {
  flex: 1 1 380px;
  min-width: 260px;
}

.tier-panel {
  display: none;
}

.tier-panel.is-active {
  display: block;
  animation: tier-panel-in 0.25s var(--ease);
}

@keyframes tier-panel-in {
  from {
    opacity: 0;
    transform: translateY(6px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

.tier-panel h3 {
  margin: 0;
  font-family: var(--font-mono);
  font-size: 16px;
  font-weight: 700;
  color: var(--text-0);
}

.tier-panel .hint-text {
  margin-top: 8px;
}

.tier-panel ul {
  margin: 14px 0 0;
  padding-left: 18px;
  font-size: 16px;
  color: var(--text-1);
  line-height: 1.75;
  /* multi-column instead of one long single-file list -- keeps the
     15-18-item Lite/Comprehensive lists short VERTICALLY inside a box
     that's wide rather than tall */
  columns: 2 220px;
  column-gap: 28px;
}

.tier-panel ul li {
  break-inside: avoid; /* a single item never splits across the column boundary */
}

@media (prefers-reduced-motion: reduce) {
  .tier-panel.is-active {
    animation: none;
  }
}

@media (max-width: 860px) {
  .tier-rotating {
    height: 140vh;
    /* The desktop margin-bottom trim above is sized against a wide-
       layout stage that's vertically centred with real dead space below
       it -- this layout already starts the wheel at the top and lets the
       detail box fill the rest (see `.tier-rotating-stage`'s own comment
       right below), so there's no comparable dead space to trim, and the
       desktop formula's constants were never measured against a phone-
       sized viewport height. */
    margin-bottom: 0;
  }

  /* The full-height sticky stage from the desktop layout stays. Previously
     this used `justify-content: flex-start` with the detail box filling
     all remaining space (`flex: 1 1 auto`) as a normal flow sibling of the
     wheel, which pinned the rotating word to the very top of the screen --
     unlike desktop, where it sits centred. That was a deliberate
     workaround at the time: a flex column can't centre a wheel AND a
     full-length tier list (some run 16 bullet items) inside one fixed
     100svh box without either clipping one of them or overflowing the
     sticky area, found by screenshotting this on a real phone-sized
     viewport.
     Capping the detail box's height and centring the whole wheel+box
     stack was tried next (on request, to match desktop's centred word),
     but the stack's own height still left the word sitting high in the
     upper third, not mid-screen, because the detail box's share of that
     stack still dominates it -- the word is the FIRST item in the stack,
     so centring the stack centres the midpoint between the two, not the
     word itself. Screenshotted and confirmed this directly.
     Fixed by taking the detail box out of the flex flow entirely
     (`position: absolute`, anchored to the stage's own bottom padding --
     `.tier-rotating-stage` is already a positioning context via its
     `position: sticky`) so the wheel is the ONLY flex item `justify-
     content: center` has to centre -- it now centres in the stage's full
     height exactly like desktop's wheel does, independent of the detail
     box's height. The detail box becomes a translucent floating card
     anchored to the bottom of the screen instead (Apple materials
     guidance: a parallel, non-blocking panel uses translucency and offset
     without a scrim, rather than fighting the primary content for the
     same vertical space). */
  .tier-rotating-stage {
    flex-direction: column;
    justify-content: center;
    align-items: stretch;
    height: 100vh;
    height: 100svh;
    min-height: 0;
    padding: 24px 0;
    overflow: hidden;
    /* Centring now does what the desktop lift does, so no separate lift
       is needed here either. */
    --tier-wheel-lift: 0px;
  }

  .tier-rotating-wheel {
    flex: 0 0 auto;
    width: 100%;
    font-size: clamp(26px, 9vw, 40px);
  }

  .tier-detail-box {
    position: absolute;
    left: 0;
    right: 0;
    bottom: 24px;
    width: 100%;
    /* Bounded well under the stage height so it reads as a distinct
       floating card, not a second full-height panel competing with the
       centred word above it. The long lists still don't get clipped --
       they scroll inside this box, with a visible top fade (see
       `.tier-detail-box::after` below) hinting there's more. */
    max-height: min(36svh, 300px);
    overflow-y: auto;
    -webkit-overflow-scrolling: touch;
    background: rgba(13, 13, 13, 0.78);
    backdrop-filter: blur(20px) saturate(160%);
    -webkit-backdrop-filter: blur(20px) saturate(160%);
    border: 1px solid rgba(255, 255, 255, 0.08);
    border-radius: var(--radius-md, 0);
    box-shadow: 0 16px 40px -12px rgba(0, 0, 0, 0.6);
  }

  /* Scroll-edge fade instead of a hard cutoff where the list's content
     meets the card's own top edge (apple-design's "scroll edge effects,
     not hard dividers" materials guidance) -- signals there's more to
     scroll to without a visible divider line. Pointer-events none so it
     never blocks a touch-scroll or a tap on the content under it. */
  .tier-detail-box::before {
    content: "";
    position: sticky;
    top: 0;
    left: 0;
    display: block;
    width: 100%;
    height: 24px;
    margin-bottom: -24px;
    background: linear-gradient(to bottom, rgba(13, 13, 13, 0.78), transparent);
    pointer-events: none;
  }

  .tier-panel ul {
    line-height: 1.85;
  }

  .tier-panel ul li {
    padding-bottom: 8px;
  }
}

/* Cursor-following edge glow on every card on this page (the get-started
   form + both tier cards): only the arc of the border nearest the cursor
   lights up, not the whole box. The border itself IS the gradient -- a
   "gradient border" built from two background layers clipped to different
   boxes (the classic technique for a colored border that isn't a flat,
   uniform line): layer 1 (padding-box) paints the card's own fill color
   over the inside, layer 2 (border-box) shows through only in the 1px
   border ring.

   Layer 2's gradient SHAPE is fixed (a plain centered radial-gradient tile,
   --glow-size square, accent fading to this card's resting border color by
   70% of the radius) and its POSITION is what moves, via `background-position:
   var(--spot-x) var(--spot-y)` -- not hovered, --spot-x/--spot-y default to a
   point far outside the card, so the tile sits off to the side and the
   border reads as a plain, uniform line, identical to not having this rule
   at all. --spot-x/--spot-y are tracked in px by the inline script below,
   relative to each card, offset by half --glow-size so the tile's CENTER
   (not its top-left corner) lands under the cursor.

   Two earlier versions of this both put the moving part INSIDE the radial-
   gradient function itself (`radial-gradient(... at var(--spot-x) var(--spot-y),
   ...)`), first through a `mask-image`, then as this layer's own
   `background-image`. Both silently froze on their first value in headless
   Chromium testing -- confirmed via getComputedStyle that the custom
   properties, and even the resulting computed `background-image` string
   embedding the new position, kept updating correctly on every change
   (mouse-driven AND a direct JS-forced-reflow test); the painted pixels
   never did, on this page specifically -- an isolated minimal reproduction
   of the exact same gradient-in-a-var() technique updated live with no
   issue at all, and `will-change: background-image` didn't fix it here
   either. `background-position` is the one dynamic-background mechanism
   that's been reliably animatable since CSS2 (parallax/hover-shift effects
   have relied on exactly this for years) and it updates live with no such
   issue, which is why the moving part lives there now instead of inside
   the gradient function. */
.landing-page .card {
  --card-border: var(--panel-border);
  --glow-size: 280px;
  border-color: transparent;
  /* Hardcoded #111111, not var(--surface) -- this page's cards keep the
     original near-black regardless of what --surface resolves to
     elsewhere (see body.landing-page's own matching override above). */
  background-color: #111111;
  background-image:
    linear-gradient(#111111, #111111),
    radial-gradient(circle, var(--accent), var(--card-border) 70%);
  background-origin: padding-box, border-box;
  background-clip: padding-box, border-box;
  background-repeat: no-repeat, no-repeat;
  background-size: auto, var(--glow-size) var(--glow-size);
  background-position:
    0 0,
    calc(var(--spot-x, -9999px) - (var(--glow-size) / 2)) calc(var(--spot-y, -9999px) - (var(--glow-size) / 2));
  /* the same neutral "popped forward" elevation as the New Assessment
     wizard's active step chevron (new-assessment.html's
     .wizard-step-dot.is-active) -- a plain dark drop shadow plus a faint
     top highlight line, not a colored glow, applied here on request to
     every card on this page instead of just one active/selected state */
  box-shadow: 0 3px 10px rgba(0, 0, 0, 0.5), inset 0 1px 0 rgba(255, 255, 255, 0.05);
  transition: border-color 0.15s var(--ease), transform 0.15s var(--ease), box-shadow 0.15s var(--ease);
}

.landing-page .card:focus-within {
  /* no mousemove fires from keyboard focus, so without this, tabbing to
     the form would show a plain border instead of any affordance at all */
  --spot-x: 50%;
  --spot-y: 50%;
}

/* A little breathing room at every section boundary on this page -- on top
   of `.layout`'s own 20px flex gap (shared site-wide, left alone so this
   doesn't change spacing on the dashboard/wizard pages too). */
.landing-tiers {
  /* extra space specifically above this section, on request -- separates
     it more clearly from the hero's "Check my site" button right above */
  margin-top: 64px;
}

/* On request: the "Tiers" label sat right at the top of this section while
   the actual rotating word drum (`.tier-rotating-wheel`, inside the sticky-
   pinned `.tier-rotating-stage` below) renders vertically CENTERED in the
   viewport -- by design, see `.tier-rotating-stage`'s own comment. A plain
   margin tweak here can't close that gap: this heading sits BEFORE
   `.tier-rotating` in normal flow, so pushing it down with margin just
   shifts the whole section (heading + stage) down by the same amount --
   the gap between heading and drum stays identical, confirmed directly by
   measuring both elements' positions before and after trying that. Made
   sticky instead, pinned just below the topbar (71px tall, see `.topbar`)
   with a little breathing room -- so instead of scrolling away the moment
   you pass it (which is what made it read as "too far from the rotating
   text" in the first place: by the time the drum's current word is
   actually visible, centred in the viewport, the label had already
   scrolled off the top), it stays visible near the top of the viewport for
   as long as the tiers section is on screen, including the whole time the
   drum is scroll-pinned. Doesn't touch `.tier-rotating-stage`/`.tier-
   rotating-wheel` or tier-rotating.js's own pin-timing math at all. */
.landing-tiers .section-heading {
  position: sticky;
  top: 88px;
  z-index: 1;
}

/* Stat boxes making the case for running an assessment at all -- real,
   sourced figures (see landing.html's own citation line on each card), not
   invented numbers. Reuses `.card`'s existing glow-on-hover treatment
   (`.landing-page .card`) rather than a separate visual language, so these
   read as part of the same page rather than a bolted-on banner. Already has
   a larger 64px top margin (not just the "little space" below) -- on
   request, to separate it more clearly from the tiers section above it. */
.landing-stats {
  margin-top: 64px;
  margin-bottom: 36px;
}

.stats-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
  gap: 16px;
}

/* Component-catalog-card styling, on request: a two-cell header strip
   (an index tag, a vertical rule, the source citation) over a bottom rule,
   then the big stat as its own body block below -- instead of the
   previous plain stacked number/label/source. `.card`'s own uniform 22px
   padding (see its base rule above) is overridden to 0 here so the head
   row's rules can run edge-to-edge under the card's own square corners
   (`--radius-lg: 0`, no radius to clip against anyway, but `overflow:
   hidden` is cheap insurance if that ever changes) -- .stat-card-head and
   .stat-card-body each carry their own inset padding instead. */
.stat-card {
  display: flex;
  flex-direction: column;
  padding: 0;
  overflow: hidden;
}

/* Flat, not the shared `.landing-page .card` glow-border/elevation
   treatment, on request: instead of the gradient-border-glow + drop-
   shadow "popped forward" look every other card on this page uses, a
   plain dark grey fill with NO outline at all. Overrides that rule's own
   background-image/box-shadow stack entirely (rather than just adding on
   top of it) so none of its layers show through, and kills the shared
   hover lift too -- that translateY is part of the same "3D" read this
   is removing.

   Two earlier colored-outline versions were tried and reverted on
   request (a solid #fff border, then a dim --accent one) -- what's
   actually wanted is no outline at all, just a dark grey surface. Reuses
   #111111, the same hardcoded dark grey `.landing-page .card` itself
   uses (see that rule), so this reads as the same card surface as the
   rest of the page rather than introducing a second shade of dark. The
   header-row dividers below switch to var(--panel-border) (this
   project's standard subtle divider color, used site-wide) to match --
   no accent color left anywhere on the card now that the outline is
   gone, just neutral structure. */
.landing-page .stat-card {
  background: #111111;
  background-image: none;
  border: none;
  box-shadow: none;
}

.landing-page .stat-card:hover {
  transform: none;
}

.stat-card-head {
  display: flex;
  align-items: stretch;
  border-bottom: 1px solid var(--panel-border);
}

.stat-card-index {
  display: flex;
  align-items: center;
  padding: 0 14px;
  border-right: 1px solid var(--panel-border);
  font-family: var(--font-mono);
  font-size: 13px;
  font-weight: 600;
  color: var(--text-0);
}

/* .stat-source now lives in the head row (was the bottom-pinned citation
   line in the old stacked layout) -- these overrides replace that
   bottom-aligned/uppercase treatment with a plain header-cell one. */
.stat-card-head .stat-source {
  flex: 1;
  margin-top: 0;
  padding: 10px 14px;
  text-transform: none;
}

.stat-card-body {
  display: flex;
  flex-direction: column;
  gap: 6px;
  padding: 20px 14px 22px;
}

.stat-number {
  font-family: var(--font-mono);
  font-size: 44px;
  font-weight: 700;
  line-height: 1;
  color: var(--red); /* bright vibrant red, on request -- was var(--text-0) (plain white) */
  letter-spacing: -0.02em;
}

.stat-label {
  margin: 0;
  color: var(--text-1);
  font-size: 15px;
  line-height: 1.4;
}

.stat-source {
  color: var(--text-2);
  font-family: var(--font-mono);
  /* 13px, matching .stat-card-index -- this used to be an 11px footnote
     pinned to the card's bottom edge, small enough to read as a citation
     aside. Now that it shares the header row as a coequal cell next to
     the index tag (see .stat-card-head above), 11px read as too small for
     that more prominent role -- flagged by the design-review hook, and a
     real readability call, not a false positive to ignore. */
  font-size: 13px;
  letter-spacing: 0.02em;
  text-transform: uppercase;
}

@media (max-width: 640px) {
  .stat-number {
    font-size: 32px;
  }
}

/* Same lift-on-hover language as `.stat-card` above, applied to every card
   on this page -- on request, to make the whole page's boxes feel a touch
   more responsive/alive rather than just the new stat boxes. */
@media (hover: hover) and (pointer: fine) {
  .landing-page .card:hover {
    transform: translateY(-2px);
  }
}

.landing-footer {
  margin-top: 16px;
  padding: 4px 0 40px;
}

.landing-footer a {
  color: var(--text-2);
  font-size: 14px;
  text-decoration: none;
}

@media (hover: hover) and (pointer: fine) {
  .landing-footer a:hover {
    color: var(--accent);
  }
}

/* --- /get-started polish pass ------------------------------------------
   Appended after the page's base card rules so it wins the cascade without
   editing them (easy to revert: delete this block, or the whole branch).
   Look: large-radius panels on a faint vertical gradient surface, a
   hairline white border instead of a flat grey one, a bright top edge, and
   a deep soft shadow so each box sits clearly off the black wave
   background. Layout geometry (tier pin math, wave canvas offsets) is not
   touched; only paint, radius, padding and type. */
.landing-page {
  --lp-radius: 22px;
  --lp-hairline: rgba(255, 255, 255, 0.08);
  --lp-surface-top: #171717;
  --lp-surface-bottom: #0e0e0e;
}

.landing-page .card {
  --card-border: var(--lp-hairline);
  border-radius: var(--lp-radius);
  border-color: transparent;
  background-color: var(--lp-surface-bottom);
  background-image:
    linear-gradient(180deg, var(--lp-surface-top) 0%, var(--lp-surface-bottom) 100%),
    radial-gradient(circle, rgba(var(--accent-rgb), 0.55), var(--lp-hairline) 70%);
  box-shadow:
    0 1px 0 rgba(255, 255, 255, 0.06) inset,
    0 24px 48px -24px rgba(0, 0, 0, 0.8),
    0 2px 6px rgba(0, 0, 0, 0.4);
}

/* Resting border: the glow layer is parked off-card, so the 1px ring shows
   the second layer's edge colour. Give it a steady hairline. */
.landing-page .card:not(:hover):not(:focus-within) {
  background-image:
    linear-gradient(180deg, var(--lp-surface-top) 0%, var(--lp-surface-bottom) 100%),
    linear-gradient(var(--lp-hairline), var(--lp-hairline));
}

/* Tier panel: roomier, clearer hierarchy. */
.landing-page .tier-detail-box {
  padding: 22px 28px;
}

.landing-page .tier-panel h3 {
  font-size: 24px;
  letter-spacing: -0.01em;
  line-height: 1.15;
}

.landing-page .tier-panel .hint-text {
  margin-top: 10px;
  font-size: 15px;
  line-height: 1.55;
  max-width: 62ch;
}

.landing-page .tier-panel ul {
  margin-top: 16px;
  padding: 16px 0 0;
  border-top: 1px solid var(--lp-hairline);
  list-style: none;
  font-size: 14px;
  line-height: 1.4;
  column-gap: 28px;
}

.landing-page .tier-panel ul li {
  position: relative;
  padding: 0 0 5px 20px;
}

.landing-page .tier-panel ul li::before {
  content: "";
  position: absolute;
  left: 2px;
  top: 0.62em;
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: rgba(var(--accent-rgb), 0.75);
}

/* Stat cards: same panel language, header strip rounded with the card. */
.landing-page .stat-card {
  background-color: var(--lp-surface-bottom);
  background-image: linear-gradient(180deg, var(--lp-surface-top) 0%, var(--lp-surface-bottom) 100%);
  border: 1px solid var(--lp-hairline);
  border-radius: var(--lp-radius);
  box-shadow:
    0 1px 0 rgba(255, 255, 255, 0.06) inset,
    0 24px 48px -24px rgba(0, 0, 0, 0.8),
    0 2px 6px rgba(0, 0, 0, 0.4);
}

.landing-page .stats-grid {
  gap: 20px;
}

.landing-page .stat-card-head {
  border-bottom-color: var(--lp-hairline);
}

.landing-page .stat-card-index {
  border-right-color: var(--lp-hairline);
  padding: 0 16px;
  color: var(--text-2);
}

.landing-page .stat-card-head .stat-source {
  padding: 12px 16px;
  font-size: 12px;
  line-height: 1.4;
}

.landing-page .stat-card-body {
  gap: 10px;
  padding: 28px 18px 28px;
}

.landing-page .stat-number {
  font-size: 48px;
}

.landing-page .stat-label {
  font-size: 15px;
  line-height: 1.5;
  max-width: 30ch;
}

/* Section headings and footer: a little more presence and air. */
.landing-page .section-heading {
  letter-spacing: 0.01em;
}

.landing-page .landing-footer a {
  display: inline-block;
  padding: 10px 18px;
  border: 1px solid var(--lp-hairline);
  border-radius: 999px;
  transition: color 0.15s var(--ease), border-color 0.15s var(--ease);
}

@media (hover: hover) and (pointer: fine) {
  .landing-page .landing-footer a:hover {
    border-color: rgba(var(--accent-rgb), 0.5);
  }
}

/* Sign-in dialog matches the panels. */
.landing-page .confirm-dialog {
  border: 1px solid var(--lp-hairline);
  border-radius: var(--lp-radius);
  background: linear-gradient(180deg, var(--lp-surface-top), var(--lp-surface-bottom));
  box-shadow: 0 32px 80px -20px rgba(0, 0, 0, 0.9);
}

@media (max-width: 860px) {
  .landing-page .tier-detail-box {
    padding: 24px 22px;
  }
  .landing-page .tier-panel h3 {
    font-size: 22px;
  }
}

/* --- New-assessment wizard --------------------------------------------- */

.wizard-steps {
  display: flex;
  margin-bottom: 4px;
}

/* A breadcrumb-style chevron chain: each step is an arrow pointing at the
   next, cut with clip-path (a point on the right, a matching notch on the
   left), then pulled flush against its neighbor with a negative margin so
   the notch exactly swallows the previous step's point -- no gap, no
   seam. Plain CSS, no icon font/library: each step's glyph is an inline
   SVG using stroke="currentColor" so it tracks the text color for free. */
.wizard-step-dot {
  position: relative;
  flex: 1;
  display: flex;
  align-items: center;
  gap: 9px;
  padding: 13px 22px 13px 32px;
  background: var(--surface);
  border: none;
  font-family: inherit;
  font-size: 14.5px;
  font-weight: 500;
  color: var(--text-2);
  cursor: not-allowed;
  clip-path: polygon(0 0, calc(100% - 15px) 0, 100% 50%, calc(100% - 15px) 100%, 0 100%, 16px 50%);
  transition: background 0.15s var(--ease-hover), color 0.15s var(--ease-hover);
}

.wizard-step-dot:first-child {
  clip-path: polygon(0 0, calc(100% - 15px) 0, 100% 50%, calc(100% - 15px) 100%, 0 100%);
  padding-left: 20px;
}

.wizard-step-dot:not(:first-child) {
  margin-left: -15px;
}

.wizard-step-dot svg {
  width: 15px;
  height: 15px;
  flex-shrink: 0;
}

.wizard-step-dot span {
  white-space: nowrap;
}

.wizard-step-dot:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: -2px;
}

/* a completed step: the operator filled it in already, so it's a real
   back-link (cursor: pointer, hover picks out in accent) -- this is what
   "view the past steps you filled in" from any later step means in
   practice */
.wizard-step-dot.is-done {
  color: var(--text-1);
  background: var(--surface-strong);
  cursor: pointer;
}

.wizard-step-dot.is-done:hover {
  color: var(--accent);
}

/* the current step: brightest text, a visibly raised panel -- a neutral
   (non-colored) drop shadow plus a faint top highlight line is what reads
   as "this one is popped forward," not a chromatic glow */
.wizard-step-dot.is-active {
  color: var(--text-0);
  font-weight: 700;
  background: var(--surface-strong);
  cursor: default;
  box-shadow: 0 3px 10px rgba(0, 0, 0, 0.5), inset 0 1px 0 rgba(255, 255, 255, 0.05);
}

.assessment-type-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
  gap: 16px;
  margin: 16px 0;
}

.assessment-type-card {
  background: var(--surface-strong);
  border: 1px solid var(--panel-border);
  padding: 18px;
  cursor: pointer;
  transition: border-color 0.15s var(--ease-hover), background 0.15s var(--ease-hover);
}

.assessment-type-card h3 {
  margin: 0 0 6px;
  color: var(--text-0);
  font-size: 16px;
}

@media (hover: hover) and (pointer: fine) {
  .assessment-type-card:hover {
    border-color: rgba(var(--accent-rgb), 0.4);
  }
}

.assessment-type-card.is-selected {
  border-color: var(--accent);
  background: rgba(var(--accent-rgb), 0.08);
}

/* Website-size picker (new-assessment.html step 2): same card language as
   .assessment-type-grid/.assessment-type-card above (border, flat corners,
   accent-on-selected), just three equal-width, more compact cards -- a
   single top-level decision (how big is the target), not four distinct
   assessment modes, so it doesn't need that component's larger padding or
   minimum card width. */
.size-picker-grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 12px;
  margin: 12px 0 20px;
}

@media (max-width: 560px) {
  .size-picker-grid {
    grid-template-columns: 1fr;
  }
}

.size-picker-card {
  display: block;
  width: 100%;
  background: var(--surface-strong);
  border: 1px solid var(--panel-border);
  padding: 14px;
  text-align: left;
  font-family: inherit;
  color: inherit;
  cursor: pointer;
  transition: border-color 0.15s var(--ease-hover), background 0.15s var(--ease-hover);
}

.size-picker-card h4 {
  margin: 0 0 4px;
  color: var(--text-0);
  font-size: 15px;
}

.size-picker-card .hint-text {
  margin: 0 0 8px;
}

.size-picker-stat {
  display: block;
  font-family: var(--font-mono);
  font-size: 12px;
  color: var(--text-1);
  letter-spacing: 0.01em;
}

@media (hover: hover) and (pointer: fine) {
  .size-picker-card:hover {
    border-color: rgba(var(--accent-rgb), 0.4);
  }
}

.size-picker-card.is-selected {
  border-color: var(--accent);
  background: rgba(var(--accent-rgb), 0.08);
}

.size-picker-card.is-selected .size-picker-stat {
  color: var(--accent);
}

/* Native <details>/<summary>, same pattern as the "Create a new
   credentials file" toggles on step 2 -- no JS needed for expand/collapse.
   Collapsed by default (the `hidden` attribute above, toggled by
   renderChecklist(), controls whether this whole block shows at all; the
   browser's own open/closed state is what's being styled here) so the
   assessment-type step leads with the four type cards, not a wall of ~30
   checkboxes most people will never touch. */
.advanced-options summary {
  cursor: pointer;
  font-family: var(--font-mono);
  font-size: 14px;
  font-weight: 600;
  color: var(--text-0);
  margin: 18px 0 4px;
}

.advanced-options[open] summary {
  margin-bottom: 14px;
}

.checklist-group {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
  gap: 4px 18px;
  margin-bottom: 14px;
}

.checklist-item {
  display: flex;
  align-items: flex-start;
  gap: 8px;
  padding: 4px 0;
  font-size: 14.5px;
  color: var(--text-0);
}

.checklist-item .hint-text {
  margin: 0;
}

.state-mutating-tag {
  color: var(--amber);
  font-size: 0.85em;
  cursor: help;
}

.state-mutating-warning {
  margin-top: 4px;
  padding: 12px 16px;
  border-radius: var(--radius-sm);
  border: 1px solid rgba(var(--amber-rgb), 0.35);
  background: rgba(var(--amber-rgb), 0.08);
  color: var(--text-1);
  font-size: 13.5px;
  line-height: 1.5;
}

.state-mutating-warning strong {
  color: var(--amber);
}

.wizard-nav {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-top: 22px;
}

.wizard-nav .primary-button,
.wizard-nav .secondary-button {
  width: auto;
}

.preflight-item {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
}


/* Code evidence: line-number gutter, matched lines highlighted. */
.code-evidence {
  border-radius: var(--radius-sm);
  background: rgba(0, 0, 0, 0.3);
  max-height: 320px;
  overflow: auto;
  padding: 6px 0;
  font-family: var(--font-mono);
  font-size: 12.5px;
  line-height: 1.55;
}

.code-row {
  display: flex;
  min-width: max-content;
  border-left: 3px solid transparent;
}

.code-row.is-hit {
  background: rgba(var(--red-rgb), 0.14);
  border-left-color: var(--red);
}

.code-ln {
  flex: 0 0 auto;
  min-width: 3.2em;
  padding: 0 12px 0 10px;
  text-align: right;
  color: var(--text-3, #7d7d85);
  user-select: none;
}

.code-row.is-hit .code-ln {
  color: var(--text-1);
}

.code-text {
  flex: 1 1 auto;
  padding-right: 14px;
  white-space: pre;
  color: var(--text-1);
}
