:root {
  /* 90% of the browser's own default, which is 14.4px on a default setup.
     Deliberately a percentage rather than a px value: a px value here would
     override the reader's browser font-size preference outright, so someone who
     raised their default for readability would be silently pushed back down to
     this size. A percentage scales that preference instead of replacing it, and
     produces the identical result for everyone who has not changed it.
     Every font-size in this stylesheet is expressed in rem, so this is the
     single control point for the app's whole type scale. One deliberate
     exception: .model-name is 0.92em, so a model id shrinks relative to
     whatever line it sits in rather than against the root - it appears in
     contexts at different sizes and should stay proportional to each. Lengths
     that should NOT track type size - hairline borders, radii, fixed structural
     spacing, breakpoints - stay in px on purpose. */
  font-size: 90%;

  --bg: #14171c;
  --bg-raised: #1c2129;
  --bg-card: #20262f;
  --border: #2e3642;
  --text: #e8ebef;
  --text-dim: #9aa4b2;
  --accent: #c9a15a;
  --accent-dim: #8a713f;
  --defense: #5b8fd6;
  --prosecution: #c26b6b;
  --ok: #4caf7d;
  --warn: #d6a94c;
  --fail: #d16060;
  --progress: #7f8a9a;
  --aborted: #8a8fa3;
  --radius: 10px;
}

* {
  box-sizing: border-box;
}

.hidden {
  display: none;
}

body {
  margin: 0;
  background: var(--bg);
  color: var(--text);
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
  line-height: 1.5;
}

.layout {
  display: flex;
  min-height: 100vh;
}

.sidebar {
  /* Shrinks fluidly with the viewport instead of staying a fixed size
     until the single hard breakpoint below forces it full-width - gives
     the main content (including the call log table, which needs real
     width of its own) more room on viewports that are narrower than
     "wide" but not yet narrow enough to stack. No horizontal scrollbar is
     wanted anywhere on this page (see table.call-log below), so this is
     load-bearing, not cosmetic - shrinking the sidebar is real width the
     table gets to work with instead.
     The 220px ceiling this used to have was itself the problem on wide
     viewports: .main is capped at its own max-width (below) and centred
     within whatever's left over, so once the sidebar stopped growing,
     every extra pixel of a wide window just became empty margin around
     .main instead of going anywhere useful - real, measured waste (over
     200px per side at a 1728px viewport). Raised the preferred rate
     (16vw -> 20vw) and the ceiling (220px -> 360px) so the sidebar keeps
     absorbing width as the viewport grows, rather than handing it to
     nothing. 360px is still a deliberate ceiling, not unlimited growth -
     on a genuinely huge display this keeps the sidebar from becoming
     disproportionate even though .main's own max-width caps it too. */
  width: clamp(135px, 20vw, 360px);
  flex-shrink: 0;
  background: var(--bg-raised);
  border-right: 1px solid var(--border);
  padding: 20px 16px;
  display: flex;
  flex-direction: column;
  gap: 16px;
  /* Pinned to the viewport (title/buttons/"Run history" heading always
     reachable) rather than scrolling away with the rest of the page once
     .main's own content (case card, agent cards, call log) grows taller
     than one viewport, which it routinely does. position: sticky rather
     than fixed specifically so this stays a normal flex child of
     .layout, sharing width with .main exactly as before - fixed would
     take it out of flow entirely and need its own hand-maintained
     width/offset maths duplicating the clamp() above. align-self:
     flex-start stops .layout's default stretch behaviour from fighting
     the explicit height below. Capped at 100vh (never taller than the
     viewport) with overflow hidden - the fix for the sidebar previously
     growing past .main's height and dragging the whole page down with
     it - .history-list below is the one part that actually scrolls,
     internally, once real content forces the issue. */
  position: sticky;
  top: 0;
  align-self: flex-start;
  height: 100vh;
  overflow: hidden;
}

.sidebar-top-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  margin-bottom: 2px;
}

/* This rule's line box is what sets .sidebar-top-row's height, which makes
   it load-bearing for how large the GitHub icon beside it can be before the
   row (and everything below it in the sidebar) grows - see the size-ceiling
   note on that icon's <svg> in index.html. */
.app-title {
  margin: 0;
  font-size: 1.4rem;
  letter-spacing: 0.02em;
  color: var(--accent);
}

/* GitHub link icon, top-right of the sidebar. Semi-transparent at rest and
   brighter on hover/active, but deliberately well short of a flat, stark
   100%: at full strength the octocat reads as near-white against this dark
   sidebar and shouts louder than a secondary link in a corner should. The
   hover value is kept clearly above the resting one so the feedback is
   still unmistakable - the two are tuned as a pair, and dimming this one
   without checking it against the rest value would flatten the hover.
   Also a soft shape-aware halo on hover - drop-shadow (not box-shadow) so
   it follows the actual octocat silhouette rather than the icon's
   rectangular bounding box. That is a separate control from this opacity:
   the halo is the light around the mark, this is the mark itself. */
.github-link {
  display: inline-flex;
  flex-shrink: 0;
  color: var(--text);
  opacity: 0.65;
  /* Invisible at rest (alpha 0), and present only so the hover transition
     has something of the same shape to interpolate from. Both halves of
     this matter, and neither is obvious:

     1. The function list must match the hover state's exactly - same two
        drop-shadows, same blur radii - so only the colour changes.
     2. The colour must come from color-mix() here too, not the plainer
        `transparent`. color-mix() resolves to a modern color(srgb ...)
        value, and Edge interpolates a modern colour against a legacy one
        (`transparent`, or the implied colour of `none`) by producing
        rgba(1, 0, 0, ...) - a near-black - throughout the transition.
        Measured in Edge 154 on 2026-10-03, at 25%, 50% and 75% of the
        way: from `none` and from `transparent` alike, rgba(1, 0, 0, ...)
        with only the alpha climbing; from this, oklab(0.9389 ...), the
        correct near-white, at every step. WebKit 26.6 gives the
        near-white from all three.
        Worth knowing before "simplifying" this: `var(--text) 0%` does
        not keep the colour - both engines compute it as
        color(srgb 0 0 0 / 0), transparent black, same as `transparent`
        would look. What it keeps is the colour TYPE, and that alone is
        what makes the interpolation work. Being transparent black costs
        nothing: an endpoint at alpha 0 contributes no hue to the steps
        between, as the samples above show.

     The visible symptom of getting either wrong is a glow that appears to
     do nothing for the length of the transition and then snap in at the
     end, while the opacity beside it fades smoothly - which is what
     happened when this rule had no filter at all. */
  filter: drop-shadow(0 0 10px color-mix(in srgb, var(--text) 0%, transparent))
    drop-shadow(0 0 20px color-mix(in srgb, var(--text) 0%, transparent));
  transition:
    opacity 0.2s ease,
    filter 0.2s ease;
}

.github-link:hover,
.github-link:active,
.github-link:focus {
  opacity: 0.86;
  /* A halo of light, not a cast shadow. The sidebar is #1c2129, so a dark
     shadow here is close to invisible no matter how far its opacity is
     pushed - darkening an already-dark surface barely changes its
     luminance, which is why the previous black version read as nothing.
     Light on dark is the inversion that actually registers. Zero offset on
     both layers, since a halo should sit around the mark rather than fall
     below it the way the old 2px/4px offsets implied. Two layers again -
     a tight bright one that hugs the octocat's silhouette and a wider,
     fainter one that fades out - rather than one flat blur. drop-shadow,
     not box-shadow, so both follow the real silhouette instead of the
     icon's rectangular bounding box. */
  /* Two layers with genuinely different jobs, which is why they are tuned
     separately rather than as one number:
       10px - the inner layer, closest to the octocat's silhouette. This is
              the one that edge-lights the mark and makes the ICON itself
              look brighter, even though it is drawn outside the glyph
              rather than on it.
       20px - the diffuse surround. Reads as the glow around the mark and
              barely touches how bright the mark itself appears.
     The inner layer's alpha was cut harder than the outer one's (60 -> 38
     against 35 -> 22): the complaint that addressed was the mark reading too
     hot, not the surround being too wide. Both radii were later widened, from
     5px and 13px to the 10px and 20px below, at unchanged alpha - the same
     light spread wider, so it reads as a soft aura rather than a bright rim.
     The resting filter above uses the same radii, so only the alpha animates.
     The 20px layer reaches 3px past the sidebar's edge (.sidebar is overflow:
     hidden and the icon sits 17px in) and is clipped there. That was checked
     and is invisible - it is the last few per cent of the falloff - but it is
     worth knowing before widening either layer. */
  filter: drop-shadow(0 0 10px color-mix(in srgb, var(--text) 38%, transparent))
    drop-shadow(0 0 20px color-mix(in srgb, var(--text) 22%, transparent));
}

.sidebar-heading {
  margin: 8px 0 0;
  font-size: 0.8rem;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--text-dim);
}

.history-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 8px;
  overflow-y: auto;
  padding-right: 4px;
  /* Fills whatever vertical space .sidebar's fixed 100vh has left over
     after its other children (title, buttons, heading) - min-height: 0
     overrides flex's default min-height: auto, which would otherwise let
     this list's real content size push .sidebar taller than 100vh
     instead of actually scrolling within the space it's given. This is
     the one thing inside .sidebar that scrolls; above 900px, everything
     above it stays fixed in place regardless of history length or page
     scroll. Below 900px the sidebar stacks above .main and scrolls away
     with the page (see the 900px block at the end). */
  flex: 1 1 auto;
  min-height: 0;
}

.history-item {
  background: var(--bg-card);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 10px;
  cursor: pointer;
  font-size: 0.85rem;
  transition: border-color 0.2s ease;
}

/* White rather than the theme's gold accent. This rule and .active (below)
   have equal specificity - a class and a pseudo-class against two classes -
   so .active wins by coming later in the file, which keeps an entry that is
   both the active one and currently hovered at the active brightness
   without a combined :hover.active selector. Keep .active below this rule. */
.history-item:hover {
  border-color: color-mix(in srgb, var(--text) 45%, transparent);
}

.history-item.active {
  border-color: color-mix(in srgb, var(--text) 85%, transparent);
}

/* Applied to .history-list while a trial is running (see
   updateHistoryLockState() in app.js) - clicking an entry already
   correctly does nothing during a live run (loadTrial()'s own
   state.running guard), this makes that visible instead of leaving
   every entry looking clickable with nothing happening when clicked.
   No pointer-events: none here (unlike .sidebar.loading-locked, the
   separate lock used for a loadTrial() fetch) - the point is to
   actively show a not-allowed cursor, which pointer-events: none would
   prevent by taking the element out of hit-testing (and hover) entirely
   and falling through to whatever's underneath for cursor purposes. */
.history-list.running-locked .history-item {
  opacity: 0.45;
  cursor: not-allowed;
}

.history-list.running-locked .history-item:hover {
  border-color: var(--border);
}

.history-item-date {
  color: var(--text-dim);
  font-size: 0.75rem;
  margin-bottom: 6px;
}

.history-loading {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 12px;
  padding: 32px 12px;
  border: 1px dashed var(--border);
  border-radius: var(--radius);
  color: var(--text-dim);
  font-size: 0.85rem;
  text-align: center;
}

.main {
  flex: 1;
  position: relative;
  padding: 28px 32px 60px;
  /* Raised from 960px by roughly what the sidebar's width gave up at the
     time (its widest case had just been cut from 280px to 220px), to hand
     this section - the call log table included - real extra width rather
     than shrink the page overall.
     That sidebar cut has since been reversed and then some: its ceiling is
     clamp(135px, 20vw, 360px) today, for its own separate reason (see the
     .sidebar comment above - beyond it, extra viewport width was just
     becoming empty margin). So this 1040px no longer offsets a narrower
     sidebar; it now simply is the width .main gets, and the two were
     tuned independently rather than as a trade. */
  max-width: 1040px;
  margin: 0 auto;
  width: 100%;
  overflow-x: hidden;
}

/* Shown while a history entry's trial data is being fetched (loadTrial() in
   app.js), while a new trial is being created (beginTrial()) and while an abort
   settles (abortCurrentTrial()) - covers .main only, not the sidebar, since the
   sidebar stays independently readable (though not clickable - see
   pointer-events below) meanwhile. position:fixed rather than absolute so it
   stays covering the visible viewport regardless of how tall .main's own
   content is or how far the page is scrolled.
   The `left` below is deliberately the exact same clamp() as .sidebar's own
   width - position:fixed takes this out of flow, so it cannot inherit that
   width and has to restate it. The two must stay in step: if one is changed
   without the other, the overlay silently starts covering the sidebar or
   leaving a strip of .main uncovered.
   tests/shared-constants.test.js compares them. */
.main-loading-overlay {
  position: fixed;
  inset: 0;
  left: clamp(135px, 20vw, 360px);
  display: flex;
  align-items: center;
  justify-content: center;
  background: color-mix(in srgb, var(--bg) 70%, transparent);
  z-index: 5;
}

.main-loading-overlay.hidden {
  display: none;
}

/* Applied to the sidebar for the same span the overlay above is shown -
   stops a rapid double-click on a second history entry (or the "Begin new
   trial" button) from piling a second request on top of one already in
   flight. */
.sidebar.loading-locked {
  pointer-events: none;
  opacity: 0.6;
}

@media (max-width: 900px) {
  /* Matches the sidebar-stacking breakpoint - once the sidebar sits above
     .main instead of beside it, the overlay should cover the full width
     again rather than leaving a sidebar-sized gap on the left. */
  .main-loading-overlay {
    left: 0;
  }
}

.btn {
  border: 1px solid var(--border);
  background: var(--bg-card);
  color: var(--text);
  border-radius: var(--radius);
  padding: 10px 14px;
  font-size: 0.9rem;
  cursor: pointer;
}

.btn:hover:not(:disabled) {
  border-color: var(--accent-dim);
}

.btn:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

.btn-primary {
  background: var(--accent);
  color: #1a1508;
  border-color: var(--accent);
  font-weight: 600;
}

/* :active/:focus included, not just :hover, so a press or keyboard-focus
   without a preceding mouse hover still gets the same treatment. */
.btn-primary:hover:not(:disabled),
.btn-primary:active:not(:disabled),
.btn-primary:focus:not(:disabled) {
  background: #d9b46e;
  border-color: var(--accent-dim);
}

.btn-danger {
  background: color-mix(in srgb, var(--fail) 18%, var(--bg-card));
  color: var(--fail);
  border-color: var(--fail);
  font-weight: 600;
}

/* border-color needs its own explicit override here - the shared
   .btn:hover:not(:disabled) rule (border-color: var(--accent-dim), the
   gold accent) otherwise wins on specificity over .btn-danger's own
   resting border-color: var(--fail), so without this the Abort button's
   border would turn gold on hover instead of staying red. :active and
   :focus included too (not just :hover) so a press or keyboard focus
   without a preceding hover - keyboard activation, touch - gets the same
   red border rather than falling back to the shared gold rule there too. */
.btn-danger:hover:not(:disabled),
.btn-danger:active:not(:disabled),
.btn-danger:focus:not(:disabled) {
  background: color-mix(in srgb, var(--fail) 30%, var(--bg-card));
  border-color: var(--fail);
}

.case-card {
  background: var(--bg-raised);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 24px;
}

.case-code {
  margin: 0 0 4px;
  color: var(--accent);
  font-size: 0.8rem;
  text-transform: uppercase;
  letter-spacing: 0.08em;
}

.case-card h2 {
  margin: 0 0 16px;
}

.case-meta {
  display: flex;
  gap: 32px;
  margin: 0 0 16px;
  flex-wrap: wrap;
}

.case-meta dt {
  font-size: 0.75rem;
  color: var(--text-dim);
  text-transform: uppercase;
  letter-spacing: 0.05em;
}

.case-meta dd {
  margin: 2px 0 0;
  font-weight: 600;
}

.case-act-alleged {
  color: var(--text);
}

.case-details {
  margin-top: 12px;
  border-top: 1px solid var(--border);
  padding-top: 12px;
}

.case-details summary {
  cursor: pointer;
  font-weight: 600;
  color: var(--accent);
}

.case-background {
  padding-top: 8px;
  color: var(--text-dim);
}

.case-background p {
  margin: 0 0 10px;
}

.case-background p:last-child {
  margin-bottom: 0;
}

.case-facts {
  margin-top: 8px;
  color: var(--text-dim);
  padding-left: 20px;
}

.case-facts li {
  margin-bottom: 6px;
}

.case-question {
  margin-top: 12px;
  border-top: 1px solid var(--border);
  padding-top: 16px;
}

.case-question-label {
  margin: 0 0 4px;
  font-size: 0.75rem;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  color: var(--text-dim);
}

.case-scope-note {
  color: var(--text-dim);
  font-size: 0.85rem;
  font-style: italic;
}

.phase {
  margin-top: 32px;
}

.phase.hidden {
  display: none;
}

.phase h3 {
  margin-bottom: 4px;
}

.judges-note {
  color: var(--text-dim);
  font-size: 0.85rem;
  margin-top: 0;
}

/* Shown only when fewer than 4 representative arguments were available to
   the judges (see updateJudgesCaveat in app.js) - deliberately styled as a
   note, not an error: it says what the rulings were reasoned from, and
   each judge's own card shows how its call went. --warn rather than --fail
   for that reason. */
.judges-caveat {
  color: var(--warn);
  font-size: 0.85rem;
  margin: 6px 0 0;
}

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

/* Exactly 4 representatives, always - auto-fit above was giving 3 cards
   one row and stranding the 4th alone on a second row with empty space
   beside it on any viewport wide enough to fit 3+ columns. A fixed 2x2
   is a better fit for a fixed count than a column count that varies with
   width. Doesn't touch #judge-cards (3 judges - a fixed 2-column grid
   would leave the same kind of lonely third card, so auto-fit is still
   the right call there). */
#representative-cards {
  grid-template-columns: repeat(2, 1fr);
}

@media (max-width: 600px) {
  /* Below this, two 260px-minimum columns plus their gap no longer fit
     comfortably - single column instead of letting cards get uncomfortably
     narrow. */
  #representative-cards {
    grid-template-columns: 1fr;
  }
}

.card {
  background: var(--bg-card);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 16px;
  min-width: 0;
}

.card-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 10px;
  gap: 8px;
  flex-wrap: wrap;
}

.card-name {
  font-weight: 700;
}

.card-seat {
  font-size: 0.75rem;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  padding: 2px 8px;
  border-radius: 999px;
}

.card-seat.defense {
  background: color-mix(in srgb, var(--defense) 25%, transparent);
  color: var(--defense);
}

.card-seat.prosecution {
  background: color-mix(in srgb, var(--prosecution) 25%, transparent);
  color: var(--prosecution);
}

.card-body {
  color: var(--text);
  font-size: 0.9rem;
  white-space: pre-line;
}

.card-body.dim {
  color: var(--text-dim);
}

/* Positioning anchor for the bottom and top fades below - see
   attachScrollFade() in app.js for why this needs to be a plain
   (non-scrolling) wrapper rather than putting the fades directly inside
   .card-body-scroll. */
.card-body-scroll-wrap {
  position: relative;
}

/* A reported Firefox-only rendering glitch (2026-09-04): the
   bottom-fade gradient below composited on top of scrolling card text
   came out jagged/"torn" per line instead of a smooth blend (not
   reproduced in Chrome). transform: translateZ(0), a standard mitigation
   for text-under-overlay compositing, was tried first on
   .card-body-scroll and then on this wrapper, made no difference to the
   jaggedness in either place, and was reverted (2026-09-04). A change to
   the scrollbars seen in Firefox during those attempts, on .history-list
   as well as here, came from the browser updating to Firefox 155 at the
   same time (see the fallback block further down), not from the
   transform. The fade stays as it is in every browser: it does its job in
   Firefox too. */

/* Hidden by default (opacity 0, see .has-more-below below) - only shown
   once app.js confirms there's real hidden content, and hidden again
   once scrolled to the actual bottom, so this never falsely implies more
   text exists when there isn't any. pointer-events: none so it never
   blocks scrolling/selecting the text underneath it. The gradient's
   solid end matches .card's own background (--bg-card) so it blends
   into the card rather than reading as its own separate block.
   It stops where the card's scrollbar begins, so the scrollbar's
   bottom arrow stays fully visible rather than half faded into a stray
   mark at the end of the last line. The scrollbar's width depends on
   the browser and the OS (none at all where scrollbars overlay the
   content), so attachScrollFade() in app.js measures it on each card
   and sets --card-scrollbar-width; 0px is its value before that. */
.card-body-scroll-wrap::after {
  content: '';
  position: absolute;
  left: 0;
  right: var(--card-scrollbar-width, 0px);
  bottom: 0;
  height: 42px;
  background: linear-gradient(to bottom, transparent, var(--bg-card));
  pointer-events: none;
  opacity: 0;
  transition: opacity 0.15s ease;
}

.card-body-scroll-wrap.has-more-below::after {
  opacity: 1;
}

/* The same fade at the top edge, shown while the text is scrolled away
   from its start (.has-more-above, set by attachScrollFade() in app.js),
   so the first visible line fades in rather than being cut through its
   letters. It stops at the scrollbar too, for the top arrow's sake. */
.card-body-scroll-wrap::before {
  content: '';
  position: absolute;
  left: 0;
  right: var(--card-scrollbar-width, 0px);
  top: 0;
  height: 42px;
  background: linear-gradient(to top, transparent, var(--bg-card));
  pointer-events: none;
  opacity: 0;
  transition: opacity 0.15s ease;
}

.card-body-scroll-wrap.has-more-above::before {
  opacity: 1;
}

/* Only the actual argument/ruling text (buildRepresentativeCard/
   buildJudgeCard in app.js) gets this, not the short status/dim text
   buildAgentStatusBody renders while a call is loading or failed - real
   arguments/rulings run long enough to make card heights
   wildly inconsistent across a grid otherwise, forcing a lot of scrolling
   just to reach the call log below. A capped height with its own scrollbar
   keeps every card a predictable size while still showing a real, readable
   chunk of text before scrolling is needed.
   The cap is a whole number of lines (lh is the element's own line height:
   0.9rem at 1.5, 19.44px at the default root size), so the box ends between
   two lines. A cap in pixels that is not a multiple of the line height, such
   as 340px (17.49 lines), cuts the last visible line through its letters,
   which shows under the fade as a row of letter tops. 18 lines is the whole
   number nearest 340px that shows no less text. A browser without lh keeps
   the 340px. */
.card-body-scroll {
  max-height: 340px;
  max-height: 18lh;
  overflow-y: auto;
  padding-right: 4px;
}

/* Shared scrollbar treatment for the two scrollable regions on this page
   (agent-card bodies, and .history-list below) - the same weight and the
   same white-opacity tiers for both. .history-list
   previously had NO scrollbar styling at all, which is exactly why
   Chrome/Edge fell back to their bulky native default there (wide enough
   to visually collide with a history card's own border) while
   .card-body-scroll looked fine - it already had this treatment,
   .history-list simply never got it.

   Three tiers, per exact spec: very transparent white at rest; more
   opaque once the *container* (the card, or the history list) is
   hovered, for discoverability; most opaque while the thumb itself is
   actively being clicked and dragged.

   The rest<->hover tiers are driven by --scrollbar-thumb-opacity, a CSS custom
   property written every animation frame by attachScrollbarFade() in app.js -
   NOT a bare CSS :hover selector, and NOT a CSS transition or animation on the
   colour itself. Each individual frame is just an ordinary instant
   custom-property change, which every browser already handles fine - repeated
   every frame for ~SCROLLBAR_FADE_MS, that produces a real smooth fade with no
   CSS animation involved. The 15% literal below is only the fallback for the
   (practically unreachable) case the custom property was never set - the real
   values live in app.js's SCROLLBAR_REST_OPACITY/SCROLLBAR_HOVER_OPACITY, and
   this one must match the rest value. CSS can't read a JS constant, so
   tests/shared-constants.test.js compares the two.

   The dragging tier is unrelated to any of the above: a
   ::-webkit-scrollbar-thumb:active rule (see the fallback block further
   down), snapping instantly rather than fading - direct feedback to a
   physical mouse press, deliberately not softened the way the ambient
   hover cue is. In practice, browser support for it was measured to be
   limited: as measured in September and October 2026 (see the fallback
   block below for versions and dates), Firefox did not apply styling from
   a ::-webkit-scrollbar-* rule at all, and Chrome, Edge and WebKit
   (Safari's engine) ignored every such rule on an element that also sets
   the standard scrollbar-width, as both elements here do. So Chrome, Edge
   and Firefox showed two tiers, rest and hover.

   The custom-property fade mechanism above (--scrollbar-thumb-opacity)
   is unrelated to any of this and unaffected by it - Firefox
   participates in that on equal footing with Chromium, since it's
   driven by the standard scrollbar-color property, not the webkit
   fallback (a real improvement over the older class-swap design: :hover
   was skipped in Firefox there entirely since scrollbar-color has no
   pseudo-classes, but writing a plain custom property recalculates a
   rule that reads it the same way in Firefox as in Chromium, so Firefox
   gets the full fade too, not just an instant two-state jump) - at
   least on Firefox versions/settings where scrollbar-color is actually
   honoured at all; see the fallback block below for the notes about
   Firefox's behaviour. It is also what Chrome and Edge
   render: the thin width and the thumb colour come from these two
   standard properties, not from the fallback block. */
.history-list,
.card-body-scroll {
  scrollbar-width: thin;
  scrollbar-color: color-mix(in srgb, var(--text) var(--scrollbar-thumb-opacity, 15%), transparent) transparent;
}

/* Fallback for browsers that support ::-webkit-scrollbar but not the standard
   scrollbar-width/scrollbar-color properties above - and only those. Measured
   2026-09-26 in headless Edge 153 and Chrome 154: on an element that sets
   scrollbar-width, Chromium ignores every ::-webkit-scrollbar rule for it (a
   30px webkit width measured 10px, the same as scrollbar-width: thin on its
   own). Both elements here set it, so in those versions of Chrome and Edge
   nothing in this block applied - not the 8px width, not the thumb colour, not
   the :active tier. Measured 2026-10-02 in WebKit 26.6 (Safari's engine, run
   through Playwright's Windows build), the same holds: with scrollbar-width
   set, an 8px or a 30px webkit width laid out at 0px, against 8px and 30px on
   an element without it. WebKit also supports scrollbar-width but not
   scrollbar-color, so there the thumb keeps WebKit's own colour.
   In Firefox (as of 2026-09-04): versions 153 and 154 treated a non-zero
   width here as a request for classic, always-visible scrollbars (their
   release notes: "rules with non-zero width/height disable overlay
   scrollbars for the affected container"). Firefox 155 limited that to a
   small list of sites, kept in its layout.css.fake-webkit-scrollbar.
   enabled-domains preference (MDN's Firefox 155 notes, read 2026-10-05:
   https://developer.mozilla.org/en-US/docs/Mozilla/Firefox/Releases/155),
   which this site was not on; a test page in Firefox 155 confirmed it on
   2026-09-04, and showed the same with or without an @supports gate on
   this block, so it has none. Firefox on Windows drew
   its own overlay scrollbar - hidden at rest, a thin indicator on hover,
   wider with the pointer on it - in the colour scrollbar-color sets, and
   no CSS property turned that off (a Mozilla engineer on
   bugzilla.mozilla.org: "no built-in CSS property to globally disable
   overlay scrollbars; this is controlled by the operating system"). A
   visitor can bring classic scrollbars back with Windows 11's "Always
   show scrollbars" setting (Settings -> Accessibility -> Visual effects),
   or by setting the about:config preference
   widget.non-native-theme.scrollbar.style to 4. */
.history-list::-webkit-scrollbar,
.card-body-scroll::-webkit-scrollbar {
  width: 8px;
}

.history-list::-webkit-scrollbar-track,
.card-body-scroll::-webkit-scrollbar-track {
  background: transparent;
}

.history-list::-webkit-scrollbar-thumb,
.card-body-scroll::-webkit-scrollbar-thumb {
  background: color-mix(in srgb, var(--text) var(--scrollbar-thumb-opacity, 15%), transparent);
  border-radius: 999px;
}

.history-list::-webkit-scrollbar-thumb:active,
.card-body-scroll::-webkit-scrollbar-thumb:active {
  background: color-mix(in srgb, var(--text) 50%, transparent);
}

.badge {
  display: inline-block;
  border-radius: 999px;
  /* Fallback only - see the @supports block below, which the current release of
     every common browser took as of 2026-09-27. Symmetric padding centres the
     line box, not the text, and where the text lands in that box depends on the
     font: the page uses the system font stack, so Windows draws badges in Segoe
     UI (tall ascent, text sits 1-2px low) and Android in Roboto (balanced, text
     roughly centred). No fixed padding is right for both. */
  padding: 3px 10px;
  font-size: 0.75rem;
  /* Keeps a badge that wraps onto two lines compact. With the text-box trim
     below, it changes only the gap between wrapped lines, not a one-line
     pill's height; without the trim it also sets that height, about 1.1em
     plus the 6px of padding. */
  line-height: 1.1;
  /* white-space: normal lets a multi-word badge (e.g. "abort requested")
     wrap between words when its column is narrow. Deliberately no
     word-break: break-word here, unlike earlier - that broke single-word
     badges like "degenerated" mid-character ("degenerat" / "ed") the
     moment the column got tight, which reads as broken rather than
     responsive. overflow-wrap: break-word on the parent <td> is still
     there as a last-resort fallback for a genuinely unbreakable string,
     but no real badge text here needs it. */
  white-space: normal;
}

/* Centres badge text the same way whatever font the device draws it in.
   text-box trims the badge's content down to the band between the baseline and
   the top of a capital letter, so the padding is measured from the letters
   themselves rather than from the font's own (and font-specific) ascent and
   descent. Rendered in Edge, Firefox and WebKit (Safari's engine) on
   2026-09-27, every call-log and sidebar label sat within 0.25px of the same
   place in Segoe UI (Windows) as in Roboto (Android).
   The bottom padding is 0.1em - 0.4px larger than the top: 0.1em sets the
   pill's centre halfway between the middle of a capital letter and the middle
   of a lowercase one, and the 0.4px, split across top and bottom, lowers the
   text 0.2px from there, which looked better centred on screen. Every badge
   label is lowercase. Some have an ascender and no descender ("truncated",
   "failed", "aborted"); the rest have a descender, with or without an ascender
   ("degenerated"), or neither ("success").
   Measured by their ink in Edge on 2026-09-27, the first family sat slightly
   above the pill's centre and the second slightly below it, a descender pulling
   the ink box down further than the letters' bodies; "no response", with a
   descender and no ascender to balance it, measured lowest. Measured instead by
   where the ink's weight sits, it sat as close to centre as every other label:
   its thin descender stretches the ink's box far more than it moves the
   letters' visual centre. Rendering snaps to the screen's pixel grid, so
   sub-pixel changes here show only approximately.
   The total, 0.8em + 6px plus a capital's height (~0.7em), makes a
   one-line pill about 1.5em + 6px tall (22px at the default text size)
   whatever the line-height. */
@supports (text-box: trim-both cap alphabetic) {
  .badge {
    text-box: trim-both cap alphabetic;
    padding: calc(0.35em + 3.2px) 10px calc(0.45em + 2.8px);
  }
}

.badge-ok {
  background: color-mix(in srgb, var(--ok) 25%, transparent);
  color: var(--ok);
}

.badge-warn {
  background: color-mix(in srgb, var(--warn) 25%, transparent);
  color: var(--warn);
}

.badge-fail {
  background: color-mix(in srgb, var(--fail) 25%, transparent);
  color: var(--fail);
}

.badge-progress {
  background: color-mix(in srgb, var(--progress) 25%, transparent);
  color: var(--progress);
}

.badge-aborted {
  background: color-mix(in srgb, var(--aborted) 25%, transparent);
  color: var(--aborted);
}

/* A soft-edged separator above the "Model: ..."/"Answered by: ..." line -
   .model-chain is the shared class for both (the still-loading model line in
   buildAgentStatusBody() and the post-success "Answered by:" line in
   buildRepresentativeCard()/buildJudgeCard()), so one rule covers both cases. A
   plain border-top would read as a harsh, abrupt cutoff against the surrounding
   prose - ::before with a gradient background instead tapers to transparent at
   both ends, positioned in the gap padding-top opens up above the text
   (position: relative on .model-chain gives ::before's absolute positioning
   something to anchor to). */
.model-chain {
  color: var(--text-dim);
  font-size: 0.78rem;
  margin: 6px 0 0;
  padding-top: 10px;
  position: relative;
}

.model-chain::before {
  content: '';
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  height: 1px;
  background: linear-gradient(to right, transparent, var(--border) 15%, var(--border) 85%, transparent);
}

.model-name {
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: 0.92em;
}

/* The date and time fragments in formatDateTimeHtml() (app.js) - each one
   individually protected from wrapping, while the <br /> between them is
   the only break point allowed. No min-width/scroll fallback needed for
   this: each fragment is short enough (max ~12 characters) to fit
   realistic column widths on its own even where the full combined string
   would not have. */
.datetime-part {
  white-space: nowrap;
}

.spinner {
  display: inline-block;
  width: 14px;
  height: 14px;
  vertical-align: -2px;
  margin-right: 8px;
  border: 2px solid color-mix(in srgb, var(--accent) 30%, transparent);
  border-top-color: var(--accent);
  border-radius: 50%;
  /* Duration must match SPINNER_ANIMATION_MS in app.js (asserted by
     tests/shared-constants.test.js). With the reduced-motion duration
     below, it is what keeps a freshly re-created spinner element visually
     in sync with ones already on screen, via a computed negative
     animation-delay - see spinnerHtml() in app.js. */
  animation: spin 0.8s linear infinite;
}

/* Used for the run-history placeholder and the main loading overlay -
   elements created once per fetch, or once in index.html, rather than
   recreated on every card update - so they don't need the animation-delay
   trick app.js uses for the per-card spinners. */
.spinner.spinner-lg {
  width: 28px;
  height: 28px;
  margin-right: 0;
  border-width: 3px;
}

@keyframes spin {
  to {
    transform: rotate(360deg);
  }
}

/* Must match SPINNER_REDUCED_MOTION_MS in app.js, and stay a whole multiple
   of the 0.8s above (both asserted by tests/shared-constants.test.js) - see
   spinnerHtml() in app.js. */
@media (prefers-reduced-motion: reduce) {
  .spinner {
    animation-duration: 2.4s;
  }
}

.verdict-justified {
  color: var(--ok);
  font-weight: 700;
}

.verdict-not-justified {
  color: var(--fail);
  font-weight: 700;
}

.table-scroll {
  overflow-x: auto;
  border: 1px solid var(--border);
  border-radius: var(--radius);
}

table.call-log {
  /* Deliberately no min-width here, and nothing forcing any column to
     nowrap - width: 100% plus table-layout: fixed guarantees the table
     never exceeds its container no matter how narrow, so .table-scroll's
     overflow-x: auto never actually has anything to scroll. A horizontal
     scrollbar is explicitly not wanted here even as a fallback; wrapped
     text is the accepted outcome on a narrow viewport instead.
     This size (and the padding below) used to be the >1000px-viewport
     default, with a smaller size only kicking in below that - but this
     table's real rendered width is capped by .main's max-width (1040px)
     regardless of viewport, so even a very wide window gives this table
     only ~974px to work with, measured. A viewport media query alone can't
     see that, so a "wide" viewport still looked cramped. Compact is now
     just the permanent default; see the breakpoints further down for the
     cases that still shrink it further (a viewport under 1150px, and a
     phone under ~480px, where the sidebar is already stacked). */
  width: 100%;
  border-collapse: collapse;
  table-layout: fixed;
  font-size: 0.78rem;
}

table.call-log th,
table.call-log td {
  padding: 7px 8px;
  text-align: center;
  border-bottom: 1px solid var(--border);
  /* Deliberately much fainter than the row divider above - a hint of
     separation between columns, not a visible grid. */
  border-right: 1px solid color-mix(in srgb, var(--border) 40%, transparent);
  overflow-wrap: break-word;
}

table.call-log th:last-child,
table.call-log td:last-child {
  border-right: none;
}

table.call-log th {
  background: var(--bg-raised);
  color: var(--text-dim);
  font-size: 0.72rem;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  /* Every header is a single word ("Agent", "Type", "Duration", ...) -
     none of them ever needs to wrap at all, let alone mid-word. Overrides
     the overflow-wrap: break-word inherited from the shared th/td rule
     above, which data cells genuinely need (long model ids, status
     captions) but headers never do. */
  white-space: nowrap;
}

table.call-log tbody tr:last-child td {
  border-bottom: none;
}

/* Sums every logged row, including discarded retries/escalations - see
   renderCallLogTotals() in app.js. Set off from the ordinary rows above it
   (a heavier top border, a slightly raised background, bold first cell)
   since it's a computed summary, not another real call. */
table.call-log tfoot tr.call-log-total-row td {
  border-top: 2px solid var(--border);
  background: var(--bg-raised);
  font-weight: 600;
}

/* Both of the totals row's own cells span several columns - the title covers
   Agent/Type/Model, the footnote covers Status/Time - and both are centred
   within that span by the shared table.call-log td rule above, like every
   other cell in the table. The title carried a `text-align: left` override
   here until it was removed: across three columns that read as though the
   caption belonged to the Agent column alone rather than to the span it
   actually covers. Nothing replaces it, since centred is already the
   inherited behaviour. The card layout below 720px still left-aligns both
   deliberately - there every cell is its own full-width block, so there is
   no span to sit in the middle of, and a heading reads better flush left. */

/* A discarded attempt, followed by a retry or an escalation. Three kinds
   qualify, all matched in renderCallLog() in app.js: truncated or degenerate
   content (DEGENERATE_RETRIED_*_MARKER), a plain HTTP failure that escalated to
   the next tier (HTTP_ERROR_ESCALATED_MARKER), and a transient failure that was
   retried or escalated (TRANSIENT_RETRIED_MARKER). Dimmed to signal the failure
   did not end the call, distinct from a [degenerate-final] row (nothing left to
   try - the last tier failing too, or no time budget for another), which
   deliberately stays at full opacity since that outcome genuinely is fatal.
   The abort endpoint's "abort requested" rows are dimmed too: they record
   the request to stop, not an outcome, and the role's own last row
   follows. That last row - [aborted-mid-call], when the call was still
   running - is the role's outcome, so it stays at full opacity. */
table.call-log tbody tr.row-dimmed {
  opacity: 0.53;
}

table.call-log abbr {
  text-decoration-style: dotted;
  cursor: help;
}

/* Type and Model each render both a short and a full spelling, and exactly
   one is ever shown (see formatCallTypeHtml/renderCallLog in app.js). The
   table view is the width-constrained one, so it takes the abbreviated
   form with the full text on hover; the card view below 720px swaps them.
   Doing it this way keeps a single render path - no width checks in JS,
   no second DOM to keep in sync. */
table.call-log .col-full {
  display: none;
}

.status-caption {
  color: var(--text-dim);
  font-size: 0.72rem;
  margin-top: 2px;
}

/* In the table, a caption is centred in a narrow column and is often just
   too long for one line, so greedy wrapping left its last word alone:
   "(retried with the same / model)", "(the user aborted the / trial)".
   `pretty` keeps the lines as full as it can while moving a word down to
   keep the last one company: "(the user aborted / the trial)". A browser
   without it keeps `balance`, which evens the lines out instead and splits
   that one as "(the user / aborted the trial)". Measured in headless Edge
   on 2026-10-13 from a 640px table to a 974px one: no caption left alone
   on its last line, and no row taller than before. Table view only: in the
   card view below 720px a caption has a whole row and fits on one line, so
   that layout is left exactly as it was. A browser with neither wraps as
   before. */
@media (width > 720px) {
  table.call-log .status-caption {
    text-wrap: balance;
    text-wrap: pretty;
  }
}

/* Deterministic centring for a cell that stacks two elements vertically
   (a bold total over a dim breakdown in the Tokens column; a badge over
   a status-caption in the Status column) - text-align: center on the
   <td> alone left room for doubt here: an inline badge/strong followed
   by a block-level status-caption <div> makes the browser wrap the
   leading inline content in its own anonymous block box, and depending
   on exactly how surrounding template-literal whitespace collapses,
   that anonymous box is not guaranteed to end up the same width as the
   explicit block below it. flex + align-items: center centres each
   child by its own actual box, sidestepping that question entirely
   rather than trusting the anonymous-box behaviour to work out. */
.cell-stack {
  display: flex;
  flex-direction: column;
  align-items: center;
}

/* The sidebar-stacking breakpoint further down is 900px, but that alone
   doesn't mean the table has real room again the moment it's crossed -
   the sidebar (clamp(135px, 20vw, 360px)) keeps taking a fifth of the
   viewport right up to ~1800px, so from 900px up to roughly 1150px, the
   two are still splitting a viewport too narrow for both: at exactly
   960px, .main is only left with ~700px after the sidebar and its own
   padding - well under what the base sizing above assumes. Content
   overflowing a column at this width is what produced a "skewed,
   cropped" look reported at 960px (text-align: center still centres the
   content, but a neighbouring cell's background then paints over
   whichever side overflows past the column's real boundary) - the actual
   fix is making sure nothing overflows in the first place, not the
   centring rule itself.
   Placed before the narrower 480px breakpoint below, not after - both
   match at a sub-480px viewport, and CSS resolves that tie by source
   order (equal specificity, last one wins), so the more aggressive
   480px sizing needs to come second or it would be silently overridden
   by this milder tier at the very viewports that need the most help. */
@media (max-width: 1150px) {
  table.call-log {
    font-size: 0.74rem;
  }
  table.call-log th,
  table.call-log td {
    padding: 6px 6px;
  }
  table.call-log th {
    font-size: 0.66rem;
  }
}

/* Below 720px the call log stops being a table and becomes one card per
   call.
   Eight columns cannot be made to work at these widths. Shrinking type
   and padding (which is all the old sub-480px rule here did) only
   postpones the problem: at a 413px viewport every cell was wrapping
   mid-word into a ragged column two or three characters wide, with
   "Truncated (retried with the same model)" running down the screen one
   word per line. That is a layout that has genuinely run out of room,
   not one that needs tuning, so below this breakpoint each row is laid
   out as a small card instead - the label on the left, the value on the
   right, one field per line.
   Done purely in CSS against the same markup rather than by rendering a
   second DOM: the cells carry their column name in data-label (see
   renderCallLog() in app.js), so there is one render path, no resize
   listener, and no chance of a card view drifting out of sync with the
   table it mirrors. The <thead> is hidden rather than removed, so the
   table stays a real table for assistive tech and for the totals row. */
@media (max-width: 720px) {
  .table-scroll {
    /* The cards carry their own borders; an outer frame around them
       reads as a stray box once the table grid is gone. */
    border: none;
    border-radius: 0;
  }

  table.call-log,
  table.call-log tbody,
  table.call-log tfoot,
  table.call-log tr,
  table.call-log td {
    display: block;
    width: auto;
  }

  /* Visually hidden rather than display: none - the header cells are what
     give the data cells their accessible names. */
  table.call-log thead {
    position: absolute;
    width: 1px;
    height: 1px;
    overflow: hidden;
    clip: rect(0 0 0 0);
    clip-path: inset(50%);
    white-space: nowrap;
  }

  table.call-log tr {
    border: 1px solid var(--border);
    border-radius: var(--radius);
    background: var(--bg-raised);
    margin-bottom: 10px;
    padding: 4px 2px;
  }

  /* The label lives in a fixed left gutter as an absolutely positioned
     pseudo-element, and the value stays in ordinary flow.
     Flex and grid were both tried first and both are wrong here, for the
     same underlying reason: they turn every child element of the cell into
     a separate item. A cell whose value is a single text node survives
     that, but two of these are not - the Status cell holds a badge (which
     then stretched to the full column width) and the Time cell holds two
     spans (the second of which wrapped onto its own row, left-aligned,
     nowhere near its label). Flex additionally refused to shrink an
     unbroken model id below its min-content width, pushing it off-screen.
     Normal flow has none of those problems: multiple children stay
     inline, wrap naturally, and honour text-align. */
  /* The divider between fields inside one card. Deliberately fainter than
     --border itself: in the table layout that colour separates one call
     from another, but here it runs between every field of a single call,
     many times per card, and at full strength that many lines read as a
     grid drawn over the card rather than as quiet grouping. Named because
     the value has to be identical in two places (see the last-child rule
     just below) and they must not drift apart. */
  table.call-log {
    --card-row-divider: color-mix(in srgb, var(--border) 34%, transparent);
  }

  table.call-log td {
    display: block;
    position: relative;
    text-align: right;
    border-right: none;
    border-bottom: 1px solid var(--card-row-divider);
    /* Left padding is the label gutter. "Duration" is the longest label and
       needs about 53px at this size (measured - see the label rule below);
       92px leaves real margin plus a gap. */
    padding: 6px 10px 6px 92px;
    min-height: 1.2em;
  }

  /* The base table rules strip the final ROW's bottom borders, which is
     right for a table and wrong for cards, where every row is its own
     box. Restore the divider first, then remove it only on each card's
     own last field. Both of these tie the base rule on specificity and
     win on source order, so the order here matters. */
  table.call-log tbody tr:last-child td {
    border-bottom: 1px solid var(--card-row-divider);
  }

  table.call-log tbody tr td:last-child,
  table.call-log tfoot tr td:last-child {
    border-bottom: none;
  }

  table.call-log td::before {
    content: attr(data-label);
    position: absolute;
    left: 10px;
    /* Centred against the whole row rather than against the value's first
       line. top: 50% resolves against the cell's padding box and the
       translate pulls the label back by half its own height, so the label
       lands on the row's vertical centre whatever the value's height -
       one line, a two-line token stack, a taller status badge, or a model
       id that wraps. The cell's top and bottom padding are equal, so
       centring in the padding box is centring in the visible row.
       This supersedes an earlier approach that matched the label's line
       box to the value's first line and pinned it to the cell's
       padding-top: correct for single-line rows, but it left the label
       sitting up against the first line on every multi-line one. */
    top: 50%;
    transform: translateY(-50%);
    color: var(--text-dim);
    /* Just under the value it labels (0.74rem), so the label still reads as
       subordinate - but that is carried by the dim colour, the uppercase and
       the letter-spacing, not by being conspicuously tiny. It was 0.66rem
       from the day this layout was written, which rendered at 10.56px
       then and 9.50px once :root dropped to 90%: the same
       declaration, a pixel smaller, and at this size a pixel is ten per
       cent. This restores roughly the original appearance without opting
       the label out of that deliberate global scale-down. The 92px gutter
       is nowhere near a constraint on this - the longest label, DURATION,
       needs 53px of the 82px usable. */
    font-size: 0.72rem;
    text-transform: uppercase;
    letter-spacing: 0.04em;
    text-align: left;
    /* Labels are single words and must never wrap inside the gutter. */
    white-space: nowrap;
  }

  /* The agent's name is the card's heading, not another labelled field. */
  table.call-log tbody td[data-label='Agent'] {
    text-align: left;
    font-weight: 700;
    font-size: 0.9rem;
    padding: 8px 10px 6px;
  }

  table.call-log tbody td[data-label='Agent']::before {
    content: none;
  }

  /* Both of these stack a value over a dim caption; against a right-hand
     edge they read better right-aligned as a unit. */
  table.call-log td .cell-stack {
    align-items: flex-end;
    text-align: right;
  }

  /* A card row has room for the whole value, so these show the real thing
     rather than the table's width-driven abbreviations: "Representative"
     instead of "R", and the model id complete with its vendor prefix. No
     <abbr> either - a hover tooltip is close to useless on the touch
     devices this layout is for. */
  table.call-log .col-short {
    display: none;
  }

  table.call-log .col-full {
    display: inline;
  }

  /* formatDateTimeHtml splits the timestamp across two lines for the
     narrow Time column. A card row fits it on one line, so the break is
     dropped - but the two spans have no whitespace between them in the
     markup, and a space introduced via content would be collapsed away,
     so the gap is a margin.
     Selected via `br + .datetime-part`, not `.datetime-part +
     .datetime-part`: the <br> is still an element between the two spans
     even while display: none, so the adjacent-sibling combinator does not
     see them as adjacent to each other. */
  table.call-log td[data-label='Time'] br {
    display: none;
  }

  table.call-log td[data-label='Time'] br + .datetime-part {
    margin-left: 0.35em;
  }

  /* The totals row is a card too. Its per-cell top border and raised
     background are what set it apart inside a table; as a card those
     would draw a 2px line above every one of its fields, so they move to
     the card itself. */
  table.call-log tfoot tr.call-log-total-row td {
    border-top: none;
    background: transparent;
  }

  /* The totals card is a computed summary, not another call, so it carries
     both a thicker and a brighter border than the ordinary cards - in the
     table view that separation comes from a heavy top rule and a raised
     background, neither of which reads as a divider once every row is its
     own box. Mixed towards --text-dim rather than set to a flat lighter
     grey so it still belongs to the same palette as --border. */
  table.call-log tfoot tr.call-log-total-row {
    border-width: 2px;
    border-color: color-mix(in srgb, var(--border) 60%, var(--text-dim));
  }

  /* Its first and last cells are a heading and a footnote rather than
     labelled fields. */
  table.call-log tfoot tr.call-log-total-row td.total-row-title,
  table.call-log tfoot tr.call-log-total-row td.total-row-note {
    text-align: left;
    padding-left: 10px;
  }

  table.call-log tfoot tr.call-log-total-row td.total-row-title {
    font-size: 0.9rem;
  }
}

/* A real narrow-phone viewport. The call log is already a card layout by
   here (see the 720px block above), so this only trims the type a little
   further for the smallest screens this app targets, down to ~350px. */
@media (max-width: 480px) {
  table.call-log {
    font-size: 0.68rem;
  }
  /* The left value must stay the label gutter. An earlier version of this
     rule set `padding: 5px 8px`, which quietly overrode the 92px gutter
     the 720px block establishes and let a long value (a full model id)
     run underneath its own label - invisible at any width above 480px. */
  table.call-log td {
    padding: 5px 8px 5px 84px;
  }
  /* The values shrink to 0.68rem here, so the label has to come back down
     with them or it would end up larger than the field it labels. 0.66rem
     against a 0.68rem value is the same proportion the wider tier now has
     (0.72 against 0.74), so the label sits the same distance below its
     value at both sizes rather than changing character between them. */
  table.call-log td::before {
    font-size: 0.66rem;
  }

  table.call-log tbody td[data-label='Agent'],
  table.call-log tfoot tr.call-log-total-row td.total-row-title {
    font-size: 0.84rem;
  }
}

/* Below this, the sidebar stops competing with .main for horizontal
   space at all and moves above it instead, full-width. Raised from
   720px - at that threshold the two were still fighting over real
   estate neither could spare: Status/Time in the call log table were
   observed breaking down at a 896px viewport, well above the old
   breakpoint. 900px (rather than the ~850px first suggested) is
   deliberately just past that 896px case specifically, so the exact
   viewport that showed the problem falls on the "stacked" side of this
   breakpoint, not just short of it. */
@media (max-width: 900px) {
  .layout {
    flex-direction: column;
  }
  .sidebar {
    width: 100%;
    border-right: none;
    border-bottom: 1px solid var(--border);
    /* Stacked full-width at the top of the page, not a real side-by-side
       sidebar any more - sticky-pinning it here would keep it plastered
       across the whole viewport indefinitely as the user scrolls through
       .main below, which reads as broken rather than useful at this
       width. Reverts to plain, unpositioned flow. The history list still
       needs its own bound, though - a full run history stacked inline
       above the case content would otherwise push everything else an
       arbitrary distance down the page. */
    position: static;
    height: auto;
    overflow: visible;
  }
  .history-list {
    flex: none;
    max-height: 260px;
  }
  .main {
    padding: 20px;
  }
}
