/* ============================================================================
   Control Panel — design tokens + shell styles (Stage 1 + 2)
   Drop this file at web/static/css/panel-v2.css and load it AFTER styles.css:
     <link rel="stylesheet" href="/static/css/panel-v2.css?v=1" />
   It only overrides; nothing in styles.css needs deleting on day one.
   ========================================================================= */

/* No remote @import for IBM Plex Sans/Mono here: the CSP this app serves
   (style-src 'self' 'unsafe-inline', see securityHeaders in main.go) pins
   style sources to the panel's own origin, so a fonts.googleapis.com @import
   has never actually loaded a font — only ever thrown a CSP violation on
   every page load. The --font/--mono tokens below already fall back to
   Helvetica/Arial and ui-monospace, which is what has been rendering all
   along; removing the dead import changes nothing but the console noise. */

/* ---------- tokens ---------- */
body.theme-dark,
body.theme-custom {
  /* Native widgets (the <select> popup list, scrollbars, date pickers) are drawn
     by the browser, not by these tokens — without this they render light-on-light
     and the environment switcher's dropdown opens as white-on-white. */
  color-scheme: dark;
  --bg:        #0b0f14;
  --bg-sunk:   #080c11;
  --surface:   #111820;
  --surface-2: #0d141c;
  --line:      #1e2833;
  --line-soft: #161e27;
  --text:      #e6edf3;
  --text-dim:  #9aa7b4;
  --text-mute: #6d7987;
  --accent:    #5eb0ef;
  --accent-ink:#061019;
  --ok:        #3fb950;
  --warn:      #d29922;
  --danger:    #f85149;
  --radius:    10px;
  --radius-sm: 7px;
  --sidebar-w: 224px;
  --font:      'IBM Plex Sans', Helvetica, Arial, sans-serif;
  --mono:      'IBM Plex Mono', ui-monospace, monospace;
}

body.theme-light {
  color-scheme: light;
  --bg:        #f4f4f2;
  --bg-sunk:   #ffffff;
  --surface:   #ffffff;
  --surface-2: #fbfbfa;
  --line:      #e2e2dd;
  --line-soft: #f0f0ec;
  --text:      #17181a;
  --text-dim:  #55595e;
  --text-mute: #8d9298;
  --accent:    #1e6bb8;
  --accent-ink:#ffffff;
  --ok:        #1f7a3d;
  --warn:      #9a6b12;
  --danger:    #b3261e;
}

body.theme-matrix {
  color-scheme: dark;
  --bg: #000; --bg-sunk: #000; --surface: #020a02; --surface-2: #010501;
  --line: #0f3d0f; --line-soft: #0a2a0a;
  --text: #00ff00; --text-dim: #00c400; --text-mute: #008a00;
  --accent: #00ff00; --accent-ink: #001400;
  --ok: #00ff00; --warn: #d0d000; --danger: #ff4444;
  /* This theme is a single-hue conceit, so the ring keeps its own red rather
     than becoming green-on-green -- the two slices have to stay tellable
     apart even here. */
  --ds-used: #ff4444; --ds-free: #00ff00;
}

/* ---------- base ---------- */
body {
  background: var(--bg);
  color: var(--text);
  font-family: var(--font);
  padding: 0;
  overflow: hidden;               /* sections scroll, the page never does */
}
/* dvh, with vh as the fallback for browsers that lack it: on a phone 100vh is
   the *large* viewport -- the height the page would have if the browser's URL
   bar were hidden -- so a 100vh shell is taller than what is actually on
   screen, and the bottom of it (the tab bar) sits behind the browser chrome.
   The panel scrolls inside .cp-section rather than scrolling the document, so
   that chrome never retracts and the tab bar never comes back. 100dvh is the
   height that is really visible, and it tracks the URL bar as it moves. */
body .container { width: 100%; height: 100vh; height: 100dvh; }

/* numbers, hosts, paths, timestamps, log lines */
.mono, .cp-mono,
#browser-path, #console-output, #console-input,
.svc-port, .split-pane-body { font-family: var(--mono); }

/* ---------- app shell (built by app-shell.js) ---------- */
.cp-app { display: flex; height: 100vh; height: 100dvh; overflow: hidden; }

.cp-side {
  width: var(--sidebar-w); flex: none;
  background: var(--bg-sunk);
  border-right: 1px solid var(--line);
  display: flex; flex-direction: column;
  transition: width .15s ease;
}
@media (prefers-reduced-motion: reduce) {
  .cp-side { transition: none; }
}
.cp-brand {
  cursor: pointer;
  height: 56px; flex: none; display: flex; align-items: center; gap: 10px;
  padding: 0 16px; border-bottom: 1px solid var(--line);
  font-size: 14px; font-weight: 600; letter-spacing: -0.01em;
}
.cp-brand img { width: 24px; height: 24px; border-radius: 6px; object-fit: cover; }

.cp-envbox { padding: 12px 12px 6px; }
.cp-label {
  font-family: var(--mono); font-size: 10px; letter-spacing: 0.12em;
  color: var(--text-mute); padding: 0 6px 8px; display: block;
}
.cp-envcard {
  background: var(--surface-2); border: 1px solid var(--line);
  border-radius: 8px; padding: 8px 10px;
}
/* Not transparent: Chrome/Edge draw the drop-down list from the control's own
   background, so a transparent select opens a white list on a dark page. This
   is the same colour as .cp-envcard, so the closed control still reads as flush
   with the card. */
.cp-envcard select {
  width: 100%; background: var(--surface-2); border: none; color: var(--text);
  font-family: var(--font); font-size: 13px; font-weight: 600; padding: 0;
  cursor: pointer; outline: none;
}
.cp-envmeta {
  display: flex; align-items: center; gap: 6px; margin-top: 6px;
  font-family: var(--mono); font-size: 11px; color: var(--text-mute);
}

.cp-nav { padding: 10px 12px; display: flex; flex-direction: column; gap: 2px; overflow-y: auto; }
/* The rail is a fixed-height column, so the nav is the child that has to give
   way -- stated rather than left to the flex-shrink default, because a scroll
   container's automatic minimum size is exactly the thing browsers disagree
   about, and the packaged WebView is not the one we develop against. */
.cp-nav { flex: 1 1 auto; min-height: 0; }
/* The list scrolls on a short window, and left to the browser that is a 15px
   classic scrollbar with an arrow button at each end -- the widest chrome in
   the panel, sitting inside a 12px-padded rail. Every other scroller the
   design owns is thin or hidden (.cp-tablist, .cp-m-envchips,
   #dbclient-content), so this matches them. Both the standard properties and
   the -webkit- ones: newer Chrome honours the first pair, the WebView the
   packaged app ships against may only know the second. */
.cp-nav { scrollbar-width: thin; scrollbar-color: var(--line) transparent; }
.cp-nav::-webkit-scrollbar { width: 8px; }
.cp-nav::-webkit-scrollbar-track { background: transparent; }
.cp-nav::-webkit-scrollbar-thumb {
  background: var(--line); border-radius: 4px;
  border: 2px solid transparent; background-clip: padding-box;
}
.cp-nav::-webkit-scrollbar-thumb:hover {
  background: var(--text-mute); background-clip: padding-box;
}
/* A fade on whichever edge still has list behind it. Without one, a row the
   fold cuts in half reads as a whole row that does not work: its lower half is
   outside the scroller, so the press lands on Collapse underneath and the
   sidebar folds to icons instead of the section opening. app-shell.js sets the
   classes; no mask support means no fade, which is where we were. */
.cp-nav.is-clip-top.is-clip-bottom {
  -webkit-mask-image: linear-gradient(to bottom, transparent 0, #000 16px, #000 calc(100% - 16px), transparent 100%);
          mask-image: linear-gradient(to bottom, transparent 0, #000 16px, #000 calc(100% - 16px), transparent 100%);
}
.cp-nav.is-clip-top:not(.is-clip-bottom) {
  -webkit-mask-image: linear-gradient(to bottom, transparent 0, #000 16px);
          mask-image: linear-gradient(to bottom, transparent 0, #000 16px);
}
.cp-nav.is-clip-bottom:not(.is-clip-top) {
  -webkit-mask-image: linear-gradient(to bottom, #000 calc(100% - 16px), transparent 100%);
          mask-image: linear-gradient(to bottom, #000 calc(100% - 16px), transparent 100%);
}
.cp-nav-item {
  display: flex; align-items: center; gap: 10px; justify-content: space-between;
  padding: 8px 10px; border-radius: var(--radius-sm);
  color: var(--text-dim); font-size: 13px; cursor: pointer;
  border: none; background: none; font-family: var(--font); text-align: left; width: 100%;
  /* A row is never compressed; the list scrolls instead. .cp-nav is a column
     flex container, so once the rail overflows its items are candidates for
     shrinking -- and a flex item only resists because its automatic minimum
     size is its content. The ripple (app-animations.js) adds overflow:hidden
     to whatever is pressed, which resolves that minimum to zero, and the row
     being pressed collapsed from 26px to 10px under the cursor. The press then
     landed below the row and no click was produced at all, so the section
     simply did not open -- on every row, as soon as the rail was one row
     taller than the window. */
  flex: none;
}
.cp-nav-item > span:first-child { display: flex; align-items: center; gap: 10px; }
.cp-nav-item:hover { background: var(--surface-2); color: var(--text); }
.cp-nav-item.is-active { background: #14202c; color: var(--text); font-weight: 500; }
body.theme-light .cp-nav-item.is-active { background: #eaf2fa; }
.cp-nav-item.is-active svg { stroke: var(--accent); }
.cp-nav-item svg { width: 16px; height: 16px; stroke-width: 1.9; fill: none; stroke: currentColor; }
.cp-nav-count { font-family: var(--mono); font-size: 11px; color: var(--text-mute); }
.cp-nav-count.is-alert { color: var(--danger); background: rgba(248,81,73,0.12); border-radius: 4px; padding: 1px 6px; }
.cp-nav-sep { height: 1px; background: var(--line); margin: 12px 6px; flex: none; }

/* Sits directly above .cp-user - both are pushed to the very bottom of the
   flex column, this one by its own margin-top:auto rather than .cp-user's, so
   it lands right above the user row instead of leaving a gap under it. */
.cp-nav-toggle {
  margin-top: auto;
  display: flex; align-items: center; gap: 10px;
  padding: 8px 22px 8px 22px; margin-left: 0; margin-right: 0;
  color: var(--text-mute); font-size: 12px; cursor: pointer;
  border: none; background: none; border-top: 1px solid var(--line);
  font-family: var(--font); text-align: left; width: 100%;
}
.cp-nav-toggle:hover { color: var(--text); background: var(--surface-2); }
.cp-nav-toggle > span:first-child { display: flex; align-items: center; gap: 10px; }
.cp-nav-toggle svg { width: 15px; height: 15px; stroke-width: 1.9; fill: none; stroke: currentColor; flex: none; }

.cp-user {
  padding: 12px; border-top: 1px solid var(--line);
  display: flex; align-items: center; gap: 10px;
}
.cp-avatar {
  width: 28px; height: 28px; border-radius: 50%; background: #223040;
  display: flex; align-items: center; justify-content: center;
  font-size: 11px; font-weight: 600; color: var(--text-dim);
}
.cp-user-name { font-size: 12px; font-weight: 500; line-height: 1.3; }
.cp-user-role { font-family: var(--mono); font-size: 10px; color: var(--text-mute); }
.cp-signout {
  margin-left: auto; flex: none; width: 26px; height: 26px;
  display: flex; align-items: center; justify-content: center;
  border: 1px solid transparent; border-radius: var(--radius-sm);
  background: none; color: var(--text-mute); font-size: 13px; cursor: pointer;
}
.cp-signout svg { width: 15px; height: 15px; stroke-width: 1.9; fill: none; stroke: currentColor; flex: none; }
.cp-signout:hover { background: var(--surface-2); border-color: var(--line); color: var(--danger); }

/* ---------- collapsed nav: icon rail only, more room for whatever section is open ---------- */
.cp-side.is-collapsed { width: 64px; }
.cp-side.is-collapsed .cp-brand { justify-content: center; padding: 0; gap: 0; }
.cp-side.is-collapsed .cp-brand #app-title,
.cp-side.is-collapsed .cp-envbox,
.cp-side.is-collapsed .cp-nav-label,
.cp-side.is-collapsed .cp-nav-count,
.cp-side.is-collapsed .cp-user-name,
.cp-side.is-collapsed .cp-user-role {
  display: none;
}
.cp-side.is-collapsed .cp-nav-item,
.cp-side.is-collapsed .cp-nav-toggle {
  justify-content: center; padding-left: 0; padding-right: 0;
}
.cp-side.is-collapsed .cp-nav-toggle svg { transform: rotate(180deg); }
.cp-side.is-collapsed .cp-user { justify-content: center; padding: 12px 6px; }
.cp-side.is-collapsed .cp-signout { margin-left: 0; }

/* ---------- main column ---------- */
.cp-main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.cp-topbar {
  height: 56px; flex: none; padding: 0 20px;
  border-bottom: 1px solid var(--line); background: var(--surface-2);
  display: flex; align-items: center; justify-content: space-between; gap: 16px;
}
.cp-topbar-titles { display: flex; align-items: baseline; gap: 12px; min-width: 0; }
/* Truncates rather than wraps. .cp-topbar-titles is the flex child that
   gives way when the action buttons want the room, and a title with no
   min-width of its own overflowed the box it had been squeezed into --
   between ~760 and ~880px "My dashboard" broke onto a second line and ran
   under the RAM chip. The phone block below sets the same three properties
   at a larger size; this makes them the rule at every width. */
.cp-title {
  font-size: 16px; font-weight: 600; min-width: 0;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.cp-subtitle { font-family: var(--mono); font-size: 11px; color: var(--text-mute); }
.cp-topbar-actions { display: flex; align-items: center; gap: 10px; }
/* The environment readout on the bar (app-shell.js). Sized like the RAM chip
   beside it so the two read as one group, but it is a button: the dot is the
   only thing left saying which host the page operates on once the rail
   collapses, and pressing it opens the rail's switcher.
   Themed from the tokens rather than the neighbouring chip's hardcoded rgba --
   that fill is a dark translucent slab, which is wrong on Light, and this is
   the one chip on the bar that has to stay readable. */
.cp-topbar-env {
  display: inline-flex; align-items: stretch;
  max-width: 250px; white-space: nowrap;
  font-family: var(--font); font-size: 12px; font-weight: 600;
  color: var(--text-dim);
  height: 28px;
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: 6px;
  cursor: pointer;
  /* box-shadow only, unlike the RAM chip's four. The colours here come from
     the theme tokens, and Chromium leaves a transitioned property stale when
     the only thing that changed is the custom property behind it -- switching
     to Light left this chip painted in the dark palette until some unrelated
     recalc repainted it. Untransitioned, it re-resolves with the theme. */
  transition: box-shadow 0.15s;
}
.cp-topbar-env:hover {
  color: var(--text);
  border-color: var(--text-mute);
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
/* The chip is two controls in one shape: the name opens the switcher, the arrow
   opens this environment in a window of its own. A nested <button> would be
   invalid markup, so the chip itself is the frame and neither half draws one. */
.cp-topbar-env-main,
.cp-topbar-env-open {
  display: inline-flex; align-items: center;
  font-family: inherit; font-size: inherit; font-weight: inherit;
  color: inherit; background: transparent; border: 0;
  cursor: pointer; min-width: 0;
}
.cp-topbar-env-main { gap: 7px; padding: 0 4px 0 11px; flex: 0 1 auto; }
/* An <a>, not a button, so ctrl/middle/right-click all do what a link does.
   That means the page's own link colour and underline have to be turned off
   here: this one is chrome, not prose. */
.cp-topbar-env-open {
  padding: 0 8px 0 6px; flex: 0 0 auto;
  color: var(--text-mute);
  text-decoration: none;
  border-left: 1px solid var(--line);
  margin-left: 3px;
}
.cp-topbar-env-open:link,
.cp-topbar-env-open:visited { color: var(--text-mute); }
.cp-topbar-env-open:hover,
.cp-topbar-env-open:focus-visible { color: var(--text); text-decoration: none; }
/* No environment selected: refreshEnvDot drops the href, so say so visually
   rather than leaving a live-looking link that goes nowhere. */
.cp-topbar-env-open:not([href]) { opacity: .4; cursor: default; }
body.has-env-accent .cp-topbar-env-open { border-left-color: var(--env-accent); }
.cp-topbar-env.is-down .cp-topbar-env-open { border-left-color: var(--danger); }

/* Truncates rather than pushing the buttons off the bar: environment names are
   free text and "uat-emea-secondary-failover" is a realistic one. */
.cp-topbar-env-name { overflow: hidden; text-overflow: ellipsis; min-width: 0; }
/* currentColor, so app-shell.js sets one property and the glow cannot disagree
   with the dot: it paints the verdict as `color` (mute while unknown, --ok when
   the first hop answered, --danger when it did not). */
.cp-topbar-env-dot {
  width: 7px; height: 7px; border-radius: 50%; flex: none;
  color: var(--text-mute);
  background: currentColor;
  box-shadow: 0 0 6px currentColor;
}
/* .is-on and .is-down are set by shell.refreshEnvDot -- the chip's text, not
   the dot, which paints itself from the colour the same function gives it. */
.cp-topbar-env.is-on { color: var(--text); }
/* Unreachable: the name goes with the dot. A red dot beside a normal-looking
   name is easy to read past, and this is the state the dot exists for. */
.cp-topbar-env.is-down { color: var(--danger); border-color: var(--danger); }

/* ---------- the selected environment's colour ----------------------------
   --env-accent is written on <body> by app-shell.js (publishEnvAccent) from
   the `color` set on the environment in Settings -> Environments, and removed
   when it has none. Every rule below is scoped to .has-env-accent so an
   environment without a colour looks exactly as it always has.

   Health beats identity everywhere: none of these touch the dot, and the chip
   rule cannot fire while the host is unreachable -- .is-on is dropped in that
   state (shell.refreshEnvDot), so .is-down keeps the chip red. */
body.has-env-accent .cp-topbar-env.is-on {
  color: var(--env-accent);
  border-color: var(--env-accent);
  background: var(--env-accent-soft);
}
/* The sidebar's environment card: a bar down its leading edge rather than a
   coloured border, as an inset shadow, so nothing moves by a pixel. */
body.has-env-accent .cp-envcard {
  border-color: var(--env-accent);
  box-shadow: inset 3px 0 0 var(--env-accent);
}
body.has-env-accent .cp-envbox .cp-label { color: var(--env-accent); }
/* The view tab you are standing in. The tabs belong to the environment as much
   as to the view -- the same query, run against PROD, is a different act. */
body.has-env-accent .cp-tab.is-active { box-shadow: inset 0 2px 0 var(--env-accent); }
body.has-env-accent .cp-tab.is-active .cp-tab-icon svg { stroke: var(--env-accent); }
/* The flash the chip's click leaves on the switcher it just opened the rail
   for -- a focus ring on a select inside a card reads as nothing much. */
@keyframes cp-envcard-flash {
  0%, 100% { box-shadow: none;                 border-color: var(--line); }
  25%      { box-shadow: 0 0 8px var(--accent); border-color: var(--accent); }
}
.cp-envcard.is-flash { animation: cp-envcard-flash 1.1s ease-in-out; }

.cp-topbar-mem {
  display: inline-flex;
  align-items: center;
  /* height below is fixed, so wrapping does not grow the chip -- it cuts
     the text in half. On a narrow window the flex row squeezed this until
     "RAM:" and "MB" sat outside the 28px box. */
  white-space: nowrap;
  font-size: 11px;
  font-family: var(--mono);
  font-weight: 500;
  color: var(--text-mute);
  padding: 0 10px;
  height: 28px;
  line-height: 26px;
  background: rgba(30, 41, 59, 0.4);
  border: 1px solid rgba(255, 255, 255, 0.08);
  border-radius: 6px;
  margin-right: 2px;
  cursor: default;
  user-select: none;
  transition: color 0.15s, border-color 0.15s, background-color 0.15s, box-shadow 0.15s;
}
.cp-topbar-mem:hover {
  color: var(--text-bright);
  border-color: rgba(255, 255, 255, 0.25);
  background: rgba(30, 41, 59, 0.7);
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
.cp-topbar-mem svg {
  color: var(--accent);
  transition: color 0.15s;
}
.cp-topbar-mem:hover svg {
  color: var(--text-bright);
}

/* ---------- view tabs (app-viewtabs.js) ----------------------------------
   The strip of views you have been in, between the top bar and the section
   below it. Sunk a shade below both so it reads as chrome rather than as part
   of the panel it sits over, and the active tab lifts back up to the section's
   own surface — the tab and the view it opens are one surface, the way a
   browser tab meets its page. Phone-only shell has its own bottom tab bar
   (.cp-m-tabs); this is hidden there, see the 760px block below. */
.cp-tabstrip {
  flex: none; height: 40px; padding: 0 8px;
  background: var(--bg-sunk); border-bottom: 1px solid var(--line);
  display: flex; align-items: stretch; min-width: 0;
}
.cp-tablist {
  display: flex; align-items: stretch; gap: 4px; min-width: 0;
  overflow-x: auto; overflow-y: hidden;
  scrollbar-width: none;                 /* the strip scrolls, quietly */
}
.cp-tablist::-webkit-scrollbar { height: 0; }

.cp-tab {
  flex: none; display: flex; align-items: center; gap: 8px;
  max-width: 230px; padding: 0 6px 0 10px; margin: 4px 0 0;
  border: 1px solid transparent; border-bottom: none;
  border-radius: var(--radius-sm) var(--radius-sm) 0 0;
  color: var(--text-dim); cursor: pointer; user-select: none;
  transition: background .12s, color .12s, border-color .12s;
}
.cp-tab:hover { background: var(--surface-2); color: var(--text); }
.cp-tab.is-active {
  background: var(--bg); color: var(--text);
  border-color: var(--line); border-bottom: 1px solid var(--bg);
  margin-bottom: -1px;                   /* eats the strip's border, joins the view */
}
.cp-tab-icon { display: flex; flex: none; }
.cp-tab-icon svg {
  width: 15px; height: 15px; fill: none; stroke: currentColor; stroke-width: 1.6;
}
.cp-tab.is-active .cp-tab-icon svg { stroke: var(--accent); }
.cp-tab-text { display: flex; flex-direction: column; min-width: 0; line-height: 1.15; }
.cp-tab-label {
  font-size: 12px; font-weight: 500;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* What the view is currently pointed at — the open database session, the
   services being followed. Only rendered when the view has one to give. */
.cp-tab-sub {
  font-family: var(--mono); font-size: 10px; color: var(--text-mute);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.cp-tab-close {
  flex: none; width: 18px; height: 18px; margin-left: 2px; padding: 0;
  display: flex; align-items: center; justify-content: center;
  font-size: 14px; line-height: 1; border: none; border-radius: 4px;
  background: transparent; color: var(--text-mute); cursor: pointer;
  opacity: 0; transition: opacity .12s, background .12s, color .12s;
}
.cp-tab:hover .cp-tab-close, .cp-tab.is-active .cp-tab-close { opacity: 1; }
.cp-tab-close:hover { background: var(--line); color: var(--text); }
/* A pinned tab keeps its place and cannot be closed by accident: the × is
   replaced by the pin mark, which the context menu toggles back. */
.cp-tab.is-pinned .cp-tab-close { opacity: 1; color: var(--accent); }
.cp-tab.is-pinned .cp-tab-close::before { content: "\25CF"; font-size: 8px; }
.cp-tab.is-pinned .cp-tab-close { font-size: 0; }
/* A log tab whose followers have stopped: still one click from being followed
   again, so it stays on the strip rather than vanishing. */
.cp-tab.is-dormant .cp-tab-icon svg { stroke: var(--text-mute); }
.cp-tab.is-dormant .cp-tab-label { color: var(--text-mute); }

.cp-tab-menu {
  position: fixed; z-index: 2000; min-width: 150px; padding: 4px;
  background: var(--surface); border: 1px solid var(--line);
  border-radius: var(--radius-sm); box-shadow: 0 8px 24px rgba(0,0,0,.35);
  display: flex; flex-direction: column;
}
.cp-tab-menu-row {
  text-align: left; font-family: var(--font); font-size: 12px;
  padding: 7px 10px; border: none; border-radius: 5px;
  background: transparent; color: var(--text-dim); cursor: pointer;
}
.cp-tab-menu-row:hover { background: var(--surface-2); color: var(--text); }

@media (max-width: 760px) {
  /* The phone already has a tab bar of its own at the foot of the screen, and
     40px of chrome costs more there than it returns. */
  .cp-tabstrip { display: none !important; }
}

/* The phone-only shell parts. app-shell.js builds them once, on every screen
   size, so nothing has to be moved in or out of the DOM on a resize; the
   760px block at the foot of this file is what makes them visible. */
.cp-m-back, .cp-m-barmore, .cp-m-envchips, .cp-m-tabs, .cp-m-sheetwrap { display: none; }

/* Two readings that only earn their place on a phone, where one card or a
   handful of rows fills the screen and the shape of the whole fleet cannot be
   taken in at a glance. On a desktop the dashboard row and the service list
   are both fully visible, so these would only restate what is already there.
   The elements are built unconditionally (app-metrics.js / index.html) and
   revealed by the 760px block at the foot of this file. */
.dash-headline, .svc-stats { display: none; }

/* Custom actions renders each action twice over: the button carries "⚡ Name"
   for the desktop strip, and .ca-meta carries the same name plus how it runs
   for the phone's one-row-per-action list. Only one of the two is ever
   visible -- here the phone's half is off, and the 760px block flips it. */
.ca-scope, .ca-meta, .ca-run { display: none; }

/* position: relative so a view that mounts itself over the section area --
   the split log grid (app-logs.js) -- can be an absolute child of it. */
.cp-sections { flex: 1; min-height: 0; overflow: hidden; position: relative; }
.cp-section { display: none; height: 100%; overflow-y: auto; padding: 20px; }
.cp-section.is-active { display: block; }

/* the existing .card panels, restyled — no shadow, one border, one radius */
.cp-section .card {
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  box-shadow: none;
  padding: 0;
  margin: 0 0 16px 0;
}
.cp-section .card > h2,
.cp-section .card > .svc-head > h2 {
  margin: 0; padding: 0 18px; height: 48px;
  display: flex; align-items: center; justify-content: space-between;
  font-size: 14px; font-weight: 600; color: var(--text);
  border-bottom: 1px solid var(--line);
}
.cp-section .card > *:not(h2):not(.svc-head):not(.panel-resize-grip):not(.panel-resize-grip-v) { padding: 0 18px 18px; }
.cp-section .card > .svc-head { padding: 0; }

/* The Database card's header, dropped in the Database section.

   The section is this one card, the page title above it already reads
   "Database", and the embedded client inside it has a title bar of its own -
   so 49px of the window went on saying the word a third time. The client is
   the densest thing the panel embeds; that strip is worth more to it than to
   the label.

   Nothing is lost with it. The header carries no tools - no maximize button,
   no bulk bar, just the title span - and its one behaviour is collapsing the
   card, which in a section holding nothing else only leaves an empty page.

   Scoped to this section rather than to the card: in a multi-card column the
   header is the only thing naming the card, and there it earns its place.

   !important because index.html gives the heading an inline display:flex (it
   is the collapse target), which an id rule cannot outrank. */
#cp-section-database > #dbclient-card > h2 { display: none !important; }

/* one accent: neutral buttons, a single filled primary per view */
.act, .env-btn, .util-btn {
  font-family: var(--font); font-weight: 500; font-size: 12px;
  border-radius: var(--radius-sm); height: 32px; padding: 0 12px;
  display: inline-flex; align-items: center; gap: 7px;
  background: var(--surface-2); border: 1px solid var(--line); color: var(--text-dim);
  cursor: pointer; transition: border-color .12s, color .12s, background .12s;
  margin: 0 6px 6px 0;
}
.act:hover, .env-btn:hover, .util-btn:hover {
  transform: none; box-shadow: none; color: var(--text); border-color: #31445a;
}
/* The topbar action icons (Follow logs, Refresh, Settings) come from the same
   svg() helper as the sidebar nav, which relies on CSS for size and color —
   .cp-nav-item svg carries that, but nothing did for .act, so an icon-only
   button like Settings rendered nothing at all (an unsized, unstroked SVG is
   invisible) and the ones with a text label had an oversized icon crowding it. */
.act svg { width: 15px; height: 15px; stroke-width: 1.9; fill: none; stroke: currentColor; flex: none; }
.act.is-primary, .env-btn.btn-save {
  background: var(--accent); border-color: var(--accent); color: var(--accent-ink); font-weight: 600;
}
.act.is-danger, .env-btn.btn-del, .act.sm-danger {
  background: rgba(248,81,73,0.10); border-color: rgba(248,81,73,0.28); color: var(--danger);
}
.act.is-go, .act.sm-start {
  background: rgba(63,185,80,0.12); border-color: rgba(63,185,80,0.30); color: var(--ok);
}
/* kill the twelve per-action colours — state carries colour, actions don't */
.btn-artifact, .btn-s3up, .btn-s3down, .btn-jenkins, .btn-setup,
.btn-install-all, .btn-start-all, .btn-stop-all, .btn-restart-all,
.btn-status, .btn-custom-action, .btn-custom-snap,
.sm-collect, .sm-install, .sm-stop, .sm-restart, .sm-status, .sm-log, .sm-exec {
  background: var(--surface-2); color: var(--text-dim); border: 1px solid var(--line);
}

/* inputs */
.filter-input, .src-select, input[type="text"], input[type="number"], select, textarea {
  background: var(--surface-2); border: 1px solid var(--line); color: var(--text);
  border-radius: var(--radius-sm); font-family: var(--font); font-size: 12.5px;
  height: 32px; padding: 0 11px;
}
textarea { height: auto; padding: 10px 12px; font-family: var(--mono); }
.filter-input:focus, input:focus, select:focus, textarea:focus { outline: none; border-color: var(--accent); }
/* The drop-down list of a <select> is drawn by the browser, which takes its
   colours from the options rather than from the page — without these it opens
   as a white list whatever the theme is. */
select option, select optgroup {
  background-color: var(--surface); color: var(--text);
}

/* service rows: a table, not twenty wrapping boxes */
.grid { display: block; }

/*
  .service-item is the LOCAL PROCESSES row (app-boot.js). The services grid used
  to share the class and moved to .svc-row, which left this rule describing
  markup that no longer existed: a seven-column grid over a row whose three
  children are a head, a status line and an actions group. The head landed in the
  28px checkbox column, so every process name was squashed to a few pixels and
  wrapped one character per line.

  A flex row instead, matching what the row actually contains. The name takes the
  space that is left; the status and the buttons keep their natural width at the
  right. The phone layout in the @media block further down stacks the same three
  parts vertically and still wins there, being both later and more specific.
*/
.service-item {
  display: flex; align-items: center; gap: 12px;
  max-width: none; min-width: 0; width: 100%;
  background: transparent; border: none; border-bottom: 1px solid var(--line-soft);
  border-radius: 0; margin: 0; padding: 0 4px; min-height: 44px; font-size: 13px;
}
.service-item:hover { background: var(--surface-2); }

/* min-width:0 is what lets a long command line be truncated rather than forcing
   the row wider than the card. */
.service-item .svc-head {
  display: flex; align-items: center; gap: 8px;
  flex: 1 1 auto; min-width: 0; margin: 0;
}
.service-item .svc-name {
  display: flex; align-items: center; gap: 6px;
  flex: 1 1 auto; min-width: 0;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
  /* The generic .svc-name breaks anywhere, which is right for a wrapped card and
     wrong for a single-line row: it split "node.exe" across two lines. */
  word-break: normal; padding: 0;
}
.service-item .svc-name input { margin: 0; flex: none; }
.service-item .svc-statusrow {
  display: flex; align-items: center; gap: 4px; flex: none; white-space: nowrap;
}
.service-item .service-actions {
  display: flex; align-items: center; gap: 6px; flex: none; margin-left: auto;
}
.service-item .service-actions .act { margin: 0; }
.svc-name { font-weight: 500; font-size: 13px; }
.svc-port {
  font-family: var(--mono); font-size: 11px; color: var(--accent);
  background: none; border: none; padding: 0 0 0 6px;
}

/* status dots */
.status-dot { width: 6px; height: 6px; border-radius: 50%; display: inline-block; }
.status-running, .st-running { color: var(--ok); }
.status-stopped, .st-stopped { color: var(--danger); }
.status-starting, .st-starting { color: var(--warn); }

/* console + logs */
.console-box, .split-pane-body, #console-output {
  background: var(--bg-sunk); border: 1px solid var(--line); border-radius: var(--radius-sm);
  font-family: var(--mono); font-size: 12px; line-height: 1.75; color: #b6c2ce;
}
.split-overlay { background: var(--bg); overflow: hidden; }
.split-bar { background: var(--surface-2); border-bottom: 1px solid var(--line); flex: none; position: sticky; top: 0; z-index: 10; }
.split-pane { background: var(--surface-2); border: 1px solid var(--line); border-radius: 9px; }
.split-pane-head { background: #101a24; border-bottom: 1px solid var(--line); font-size: 12px; }
.split-pane-count { font-family: var(--mono); font-size: 10px; color: var(--warn); }

/* settings: a page, not a cramped modal */
.modal-content { background: var(--surface); border: 1px solid var(--line); border-radius: 12px; }
.settings-tabs { border-bottom: 1px solid var(--line); }
.stab {
  background: none; border: none; color: var(--text-dim);
  font-family: var(--font); font-size: 13px; padding: 10px 12px; cursor: pointer;
}
.stab.active { color: var(--text); font-weight: 600; border-bottom: 2px solid var(--accent); }

/* the old header is replaced by the shell */
.header-container, .main-layout > .left-col > .card > h2 > span:first-child { }
.header-container { display: none; }

/* ============================================================================
   Stage 3 — Services master–detail + log dock
   The selector list, the detail pane and the dock at its foot. Colour only ever
   carries state (ok / warn / danger); every number, host, path and timestamp is
   mono. No new colours — tokens only.
   ========================================================================= */
.svc-split {
  display: flex; align-items: stretch; gap: 0;
  border: 1px solid var(--line); border-radius: var(--radius);
  background: var(--surface); overflow: hidden;
  height: auto; max-height: calc(100vh - 260px); min-height: 360px;
}

/* ---- bulk bar ----
   Actions on a selection are neutral: in this section green and red are already
   spoken for by running / stopped. Only the row dots and the detail header carry
   colour. The bar dims until something is ticked. */
.svc-bulkbar { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; padding: 0 0 14px; }

/* A card heading that carries its own controls. The heading is already a flex
   row (title left, whatever this is right), so the tools only need to stop
   inheriting the heading's pointer cursor -- they are buttons, not part of the
   collapse target. */
.card-head-tools {
  display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
  cursor: default; font-weight: 400;
}
.cp-section .card > h2 .card-head-tools .act { margin: 0; }

/* The bulk bar rides in the card's header row rather than on a row of its own:
   a strip of controls above the list cost 46px of height that the list wanted
   more, and the header had room to spare between the title and the counts.
   Three parts, left to right: title, bar, counts.

   The border-bottom moves from the h2 to the head, or it would underline only
   the title's own width now that it is no longer the whole row. */
#services-card > .svc-head {
  display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
  margin: 0; padding: 0 18px 0 0;
  border-bottom: 1px solid var(--line);
}
.cp-section #services-card > .svc-head > h2 {
  flex: none; border-bottom: none; padding-right: 0;
}
/* Sized to its buttons, not greedy: growing to fill pushed the counts onto a
   second row even where all three parts fit comfortably side by side. */
#services-card > .svc-head > .svc-bulkbar {
  flex: 0 1 auto; padding: 0; margin: 0; min-width: 0;
}
/* The counts keep the far right, and stay there when the bar wraps under a
   narrow window rather than being pushed off the end of it. */
#services-card > .svc-head > #summary { flex: none; margin-left: auto; }
#summary { display: flex; align-items: center; }

/* Title + bar + counts is more than one row can hold (1253px of content in a
   1064px card), and a header that wraps to two rows saves none of the height
   this move was for. What gives way is whatever is not carrying information at
   that moment: with nothing ticked the six bulk buttons are disabled and the
   count reads "no services selected", which the disabled buttons already say.

   The fleet counts never give way -- knowing how many services are down is the
   reason to look at this header at all, and it is most relevant precisely when
   something is selected to be acted on. What goes is their words: the dot
   carries the state and the number carries the value, so the three readings
   keep their meaning in a third of the width. Dropped for good rather than
   only while something is ticked, so the header never reflows under the
   pointer; the words survive as the title on each segment. */
#services-card > .svc-head .svc-bulk.is-empty { display: none; }
#services-card > .svc-head .svc-selcount:not(.is-active) { display: none; }
#services-card > .svc-head .seg-lbl { display: none; }
#services-card > .svc-head .seg { gap: 4px; }

/* Stopped is red here as it is everywhere else in this stylesheet -- the row
   dot, the pill and the detail badge all read var(--danger) (see .svc-row
   .pill.st-stop). The summary kept the older sheet's amber, so the same fact
   arrived in two colours depending on where you looked at it: a service that
   is down reads as a warning in the header and as a failure one line below.
   Hard-coded hex there, so these are hex-for-hex replacements rather than a
   cascade the theme tokens would have won on their own. */
.status-merged .d-stop {
  background: var(--danger);
  box-shadow: 0 0 7px rgba(248, 81, 73, 0.75);
}
.status-merged .seg-stop .seg-num { color: var(--danger); }
/* The buttons sat on a 6px bottom margin that made sense stacked above a list
   and now just makes the header taller than the 48px every other card uses. */
#services-card > .svc-head .act { margin-bottom: 0; }
/* The list now starts where the toolbar used to, so it needs the breathing
   room the toolbar's own padding was providing. */
#services-card > #services-content { padding-top: 14px; }
.svc-bulkbar .selall {
  display: inline-flex; align-items: center; gap: 6px;
  font-size: 12.5px; color: var(--text-dim); margin: 0 4px 0 2px;
}
.svc-selcount {
  font-family: var(--mono); font-size: 11px; color: var(--text-mute);
  margin: 0 8px 0 2px; white-space: nowrap;
}
.svc-selcount.is-active { color: var(--accent); }
.svc-bulk { display: inline-flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.svc-bulkbar .act.sm-start,
.svc-bulkbar .act.sm-danger {
  background: var(--surface-2); border-color: var(--line); color: var(--text-dim);
}
/* Marks a row the Windows grid is carrying on docker's behalf rather than
   schtasks'. Quiet: it is a footnote on the name, not a second status. */
.svc-origin {
  margin-left: 7px; padding: 1px 6px; border-radius: 9px;
  font-family: var(--mono); font-size: 9px; font-weight: 500; letter-spacing: .02em;
  text-transform: uppercase; vertical-align: 1px;
  color: var(--text-mute); background: var(--surface-2); border: 1px solid var(--line);
}

.svc-bulk.is-empty .act { opacity: .45; cursor: not-allowed; }
.svc-bulk.is-empty .act:hover { color: var(--text-dim); border-color: var(--line); }

/* .toolbar .jbtn is pinned to a 40px square with no padding (styles.css) --
   sized for the icon-only buttons that class was written for. Every worded
   button in a section toolbar inherits it too: "Remove" is 45px of text in a
   40px box, so its label overflowed the border, while Start/Stop/Restart sat
   flush against the left edge next to a "Refresh" that keeps the normal 12px.
   Labels size to their text and centre in it; an icon-only button still lands
   at ~40px on its padding alone, so nothing that rule was meant for changes.
   The phone block further down already did this -- it is not a small-screen
   concern, so it moves up here and that override drops the width/padding half. */
.cp-section .toolbar .jbtn {
  width: auto; padding: 0 12px; justify-content: center;
}

/* ---- selector list ---- */
.svc-list-pane {
  width: 268px; flex: none; min-width: 0;
  border-right: 1px solid var(--line); background: var(--surface-2);
  display: flex; flex-direction: column;
}
.svc-list-head {
  padding: 10px 12px; border-bottom: 1px solid var(--line); flex: none;
  display: flex; flex-direction: column; gap: 10px;
}
.svc-list-head .filter-input { width: 100%; margin: 0; }
.svc-ns-picker { position: relative; }
.svc-ns-toggle {
  width: 100%; display: flex; align-items: center; gap: 10px;
  padding: 8px 10px; border: 1px solid var(--line); border-radius: var(--radius-sm);
  background: var(--surface); color: var(--text-dim); cursor: pointer;
  font-family: var(--font); font-size: 12.5px; text-align: left;
}
.svc-ns-toggle:hover,
.svc-ns-picker.is-open .svc-ns-toggle {
  border-color: var(--accent); color: var(--text);
}
.svc-ns-toggle-label {
  flex: none; font-family: var(--mono); font-size: 10px; letter-spacing: 0.08em;
  text-transform: uppercase; color: var(--text-mute);
}
.svc-ns-toggle-value {
  flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.svc-ns-toggle-caret { flex: none; color: var(--text-mute); font-size: 11px; }
.svc-ns-menu {
  position: absolute; left: 0; right: 0; top: calc(100% + 6px); z-index: 35;
  display: none; flex-direction: column; gap: 4px; max-height: 240px; overflow-y: auto;
  padding: 6px; border: 1px solid var(--line); border-radius: var(--radius-sm);
  background: var(--surface); box-shadow: 0 8px 24px rgba(0,0,0,.35);
}
.svc-ns-menu.is-open { display: flex; }
.svc-ns-menu-actions {
  display: flex; gap: 6px; padding-bottom: 4px; margin-bottom: 2px;
  border-bottom: 1px solid var(--line-soft);
}
.svc-ns-search { padding-bottom: 4px; margin-bottom: 2px; }
.svc-ns-search .filter-input {
  width: 100%; margin: 0; font-size: 12px;
}
.svc-ns-menu-btn {
  background: var(--surface-2); border: 1px solid var(--line); border-radius: 5px;
  color: var(--text-dim); font-family: var(--font); font-size: 11.5px;
  padding: 5px 9px; cursor: pointer;
}
.svc-ns-menu-btn:hover { color: var(--text); border-color: var(--accent); }
.svc-ns-option {
  display: flex; align-items: center; gap: 8px; min-width: 0;
  padding: 6px 8px; border-radius: 5px; color: var(--text-dim);
  font-size: 12px; cursor: pointer;
}
.svc-ns-option:hover { background: var(--surface-2); color: var(--text); }
.svc-ns-option input { margin: 0; flex: none; }
.svc-ns-option span {
  min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.svc-ns-empty {
  padding: 6px 8px; color: var(--text-mute); font-size: 12px;
}
.svc-split .grid { flex: 1; min-height: 0; overflow-y: auto; display: block; }

.svc-row {
  display: flex; align-items: center; gap: 10px;
  padding: 9px 12px; border-bottom: 1px solid var(--line-soft);
  border-left: 2px solid transparent; cursor: pointer;
}
.svc-row:hover { background: var(--surface); }
.svc-row.is-selected { background: var(--surface); border-left-color: var(--accent); }
.svc-row.is-selected.st-stop { border-left-color: var(--danger); }
.svc-row.is-selected.st-unknown, .svc-row.is-selected.st-none { border-left-color: var(--warn); }
.svc-row .svc-cb { flex: none; margin: 0; }
.svc-row .svc-dot { width: 7px; height: 7px; border-radius: 50%; flex: none; background: var(--text-mute); }
.svc-row.st-run  .svc-dot { background: var(--ok); }
.svc-row.st-stop .svc-dot { background: var(--danger); }
.svc-row.st-none .svc-dot, .svc-row.st-unknown .svc-dot { background: var(--warn); }
.svc-rowmain { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.svc-row .svc-name {
  font-size: 12.5px; font-weight: 500; overflow: hidden;
  text-overflow: ellipsis; white-space: nowrap; display: block;
}
.svc-row.is-selected .svc-name { font-weight: 600; }
.svc-rowmeta {
  font-family: var(--mono); font-size: 10.5px; color: var(--text-mute);
  display: flex; align-items: center; gap: 6px; min-width: 0;
}
/* the status pill becomes plain mono text in the row — the dot carries state */
.svc-row .pill {
  background: none; border: none; padding: 0; box-shadow: none;
  font-family: var(--mono); font-size: 10.5px; font-weight: 400; color: var(--text-mute);
}
.svc-row .pill.st-run { color: var(--ok); }
.svc-row .pill.st-stop { color: var(--danger); }
.svc-row .pill.st-none, .svc-row .pill.st-unknown { color: var(--warn); }
.svc-row .svc-port { padding: 0; }

/* ---- detail pane ---- */
.svc-detail-pane { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.svc-detail {
  flex: 1; min-height: 0; overflow-y: auto;
  padding: 18px 20px; display: flex; flex-direction: column; gap: 16px;
}
.svc-empty {
  flex: 1; display: flex; flex-direction: column; align-items: center;
  justify-content: center; gap: 8px; color: var(--text-mute); font-size: 13px;
}
.svc-empty svg { width: 28px; height: 28px; stroke: var(--line); stroke-width: 1.6; fill: none; }

.svc-d-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; flex-wrap: wrap; }
.svc-d-title { display: flex; align-items: center; gap: 12px; }
.svc-d-title .svc-dot { width: 9px; height: 9px; border-radius: 50%; background: var(--text-mute); }
.svc-d-name { font-size: 21px; font-weight: 600; letter-spacing: -0.01em; }
.svc-d-state {
  font-family: var(--mono); font-size: 10.5px; text-transform: uppercase;
  border-radius: 5px; padding: 2px 8px; border: 1px solid var(--line); color: var(--text-mute);
}
.svc-d-state.st-run  { color: var(--ok); border-color: rgba(63,185,80,0.32); background: rgba(63,185,80,0.10); }
.svc-d-state.st-stop { color: var(--danger); border-color: rgba(248,81,73,0.30); background: rgba(248,81,73,0.08); }
.svc-d-state.st-none, .svc-d-state.st-unknown { color: var(--warn); border-color: rgba(210,153,34,0.30); background: rgba(210,153,34,0.08); }
/* Live memory, beside the state chip and built to the same measurements, so
   the two read as one pair rather than a chip and a label. Neutral until there
   is a percentage to judge: a container with no memory limit has a real usage
   figure but nothing to be high or low against. */
.svc-d-mem {
  font-family: var(--mono); font-size: 10.5px;
  border-radius: 5px; padding: 2px 8px; border: 1px solid var(--line); color: var(--text-mute);
}
/* Same tints as .svc-d-state above, keyed off the RAM thresholds in
   Settings -> Metrics (see renderServiceMemChip). */
.svc-d-mem.lvl-ok   { color: var(--ok); border-color: rgba(63,185,80,0.32); background: rgba(63,185,80,0.10); }
.svc-d-mem.lvl-warn { color: var(--warn); border-color: rgba(210,153,34,0.30); background: rgba(210,153,34,0.08); }
.svc-d-mem.lvl-crit { color: var(--danger); border-color: rgba(248,81,73,0.30); background: rgba(248,81,73,0.08); }
.svc-d-sub { font-family: var(--mono); font-size: 11.5px; color: var(--text-mute); margin-top: 7px; }
.svc-d-actions { display: flex; gap: 8px; flex-wrap: wrap; }
.svc-d-actions .act { margin: 0; }
.svc-d-more { position: relative; }
.svc-d-menu {
  position: absolute; right: 0; top: 36px; z-index: 40; min-width: 190px;
  background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius-sm);
  padding: 5px; display: none; flex-direction: column;
}
.svc-d-menu.is-open { display: flex; }
.svc-d-menu button {
  background: none; border: none; color: var(--text-dim); font-family: var(--font);
  font-size: 12.5px; text-align: left; padding: 7px 9px; border-radius: 5px; cursor: pointer;
}
.svc-d-menu button:hover { background: var(--surface-2); color: var(--text); }
.svc-d-menu button.is-danger { color: var(--danger); }

.svc-d-tabs { display: flex; gap: 22px; border-bottom: 1px solid var(--line); }
.svc-d-tab {
  background: none; border: none; border-bottom: 2px solid transparent;
  font-family: var(--font); font-size: 13px; color: var(--text-mute);
  padding: 0 0 9px; cursor: pointer;
}
.svc-d-tab.is-active { color: var(--text); font-weight: 600; border-bottom-color: var(--accent); }

.svc-tiles { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; }
.svc-tile {
  background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius);
  padding: 13px 15px; display: flex; flex-direction: column; gap: 7px; min-width: 0;
}
.svc-tile-label { font-family: var(--mono); font-size: 10px; letter-spacing: 0.12em; color: var(--text-mute); }
.svc-tile-value { font-size: 18px; font-weight: 600; }
.svc-tile-value.is-ok { color: var(--ok); }
.svc-tile-value.is-warn { color: var(--warn); }
.svc-tile-value.is-bad { color: var(--danger); }
.svc-tile-value.is-none { color: var(--text-mute); font-size: 15px; font-weight: 500; }
.svc-tile-note {
  font-family: var(--mono); font-size: 10.5px; color: var(--text-mute);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.svc-tile-link { color: var(--accent); text-decoration: none; }
.svc-tile-link:hover { text-decoration: underline; }

.svc-chart {
  background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius);
  padding: 14px 16px; display: flex; flex-direction: column; gap: 12px;
  min-height: 150px;
}
.svc-chart-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.svc-chart-bars { display: flex; align-items: flex-end; gap: 3px; flex: 1; min-height: 96px; }
.svc-chart-bars span { flex: 1; border-radius: 2px; background: #1d4a2c; min-height: 2px; }
.svc-chart-bars span.is-warn { background: #4a3d1f; }
.svc-chart-bars span.is-bad { background: #6e2b26; }
.svc-chart-bars span.is-down { background: #331c1a; }
body.theme-light .svc-chart-bars span { background: #cde8d3; }
body.theme-light .svc-chart-bars span.is-warn { background: #f0e0b8; }
body.theme-light .svc-chart-bars span.is-bad { background: #f2c9c6; }
body.theme-light .svc-chart-bars span.is-down { background: #ead9d7; }

.svc-pane-note { font-family: var(--mono); font-size: 11px; color: var(--text-mute); }
.svc-table { width: 100%; border-collapse: collapse; font-size: 12.5px; }
.svc-table th {
  text-align: left; font-family: var(--mono); font-size: 10px; letter-spacing: 0.12em;
  color: var(--text-mute); font-weight: 400; padding: 0 10px 8px 0; border-bottom: 1px solid var(--line);
}
.svc-table td { padding: 8px 10px 8px 0; border-bottom: 1px solid var(--line-soft); color: var(--text-dim); }
.svc-table td.mono { font-family: var(--mono); font-size: 11.5px; }

/* A count of environment changes staged on the Inspect tab but not applied.
   Empty and invisible until there is one, so the strip is unchanged in the
   ordinary case. */
.svc-d-tab-badge:empty { display: none; }
.svc-d-tab-badge.is-on {
  display: inline-block; margin-left: 6px; padding: 0 5px;
  font-family: var(--mono); font-size: 9.5px; line-height: 15px;
  color: var(--bg); background: var(--accent); border-radius: 8px;
}

/* ---- Inspect tab (app-inspect.js) ----
   Its own table class rather than .svc-table: that one carries a phone layout
   further down which labels its columns BUILD / SOURCE / WHEN / RESULT for the
   History tab, and would mislabel every row here. */
.svc-insp { display: flex; flex-direction: column; gap: 6px; }
.svc-insp-head {
  display: flex; align-items: center; justify-content: space-between;
  gap: 12px; flex-wrap: wrap;
}
.svc-insp-head-actions { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.svc-insp-note { font-family: var(--mono); font-size: 11px; color: var(--text-mute); }
.svc-insp-revealed {
  font-family: var(--mono); font-size: 10px; letter-spacing: 0.08em;
  color: var(--warn); border: 1px solid var(--warn); border-radius: 3px; padding: 2px 6px;
}
.svc-insp-label { display: block; margin-top: 14px; }
/* The container's name, shown only when a pod holds more than one — a sidecar
   must not look like part of the main container's configuration. */
.svc-insp-ctr {
  margin-top: 16px; padding-top: 12px; border-top: 1px solid var(--line);
  font-family: var(--mono); font-size: 12px; color: var(--text);
}
.svc-insp-pairs {
  display: grid; grid-template-columns: minmax(110px, max-content) minmax(0, 1fr);
  gap: 5px 14px; align-items: baseline;
}
.svc-insp-k { font-size: 12px; color: var(--text-mute); }
.svc-insp-v {
  font-size: 11.5px; color: var(--text-dim);
  /* Image IDs and long entrypoints must wrap rather than widen the pane. */
  overflow-wrap: anywhere; word-break: break-word;
}
.svc-insp-table { width: 100%; border-collapse: collapse; font-size: 12.5px; table-layout: fixed; }
.svc-insp-table th {
  text-align: left; font-family: var(--mono); font-size: 10px; letter-spacing: 0.12em;
  color: var(--text-mute); font-weight: 400; padding: 0 10px 8px 0; border-bottom: 1px solid var(--line);
}
.svc-insp-table td {
  padding: 7px 10px 7px 0; border-bottom: 1px solid var(--line-soft);
  color: var(--text-dim); vertical-align: top; overflow-wrap: anywhere; word-break: break-word;
}
.svc-insp-table td.mono { font-family: var(--mono); font-size: 11.5px; }
/* A masked value reads as deliberately withheld, not as a value that happens to
   be bullets. Colour is not the only carrier — the text is Mask either way. */
.svc-insp-table tr.is-secret td.svc-insp-val { color: var(--warn); }
.svc-insp-from { color: var(--text-mute); font-style: italic; }
.svc-insp-none { color: var(--text-mute); font-style: italic; }

/* The reading's age. Stale is a statement about the reading, not a warning
   about the service, so it dims rather than alarms. */
.svc-insp-age { font-family: var(--mono); font-size: 10.5px; color: var(--text-mute); }
.svc-insp-age.is-stale { color: var(--warn); }

/* ---- Inspect: the filter ----
   Narrows the tables only. The overview and the image stay put: they are what
   makes a matched row mean something. */
.svc-insp-filterbar { display: flex; align-items: center; gap: 8px; margin-top: 10px; }
.svc-insp-filter {
  flex: 1; min-width: 0; max-width: 380px;
  background: var(--bg-sunk); color: var(--text); border: 1px solid var(--line);
  border-radius: 4px; padding: 5px 8px; font-family: var(--mono); font-size: 11.5px;
}
.svc-insp-filter:focus { outline: none; border-color: var(--accent); }
.svc-insp-filter-n { font-family: var(--mono); font-size: 10.5px; color: var(--text-mute); }

/* A value that can be copied by clicking it. The affordance is deliberately
   quiet — every value in the tab carries it, and four dozen underlined cells
   would read as links to somewhere. */
.insp-copy { cursor: pointer; border-bottom: 1px dotted transparent; }
.insp-copy:hover { border-bottom-color: var(--text-mute); }
.insp-copy.is-masked { cursor: default; }
.insp-copy.is-masked:hover { border-bottom-color: transparent; }

/* A long value — a java entrypoint carrying thirty -D flags — is clipped to a
   few lines rather than being allowed to make one cell taller than the pane. */
.insp-clip { display: inline-block; max-height: 3.4em; overflow: hidden; vertical-align: top; }
.insp-clip.is-open { max-height: none; }
.insp-clip-t { padding: 0 6px; font-size: 10px; }

/* ---- Inspect: comparing two environments ---- */
.svc-insp-cmp-pick { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-top: 6px; }
.svc-insp-cmp-sel {
  background: var(--bg-sunk); color: var(--text); border: 1px solid var(--line);
  border-radius: 4px; padding: 5px 8px; font-size: 12px; min-width: 180px;
}
.svc-insp-cmp-mark {
  font-family: var(--mono); font-size: 10px; letter-spacing: 0.06em;
  color: var(--text-mute); white-space: nowrap; text-align: right;
}
/* Difference is carried by the word in the last column as well as the tint, so
   the table still reads on a monochrome screen. */
.svc-insp-table tr.is-diff td { background: var(--accent-soft, rgba(88,166,255,.07)); }
.svc-insp-table tr.is-diff td.svc-insp-cmp-mark { color: var(--accent); }
.svc-insp-table tr.is-uncmp td.svc-insp-cmp-mark { color: var(--warn); }
.svc-insp-table tr.is-same td { opacity: 0.72; }

/* ---- Inspect: editing the environment ---- */
.svc-insp-rowact { white-space: nowrap; text-align: right; }
.svc-insp-rowact .act.sm { padding: 1px 7px; font-size: 10.5px; margin-left: 4px; }
/* A pending change is not yet true of the service. Marked so the table cannot
   be misread as a reading of what is running right now. */
.svc-insp-table tr.is-changed td { background: var(--accent-soft, rgba(88,166,255,.07)); }
.svc-insp-table tr.is-removed td { opacity: 0.55; text-decoration: line-through; }
.svc-insp-table tr.is-removed td.svc-insp-rowact { text-decoration: none; opacity: 1; }
.svc-insp-pending { color: var(--accent); }
.svc-insp-new {
  font-family: var(--mono); font-size: 9px; letter-spacing: 0.08em;
  color: var(--accent); border: 1px solid var(--accent); border-radius: 3px; padding: 0 4px;
}
.svc-insp-addenv { margin-top: 8px; display: flex; gap: 6px; flex-wrap: wrap; }
.svc-insp-pending-bar {
  display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
  margin-top: 14px; padding: 10px 12px;
  border: 1px solid var(--accent); border-radius: 4px;
}
.svc-insp-pending-n { font-size: 12.5px; color: var(--text); }

/* The block editor. A monospace box wide enough that a JDBC url does not wrap
   in the middle of a line the operator is trying to read. */
.svc-env-bulk {
  width: 100%; min-height: 220px; resize: vertical;
  background: var(--bg-sunk); color: var(--text); border: 1px solid var(--line);
  border-radius: 4px; padding: 8px 10px;
  font-family: var(--mono); font-size: 11.5px; line-height: 1.55;
  white-space: pre; overflow: auto;
}
.svc-env-bulk:focus { outline: none; border-color: var(--accent); }
.svc-env-bulk-opt {
  display: flex; align-items: center; gap: 6px;
  font-size: 12px; color: var(--text-dim); cursor: pointer;
}
/* The confirmation replaces the pane, so the destructive choice is the only
   thing on screen while it is being made. */
.svc-insp-confirm { display: flex; flex-direction: column; gap: 8px; }
.svc-insp-confirm-title { font-size: 14px; color: var(--text); }
.svc-insp-confirm-list { margin: 0; padding-left: 18px; font-size: 12px; color: var(--text-dim); }
.svc-insp-confirm-list li { margin: 2px 0; overflow-wrap: anywhere; }
.svc-insp-cmd {
  font-family: var(--mono); font-size: 11px; color: var(--text-dim);
  background: var(--bg-sunk); border: 1px solid var(--line-soft); border-radius: 4px;
  padding: 10px; margin: 0; white-space: pre-wrap; overflow-wrap: anywhere;
  max-height: 220px; overflow-y: auto;
}
.svc-insp-warn {
  font-size: 12px; color: var(--danger);
  border: 1px solid var(--danger); border-radius: 4px; padding: 8px 10px;
}

/* ---- log dock ---- */
.svc-dock {
  height: 288px; flex: none; border-top: 1px solid var(--line);
  background: var(--bg-sunk); display: flex; flex-direction: column; min-height: 0;
}
.svc-dock-tabs {
  min-height: 36px; flex: none; display: flex; align-items: stretch;
  flex-wrap: wrap; border-bottom: 1px solid var(--line-soft); overflow-x: auto;
}
.svc-dock-tab {
  display: flex; align-items: center; gap: 8px; padding: 0 14px;
  background: none; border: none; border-bottom: 2px solid transparent;
  font-family: var(--font); font-size: 12px; color: var(--text-mute);
  cursor: pointer; white-space: nowrap;
}
.svc-dock-tab.is-active { color: var(--text); border-bottom-color: var(--accent); }
.svc-dock-tab .svc-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--ok); }
.svc-dock-tab.st-stop .svc-dot { background: var(--danger); }
.svc-dock-tab.st-none .svc-dot, .svc-dock-tab.st-unknown .svc-dot { background: var(--warn); }
.svc-dock-link { color: var(--accent); }
.svc-dock-tools {
  margin-left: auto; display: flex; align-items: center; flex-wrap: wrap;
  gap: 6px 10px; padding: 6px 14px;
}
.svc-dock-tools .split-search { width: 190px; height: 26px; font-size: 11.5px; }
.svc-dock-live { font-family: var(--mono); font-size: 11px; color: var(--accent); }
.svc-dock-panes { flex: 1; min-height: 0; display: grid; gap: 8px; padding: 8px; overflow: hidden; }
.svc-dock-panes .split-pane { background: var(--surface-2); border: 1px solid var(--line); }
.svc-dock-panes .split-pane-head { display: none; }        /* the dock tabs name the pane */
.svc-dock-panes .split-pane.is-hidden { display: none; }

/* ============================================================================
   Stage 4 — Settings as a page
   The modal's panels, moved into the section: a grouped left sub-nav, a search
   box that filters it, and one save/discard bar at the top.
   ========================================================================= */
.cp-section#cp-section-settings { padding: 0; overflow: hidden; }
#modal-overlay.is-migrated { display: none !important; }

.set-page { height: 100%; display: flex; flex-direction: column; min-height: 0; }
.set-bar {
  height: 52px; flex: none; padding: 0 20px; border-bottom: 1px solid var(--line);
  display: flex; align-items: center; justify-content: space-between; gap: 16px;
}
.set-search { width: 260px; max-width: 40vw; }
.set-bar-actions { display: flex; align-items: center; gap: 10px; }
.set-bar-actions .env-btn { margin: 0; }

/* The one visible confirmation that Save/Cancel did something on the Settings
   page -- see showSettingsToast in app-settings-page.js. Sits between the
   search box and the buttons; empty and width:0 when idle so it never shifts
   the bar's layout, opacity-only when shown so it never causes reflow either. */
.set-toast {
  flex: 1; min-width: 0; text-align: right; font-size: 0.85rem; font-weight: 600;
  opacity: 0; transition: opacity 0.2s ease; pointer-events: none;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.set-toast.is-visible { opacity: 1; }
.set-toast-ok { color: var(--ok); }
.set-toast-bad { color: var(--danger); }

.set-split { flex: 1; min-height: 0; display: flex; }
.set-nav {
  width: 244px; flex: none; overflow-y: auto;
  border-right: 1px solid var(--line); background: var(--surface-2);
  padding: 14px 12px; display: flex; flex-direction: column; gap: 2px;
}
.set-group { display: flex; flex-direction: column; gap: 2px; }
.set-group + .set-group { margin-top: 12px; }
.set-group-label {
  font-family: var(--mono); font-size: 10px; letter-spacing: 0.12em;
  color: var(--text-mute); padding: 4px 8px 6px;
}
/* the sub-nav items ARE the old .stab buttons, so switchSettingsTab keeps working */
.set-nav .stab {
  display: flex; align-items: center; justify-content: space-between; gap: 8px;
  width: 100%; text-align: left; padding: 7px 10px; border-radius: var(--radius-sm);
  border: none; border-bottom: none; background: none;
  color: var(--text-dim); font-family: var(--font); font-size: 13px; cursor: pointer;
}
.set-nav .stab:hover { background: var(--surface); color: var(--text); }
.set-nav .stab.active {
  background: #14202c; color: var(--text); font-weight: 600; border-bottom: none;
}
body.theme-light .set-nav .stab.active { background: #eaf2fa; }
.set-nav-count { font-family: var(--mono); font-size: 11px; font-weight: 400; color: var(--text-mute); }

/* A tab that has been typed into since the last Save or Cancel. Save acts on
   all thirteen panels at once while only one is on screen, so this is the only
   thing that says where the pending edits are. A dot rather than a word: it
   sits in a 244px rail on a desktop and a 44px row on a phone. */
.set-nav .stab.is-dirty .set-nav-label::after {
  content: ""; display: inline-block; vertical-align: middle;
  width: 6px; height: 6px; margin-left: 7px; border-radius: 50%;
  background: var(--accent);
}
.set-bar.is-dirty .btn-save::after,
.set-bar.is-dirty .env-btn.btn-save::after { content: " •"; }

/* Foldable panel sections (app-settings-page.js). The wrappers are built at
   every width; here they are deliberately inert -- a desktop shows the whole
   panel at once and has the room to, so the heading stays a heading, the body
   stays open whatever the class says, and the chevron is not drawn. The 760px
   block is what turns them into controls. */
.set-sec-body { display: block; }
.set-sec-chev { display: none; }
/* The heading app-settings-page.js invents for a block that had none. A
   desktop reads the block's own paragraph, so the invented heading would only
   repeat its first clause. */
.set-sec-head-gen { display: none; }

.set-content { flex: 1; min-width: 0; overflow-y: auto; }
.set-content .modal-body {
  max-height: none; height: auto; overflow: visible;
  padding: 22px 24px; background: none; border: none;
}
.set-content .spanel { max-width: 980px; }

/* ---- Environments: list + detail ---- */
.env-split {
  display: flex; align-items: stretch;
  border: 1px solid var(--line); border-radius: var(--radius);
  overflow: hidden; min-height: 460px;
}
.env-list-pane {
  width: 320px; flex: none; border-right: 1px solid var(--line);
  background: var(--surface-2); display: flex; flex-direction: column; min-width: 0;
}
.env-list-head {
  height: 46px; flex: none; padding: 0 14px; border-bottom: 1px solid var(--line);
  display: flex; align-items: center; justify-content: space-between; gap: 10px;
  font-size: 13px; font-weight: 600;
}
.env-list-head .env-btn { margin: 0; height: 28px; }
.env-list { flex: 1; min-height: 0; overflow-y: auto; }
.env-detail { flex: 1; min-width: 0; padding: 20px 22px; overflow-y: auto; }

/* rows in the list pane */
.env-split .env-row {
  display: block; background: none; border: none;
  border-bottom: 1px solid var(--line-soft); border-left: 2px solid transparent;
  border-radius: 0; margin: 0; padding: 0;
}
.env-split .env-row.open { background: var(--surface); border-left-color: var(--accent); }
.env-split .env-row-head {
  display: flex; align-items: center; gap: 11px; padding: 11px 14px; cursor: pointer;
}
.env-split .env-row-chev { display: none; }
.env-split .env-row-dot { width: 7px; height: 7px; border-radius: 50%; flex: none; background: var(--text-mute); }
.env-split .env-row-dot.is-current { background: var(--ok); }
.env-split .env-row-main {
  flex: 1; min-width: 0; display: flex; flex-direction: column;
  align-items: flex-start; gap: 2px;
}
.env-split .env-row-name, .env-split .env-row-host { margin: 0; max-width: 100%; }
.env-split .env-row-name {
  font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.env-split .env-row.open .env-row-name { font-weight: 600; }
.env-split .env-row-host {
  font-family: var(--mono); font-size: 11px; color: var(--text-mute);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.env-split .env-row-body { display: none; }        /* the fields live in the detail pane */

/* Delete, on the row itself. Always drawn rather than revealed on hover: a
   touch screen has no hover, and the control this replaces was buried in the
   editor where nobody found it. Muted until it is pointed at, so a list of
   twenty rows is not a wall of red. */
.env-split .env-row-del {
  flex: none; background: none; border: none; border-radius: var(--radius-sm);
  padding: 4px 6px; margin: 0; cursor: pointer; font-size: 13px; line-height: 1;
  color: var(--text-mute); opacity: 0.55;
}
.env-split .env-row-del:hover { opacity: 1; color: var(--danger); background: rgba(248, 113, 113, 0.14); }
.env-split .env-row-del:focus-visible { opacity: 1; outline: 2px solid var(--danger); outline-offset: 1px; }

/* The Actions editor's footer holds only the destructive control, so it is
   separated from the fields above it rather than sitting flush against the
   schedule row it follows. */
#actions-detail .env-card-actions {
  margin-top: 16px; padding-top: 14px;
  border-top: 1px solid var(--line-soft); justify-content: flex-end;
}
.env-detail-head {
  display: flex; align-items: flex-start; justify-content: space-between;
  gap: 16px; margin-bottom: 18px;
}
.env-detail-name { font-size: 17px; font-weight: 600; }
.env-detail-sub { font-family: var(--mono); font-size: 11.5px; color: var(--text-mute); margin-top: 5px; }
.env-detail-empty { color: var(--text-mute); font-size: 13px; }

/* ============================================================================
   Stage 5 — Load tests, Database, and the remaining screens
   ========================================================================= */

/* ---- shared state patterns (from the States screen) ---------------------
   Four shapes, one look: empty, loading, error, permission denied. Any panel
   can drop one in; colour appears only on error / denied. */
.cp-state {
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  gap: 8px; padding: 34px 20px; text-align: center;
  color: var(--text-mute); font-size: 13px;
}
.cp-state-title { color: var(--text); font-size: 13.5px; font-weight: 600; }
.cp-state-note { font-family: var(--mono); font-size: 11px; color: var(--text-mute); max-width: 460px; }
.cp-state svg { width: 26px; height: 26px; stroke: var(--line); stroke-width: 1.6; fill: none; }
.cp-state.is-error .cp-state-title { color: var(--danger); }
.cp-state.is-error svg { stroke: var(--danger); }
.cp-state.is-denied .cp-state-title { color: var(--warn); }
.cp-state.is-denied svg { stroke: var(--warn); }
.cp-state-actions { display: flex; gap: 8px; margin-top: 4px; }

/* loading: skeleton rows, never a spinner on its own */
.cp-skel { display: flex; flex-direction: column; gap: 8px; padding: 16px 0; }
.cp-skel-row {
  height: 12px; border-radius: 4px; background: var(--line-soft);
  animation: cp-pulse 1.4s ease-in-out infinite;
}
.cp-skel-row:nth-child(2) { width: 82%; animation-delay: .12s; }
.cp-skel-row:nth-child(3) { width: 64%; animation-delay: .24s; }
@keyframes cp-pulse { 0%,100% { opacity: .45; } 50% { opacity: .9; } }

/* transient: an inline note that something is in flight */
.cp-transient {
  display: inline-flex; align-items: center; gap: 7px;
  font-family: var(--mono); font-size: 11px; color: var(--accent);
}

/* ---- Load tests --------------------------------------------------------- */
/* the split owns its own gutters, so drop the card's body padding here */
#cp-section-loadtests .card > #jmeter-content { padding: 0; }
.lt-split {
  display: flex; align-items: stretch; gap: 0;
  border-top: 1px solid var(--line); min-height: 520px;
}
.lt-form {
  width: 320px; flex: none; border-right: 1px solid var(--line);
  background: var(--surface-2); padding: 18px; box-sizing: border-box;
  display: flex; flex-direction: column; gap: 14px;
}
.lt-group { display: flex; flex-direction: column; gap: 14px; }
.lt-field { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
.lt-label { font-family: var(--mono); font-size: 10px; letter-spacing: 0.12em; color: var(--text-mute); }
.lt-field select, .lt-field input { width: 100%; height: 34px; }
.lt-triple { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; }
.lt-quad { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; }

/* Guidance under a field group: the suite runner's inputs are all optional
   overrides, which is not something the labels alone can say. */
.lt-hint { font-size: 0.72rem; line-height: 1.45; color: #94a3b8; }
.lt-hint strong { color: #cbd5e1; font-weight: 600; }

/* Save feedback in the suite editor. The message carries the meaning; the
   colour only reinforces it, so it still reads with colour unavailable. */
/* Section headings inside a settings panel that holds more than one thing.
   The Load tests panel carries both engines' settings, and without a heading
   each the two editors read as one confusing form. */
.spanel-h {
  display: flex; align-items: center; gap: 9px;
  margin: 0 0 8px; font-size: 0.9rem; font-weight: 650; color: #f1f5f9;
}
.spanel-tag {
  font-size: 0.6rem; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase;
  color: #a78bfa; border: 1px solid #6d5bd0; border-radius: 8px; padding: 1px 7px;
}
.spanel-rule { margin: 26px 0 18px; border: 0; border-top: 1px solid #334155; }

.loadtest-status { font-size: 0.78rem; color: #94a3b8; }
.loadtest-status.is-ok { color: #6ee7b7; }
.loadtest-status.is-error { color: #fca5a5; }

#loadtest-suites-json {
  min-height: 300px; padding: 10px 12px; border-radius: 8px;
  border: 1px solid #334155; background: #0f172a; color: #e2e8f0;
  font-size: 0.8rem; line-height: 1.5; tab-size: 2;
}
.lt-memory {
  background: var(--surface); border: 1px solid var(--line);
  border-radius: 8px; padding: 0; margin: 0; font-size: 12px;
}
.lt-memory:empty { display: none; }
.lt-run { display: flex; gap: 8px; margin-top: auto; }
.lt-run .act { height: 40px; flex: 1; justify-content: center; margin: 0; font-size: 13px; }
.lt-results {
  flex: 1; min-width: 0; padding: 18px 20px;
  display: flex; flex-direction: column; gap: 16px; overflow: hidden;
}
.lt-card {
  background: var(--surface); border: 1px solid var(--line);
  border-radius: var(--radius); display: flex; flex-direction: column;
  min-height: 0; overflow: hidden; max-height: none;
}
.lt-card-head {
  height: 44px; flex: none; padding: 0 16px; border-bottom: 1px solid var(--line);
  display: flex; align-items: center; justify-content: space-between; gap: 10px;
  font-size: 13px; font-weight: 600;
}
.lt-head-tools { display: flex; gap: 8px; }
.lt-head-tools .act { margin: 0; height: 28px; }
.lt-console {
  margin: 0; padding: 10px 14px; max-height: 220px; overflow: auto;
  font-family: var(--mono); font-size: 11.5px; line-height: 1.7;
  color: #b6c2ce; white-space: pre-wrap; word-break: break-word;
  background: var(--bg-sunk);
}
.lt-past { flex: 1; }
.lt-list { flex: 1; min-height: 0; overflow-y: auto; }

/* ---- sections that own the whole pane --------------------------------- */
/* Console, Database and the API client are single-panel screens: the card fills
   the section instead of floating in it with a scrollbar of its own. */
#cp-section-console, #cp-section-database, #cp-section-api,
#cp-section-loadtests, #cp-section-files, #cp-section-deploy { padding: 16px; }
#cp-section-console .card, #cp-section-database .card, #cp-section-api .card {
  height: 100%; margin: 0; display: flex; flex-direction: column;
}
#cp-section-console .card > #console-content,
#cp-section-console .card > .console-box,
#cp-section-database .card > #dbclient-content,
#cp-section-api .card > #api-client-content { flex: 1; min-height: 0; }
#cp-section-database #dbclient-content { height: auto; padding: 0 !important; }

/* Actions: the run output is a bounded report pane, not a page that grows.

   .console-box sizes itself with `flex: 1` and relies on a height-constrained
   column-flex parent (.console-card, styles.css) to turn that into a scrolling
   box. The Actions section has no such parent, so flex:1 resolved to auto and
   overflow-y:auto never engaged -- "Processes & Services" prints every process
   on the host, which grew the card to thousands of pixels tall instead of
   scrolling inside it. Cap it here so the page stays put.

   white-space: pre, not the console's pre-wrap: these reports are fixed-width
   ASCII tables and wrapping shears the columns apart. Long lines scroll
   sideways instead, the way the log panes do. */
#cp-section-actions #action-output {
  max-height: 55vh;
  min-height: 120px;
  overflow: auto;
  white-space: pre;
}
#cp-section-database #dbclient-content iframe {
  border-radius: 0 0 var(--radius) var(--radius); background: var(--bg);
}

/* Monitoring: monitor.js still builds its dashboard inside #monitor-overlay
   (and keeps polling while that element is not display:none), so the overlay
   stays put and hidden while its .modal-content lives in the section. */
#monitor-overlay.is-migrated { display: none !important; }
#cp-section-monitoring { padding: 16px; }
#cp-section-monitoring .modal-content {
  max-width: none !important; width: 100% !important; max-height: none !important;
  height: 100%; background: var(--surface); border: 1px solid var(--line);
  border-radius: var(--radius); display: flex; flex-direction: column;
}
#cp-section-monitoring .modal-header {
  height: 48px; flex: none; padding: 0 18px; border-bottom: 1px solid var(--line);
  display: flex; align-items: center; justify-content: space-between;
}
#cp-section-monitoring .modal-header h2 { margin: 0; font-size: 14px; font-weight: 600; border: none; padding: 0; }
#cp-section-monitoring .modal-header .util-btn { display: none; }   /* nothing to close */
#cp-section-monitoring #monitor-body { flex: 1; min-height: 0; overflow-y: auto; padding: 18px; }

/* ---------- responsive ---------- */
@media (max-width: 1100px) {
  .cp-section { padding: 16px; }
}
/* The top bar needs ~820px for the title, the five action buttons and the
   memory readout together; below that the title is what gets squeezed,
   because it is the only child that can shrink. The readout is the one
   thing on the bar nothing is waiting on -- it reports, it is not operated
   -- so it goes first and the section title keeps its room. Phones never
   see it either way: .cp-topbar-actions is hidden whole below 760px. */
@media (max-width: 900px) {
  .cp-topbar-mem { display: none; }
  /* The environment chip stays where the readout goes: it is the one thing on
     the bar that changes what every button beside it does. It gives up width
     instead, and truncates. */
  .cp-topbar-env { max-width: 160px; }
}

/* Short screens. A 1366x768 or 1280x800 laptop leaves ~600-630px of viewport
   once the browser's own chrome is out of it, and at the default rhythm the
   rail wants 56 (brand) + 107 (environment) + 520 (sixteen nav rows) + 32
   (Collapse) + 53 (user) = 768. On a 1366x768 that put everything from "Load
   tests" down off the fold and left the row AT the fold half-drawn, which is
   the click nobody could explain: the lower half of it is Collapse, so aiming
   at a nav row folded the sidebar to icons.

   Tightening the vertical rhythm buys back ~145px, which fits the list at
   1366x768. Shorter than that it still scrolls -- the fade at the cut edge is
   what says so. Vertical properties only: the collapsed icon rail sets the
   horizontal padding and this block comes after it, so a shorthand here would
   quietly un-collapse the rail. */
@media (max-height: 820px) {
  .cp-brand { height: 46px; }
  .cp-envbox { padding-top: 6px; padding-bottom: 4px; }
  .cp-label { padding-bottom: 4px; }
  .cp-nav { padding-top: 4px; padding-bottom: 4px; }
  .cp-nav-item { padding-top: 5px; padding-bottom: 5px; }
  .cp-nav-toggle { padding-top: 6px; padding-bottom: 6px; }
  .cp-nav-sep { margin-top: 6px; margin-bottom: 6px; }
  .cp-user { padding-top: 8px; padding-bottom: 8px; }
}
/* The phone layout lives at the very end of this file, after Stage 6 — its
   rules have to win over the 1000px stack-the-splits block and over Stage 6's
   !important log-dock sizing without piling on more !important. */
@media (max-width: 1100px) {
  .svc-tiles { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
/* Below ~1000px the detail pane would be narrower than the selector list, so
   stack them instead of squeezing both. */
@media (max-width: 1000px) {
  .lt-split { flex-direction: column; }
  .lt-form { width: 100%; border-right: none; border-bottom: 1px solid var(--line); }
  .env-split { flex-direction: column; }
  .env-list-pane { width: 100%; max-height: 34vh; border-right: none; border-bottom: 1px solid var(--line); }
  .set-nav { width: 200px; }
  .svc-split { flex-direction: column; height: auto; }
  .svc-list-pane {
    width: 100%; max-height: 34vh;
    border-right: none; border-bottom: 1px solid var(--line);
  }
  .svc-detail-pane { min-height: 520px; }
}

/* ============================================================================
   Stage 6 — Logs view polish styles
   ========================================================================= */

/* Flexbox layout for log dock */
.svc-dock {
  flex: 1 !important;
  min-height: 0 !important;
  height: auto !important;
}

.svc-dock-panes {
  display: flex !important;
  flex: 1;
  min-height: 0;
}

.svc-dock-panes .split-pane {
  flex: 1;
  display: flex;
  flex-direction: column;
  min-height: 0;
  background: var(--surface-2);
  border: 1px solid var(--line);
  position: relative;
}

/* Split grid layout */
.split-grid {
  flex: 1;
  display: grid;
  gap: 8px;
  padding: 8px;
  overflow: hidden;
  min-height: 0;
}

.split-overlay {
  display: flex;
  flex-direction: column;
  height: 100vh;
  height: 100dvh;                 /* see the note on body .container */
  overflow: hidden;
}
/* Mounted in .cp-sections instead of over the window (see buildSplitOverlay):
   it fills the content area and leaves the chrome -- top bar, view tabs, nav
   rail, the phone's bottom bar -- alive above and beside it, which is what
   makes the parked "Logs" tab reachable while you are following. The height
   above is replaced: the container is already the right size. */
.split-overlay.is-hosted {
  position: absolute;
  inset: 0;
  height: auto;
  z-index: 100;                   /* over the section it covers, under modals */
  overflow: hidden;
}
/* The grid's bar carries a RAM readout of its own, which was the only one on
   screen while it covered the window. Hosted, the top bar's is 40px above it. */
.split-overlay.is-hosted .cp-topbar-mem { display: none; }

/* A phone has no view-tab strip (it is hidden below 760px, in favour of the
   bottom bar), so hosting the grid there would park it on the first tap of a
   bottom tab with nothing on screen to bring it back. Fixed and inset-0 makes
   it the full-window overlay it has always been on a phone -- the chevron in
   its bar is the way out, which is what that chevron is for. Geometry only:
   the element stays where buildSplitOverlay put it, so a window resized across
   the breakpoint changes behaviour with no remount. */
@media (max-width: 760px) {
  .split-overlay.is-hosted {
    position: fixed;
    inset: 0;
    height: 100vh;
    height: 100dvh;
    z-index: 1500;
  }
  .split-overlay.is-hosted .cp-topbar-mem { display: inline-flex; }
}

/* Pane layouts */
.split-pane {
  display: flex;
  flex-direction: column;
  min-height: 0;
  position: relative;
}

.split-pane-body {
  flex: 1;
  margin: 0;
  padding: 8px 12px;
  overflow-y: auto;
  overflow-x: auto;
  font-family: var(--mono);
  font-size: 11.5px;
  line-height: 1.5;
  white-space: pre-wrap; /* Dynamic class .wrap-off will toggle this to nowrap */
  background: var(--bg-sunk);
  position: relative;
}

.split-pane-body.wrap-off {
  white-space: pre !important;
}

/* The overlay's "leave" chevron (app-logs.js). Escape still works; this is the
   same exit for anyone not reaching for a keyboard. */
.split-back {
  display: inline-flex; align-items: center; justify-content: center;
  width: 32px; height: 32px; flex: none; padding: 0; margin-right: 4px;
  border: none; background: none; color: var(--accent); cursor: pointer;
  border-radius: var(--radius-sm);
}
.split-back:hover { background: rgba(94, 176, 239, 0.12); }
.split-back svg { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-width: 2; }
/* The bar is a nowrap row in styles.css; with the chevron in it the title is
   the piece that gives way rather than the search box scrolling off. */
.split-bar .split-title { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

/* Split search and tools layout */
.split-search-wrap {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  background: rgba(15, 23, 42, 0.4);
  border: 1px solid var(--line);
  border-radius: 6px;
  padding: 2px 6px;
}

/* Narrow at rest, roomy while you are typing in it. The bar it sits on also
   carries the title, the level chips and a dozen log buttons, and a search box
   wide enough for a stack trace is width those are doing without -- while the
   term people actually type here is a service name or an id fragment. */
.split-search {
  background: none !important;
  border: none !important;
  outline: none !important;
  color: var(--text) !important;
  padding: 2px 4px !important;
  width: 104px;
  transition: width .12s ease;
}
/* Roomy while it holds a term, narrow while it does not. Keyed on the value
   rather than on focus: clicking the next-match arrow takes focus off the box,
   and a search field that shrinks away from the term you are still stepping
   through is worse than one that never grew. */
.split-search:not(:placeholder-shown) { width: 168px; }

/* The count reserved 72px in styles.css so the buttons beside it would not
   shuffle as "1/9" became "10/900". Nothing to count at rest, though, so it
   reserves nothing until it has something to say, and then takes only what a
   realistic count needs. */
.split-match-count { min-width: 46px; }
.split-match-count:empty { display: none; }

.split-case, .util-btn-log {
  background: none;
  border: none;
  color: var(--text-dim);
  font-size: 11px;
  cursor: pointer;
  padding: 2px 6px;
  border-radius: 4px;
}

.split-case:hover, .util-btn-log:hover {
  background: var(--line);
  color: var(--text);
}

/* Log download progress bar */
.log-dl-progress {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  font-size: 11px;
  color: var(--text-dim, #94a3b8);
  background: var(--line-soft, rgba(30, 41, 59, 0.6));
  border: 1px solid var(--line, rgba(71, 85, 105, 0.5));
  border-radius: 4px;
  padding: 2px 8px;
  height: 22px;
  box-sizing: border-box;
}

.log-dl-progress-track {
  width: 75px;
  height: 6px;
  background: rgba(255, 255, 255, 0.12);
  border-radius: 3px;
  overflow: hidden;
  position: relative;
}

.log-dl-progress-fill {
  height: 100%;
  width: 0%;
  background: linear-gradient(90deg, #3b82f6, #10b981);
  border-radius: 3px;
  transition: width 0.15s ease-out;
}

.log-dl-progress-track.indeterminate .log-dl-progress-fill {
  width: 40%;
  animation: log-dl-pulse 1.2s infinite ease-in-out;
}

@keyframes log-dl-pulse {
  0% { transform: translateX(-100%); }
  100% { transform: translateX(250%); }
}

.log-dl-progress-text {
  font-size: 11px;
  font-family: var(--font-mono, monospace);
  white-space: nowrap;
}

/* Groups the toolbar's buttons (view controls | export | stream controls) so
   a dozen-plus same-styled buttons in a row don't read as one undifferentiated
   strip. Invisible whenever it would start or end a wrapped line. */
.log-toolbar-divider {
  width: 1px; align-self: stretch; min-height: 18px;
  background: var(--line); margin: 0 2px; flex: none;
}

.split-case.active {
  background: var(--accent);
  color: var(--accent-ink) !important;
}

/* Level filter chips styles */
.log-filter-chips {
  display: inline-flex;
  gap: 4px;
  margin: 0 8px;
}

.log-filter-chip {
  background: var(--line-soft);
  border: 1px solid var(--line);
  color: var(--text-dim);
  font-size: 12px;
  line-height: 1;
  font-weight: 600;
  padding: 4px 7px;
  border-radius: 12px;
  cursor: pointer;
  font-family: var(--font);
  transition: all 0.15s ease;
}

.log-filter-chip:hover {
  background: var(--line);
  color: var(--text);
}

.log-filter-chip.active {
  background: var(--accent);
  border-color: var(--accent);
  color: var(--accent-ink);
}

/* Line styles */
.log-line {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-start;
  padding: 1px 4px;
  border-radius: 4px;
  font-family: var(--mono);
}

.log-line.level-error {
  background-color: rgba(248, 81, 73, 0.06) !important; /* danger tint */
}

/* Hide non-matching lines */
.split-pane-body.filter-info .log-line:not(.level-info),
.split-pane-body.filter-warn .log-line:not(.level-warn),
.split-pane-body.filter-error .log-line:not(.level-error) {
  display: none !important;
}

.log-timestamp {
  color: var(--text-mute);
  margin-right: 12px;
  flex-shrink: 0;
  user-select: none;
}

/* The service tag in the merged console. Fixed width so the messages line up
   into a column the eye can run down - a ragged left edge is what makes an
   interleaved log unreadable - and truncated rather than wrapped, with the full
   name on the element's title. Colour comes from logSourceColor() inline, so a
   service keeps the same colour across sessions. */
.log-source {
  display: inline-block;
  flex-shrink: 0;
  width: 108px;
  max-width: 108px;
  margin-right: 10px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-family: var(--mono);
  font-size: 10.5px;
  font-weight: 600;
  text-align: right;
  opacity: .95;
}
/* Narrow screens cannot spare 108px of gutter; the tag shrinks rather than
   pushing the message off the pane. */
@media (max-width: 900px) {
  .log-source { width: 74px; max-width: 74px; margin-right: 6px; }
}

.log-level-badge {
  display: inline-block;
  font-size: 9px;
  font-weight: bold;
  padding: 1px 6px;
  border-radius: 4px;
  margin-right: 12px;
  text-transform: uppercase;
  flex-shrink: 0;
  user-select: none;
  min-width: 44px;
  text-align: center;
}

.log-level-badge.badge-info {
  background: var(--line);
  color: var(--text-dim);
}

.log-level-badge.badge-warn {
  background: rgba(210, 153, 34, 0.15);
  color: var(--warn);
}

.log-level-badge.badge-error {
  background: rgba(248, 81, 73, 0.15);
  color: var(--danger);
}

.log-message {
  flex: 1;
  min-width: 200px;
  word-break: break-all;
}

/* Stack trace folding styling */
.stack-trace-toggle {
  cursor: pointer;
  color: var(--accent);
  font-family: var(--font);
  font-size: 11px;
  display: block;
  margin: 2px 0 2px 24px;
  user-select: none;
  font-weight: 500;
}

.stack-trace-container {
  margin-left: 24px;
  padding-left: 8px;
  border-left: 1px solid var(--line);
  font-family: var(--mono);
  font-size: 11px;
  color: var(--text-dim);
  width: 100%;
}

.stack-trace-line {
  padding: 1px 0;
  word-break: break-all;
}

/* Minimap styling */
.log-minimap {
  position: absolute;
  top: 0;
  right: 0;
  width: 6px;
  height: 100%;
  background: rgba(15, 23, 42, 0.15);
  border-left: 1px solid rgba(255, 255, 255, 0.03);
  z-index: 10;
  cursor: pointer;
  user-select: none;
}

.log-minimap-tick {
  position: absolute;
  left: 0;
  width: 100%;
  height: 2px;
  background: var(--danger);
  opacity: 0.8;
}

/* Stage 6b Logs Tab active layout overrides */
.svc-detail.logs-tab-active {
  padding: 0 !important;
  overflow: hidden !important;
  gap: 0 !important;
}

.svc-detail.logs-tab-active .svc-d-head {
  padding: 18px 20px 0 20px !important;
}

.svc-detail.logs-tab-active .svc-d-tabs {
  padding: 0 20px !important;
  margin-top: 16px !important;
}

.svc-detail.logs-tab-active #svc-d-body {
  flex: 1;
  min-height: 0;
  height: 100%;
  gap: 0 !important;
}

/* ============================================================================
   Stage 7 — the phone layout (<= 760px)

   Last in the file on purpose: it has to win over the 1000px "stack the
   splits" block above and over Stage 6's !important log-dock sizing, and
   source order is cheaper than another round of !important.

   What a phone gets that a desktop does not:
     - a five-item tab bar instead of the sidebar's fourteen (app-shell.js
       builds it from the same allowed() filter, so it stays role-aware)
     - a two-row header: back chevron + title + "..." , then environment chips
     - master-detail splits as list -> pushed detail, one scroller per screen
     - tables as stacked cards, except the ones that are console output

   Tokens only -- no new colours, no remote fonts (see the note at the top).
   ========================================================================= */
@media (max-width: 760px) {

  /* ---- frame ------------------------------------------------------------
     The sidebar goes entirely: squeezed into a bottom strip it put eight of
     the fourteen sections off the right edge with nothing to say so. */
  /* The shell is pinned to the visual viewport rather than laid out in the
     document flow. Two things follow from that, and both are the reason the
     header and the tab bar used to drift off screen:
       - position:fixed on a phone is measured against the *small* viewport
         (URL bar showing), so nothing of the shell is ever hidden behind the
         browser's own chrome, and it stays put while that chrome slides;
       - the document itself then has nothing to scroll, so an overscroll drag
         inside a section cannot pull the whole page -- header and all -- up
         with it. .cp-section stays the one scroller (see below).
     inset:0 supplies the height, so the 100dvh above is not needed here. */
  .cp-app { flex-direction: column; position: fixed; inset: 0; height: auto; }
  html, body { overscroll-behavior: none; }
  .cp-side { display: none; }
  .cp-main { order: 1; flex: 1; min-height: 0; }

  /* ---- header row 1 ----------------------------------------------------
     The header, the chips and the tab bar are flex:none siblings of the
     scrolling .cp-sections, which is what makes them sticky: they are outside
     the scroller, not pinned inside it. z-index only so a card's own sticky
     bits (table headers, the settings bar) pass under them rather than over. */
  .cp-topbar {
    /* env() is 0 on everything but a notched phone / installed PWA, where the
       bar would otherwise start underneath the status bar. */
    height: auto; min-height: 56px; box-sizing: border-box;
    padding: env(safe-area-inset-top) 8px 0 10px; gap: 8px;
    position: relative; z-index: 20;
  }
  .cp-topbar-titles { flex: 1; min-width: 0; }
  .cp-title {
    font-size: 19px; font-weight: 700; letter-spacing: -0.2px;
    overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0;
  }
  .cp-subtitle { display: none; }          /* the title alone fits; both do not */

  /* Follow logs / Refresh / Terminal / Settings are wider than the screen
     together, which is why they used to be clipped past the right edge. They
     move into the "..." sheet -- the buttons stay in the DOM for desktop, the
     sheet rows call the same handlers. */
  .cp-topbar-actions { display: none; }

  .cp-m-barmore {
    display: flex; align-items: center; justify-content: center;
    width: 44px; height: 44px; flex: none; padding: 0;
    border: 1px solid var(--line); border-radius: var(--radius-sm);
    background: var(--surface); color: var(--text-dim);
    font-family: var(--font); font-size: 17px; line-height: 1; cursor: pointer;
  }
  .cp-m-back {
    display: none; align-items: center; justify-content: center;
    width: 44px; height: 44px; flex: none; padding: 0; margin-left: -8px;
    border: none; background: none; color: var(--accent); cursor: pointer;
  }
  /* Only on a pushed detail view -- there is nothing to go back to otherwise. */
  body.cp-m-pushed .cp-m-back { display: flex; }
  .cp-m-back svg {
    width: 22px; height: 22px; fill: none; stroke: currentColor; stroke-width: 2;
  }

  /* ---- header row 2: the environment switcher, as chips ----------------- */
  .cp-m-envchips {
    display: flex; flex: none; gap: 6px; padding: 8px 10px;
    overflow-x: auto; -webkit-overflow-scrolling: touch;
    background: var(--surface-2); border-bottom: 1px solid var(--line);
    position: relative; z-index: 20;
  }
  .cp-m-envchips:empty { display: none; }
  .cp-m-envchip {
    flex: none; height: 44px; padding: 0 16px; border-radius: 999px;
    border: 1px solid var(--line); background: var(--bg-sunk); color: var(--text-dim);
    font-family: var(--mono); font-size: 12px; font-weight: 600; cursor: pointer;
    white-space: nowrap;
  }
  .cp-m-envchip.is-active {
    border-color: var(--accent); color: var(--accent); background: var(--surface);
  }
  /* buildEnvChips paints each chip its own environment's colour inline, which
     beats both rules above; this is only the selected chip's weight, so the
     colour reads as chosen rather than as one of a row. */
  .cp-m-envchip.is-active { font-weight: 700; }

  /* ---- bottom tab bar --------------------------------------------------- */
  .cp-m-tabs {
    display: flex; flex: none; gap: 2px;
    background: var(--bg-sunk); border-top: 1px solid var(--line);
    /* clears the iOS home indicator; env() resolves to 0 everywhere else */
    padding: 6px 4px calc(6px + env(safe-area-inset-bottom));
    position: relative; z-index: 20;
  }
  .cp-m-tab {
    flex: 1; min-width: 0; min-height: 52px; padding: 4px 2px;
    display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px;
    border: none; border-radius: var(--radius-sm); background: none;
    color: var(--text-mute); font-family: var(--font); cursor: pointer;
  }
  .cp-m-tab svg {
    width: 21px; height: 21px; flex: none;
    fill: none; stroke: currentColor; stroke-width: 1.7;
  }
  .cp-m-tab-label {
    font-size: 10px; font-weight: 600; letter-spacing: 0.2px;
    max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  }
  .cp-m-tab.is-active { color: var(--accent); background: var(--surface); }
  .cp-m-tab.is-active svg { stroke: var(--accent); }

  /* ---- bottom sheets (More, and the "..." actions) ---------------------- */
  .cp-m-sheetwrap.is-open {
    display: flex; position: fixed; inset: 0; z-index: 900;
    flex-direction: column; justify-content: flex-end;
  }
  /* A sheet on its way out is still on screen for the length of its exit:
     app-shell.js keeps .is-open on until the animation reports finished,
     or the element would be display:none before the exit had a frame to
     run in. For those ~370ms (longer at a slower animation speed) a
     full-screen backdrop sat over the tab bar and still answered taps, so
     the first tap on a tab went to the backdrop and did nothing -- which
     is why a tab sometimes had to be pressed twice. It is leaving:
     nothing in it should take a tap. */
  .cp-m-sheetwrap.is-closing { pointer-events: none; }
  .cp-m-sheet-backdrop { position: absolute; inset: 0; background: rgba(0, 0, 0, 0.6); }
  .cp-m-sheet {
    position: relative; background: var(--surface);
    border-top: 1px solid var(--line); border-radius: 18px 18px 0 0;
    padding: 10px 12px calc(16px + env(safe-area-inset-bottom));
    max-height: 78vh; max-height: 78dvh; overflow-y: auto;
    overscroll-behavior: contain;
  }
  .cp-m-sheet-grip {
    width: 38px; height: 4px; border-radius: 2px;
    background: var(--line); margin: 4px auto 10px;
  }
  .cp-m-sheet-row {
    display: flex; align-items: center; gap: 12px; width: 100%;
    min-height: 52px; padding: 14px 8px; text-align: left;
    background: none; border: none; border-bottom: 1px solid var(--line-soft);
    color: var(--text); font-family: var(--font); font-size: 15px; cursor: pointer;
  }
  .cp-m-sheet-label { flex: 1; min-width: 0; }
  .cp-m-sheet-hint { font-family: var(--mono); font-size: 11px; color: var(--text-mute); }
  .cp-m-sheet-chev { color: var(--text-mute); }
  /* Where you already are. Marked the same way the tab bar marks its four, so
     the sheet and the bar agree about what "current" looks like. */
  .cp-m-sheet-row.is-current { color: var(--accent); font-weight: 600; }
  .cp-m-sheet-row.is-current .cp-m-sheet-chev { color: var(--accent); }
  .cp-m-sheet-row.is-cancel {
    justify-content: center; color: var(--text-dim); border-bottom: none;
  }
  .cp-m-sheet-row.is-cancel .cp-m-sheet-label { flex: none; text-align: center; }

  /* ---- modals become full-screen pages ----------------------------------
     styles.css sizes every .modal-content at 1100x640 capped to 94vw/88vh,
     which on a 390px screen is a letterboxed box with ~24px of dead backdrop
     down each side, its own scrollbar inside the page's, and a ✕ the size of a
     pointer's target rather than a thumb's. The file editor, the config diff,
     the terminal and the dynamically built action dialogs all use that same
     shape, so one rule re-dresses them: full bleed, header and footer pinned
     to the top and bottom of the screen, only the body scrolling.

     Direct child, so the two panels that MOVE their .modal-content out of an
     overlay and into a section (Monitoring, and Settings via
     app-settings-page.js) are untouched -- theirs is no longer a child of a
     .modal-overlay by the time these rules would apply. */
  /* dvh for the same reason the shell uses it: styles.css gives the overlay
     height:100%, which on a fixed element is the *large* viewport, and the
     footer with the Save button in it would sit behind the URL bar. */
  .modal-overlay {
    align-items: stretch; justify-content: stretch; padding: 0;
    height: 100vh; height: 100dvh;
  }
  .modal-overlay > .modal-content {
    width: 100% !important; max-width: none !important;
    height: 100% !important; max-height: none !important;
    border: none; border-radius: 0; box-shadow: none;
  }
  /* Dragging an edge is a mouse gesture, and full bleed has no edge to drag.
     resizable-modal.js adds these to every .modal-content. */
  .modal-overlay > .modal-content > .resize-handle { display: none; }
  .modal-overlay > .modal-content > .modal-header {
    position: sticky; top: 0; z-index: 3;
    padding: calc(12px + env(safe-area-inset-top)) 12px 12px 16px;
    gap: 10px;
  }
  .modal-overlay > .modal-content > .modal-header h2 {
    font-size: 16px; font-weight: 700; min-width: 0;
    overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  }
  /* The ✕ is the only way out of these -- on a phone it has to be a thumb
     target, not a 20px glyph in a corner. */
  .modal-overlay > .modal-content > .modal-header .util-btn {
    flex: none; min-width: 44px; min-height: 44px;
    display: inline-flex; align-items: center; justify-content: center;
    font-size: 17px;
  }
  .modal-overlay > .modal-content > .modal-body { padding: 14px; }
  .modal-overlay > .modal-content > .modal-footer {
    position: sticky; bottom: 0; z-index: 3;
    flex-wrap: wrap; gap: 8px;
    padding: 12px 14px calc(12px + env(safe-area-inset-bottom));
  }
  /* Footer buttons share the row rather than each keeping its natural width:
     "Compare across environments" alone is wider than the screen. */
  .modal-overlay > .modal-content > .modal-footer .env-btn {
    flex: 1 1 auto; min-height: 44px; margin: 0 !important; justify-content: center;
  }

  /* ---- touch targets ----------------------------------------------------
     Everything a thumb can hit is at least 44px. */
  .act, .env-btn, .util-btn { height: 44px; padding: 0 14px; font-size: 13px; }
  .svc-row,
  .env-split .env-row-head,
  .set-nav .stab,
  .svc-d-menu button,
  .svc-dock-tab,
  .env-list-head .env-btn { min-height: 44px; }
  /* The row's delete control is a thumb target too, and a small one beside a
     44px row is the one that gets missed. */
  .env-split .env-row-del { min-width: 44px; min-height: 44px; font-size: 16px; }
  .svc-d-tab { padding: 12px 0; min-height: 44px; }
  .filter-input, .src-select, input[type="text"], input[type="number"], select { height: 44px; }
  /* Several panels predate the shell and size their own controls with a
     two-class selector (.service-actions .act, .toolbar .btn-status,
     .lt-field select, .api-opt select), which outranks the single-class rules
     above. min-height beats a smaller height whatever the specificity, so one
     rule per control family covers every one of them. */
  .cp-section .act, .cp-section .env-btn, .cp-section .util-btn,
  .cp-section select, .cp-section .src-select,
  .cp-section input[type="text"], .cp-section input[type="number"],
  .cp-section .api-tab, .cp-section .stab { min-height: 44px; }
  .cp-section .service-actions .act { width: auto; min-width: 44px; line-height: 44px; padding: 0 12px; }
  /* Width and padding are settled for every screen up with the bulk bar; what
     a phone adds is the thumb-sized target. */
  .cp-section .toolbar .jbtn { min-width: 44px; line-height: 44px; }

  /* A 20px checkbox is a 20px target. appearance:none turns the input into an
     ordinary box that can own a 44px hit area and draw the tick-box inside it
     with ::before, so the control still looks 20px but is thumb-sized. */
  .cp-section .svc-cb,
  .cp-section input[type="checkbox"] {
    -webkit-appearance: none; appearance: none;
    width: 44px; height: 44px; flex: none; margin: 0; padding: 0;
    display: inline-flex; align-items: center; justify-content: center;
    background: none; border: none; cursor: pointer; vertical-align: middle;
  }
  .cp-section .svc-cb::before,
  .cp-section input[type="checkbox"]::before {
    content: ""; width: 20px; height: 20px; border-radius: 5px;
    border: 1.5px solid var(--line); background: var(--surface-2);
  }
  .cp-section .svc-cb:checked::before,
  .cp-section input[type="checkbox"]:checked::before {
    background: var(--accent); border-color: var(--accent);
  }
  .cp-section .svc-cb:focus-visible,
  .cp-section input[type="checkbox"]:focus-visible {
    outline: 2px solid var(--accent); outline-offset: -6px;
  }

  /* ---- the API client's two panes --------------------------------------
     app-apiclient.js lays its sidebar and main column out side by side with
     inline widths, which at 430px squeezed the main column to ~107px and left
     its own overflow-x:auto to hide the URL bar, the send button and the
     Headers/Auth/Body tabs. Stacked here instead. :has() picks out the flex
     row, which the renderer leaves unclassed; without :has() support the two
     panes still stack, just with the row keeping its gap. */
  div:has(> .api-sidebar) { flex-direction: column !important; }
  .api-sidebar {
    width: 100% !important; border-right: none !important; padding-right: 0 !important;
    border-bottom: 1px solid var(--line); padding-bottom: 12px;
    overflow-y: visible !important;
  }
  .api-main {
    padding-left: 0 !important; padding-top: 12px;
    overflow-x: visible !important; overflow-y: visible !important;
  }
  .api-line { flex-wrap: wrap; }
  .api-method, .api-url { min-height: 44px; box-sizing: border-box; }
  .api-send { flex: 1 1 100%; min-height: 44px; justify-content: center; }
  .api-tabs { flex-wrap: wrap; }
  .api-opts { gap: 8px 12px; }
  .api-opt select, .api-num { min-height: 44px; }

  /* The key/value tables (params, headers, form fields, variables) are a row
     of four columns on a wide screen and unusable as one at 430px, so each
     row becomes a stack. The header row goes away with it: the placeholders
     say the same thing in a quarter of the space. */
  .api-kv-head { display: none; }
  .api-kv-row { flex-wrap: wrap; gap: 5px; }
  .api-kv-row .api-kv-k, .api-kv-row .api-kv-v, .api-kv-row .api-kv-d, .api-kv-row .api-kv-type {
    flex: 1 1 100% !important; min-height: 40px; box-sizing: border-box;
  }
  .api-kv-row .api-kv-on, .api-kv-row .api-kv-x { flex: 0 0 auto; }
  .api-envedit > div:last-child { flex-direction: column !important; }
  .api-envlist { flex: 1 1 auto !important; }
  .api-bodymodes { gap: 8px 12px; }

  /* ---- one scroll container per screen ----------------------------------
     .cp-section is the page scroller. Every pane inside it that used to own a
     scrollbar of its own -- the list panes with their 34vh/40vh caps, the
     detail panes, the settings body -- goes back to flowing, and the four
     master-detail splits show one side at a time instead of both. */
  /* overscroll-behavior: scrolling past the end of a section stops there
     instead of handing the gesture to whatever is behind it. */
  .cp-section {
    padding: 12px; -webkit-overflow-scrolling: touch; overscroll-behavior: contain;
  }
  .cp-section .card { margin-bottom: 12px; }
  .cp-section .card > h2,
  .cp-section .card > .svc-head > h2 { padding: 0 14px; }
  .cp-section .card > *:not(h2):not(.svc-head):not(.panel-resize-grip):not(.panel-resize-grip-v) {
    padding: 0 14px 14px;
  }
  /* Dragging a panel edge is a mouse gesture; the grips only get in the way. */
  .panel-resize-grip, .panel-resize-grip-v { display: none; }

  .svc-split, .lt-split, .env-split, .set-split {
    flex-direction: column; height: auto; min-height: 0;
  }
  .svc-list-pane, .env-list-pane, .lt-form, .set-nav {
    /* border-box, or the 100% width plus these panes' own padding and border
       overflows the split by ~24px */
    width: 100%; box-sizing: border-box; max-height: none;
    border-right: none; border-bottom: 1px solid var(--line);
  }
  .svc-split .grid, .env-list, .set-nav, .lt-list { overflow: visible; }
  .svc-detail, .env-detail, .set-content, .lt-results { overflow: visible; }
  .svc-detail-pane { min-height: 0; }
  .svc-detail { padding: 14px 12px; }
  .env-detail { padding: 16px 14px; }
  .lt-results { padding: 14px 12px; }
  .set-content .modal-body { padding: 16px 14px; }
  /* Settings is the one section that owns its own frame; let the page scroll. */
  .cp-section#cp-section-settings { overflow-y: auto; }
  .set-page { height: auto; }

  /* ---- the settings bar, as a pinned action bar --------------------------
     Every settings panel is a form, and this bar holds the only control that
     commits one. Sitting at the top of the page scroller it left Save 2,066px
     above the screen by the time you had scrolled to the bottom of Monitoring
     & alerts -- so the way to save was to scroll all the way back up, which is
     no way at all. Pinned to the foot of the screen instead, where a form's
     commit belongs on a phone and where it cannot be scrolled away from.

     Fixed rather than sticky: the section is the scroller and the bar is
     outside it (a sibling of .set-split inside .set-page), so there is nothing
     for sticky to stick to. --cp-tabbar-h is the tab bar's measured height,
     published by app-shell.js (its labels and the home-indicator inset both
     move it), so the two stack instead of overlapping. */
  .set-bar {
    position: fixed; left: 0; right: 0; z-index: 15;
    bottom: var(--cp-tabbar-h, 64px);
    height: auto; padding: 8px 12px; gap: 8px;
    flex-wrap: wrap; justify-content: flex-end;
    background: var(--surface-2); border-top: 1px solid var(--line);
    border-bottom: none;
  }
  .set-bar-actions { flex: 1; gap: 8px; }
  .set-bar-actions .env-btn { flex: 1; min-height: 44px; justify-content: center; }
  /* The toast has the row to itself above the buttons rather than competing
     with them for a line 390px wide. */
  .set-toast { flex: 1 0 100%; text-align: center; }
  .set-toast:not(.is-visible) { display: none; }

  /* The bar is out of the flow now, so the page needs the room back at its
     foot or the last control of a panel sits underneath it. */
  #cp-section-settings .set-split { padding-bottom: var(--cp-setbar-h, 80px); }

  /* The search box filters the nav (filterSettingsNav), and the nav is not on
     screen once a tab has been pushed -- so on a detail screen it was a
     full-width control that could not affect anything visible. It belongs with
     the list it acts on. */
  .set-search { width: 100%; max-width: none; min-height: 44px; }
  /* width:auto, or the input's own 100% plus its padding and border overruns
     the bar and the placeholder is clipped off the left edge. */
  #cp-section-settings .set-search {
    flex: 1 0 100%; order: -1; width: auto; box-sizing: border-box;
  }
  body.cp-m-pushed #cp-section-settings .set-search { display: none; }

  /* Settings edits shared configuration, not the selected environment: the
     chips change nothing on any of these panels, and cost a band of the screen
     on every one of them. The switcher is one tab away in the header's "..."
     sheet if it is genuinely wanted here. */
  body.cp-m-settings .cp-m-envchips { display: none; }

  /* ---- panels fold into sections -----------------------------------------
     Monitoring & alerts is 2,739px of continuous form here and My dashboard
     2,983px: three or four screens with no landmark, no way to reach the part
     you came for, and a paragraph of prose in front of every group of
     controls. The divisions the panels already had become foldable, so a panel
     opens as a list of its own headings with the first one open. Collapsing
     also takes the prose with it -- it belongs to the section it explains. */
  .set-sec {
    border: 1px solid var(--line); border-radius: var(--radius);
    background: var(--surface); margin: 0 0 10px; overflow: hidden;
  }
  .set-sec-head {
    display: flex; align-items: center; justify-content: space-between; gap: 10px;
    margin: 0; padding: 14px; min-height: 52px; box-sizing: border-box;
    font-size: 15px; font-weight: 600; color: var(--text);
    background: none; border: none; cursor: pointer;
  }
  .set-sec-head:focus-visible { outline: 2px solid var(--accent); outline-offset: -3px; }
  .set-sec-head-gen { display: flex; }
  .set-sec-title { min-width: 0; }
  /* Its whole text is the section's title here, so printing it again inside
     the section would be the same sentence twice. */
  .set-sec-dup { display: none; }
  .set-sec-chev {
    display: block; flex: none; color: var(--text-mute); font-size: 20px;
    line-height: 1; transform: rotate(90deg); transition: transform .15s ease;
  }
  .set-sec.is-open > .set-sec-head .set-sec-chev { transform: rotate(-90deg); }
  .set-sec-body { display: none; padding: 0 14px 14px; }
  .set-sec.is-open > .set-sec-body { display: block; }
  /* The blocks that fold in place drew their own separator; the section frame
     is that separator now. */
  .set-sec[style*="border-top"] {
    border-top-color: var(--line) !important;
    margin-top: 0 !important; margin-bottom: 10px !important; padding-top: 0 !important;
  }
  @media (prefers-reduced-motion: reduce) {
    .set-sec-chev { transition: none; }
  }

  /* ---- form rows that assume a desktop's width ---------------------------
     Three selects and a button on one row come out about 70px each here --
     wide enough for "E2" and "Local (th" and nothing else. One control per
     line. !important on the fields: index.html writes flex:1 inline on each,
     which a class rule cannot otherwise outrank. */
  .sh-row { flex-wrap: wrap; gap: 8px; }
  .sh-row .sfield { flex: 1 0 100% !important; }
  .sh-row .sfield select { width: 100%; }
  .sh-row .env-btn { flex: 1 0 100%; min-height: 44px; margin: 0; justify-content: center; }
  /* Its rule list is capped at 150px with a #ddd border written inline -- a
     light-grey box on a dark page. Tokens, and room to actually read a rule. */
  #cfg-alert-self-healing-list {
    max-height: none !important; border-color: var(--line) !important;
    border-radius: var(--radius-sm); padding: 8px !important;
  }

  /* Load tests: the import picker carries an inline max-width:280px which,
     next to "Add Example Suite" in a nowrap toolbar, pushed it 18px past the
     right edge of the screen. The toolbar wraps and both take a full line. */
  .set-content .env-toolbar { flex-wrap: wrap; gap: 8px; }
  .set-content .env-toolbar > * { flex: 1 0 100%; max-width: none !important; margin: 0; }
  .set-content .env-toolbar .env-btn { min-height: 44px; justify-content: center; }
  /* The JSON editor is width:100% inline on a content-box textarea, so its own
     padding and border carried it 12px past the edge. */
  #loadtest-suites-json { box-sizing: border-box; }

  /* list -> pushed detail. The detail side is not shown until a row is tapped
     (app-shell.js sets .is-pushed); the header chevron clears it. */
  .svc-split .svc-detail-pane,
  .env-split .env-detail,
  .lt-split .lt-results,
  .set-split .set-content { display: none; }
  .svc-split.is-pushed .svc-list-pane,
  .env-split.is-pushed .env-list-pane,
  .lt-split.is-pushed .lt-form,
  .set-split.is-pushed .set-nav { display: none; }
  .svc-split.is-pushed .svc-detail-pane { display: flex; }
  .lt-split.is-pushed .lt-results { display: flex; }
  .env-split.is-pushed .env-detail,
  .set-split.is-pushed .set-content { display: block; }

  /* The bulk bar acts on the list's ticked rows, and it is a sibling of the
     split rather than a child of the list pane -- so it hides with the list
     off the body class, not off .is-pushed. */
  .svc-bulkbar { padding: 0 0 10px; }
  body.cp-m-pushed .svc-bulkbar { display: none; }
  .svc-d-head { gap: 12px; }
  .svc-d-name { font-size: 18px; }
  .svc-d-actions { width: 100%; }
  .svc-d-actions .act { flex: 1 1 auto; justify-content: center; }
  .svc-tiles { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
  /* On Health every tile can come back empty -- no monitoring target, nothing
     deployed through the panel, no restarts -- and then its note is the whole
     answer ("no uptime check matches this service", "add a target in
     Settings"). Clipped to one line in a ~150px column that reads as "no
     uptime check mat…", which is why the tab looked like it showed nothing at
     all. The notes wrap here; the desktop's four-across row still needs the
     single line, so this stays inside the phone block. */
  .svc-tile-note { white-space: normal; overflow: visible; line-height: 1.5; }
  .svc-tile-link { min-height: 44px; display: inline-block; padding: 4px 0; }
  /* Same for the chart's heading, where the label and its note sit on one row
     that does not fit: "no monitoring target for this service" is the reason
     the chart is empty. */
  .svc-chart-head { flex-wrap: wrap; row-gap: 4px; }
  .svc-chart-head .svc-pane-note { white-space: normal; }
  .lt-triple { grid-template-columns: 1fr; }
  .lt-quad { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  .lt-run .act { height: 48px; }

  /* The log dock is the deliberate exception to "one scroller": a log pane is
     a terminal, and a terminal that grows the page instead of scrolling is
     unusable. Bounded here so it is the only nested scroller on the screen.
     !important beats Stage 6's own !important sizing, which assumes a
     height-constrained desktop column. */
  /* Taller than the desktop dock: its header wraps to several rows here (see
     below), and a 300px dock would leave the log itself a few lines tall. */
  .svc-dock { height: 460px !important; flex: none !important; }

  /* The dock's header is one nowrap strip on a desktop: pane tabs on the left,
     and .svc-dock-tools (search, case toggles, level chips, wrap/clear, Stop)
     pushed right by margin-left:auto. At 390px the tools sat ~440px wide
     inside the strip's own overflow-x:auto -- present, but off the right edge
     with nothing to say so, the same failure the topbar buttons had. The strip
     wraps here and the tools take a line of their own. */
  .svc-dock-tabs { flex-wrap: wrap; height: auto; overflow-x: visible; }
  .svc-dock-tools {
    margin-left: 0; width: 100%; flex-wrap: wrap; gap: 6px;
    padding: 6px 10px; border-top: 1px solid var(--line-soft);
  }
  .svc-dock-tools .split-search { width: 100%; }
  .split-search-wrap { flex: 1 1 100%; box-sizing: border-box; }
  .log-filter-chips { margin: 0; display: flex; gap: 6px; }
  .log-filter-chip, .split-case, .util-btn-log {
    min-height: 36px; padding: 4px 12px; white-space: nowrap;
  }

  /* ---- the full-screen log viewer ---------------------------------------
     Reached from "Follow logs" / "Full screen". It covers the shell entirely,
     so its own bar is the whole navigation: the chevron app-logs.js puts there
     is the way out, and it has to stay visible whatever the title says. The
     bar is two rows here (title line, then the search-and-chips line) because
     as one nowrap row at 390px everything past the title sat off the edge. */
  .split-bar {
    flex-wrap: wrap; row-gap: 8px; align-items: center;
    padding: calc(8px + env(safe-area-inset-top)) 10px 8px;
  }
  .split-back { width: 44px; height: 44px; margin-left: -8px; }
  .split-bar .split-title { flex: 1 1 auto; font-size: 15px; }
  .split-bar .env-btn { flex: none; }
  /* The search box, its buttons and the level chips are a wrapping strip of
     their own on the second row rather than three things competing with the
     title for one line. order:1 sends it past "Stop all", which app-logs.js
     appends after it -- otherwise the strip splits the two rows apart and
     Stop lands on a third line of its own. */
  .split-bar > div { order: 1; flex: 1 1 100%; flex-wrap: wrap; row-gap: 6px; }

  /* One pane per screen, whatever column count the renderer wrote inline:
     three 120px-wide columns of log text cannot be read. The grid scrolls, so
     the bar above it stays put. */
  .split-grid {
    grid-template-columns: minmax(0, 1fr) !important;
    overflow-y: auto; -webkit-overflow-scrolling: touch; overscroll-behavior: contain;
    padding-bottom: calc(8px + env(safe-area-inset-bottom));
  }
  .split-pane { min-height: 60vh; min-height: 60dvh; }
  .split-pane-body { font-size: 11.5px; padding: 8px 10px; }

  /* ---- Overview: the environment cards ----------------------------------
     app-metrics.js still renders the structure it always has -- name + host,
     a row of CPU/RAM/DISK tiles each with a bar, a row of uptime/load/net.
     What changes here is only its dress: the legacy hardcoded slate palette
     becomes tokens, the tiles left-align, and the three info tiles collapse
     into one mono footer line. The class hooks carry no desktop rules, so
     none of this reaches a wide screen. */
  /* The Metrics card frame is chrome a phone cannot afford. Its header repeats
     the section title, its toolbar adds three lines of status text, and
     applyPanelHeight() writes an inline max-height + overflow-y that turned the
     whole dashboard into a nested scroller ~450px tall -- one environment card
     visible, the rest behind an inner scrollbar, with dead space underneath.
     Stripped here so the cards flow straight down the page scroller.
     !important: the height and overflow are inline styles from that function. */
  #metrics-dash-card { background: none; border: none; border-radius: 0; margin-bottom: 0; }
  /* !important on every card <h2> here: index.html and the renderers give them
     an inline display:flex (they are the collapse toggles), which a class or
     id rule cannot outrank. */
  #metrics-dash-card > h2 { display: none !important; }
  #metrics-dash-card > #metrics-dash-content {
    padding: 0;
    max-height: none !important; height: auto !important; overflow: visible !important;
  }
  /* Refresh All, the auto-refresh note and the sort note. The "..." sheet's
     Refresh re-reads the dashboard (app-shell.js), so nothing is lost.
     !important: the toolbar carries an inline display:flex. */
  .dash-toolbar { display: none !important; }
  /* Reordering is a drag gesture; there is nothing to drag with a thumb, so
     the handle goes and the hint that tells you to use it goes with it. */
  .dash-drag, .dash-hint-drag { display: none; }
  /* One "refreshed Ns ago" under the headline replaces a timestamp per card. */
  .dash-updated { display: none; }
  /* Per-environment custom action buttons inside each card. They are the same
     actions the Actions tab lists, one tap away, and here they roughly doubled
     a card's height -- one environment per screen on a fleet of six. Dropped
     on a phone only; the desktop card keeps them (there the whole row is
     visible at once, so they cost nothing). Sparklines stay: unlike these they
     show something not available anywhere else. */
  .dash-actions { display: none; }

  .dash-headline {
    display: flex; align-items: center; gap: 12px;
    background: var(--surface); border: 1px solid var(--line);
    border-radius: 12px; padding: 14px 16px; margin-bottom: 12px;
  }
  .dash-headline-dot {
    width: 10px; height: 10px; border-radius: 50%; flex: none; background: var(--text-mute);
  }
  .dash-headline.is-warn .dash-headline-dot { background: var(--warn); }
  .dash-headline.is-ok .dash-headline-dot { background: var(--ok); }
  .dash-headline-main { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
  .dash-headline-text { font-size: 15px; font-weight: 600; }
  .dash-headline-sub {
    font-family: var(--mono); font-size: 11px; color: var(--text-mute);
  }
  .dash-headline-sub:empty { display: none; }

  #dash-cards-row { display: block !important; }
  .dash-card {
    /* --dash-env-accent is written inline per card by renderDashboardCard from
       the environment's colour, so this !important border (which the phone
       theme needs, to beat the card's own inline slate) can still carry it.
       Without the property it falls back to the line colour, exactly as before. */
    background: var(--surface) !important;
    border: 1px solid var(--dash-env-accent, var(--line)) !important;
    border-radius: 12px !important; padding: 14px 16px !important;
    min-width: 0 !important; margin: 0 0 12px; cursor: default !important;
  }
  .dash-card-head { gap: 8px !important; margin-bottom: 12px !important; }
  .dash-card-name { font-size: 16px !important; letter-spacing: -0.2px; }
  .dash-card-host {
    margin-left: auto; font-family: var(--mono) !important;
    font-size: 11px !important; color: var(--text-mute) !important;
  }
  .dash-tiles { gap: 12px !important; margin-bottom: 12px !important; }
  .dash-tile { text-align: left !important; }
  .dash-tile-label {
    font-family: var(--mono); font-size: 10px !important; letter-spacing: 0.09em;
    color: var(--text-mute) !important; text-transform: uppercase; margin-bottom: 4px !important;
  }
  .dash-tile-value { font-size: 19px !important; font-weight: 700 !important; }
  .dash-bar { background: var(--line) !important; height: 4px !important; }

  /* The three context readings read as one line of footnote, the way the
     mockup has them, rather than as three more tiles competing with CPU/RAM. */
  .dash-inforow {
    gap: 16px !important; margin-bottom: 0 !important;
    border-top: 1px solid var(--line-soft); padding-top: 10px;
  }
  .dash-info { flex: none !important; text-align: left !important; }
  .dash-info-label { display: none; }
  .dash-info-value {
    font-family: var(--mono) !important; font-size: 12px !important;
    font-weight: 400 !important; color: var(--text-dim) !important;
  }
  /* Without its label the bare number is meaningless, so name it inline. */
  .dash-info:nth-child(1) .dash-info-value::before { content: "up "; color: var(--text-mute); }
  .dash-info:nth-child(2) .dash-info-value::before { content: "load "; color: var(--text-mute); }
  .dash-updated {
    font-family: var(--mono); font-size: 10.5px !important;
    color: var(--text-mute) !important; margin: 8px 0 0 !important;
  }
  .dash-actions, .dash-card [class*="btn-custom-action"] { margin-top: 8px; }

  /* ---- Custom actions: one row per action -------------------------------
     The desktop strip of "⚡ Name" pills wraps into an unreadable block on a
     phone, and pairs each with a camera button of its own. Here the name and
     how it runs sit on the left and the button becomes a plain "Run" -- the
     same element, so the click handler and the disabled state are untouched. */
  #custom-actions-card { background: none; border: none; border-radius: 0; margin-bottom: 12px; }
  #custom-actions-card > h2 { display: none !important; }
  /* applyPanelHeight() writes an inline max-height + overflow-y here too. */
  #custom-actions-card > #custom-actions-content {
    padding: 0;
    max-height: none !important; height: auto !important; overflow: visible !important;
  }
  .ca-scope {
    display: block; font-family: var(--mono); font-size: 11px;
    color: var(--text-mute); padding: 0 2px 10px;
  }
  .ca-list { display: block !important; }
  .ca-item {
    display: flex !important; align-items: center; gap: 8px !important;
    margin: 0 0 8px !important; padding: 12px 14px; min-height: 56px;
    box-sizing: border-box; background: var(--surface);
    border: 1px solid var(--line); border-radius: 12px;
  }
  .ca-item.is-disabled { opacity: 0.55; }
  .ca-meta { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; }
  .ca-meta-name {
    font-size: 14px; font-weight: 600; color: var(--text);
    overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  }
  .ca-meta-sub {
    font-family: var(--mono); font-size: 11px; color: var(--text-mute);
    overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  }
  /* The name and the bolt live in the button for desktop; on a phone the row
     already shows the name, so the button says only what it does. */
  .ca-item .ca-label { display: none; }
  .ca-item .ca-run { display: inline; }
  .ca-item .btn-custom-action,
  .ca-item .btn-custom-snap {
    flex: none; margin: 0; font-size: 0; gap: 0;    /* hides the bare "⚡ " text node */
  }
  .ca-item .ca-run { font-size: 13px; font-weight: 600; }
  .ca-item .btn-custom-action { min-width: 68px; justify-content: center; color: var(--accent); }
  .ca-item .btn-custom-snap { min-width: 48px; justify-content: center; font-size: 15px; }

  /* The Output card's heading sits inside a wrapper div, so the card-header
     rule (.cp-section .card > h2) never reaches it and it falls back to the
     browser's default h2 -- ~24px, twice every other heading on the screen. */
  #action-output-card h2 { font-size: 14px; font-weight: 600; }
  #action-output-card .util-btn { min-width: 44px; justify-content: center; }
  /* .console-box sizes itself with flex:1, which needs a height-constrained
     flex column to mean anything; the Output card is not one, so the pane is
     only ever as tall as the lines in it. Three lines at the foot of a page
     six actions long is why a run still read as having done nothing even once
     the page had scrolled to it. A real pane here -- and only once there is
     something in it, so an untouched Actions page is not a black slab.
     !important on max-height: applyPanelHeight() writes an inline one. */
  /* Two ids, to reach over the desktop cap above (#cp-section-actions
     #action-output), which pins the pane at 120px tall. */
  #cp-section-actions #action-output:not(:empty) { min-height: 44vh; min-height: 44dvh; }
  #cp-section-actions #action-output {
    max-height: 60vh !important; max-height: 60dvh !important;
    overflow: auto; overscroll-behavior: contain;
  }

  /* ---- Services: counts, then a card per service ------------------------ */
  /* The card headers carry a running/stopped/unknown summary pill (.status-merged,
     built by app-boot.js for both Services and Local processes). Its three
     segments are 18px-padded and never wrapped, so it ran ~70px off the right
     edge of a 390px screen. Services no longer needs it at all -- the RUNNING
     and STOPPED tiles above the list say the same thing -- but Local processes
     has no tiles, so there the pill wraps and tightens instead of going away. */
  #summary { display: none !important; }   /* inline display:flex in index.html */
  .status-merged { flex-wrap: wrap; max-width: 100%; }
  .status-merged .seg { padding: 5px 10px; }
  .status-merged .seg + .seg { border-left: none; }
  /* Refresh and Follow logs are the same two actions the "..." sheet already
     offers, and the bulk bar is long enough on a phone without them. */
  .svc-bulkbar > .btn-status, .svc-bulk .btn-status { display: none; }
  .svc-bulkbar { gap: 6px; }

  /* Selection is off by default, as in the design: no tick box in the row, no
     bulk bar above the list. A 44px checkbox plus its gap took ~56px off a
     ~330px row, which is what was truncating "control-panel" to "contr...",
     and the bulk bar pushed the list itself below the fold. Every action is
     still reachable per service in the pushed detail. "Select services" in the
     "..." sheet turns both back on for the multi-service jobs (start five,
     stop five) that the detail pane cannot do. */
  body:not(.cp-m-select) .svc-bulkbar,
  body:not(.cp-m-select) .svc-row .svc-cb { display: none; }
  body.cp-m-select .svc-row .svc-cb { display: inline-flex; }

  /* Three nested paddings (section 12 + card body 14 + list pane 12) plus two
     borders pushed the rows 39px in from each edge, leaving ~310px for a name
     and a chevron. The list pane and the split stop adding their own, so a row
     lines up with the environment cards on Overview. */
  .svc-split { border: none; border-radius: 0; background: none; }
  .svc-split .grid { padding: 0; }
  .svc-list-head {
    padding: 0 0 10px; border-bottom: none;
    display: flex; flex-direction: column; gap: 10px;
  }
  /* width:100% plus the input's own padding and border overran the pane */
  .svc-list-head .filter-input { box-sizing: border-box; }
  .svc-stats { display: flex; gap: 8px; }
  .svc-stat {
    flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px;
    background: var(--surface); border: 1px solid var(--line);
    border-radius: 12px; padding: 12px 14px;
  }
  .svc-stat-n { font-size: 21px; font-weight: 700; color: var(--ok); }
  .svc-stat-n.is-bad { color: var(--warn); }
  .svc-stat-l {
    font-family: var(--mono); font-size: 10px; letter-spacing: 0.09em; color: var(--text-dim);
  }

  /* Each row is a card with a chevron, so it reads as "tap to open" rather
     than as a line in a table. .svc-row is the push trigger already. */
  .svc-list-pane { background: none; border-bottom: none; }
  .svc-row {
    background: var(--surface); border: 1px solid var(--line);
    border-left: 1px solid var(--line); border-radius: 12px;
    margin: 0 0 8px; padding: 12px 14px; min-height: 56px; gap: 12px;
    /* .svc-row is content-box by default, so min-height:56px was 56px of
       content plus 24px of padding -- an 81px row where the design has 56. */
    box-sizing: border-box;
  }
  .svc-row.is-selected { border-color: var(--accent); background: var(--surface); }
  .svc-row .svc-dot { width: 9px; height: 9px; }
  /* The name gets the whole row. It still ellipses rather than wrapping -- a
     two-line name would break the even rhythm of the list -- but with the tick
     box gone there is room for a realistic service name before it has to. */
  .svc-rowmain { min-width: 0; }
  .svc-row .svc-name { font-size: 14px; font-weight: 600; }
  .svc-rowmeta { font-size: 12px !important; margin-top: 2px; gap: 0; }
  /* "Running · port 8181" as one line, the way the design reads it, rather
     than two spans separated by a gap. */
  /* nbsp, not a plain space: a leading space in generated content collapses
     against the start of the inline box and the dot ends up glued to "Running" */
  .svc-rowmeta .svc-port::before { content: "\00a0·\00a0"; color: var(--text-mute); }
  .svc-rowmeta .svc-port {
    padding: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  }
  .svc-row::after {
    content: "›"; flex: none; color: var(--text-mute);
    font-size: 19px; line-height: 1; padding-left: 2px;
  }

  /* The card header repeats the section title already in the header bar. The
     Services card is the only one where that is pure duplication -- Local
     processes below it still needs its own name to separate the two lists. */
  /* Not the whole head any more: it carries the bulk bar now, and the phone's
     "Select services" flow is the one thing that needs it. Only the two parts
     that genuinely repeat the section header go. */
  #services-card > .svc-head { display: block; padding: 0; border-bottom: none; }
  #services-card > .svc-head > h2,
  #services-card > .svc-head > #summary { display: none; }

  /* A visible scrollbar under the chips is chrome the design does not have,
     and on Windows it is a chunky 15px band. The chips themselves are the
     affordance that there is more to the right. */
  .cp-m-envchips { scrollbar-width: none; -ms-overflow-style: none; }
  .cp-m-envchips::-webkit-scrollbar { display: none; }

  /* ---- tables become stacked cards --------------------------------------
     A phone cannot show a five-column grid, and squeezing one produces
     columns two characters wide. Each row becomes a card, each value gets its
     column name above it. */

  /* local processes (app-boot.js builds .service-item) */
  .cp-section .service-item {
    display: block; width: 100%; height: auto; margin: 0 0 8px;
    padding: 12px 14px; border: 1px solid var(--line);
    border-radius: var(--radius); background: var(--surface);
  }
  .cp-section .service-item:hover { background: var(--surface); }
  .cp-section .service-item .svc-head {
    display: flex; flex-direction: column; align-items: flex-start; gap: 4px; padding: 0;
  }
  .cp-section .service-item .svc-name {
    font-size: 14px; font-weight: 600; display: flex; align-items: center; gap: 8px;
  }
  .cp-section .service-item .svc-port { padding: 0; }
  .cp-section .service-item .svc-statusrow {
    display: flex; flex-wrap: wrap; align-items: center; gap: 4px; margin-top: 8px;
  }
  .cp-section .service-item .service-actions {
    display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px;
  }
  .cp-section .service-item .service-actions .act { margin: 0; }

  /* service history (app-services.js builds .svc-table) -- that renderer fixes
     the four column names, so they are restated here rather than threaded
     through as data-label attributes on every cell. */
  .svc-table, .svc-table tbody, .svc-table tr, .svc-table td { display: block; width: 100%; }
  .svc-table thead { display: none; }
  .svc-table tr {
    border: 1px solid var(--line); border-radius: var(--radius);
    margin: 0 0 8px; padding: 8px 0; background: var(--surface);
  }
  .svc-table td { border: none; padding: 5px 12px; }
  .svc-table td::before {
    display: block; font-family: var(--mono); font-size: 10px;
    letter-spacing: 0.12em; color: var(--text-mute); margin-bottom: 2px;
  }
  .svc-table td:nth-child(1)::before { content: "BUILD"; }
  .svc-table td:nth-child(2)::before { content: "SOURCE"; }
  .svc-table td:nth-child(3)::before { content: "WHEN"; }
  .svc-table td:nth-child(4)::before { content: "RESULT"; }

  /* report grids (monitor.js) -- the header row names the columns on a
     desktop; on a phone it is dropped and each value carries its own label.
     !important throughout: that renderer writes display:flex and per-column
     min-widths as inline styles, which a stylesheet cannot otherwise outrank. */
  .rep-hdr { display: none !important; }
  .rep-row {
    display: block !important; padding: 12px 14px !important;
    border-bottom: 1px solid var(--line-soft); background: none !important;
  }
  .rep-row > span { display: block; min-width: 0 !important; padding: 2px 0; }
  .rep-row > span:first-child { font-size: 14px; padding-bottom: 6px; }
  .rep-row > span:not(:first-child)::before {
    content: attr(data-label);
    display: block; font-family: var(--mono); font-size: 10px;
    letter-spacing: 0.12em; color: var(--text-mute);
  }

  /* ---- File explorer ----------------------------------------------------
     index.html caps the listing at 250px with an inline max-height, which on a
     phone is about six rows of a directory inside a page that is already
     scrolling. Taller and bounded to the screen rather than to a fixed number
     of pixels, and the rows and their per-file buttons get thumb-sized.
     !important only where the inline style has to be beaten. */
  #browser-list-container {
    max-height: 55vh !important; max-height: 55dvh !important;
    overscroll-behavior: contain;
  }
  #browser-list-container td { padding: 10px 6px !important; font-size: 12.5px; }
  #browser-list-container .util-btn {
    min-width: 40px; min-height: 40px; padding: 4px 8px !important;
    display: inline-flex; align-items: center; justify-content: center;
  }
  /* The path bar and the +File/Upload/Compare row are two nowrap flex rows
     with inline styles; at 390px the third button of each sat off the edge. */
  #explorer-content > div { flex-wrap: wrap; row-gap: 6px; }

  /* ---- what must NOT become cards ---------------------------------------
     Console output, action output and API response bodies are fixed-width
     text: wrapping shears ASCII tables and JSON apart. They keep mono and
     scroll sideways instead. */
  #console-output, #action-output, .lt-console, .api-resp-body, pre.api-resp-body {
    white-space: pre; overflow-x: auto; -webkit-overflow-scrolling: touch;
    word-break: normal;
  }
  #console-output, .console-box { font-size: 11.5px; }
  .api-resp-hdrs { display: table; width: 100%; }
  /* The console's header (title, session picker, its own buttons) is a
     space-between row that does not fit; styles.css already lets it wrap on a
     desktop, it just needs to be told the pieces may shrink too. */
  .console-head-row { flex-wrap: wrap; row-gap: 8px; }
  .console-head-row > * { min-width: 0; max-width: 100%; }
  /* Its right-hand group is a nested nowrap flex row of its own. */
  .console-head-row > div { flex-wrap: wrap; row-gap: 8px; }
}

/* Stop / Remove / Deploy on a phone ask for the environment's name in full --
   a mis-tap is likelier with a thumb than with a pointer. app-shell.js builds
   this only when isMobile(), so it needs no media query of its own; it uses
   the same tokens as every other surface. */
.cp-m-confirm {
  position: fixed; inset: 0; z-index: 950;
  display: flex; align-items: center; justify-content: center; padding: 28px;
  background: rgba(0, 0, 0, 0.7);
}
.cp-m-confirm-box {
  width: 100%; max-width: 420px; box-sizing: border-box;
  display: flex; flex-direction: column; gap: 14px;
  background: var(--surface); border: 1px solid var(--line);
  border-radius: 16px; padding: 20px;
}
.cp-m-confirm-title { font-size: 16px; font-weight: 700; color: var(--text); }
.cp-m-confirm-note { font-size: 13px; line-height: 1.5; color: var(--text-dim); }
.cp-m-confirm-note strong { color: var(--text); font-family: var(--mono); }
.cp-m-confirm-input {
  height: 44px; width: 100%; box-sizing: border-box;
  border-radius: var(--radius-sm); border: 1px solid var(--line);
  background: var(--surface-2); color: var(--text);
  padding: 0 14px; font-family: var(--mono); font-size: 14px;
}
.cp-m-confirm-input:focus { outline: none; border-color: var(--accent); }
.cp-m-confirm-row { display: flex; gap: 8px; }
.cp-m-confirm-row .act { flex: 1; height: 46px; margin: 0; justify-content: center; font-size: 14px; }
.cp-m-confirm-row .act[disabled] { opacity: 0.45; cursor: not-allowed; }



/* ---------------- Disk cleanup ----------------
   A checklist with a measured size per row. Everything is themed through the
   shared variables so the panel's Light/Matrix/Custom themes carry it without a
   second set of rules. */
.hc-intro { font-size: 0.78rem; line-height: 1.5; color: var(--text-dim); margin-bottom: 10px; }
.hc-intro strong { color: var(--text); font-weight: 600; }

.hc-bulk { display: flex; gap: 6px; margin-bottom: 8px; flex-wrap: wrap; }
.hc-linkbtn {
  background: none; border: 1px solid var(--line-soft); color: var(--text-dim);
  border-radius: var(--radius-sm); padding: 3px 9px; font-size: 0.72rem;
  cursor: pointer; font-family: inherit;
}
.hc-linkbtn:hover { color: var(--text); border-color: var(--line); }
/* A disabled link button had no style of its own, which meant it looked and
   hovered exactly like a live one -- so clicking it produced silence with
   nothing to explain why. Anything that cannot be pressed has to say so before
   it is pressed. */
.hc-linkbtn:disabled { opacity: 0.4; cursor: not-allowed; }
.hc-linkbtn:disabled:hover { color: var(--text-dim); border-color: var(--line-soft); }

.hc-list { display: flex; flex-direction: column; gap: 12px; }
.hc-group { display: flex; flex-direction: column; gap: 3px; }
.hc-group-head { display: flex; flex-direction: column; gap: 1px; padding: 0 0 4px 1px; }
.hc-group-name {
  font-size: 0.68rem; font-weight: 700; letter-spacing: 0.08em;
  text-transform: uppercase; color: var(--text-mute);
}
.hc-group-hint { font-size: 0.7rem; line-height: 1.4; color: var(--text-mute); }

.hc-row {
  display: flex; align-items: flex-start; gap: 9px;
  padding: 8px 10px; border: 1px solid var(--line-soft);
  border-radius: var(--radius-sm); background: var(--surface-2);
  cursor: pointer;
}
.hc-row:hover { border-color: var(--line); }
.hc-row input[type="checkbox"] { margin: 2px 0 0 0; flex: 0 0 auto; cursor: pointer; }
.hc-row-locked { opacity: 0.55; cursor: not-allowed; }
.hc-row-locked input[type="checkbox"] { cursor: not-allowed; }

.hc-row-body { display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 1 1 auto; }
.hc-row-top { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.hc-label { font-size: 0.8rem; font-weight: 600; color: var(--text); }
/* Two lines of description, then an ellipsis with the full text in the tooltip.
   Eleven rows each free to run to five lines is a checklist nobody can see the
   shape of, and the description is context for a decision the label, the badges
   and the size have already framed. */
.hc-desc {
  font-size: 0.71rem; line-height: 1.45; color: var(--text-dim);
  display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
}
.hc-note { font-size: 0.69rem; color: var(--text-mute); font-family: var(--mono); }

.hc-badge {
  font-size: 0.6rem; letter-spacing: 0.04em; text-transform: uppercase;
  padding: 1px 5px; border-radius: 999px;
  border: 1px solid var(--line); color: var(--text-mute); white-space: nowrap;
}
.hc-badge-admin { border-color: var(--warn); color: var(--warn); }

.hc-size {
  flex: 0 0 auto; align-self: center; text-align: right; min-width: 62px;
  font-family: var(--mono); font-size: 0.76rem; color: var(--text-dim);
}
.hc-size-on { color: var(--text); font-weight: 600; }
.hc-muted { color: var(--text-mute); font-size: 0.68rem; }

.hc-foot {
  display: flex; align-items: center; justify-content: space-between;
  gap: 10px; margin-top: 12px; padding-top: 10px;
  border-top: 1px solid var(--line-soft); flex-wrap: wrap;
}
.hc-summary { font-size: 0.74rem; color: var(--text-dim); min-width: 0; flex: 1 1 180px; }
.hc-actions { display: flex; gap: 8px; flex: 0 0 auto; }
.hc-actions .act { height: 34px; margin: 0; justify-content: center; font-size: 12.5px; }
.hc-actions .act[disabled] { opacity: 0.45; cursor: not-allowed; }

.hc-status { font-size: 0.73rem; line-height: 1.5; color: var(--text-dim); margin-top: 8px; min-height: 1em; }
.hc-status.hc-ok { color: var(--ok); }
.hc-status.hc-warn { color: var(--warn); }
.hc-status.hc-error { color: var(--danger); }
.hc-hint { font-size: 0.74rem; color: var(--text-mute); }

/* Disk cleanup progress. Determinate across categories, indeterminate before
   the first one reports -- the two states are the two fill classes. */
.hc-progress { margin-top: 12px; display: flex; flex-direction: column; gap: 6px; }
.hc-bar {
  position: relative; overflow: hidden;
  height: 6px; border-radius: 999px; background: var(--bg-sunk);
  border: 1px solid var(--line-soft);
}
.hc-bar-fill {
  height: 100%; width: 0; border-radius: 999px;
  background: var(--accent);
  transition: width 300ms ease;
}
/* No fraction to draw yet: sweep a short block across instead of sitting at 0%. */
.hc-bar-indeterminate {
  width: 35% !important;
  animation: hc-sweep 1.1s ease-in-out infinite;
  transition: none;
}
@keyframes hc-sweep {
  0%   { transform: translateX(-100%); }
  100% { transform: translateX(300%); }
}
.hc-progress-lines { display: flex; justify-content: space-between; gap: 10px; flex-wrap: wrap; }
.hc-progress-text { font-size: 0.73rem; color: var(--text); }
.hc-progress-count { font-size: 0.7rem; color: var(--text-mute); }

/* Respect a reduced-motion preference: the sweep is decoration, the width is
   the information. */
@media (prefers-reduced-motion: reduce) {
  .hc-bar-indeterminate { animation: none; width: 100% !important; opacity: 0.45; }
  .hc-bar-fill { transition: none; }
}

/* ---------------- A measured-total panel ----------------
   A hero number and a few counters in a box, used by the duplicate finder. The
   cleanup checklist and the analyzer used to share it; both now put their total
   in the section hero above the tabs instead, which is why what is left here is
   the box and the type rather than a chart. */
.hc-stats {
  margin: 4px 0 14px;
  padding: 14px;
  border: 1px solid var(--line-soft);
  border-radius: var(--radius-sm);
  background: var(--surface-2);
}

.hc-hero { display: flex; align-items: baseline; gap: 7px; min-width: 0; margin-bottom: 12px; }
.hc-hero-num {
  font-size: 1.9rem; font-weight: 700; line-height: 1;
  color: var(--text); letter-spacing: -0.02em;
}
.hc-hero-cap { font-size: 0.75rem; color: var(--text-mute); }

.hc-tiles { display: flex; gap: 16px; flex-wrap: wrap; }
.hc-tile { display: flex; flex-direction: column; gap: 1px; }
.hc-tile-num { font-size: 0.9rem; font-weight: 600; color: var(--text-dim); font-family: var(--mono); }
.hc-tile-cap { font-size: 0.66rem; color: var(--text-mute); }
.hc-tile-warn .hc-tile-num { color: var(--warn); }

/* ---------------- The checklist row's verdict ----------------
   Two or three words saying whether a category has anything to give, because
   the size beside it cannot tell the three ways of reporting zero apart:
   nothing there, something there that an age rule is holding, or nobody was
   allowed to look. Emphasised only for "yes, this much" -- that is the state
   worth finding by eye down a list of eleven rows. */
.hc-state {
  flex: 0 0 auto; align-self: center; white-space: nowrap;
  font-size: 0.6rem; letter-spacing: 0.04em; text-transform: uppercase;
  padding: 2px 7px; border-radius: 999px;
  background: var(--bg-sunk); border: 1px solid var(--line-soft); color: var(--text-mute);
}
.hc-state-on { border-color: var(--hc-series-system); color: var(--hc-series-system); }
.hc-state-warn { border-color: var(--warn); color: var(--warn); }

/* ---------------- Disk cleanup: the section shell ----------------
   One card, four tabs, one hero. Everything is themed through the shared
   variables so Light, Matrix and Custom carry it without a second palette --
   which is also why the two rings read their colours back off this element in
   JS (see dcVar in app-diskclean.js) instead of hardcoding a set that only
   matches the dark surface. */
.dc-shell {
  /* Categorical slots 1-3: the three category groups, and the only place in
     this section where hue means something. Stepped for the dark surface and
     validated for colour-vision deficiency against both. */
  --hc-series-panel:  #3987e5;
  --hc-series-system: #d95926;
  --hc-series-apps:   #199e70;

  /* The explore ramp: one hue, twelve steps, largest first. A directory
     listing is a ranking, not a set of categories -- twelve hues would claim a
     kind of difference between AppData and Downloads that does not exist, so
     the ramp varies lightness and lets the order carry the meaning. */
  --dc-ramp-1:  #9fcbf2;
  --dc-ramp-2:  #8dc0ee;
  --dc-ramp-3:  #7bb5e9;
  --dc-ramp-4:  #6aa8e2;
  --dc-ramp-5:  #5c9ad8;
  --dc-ramp-6:  #4f8bcc;
  --dc-ramp-7:  #457dbe;
  --dc-ramp-8:  #3c6faf;
  --dc-ramp-9:  #35629f;
  --dc-ramp-10: #2f568e;
  --dc-ramp-11: #2a4b7d;
  --dc-ramp-12: #26416c;
}
body.theme-light .dc-shell {
  --hc-series-panel:  #2a78d6;
  --hc-series-system: #eb6834;
  --hc-series-apps:   #1baf7a;
  /* Inverted: on white the darkest step has to be the largest slice, or the
     biggest thing on the disk is drawn in the faintest colour. */
  --dc-ramp-1:  #17375c;
  --dc-ramp-2:  #1c4270;
  --dc-ramp-3:  #204d84;
  --dc-ramp-4:  #255897;
  --dc-ramp-5:  #2a63a9;
  --dc-ramp-6:  #316fba;
  --dc-ramp-7:  #3b7bc6;
  --dc-ramp-8:  #4c88cd;
  --dc-ramp-9:  #6096d4;
  --dc-ramp-10: #76a5db;
  --dc-ramp-11: #8eb4e2;
  --dc-ramp-12: #a7c4e9;
}
/* Matrix is a deliberate single-colour theme; hues would break it, so both the
   groups and the ramp separate by lightness of the one colour. */
body.theme-matrix .dc-shell {
  --hc-series-panel:  #00ff00;
  --hc-series-system: #00b400;
  --hc-series-apps:   #007a00;
  --dc-ramp-1:  #00ff00;
  --dc-ramp-2:  #00ea00;
  --dc-ramp-3:  #00d600;
  --dc-ramp-4:  #00c200;
  --dc-ramp-5:  #00ae00;
  --dc-ramp-6:  #009b00;
  --dc-ramp-7:  #008800;
  --dc-ramp-8:  #007600;
  --dc-ramp-9:  #006500;
  --dc-ramp-10: #005500;
  --dc-ramp-11: #004600;
  --dc-ramp-12: #003800;
}

/* ---------- the hero ----------
   The number the section exists to produce, at a size that says so, with the
   two buttons that change it on the same line. */
.dc-hero { display: flex; flex-direction: column; gap: 12px; margin-bottom: 4px; }
.dc-hero-line {
  display: flex; align-items: flex-end; justify-content: space-between;
  gap: 20px; flex-wrap: wrap;
}
.dc-hero-figure { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; min-width: 0; }
.dc-hero-num {
  font-size: 2.15rem; font-weight: 700; line-height: 1;
  letter-spacing: -0.025em; color: var(--text); white-space: nowrap;
}
.dc-hero-cap { font-size: 0.82rem; color: var(--text-dim); white-space: nowrap; }
.dc-hero-meta { font-size: 0.68rem; color: var(--text-mute); }
.dc-hero-actions { display: flex; gap: 8px; flex: 0 0 auto; }
.dc-hero-actions .act { height: 36px; margin: 0; justify-content: center; font-size: 12.5px; }
.dc-hero-actions .act[disabled] { opacity: 0.45; cursor: not-allowed; }

/* The whole machine in one bar. Segment widths are floored so a small one stays
   findable, which is why every segment is also named with its own number in the
   key underneath: the key is the data, the bar is the ordering. */
.dc-band {
  display: flex; height: 15px; border-radius: 4px; overflow: hidden;
  background: var(--bg-sunk); border: 1px solid var(--line-soft);
}
.dc-seg { display: block; height: 100%; }
.dc-seg-now  { background: var(--hc-series-system); }
.dc-seg-held { background: var(--hc-series-system); opacity: 0.4; }
.dc-seg-used { background: var(--accent); }
.dc-seg-free { background: var(--surface); }

.dc-band-key, .dc-viz-key { display: flex; gap: 8px 20px; flex-wrap: wrap; }
.dc-key-item {
  display: inline-flex; align-items: baseline; gap: 7px;
  font-size: 0.7rem; color: var(--text-mute);
}
.dc-key-num { color: var(--text); font-weight: 600; }
.dc-swatch {
  width: 9px; height: 9px; border-radius: 2px; flex: 0 0 auto; align-self: center;
}
.dc-swatch.dc-seg-free { border: 1px solid var(--line); }

/* A notice, not an error: something is true about this host that changes what
   the numbers mean. Warn-toned, one line, with the long explanation behind a
   button so the header does not become a paragraph. */
.dc-notice {
  display: flex; align-items: flex-start; gap: 10px; flex-wrap: wrap;
  padding: 8px 12px; border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
  border-left: 3px solid var(--warn);
  /* The border and the text colour carry it. A tinted fill would need either a
     hardcoded rgba (wrong under Light and Matrix) or color-mix, which nothing
     else in this stylesheet relies on -- same call as .cs-banner. */
  background: var(--surface-2);
}
.dc-notice-text { flex: 1 1 240px; font-size: 0.72rem; line-height: 1.5; color: var(--warn); }
.dc-notice-btn {
  flex: 0 0 auto; font-family: inherit; font-size: 0.7rem;
  border-radius: var(--radius-sm); padding: 3px 10px; cursor: pointer;
  background: transparent; border: 1px solid var(--warn); color: var(--warn);
}
.dc-notice-more {
  flex: 1 1 100%; font-size: 0.72rem; line-height: 1.55; color: var(--text-dim);
}

/* ---------- the tabs ---------- */
.dc-tabs {
  display: flex; align-items: stretch; gap: 22px; flex-wrap: wrap;
  margin: 18px 0 0; border-bottom: 1px solid var(--line);
}
.dc-tab {
  font-family: inherit; font-size: 0.82rem; font-weight: 500;
  background: none; border: none; border-bottom: 2px solid transparent;
  color: var(--text-mute); padding: 0 2px 10px; margin-bottom: -1px; cursor: pointer;
}
.dc-tab:hover { color: var(--text-dim); }
.dc-tab-on { font-weight: 600; color: var(--text); border-bottom-color: var(--accent); }

.dc-pane { padding-top: 16px; }
.dc-intro { font-size: 0.78rem; line-height: 1.5; color: var(--text-dim); margin-bottom: 12px; }
.dc-intro strong { color: var(--text); font-weight: 600; }

/* ---------- chart beside list ----------
   The same measurement twice: proportion on the left, detail on the right. The
   ring is fixed-width because a circle that reflows is a circle that resizes on
   every drag of the column grip; the list takes what is left. Below the
   breakpoint the ring goes above the list rather than shrinking into a token. */
.dc-split { display: flex; gap: 26px; align-items: flex-start; }
.dc-viz {
  /* Grows with the card but stops: past about 380px the ring is a decoration
     competing with the list, and below 260 its centre label stops fitting. */
  flex: 0 0 clamp(260px, 26%, 380px);
  display: flex; flex-direction: column; gap: 10px; min-width: 0;
}
.dc-body { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; }

.dc-viz-label {
  font-size: 0.68rem; font-weight: 700; letter-spacing: 0.08em;
  text-transform: uppercase; color: var(--text-mute);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.dc-viz-note {
  font-size: 0.68rem; line-height: 1.55; color: var(--text-mute);
  padding-left: 9px; border-left: 2px solid var(--line-soft);
}
.dc-viz-key { flex-direction: column; gap: 7px; }

.dc-ring { position: relative; width: 100%; aspect-ratio: 1 / 1; }
.dc-ring-svg, .dc-ring-svg svg { display: block; width: 100%; height: 100%; }
.dc-ring-empty { color: var(--line-soft); }
.dc-ring-center {
  position: absolute; inset: 0; pointer-events: none;
  display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px;
  padding: 0 22%; text-align: center;
}
.dc-ring-num {
  font-size: 1.6rem; font-weight: 700; line-height: 1;
  letter-spacing: -0.025em; color: var(--text);
}
.dc-ring-cap { font-size: 0.68rem; line-height: 1.4; color: var(--text-mute); }

.dc-body-head {
  display: flex; align-items: center; justify-content: space-between;
  gap: 12px; flex-wrap: wrap; margin-bottom: 10px;
}
.dc-body-tools { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.dc-count { font-size: 0.72rem; color: var(--text-dim); }

/* The same footer, above the thing it acts on.
   A directory tree is as long as the disk is deep and a schedule list as long as
   the schedules, so Analyze, Save and Delete selected at the far end of one are
   controls an operator has to go looking for — and nobody scrolls down to find a
   button they cannot see. Same element, same ids, same rules: the rule that
   separates it from its content flips from the top edge to the bottom one. */
.dc-actionbar {
  margin-top: 0; margin-bottom: 12px;
  padding-top: 0; padding-bottom: 10px;
  border-top: none; border-bottom: 1px solid var(--line-soft);
}
/* A bar holding nothing but the selection sentence needs no rule under it — the
   line is a readout, not a set of controls to fence off. Marked with a class
   rather than :has(), which nothing else in this stylesheet relies on. */
.dc-actionbar-plain { border-bottom: none; padding-bottom: 0; margin-bottom: 8px; }

/* The analyzer's small print, when there is any. Same rules as the caveat rows
   it holds; see .dm-caveat. */
.dc-caveats { display: flex; flex-direction: column; gap: 4px; margin-bottom: 10px; }

@media (max-width: 900px) {
  .dc-split { flex-direction: column; }
  .dc-viz { flex: 0 0 auto; width: 100%; max-width: 320px; align-self: center; }
}


/* ---------------- Disk analyzer ----------------
   Where the space went, as opposed to which known junk can go. The Explore tab
   of the same section, reusing its progress bar, footer and status line -- the
   two are the same shape of operation on the same machine, and a second set of
   rules for them would drift. What is new here is the location picker, the
   breadcrumb, and the ranked row with its proportional bar.

   The locations are chips on one row: three or four of them, a single choice,
   and giving each a full-width row put the thing being chosen further from the
   result than the result's own controls. The full path is the chip's tooltip and
   the meta line beside the ring, and the unelevated warning is a .dc-notice
   under the row -- a sentence does not fit in a chip. */
.dm-scopes { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
.dm-scope {
  display: flex; align-items: center; gap: 8px; text-align: left;
  padding: 6px 12px; border: 1px solid var(--line-soft);
  border-radius: 999px; background: var(--surface-2);
  cursor: pointer; font-family: inherit; color: inherit;
}
.dm-scope:hover:not([disabled]) { border-color: var(--line); }
.dm-scope-on { border-color: var(--accent); background: var(--surface); }
.dm-scope-locked, .dm-scope[disabled] { opacity: 0.55; cursor: not-allowed; }
.dm-scope-body { display: flex; align-items: center; gap: 8px; min-width: 0; }
.dm-scope-name { font-size: 0.75rem; font-weight: 600; color: var(--text); display: flex; align-items: center; gap: 6px; white-space: nowrap; }

/* View switch and the file-class filter. */
.dm-toolbar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin: 12px 0 8px; }
.dm-tabs { display: inline-flex; border: 1px solid var(--line-soft); border-radius: var(--radius-sm); overflow: hidden; }
.dm-tab {
  background: none; border: none; color: var(--text-dim); font-family: inherit;
  font-size: 0.72rem; padding: 5px 11px; cursor: pointer;
}
.dm-tab + .dm-tab { border-left: 1px solid var(--line-soft); }
.dm-tab:hover { color: var(--text); }
.dm-tab-on { background: var(--surface-2); color: var(--text); font-weight: 600; }
.dm-select {
  border: 1px solid var(--line-soft); border-radius: var(--radius-sm);
  background: var(--surface-2); color: var(--text);
  font-family: inherit; font-size: 0.72rem; padding: 5px 8px;
}
.dm-select:focus { outline: none; border-color: var(--accent); }

/* Breadcrumb. Each crumb is a button carrying its depth, never a path. */
.dm-crumbs { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; margin-bottom: 6px; }
.dm-crumbs:empty { display: none; }
.dm-crumb {
  background: none; border: none; padding: 1px 3px; cursor: pointer;
  font-family: inherit; font-size: 0.71rem; color: var(--text-dim);
  border-radius: 3px; max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.dm-crumb:hover { color: var(--text); background: var(--surface-2); }
.dm-crumb-here { color: var(--text); font-weight: 600; cursor: default; }
.dm-crumb-here:hover { background: none; }
.dm-crumb-sep { font-size: 0.71rem; color: var(--text-mute); }

/* One measured row: checkbox, name, proportional bar, size. */
.dm-rows { display: flex; flex-direction: column; gap: 2px; }
.dm-rowsnote { font-size: 0.69rem; color: var(--text-mute); margin-bottom: 5px; }
.dm-row {
  display: flex; align-items: center; gap: 8px;
  padding: 5px 8px; border: 1px solid transparent; border-radius: var(--radius-sm);
}
.dm-row:hover { background: var(--surface-2); border-color: var(--line-soft); }
.dm-row-on { background: var(--surface-2); border-color: var(--accent); }
.dm-row-remainder { opacity: 0.7; }
.dm-check { flex: 0 0 14px; display: flex; align-items: center; justify-content: center; }
.dm-check input { margin: 0; cursor: pointer; }

.dm-name-wrap { display: flex; flex-direction: column; gap: 1px; min-width: 0; flex: 1 1 40%; }
.dm-open, .dm-name-static {
  display: flex; align-items: center; gap: 6px; min-width: 0;
  background: none; border: none; padding: 0; font-family: inherit; color: inherit; text-align: left;
}
.dm-open { cursor: pointer; }
.dm-open:hover .dm-name { color: var(--accent); text-decoration: underline; }
.dm-icon { flex: 0 0 auto; color: var(--text-mute); }
.dm-open .dm-icon { color: var(--accent); }
.dm-name {
  font-size: 0.76rem; color: var(--text); min-width: 0;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.dm-name-dim { color: var(--text-mute); font-style: italic; }
.dm-meta { font-size: 0.66rem; color: var(--text-mute); padding-left: 19px; }

/* The bar is scaled to the largest row on screen, not to the parent's total:
   within one directory the comparison is between siblings, and scaling to the
   total leaves every row in a flat directory as a sliver. */
.dm-track {
  flex: 1 1 30%; min-width: 40px; max-width: 260px; height: 8px;
  background: var(--bg-sunk); border-radius: 3px; overflow: hidden;
}
.dm-fill { display: block; height: 100%; background: var(--accent); border-radius: 0 3px 3px 0; }
.dm-row-remainder .dm-fill { background: var(--text-mute); }
/* Share of the directory listed, which is what makes the row and its slice of
   the ring the same fact. Absent in the largest-files view, where twenty files
   ranked by size are not a partition of anything to be a share of. */
.dm-share { flex: 0 0 auto; min-width: 44px; text-align: right; font-size: 0.68rem; color: var(--text-mute); }
.dm-size { flex: 0 0 auto; min-width: 62px; text-align: right; font-size: 0.73rem; color: var(--text); font-weight: 600; }

.dm-empty {
  padding: 16px 14px; border: 1px dashed var(--line-soft);
  border-radius: var(--radius-sm); background: var(--surface-2);
}
/* Every reason a total might not match the disk right now. Quiet, but never
   hidden: a capped listing, a canceled walk and an unreadable directory each
   make the number mean something different. */
.dm-caveat {
  font-size: 0.69rem; line-height: 1.5; color: var(--text-mute);
  padding-left: 9px; border-left: 2px solid var(--line-soft);
}

@media (max-width: 620px) {
  .dm-track, .dm-share { display: none; }
  .dm-name-wrap { flex-basis: 100%; }
}


/* ---------------- Scheduled cleanups ----------------
   The checklist on a timer. Reuses the hc- footer, status and badge rules like
   the two panels above it; what is new is the schedule card, the cron row and
   the run history. */
.cs-list { display: flex; flex-direction: column; gap: 10px; }

.cs-banner {
  font-size: 0.72rem; line-height: 1.5; color: var(--warn);
  border: 1px solid var(--warn); border-radius: var(--radius-sm);
  padding: 8px 10px; margin-bottom: 4px;
  /* Border and text colour carry it. A tinted fill would need either a
     hardcoded rgba (wrong under the Light and Matrix themes) or color-mix,
     which nothing else in this stylesheet relies on. */
  background: var(--surface-2);
}

.cs-row {
  display: flex; flex-direction: column; gap: 7px;
  padding: 11px 12px; border: 1px solid var(--line-soft);
  border-radius: var(--radius-sm); background: var(--surface-2);
}
/* A disabled schedule keeps its definition and its history, so it stays legible
   rather than being greyed into unreadability. */
.cs-row-off { opacity: 0.72; border-style: dashed; }

.cs-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.cs-toggle {
  display: inline-flex; align-items: center; gap: 5px; cursor: pointer;
  font-size: 0.7rem; color: var(--text-dim); flex: 0 0 auto;
}
.cs-toggle input { margin: 0; cursor: pointer; }
.cs-name {
  flex: 1 1 160px; min-width: 0;
  border: 1px solid var(--line-soft); border-radius: var(--radius-sm);
  background: var(--surface); color: var(--text);
  font-family: inherit; font-size: 0.8rem; font-weight: 600; padding: 5px 8px;
}
.cs-name:focus { outline: none; border-color: var(--accent); }

.cs-when { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.cs-cron {
  flex: 0 0 130px;
  border: 1px solid var(--line-soft); border-radius: var(--radius-sm);
  background: var(--surface); color: var(--text);
  font-size: 0.74rem; padding: 5px 8px;
}
.cs-cron:focus { outline: none; border-color: var(--accent); }
.cs-check, .cs-cat {
  display: inline-flex; align-items: center; gap: 5px; cursor: pointer;
  font-size: 0.71rem; color: var(--text-dim);
}
.cs-check input, .cs-cat input { margin: 0; cursor: pointer; }
.cs-next { font-size: 0.7rem; color: var(--text-mute); }
/* Emitted empty so an edited cron can blank it in place; takes no room when it is. */
.cs-next:empty { display: none; }

/* Categories wrap as chips: the list is platform-dependent and can be a dozen
   long, and a column of them would push the history off the screen. */
.cs-cats { display: flex; flex-wrap: wrap; gap: 6px 14px; padding-top: 2px; }
.cs-cat-locked { opacity: 0.55; cursor: not-allowed; }
.cs-cat-locked input { cursor: not-allowed; }

.cs-note { font-size: 0.68rem; line-height: 1.45; color: var(--text-mute); }
.cs-note-warn { color: var(--warn); }

.cs-actions { display: flex; gap: 6px; flex-wrap: wrap; }

/* Three kinds of history entry, and they must not look alike: a run that freed
   something, one that was skipped or failed, and a gap where the panel was not
   running at all. The last is why the list exists -- a schedule that has quietly
   never fired is indistinguishable from one with nothing to do unless the misses
   are shown. */
.cs-history {
  display: flex; flex-direction: column; gap: 2px;
  border-top: 1px solid var(--line-soft); padding-top: 6px; margin-top: 1px;
}
.cs-run { display: flex; gap: 10px; font-size: 0.69rem; color: var(--text-dim); }
.cs-run-when { flex: 0 0 auto; color: var(--text-mute); }
.cs-run-what { min-width: 0; }
.cs-run-skip .cs-run-what { color: var(--text-mute); font-style: italic; }
.cs-run-missed .cs-run-what { color: var(--warn); }
.cs-run-error .cs-run-what { color: var(--danger); }

@media (max-width: 620px) {
  .cs-run { flex-wrap: wrap; gap: 0 8px; }
  .cs-cron { flex-basis: 100%; }
}


/* ---------------- Duplicate finder ----------------
   Sets of identical files. Reuses the hc- hero, tiles, footer, progress and
   status rules like the three panels above it; what is new is the set card and
   the keeper row.

   THE ROW IS A RADIO, NOT A CHECKBOX, and the styling has to make that legible:
   with checkboxes, deleting every copy of something is one careless click away.
   So the mark means "keep this one" and every other row in an enabled set is
   shown as the one that goes -- which is why "keep" and "delete" are spelled out
   at the end of each row rather than left to colour alone. */
.df-groups { display: flex; flex-direction: column; gap: 10px; }

.df-group {
  border: 1px solid var(--line-soft); border-radius: var(--radius-sm);
  background: var(--surface-2); padding: 9px 10px;
  display: flex; flex-direction: column; gap: 3px;
}
.df-group-on { border-color: var(--accent); }

.df-group-head {
  display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
  padding-bottom: 5px; margin-bottom: 2px;
  border-bottom: 1px solid var(--line-soft);
}
.df-group-size { font-size: 0.72rem; color: var(--text-dim); }
.df-group-waste { font-size: 0.72rem; font-weight: 600; color: var(--text); }
.df-group-digest { font-size: 0.65rem; color: var(--text-mute); margin-left: auto; }

.df-file {
  display: flex; align-items: center; gap: 8px;
  padding: 4px 5px; border-radius: var(--radius-sm); cursor: pointer;
  border: 1px solid transparent;
}
.df-file:hover { background: var(--surface); }
.df-file input { margin: 0; cursor: pointer; flex: 0 0 auto; }
/* A fixed row -- a hard link, or a protected path -- offers no choice, so it
   gets the same indent as the others without a control that would do nothing. */
.df-file-nomark { flex: 0 0 13px; }
.df-file-fixed { cursor: default; opacity: 0.75; }
.df-file-fixed:hover { background: none; }

.df-file-body { display: flex; flex-direction: column; gap: 1px; min-width: 0; flex: 1 1 auto; }
.df-file-path {
  font-size: 0.73rem; color: var(--text); min-width: 0;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* The directory is the part that distinguishes one copy from another, and the
   file name is usually the same in all of them -- so the directory is what must
   stay readable, and it is dimmed rather than truncated first. */
.df-file-dir { color: var(--text-mute); }
.df-file-meta { font-size: 0.65rem; color: var(--text-mute); }

.df-file-verdict {
  flex: 0 0 auto; min-width: 46px; text-align: right;
  font-size: 0.65rem; letter-spacing: 0.04em; text-transform: uppercase; color: transparent;
}
.df-file-keep { background: var(--surface); border-color: var(--line-soft); }
.df-file-keep .df-file-verdict { color: var(--ok); }
.df-file-keep .df-file-path { font-weight: 600; }
.df-file-doomed .df-file-verdict { color: var(--danger); }
.df-file-doomed .df-file-path { text-decoration: line-through; text-decoration-color: var(--danger); }

.df-tag { color: var(--text-dim); }
.df-tag-warn { color: var(--warn); }

@media (max-width: 620px) {
  .df-group-digest { margin-left: 0; }
  .df-file-verdict { display: none; }
}


/* ---------------- Device import ----------------
   A device picker, a folder tree, and a filter. Shares the hc- progress, footer
   and status rules rather than restating them -- the two panels are the same
   shape of operation (pick, run, report) on the same machine. */
.ds-section-label {
  font-size: 0.68rem; font-weight: 700; letter-spacing: 0.08em;
  text-transform: uppercase; color: var(--text-mute);
  margin: 12px 0 6px 1px;
}

/* Contacts. A checkable list rather than the file tree's rows: an address book
   has no hierarchy to browse, so the only structure worth drawing is one line
   per person and a running count of what is ticked. */
.ds-contacts-body { margin-top: 2px; }
.ds-contacts-note {
  font-size: 0.78rem; color: var(--text-dim);
  padding: 8px 2px;
}
/* The note is dim by design, so a link inside it needs its own colour to read
   as one -- inheriting --text-dim would make it indistinguishable from the
   sentence around it. */
.ds-contacts-note a {
  color: var(--accent); text-decoration: underline;
  text-underline-offset: 2px;
}
.ds-contacts-note a:hover { color: var(--text); }
.ds-contacts-error { color: var(--danger, #c0392b); }
.ds-contacts-head {
  display: flex; align-items: center; justify-content: space-between;
  gap: 8px; padding: 4px 2px 6px;
}
.ds-contacts-count { font-size: 0.76rem; color: var(--text-dim); }
.ds-contacts-bulk { display: flex; gap: 8px; }

/* Capped and scrolled: a four-hundred-entry address book must not push the
   import controls off the bottom of the panel. */
.ds-contacts-list {
  max-height: 280px; overflow-y: auto;
  border: 1px solid var(--line-soft); border-radius: var(--radius-sm);
  background: var(--surface-2);
}
/* Four columns: tick, name, organisation, contact detail. The name is the
   only one that grows, and the two trailing columns are emitted even when
   empty so a contact with no organisation lines up with one that has. */
.ds-contact-row {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr) minmax(0, auto) minmax(0, auto);
  align-items: center; gap: 10px;
  padding: 6px 10px; cursor: pointer;
  border-bottom: 1px solid var(--line-soft);
}
.ds-contact-row:last-child { border-bottom: none; }
.ds-contact-row:hover { background: var(--surface-3, var(--surface-2)); }
.ds-contact-name {
  font-size: 0.82rem; color: var(--text);
  justify-self: start; text-align: left;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.ds-contact-org {
  font-size: 0.72rem; color: var(--text-mute);
  justify-self: end; text-align: right;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  max-width: 12rem;
}
/* The detail column yields first: on a narrow panel the name is the part that
   has to survive, and a truncated number helps nobody. */
.ds-contact-detail {
  font-size: 0.72rem; color: var(--text-dim);
  justify-self: end; text-align: right;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  max-width: 16rem;
}
/* Narrow: the name is the part that has to survive, so the organisation goes
   first and the numbers follow it. */
@media (max-width: 700px) {
  .ds-contact-row { grid-template-columns: auto minmax(0, 1fr) minmax(0, auto); }
  .ds-contact-org { display: none; }
}
@media (max-width: 480px) {
  .ds-contact-row { grid-template-columns: auto minmax(0, 1fr); }
  .ds-contact-detail { display: none; }
}

.ds-devices { display: flex; flex-direction: column; gap: 6px; }
.ds-device {
  display: flex; align-items: center; gap: 10px;
  padding: 9px 11px; border: 1px solid var(--line-soft);
  border-radius: var(--radius-sm); background: var(--surface-2); cursor: pointer;
}
.ds-device:hover { border-color: var(--line); }
.ds-device-on { border-color: var(--accent); background: var(--surface-3, var(--surface-2)); }
.ds-device-blocked { opacity: 0.75; }
.ds-device-icon { flex: 0 0 auto; color: var(--text-dim); display: flex; }
.ds-device-on .ds-device-icon { color: var(--accent); }
.ds-device-body { display: flex; flex-direction: column; gap: 1px; min-width: 0; flex: 1 1 auto; }
.ds-device-name { font-size: 0.8rem; font-weight: 600; color: var(--text); }
.ds-device-meta { font-size: 0.7rem; color: var(--text-mute); }
.ds-device-hint { font-size: 0.71rem; line-height: 1.4; color: var(--warn); margin-top: 3px; }

.ds-empty {
  padding: 16px 14px; border: 1px dashed var(--line-soft);
  border-radius: var(--radius-sm); background: var(--surface-2);
}
.ds-empty-title { font-size: 0.8rem; font-weight: 600; color: var(--text); margin-bottom: 4px; }
.ds-empty-body { font-size: 0.73rem; line-height: 1.55; color: var(--text-dim); }
.ds-empty-body strong { color: var(--text); font-weight: 600; }
/* ---- what the phone says about itself ----
   Sits between the device row and the file browser, because it is context for
   the thing below it rather than a panel of its own. Quiet by default: it is
   never the reason anyone opened this page. */
.ds-stats { margin: 8px 0 2px; }
.ds-stats:empty { display: none; }
.ds-stat-line { font-size: 0.71rem; color: var(--text-mute); }
.ds-stat-name { color: var(--text-dim); font-weight: 600; }
.ds-stat-bar {
  margin-top: 5px; height: 4px; max-width: 320px;
  background: var(--surface-2); border-radius: 2px; overflow: hidden;
}
.ds-stat-bar > span { display: block; height: 100%; background: var(--accent); border-radius: 2px; }

/* ---- thumbnail grid ----
   Tiles are a fixed size and the images are cropped to fill them: a grid of
   photographs is scanned by shape and colour, and ragged edges from mixed
   portrait and landscape break that before the pictures have been looked at.
   auto-fill rather than a fixed column count, so the same grid works in the
   panel's full width and in a phone's. */
/* Compound selector, not `.ds-preview-grid` alone: the list's own rule is
   further down this file and would otherwise win on source order and leave the
   tiles as one flex column the width of the panel. */
.ds-preview-list.ds-preview-grid {
  display: grid; gap: 10px;
  grid-template-columns: repeat(auto-fill, minmax(132px, 1fr));
  padding: 8px;
  /* Taller than the row list: a grid two tiles high shows almost nothing, and
     the whole point of tiles is seeing several at once. */
  max-height: 420px;
}
.ds-tile {
  position: relative; display: flex; flex-direction: column; gap: 3px;
  cursor: pointer; min-width: 0;
}
.ds-tile-img {
  display: block; width: 100%; aspect-ratio: 1; object-fit: cover;
  border-radius: var(--radius-sm); background: var(--surface-2);
  border: 1px solid var(--line);
}
/* Waiting its turn in the queue. A plain empty plate rather than a spinner per
   tile: a hundred spinners is a screen that looks broken, and the tiles fill in
   quickly enough once each one's turn comes. */
.ds-tile-img.is-pending { background: var(--surface-2); }
/* A file with no picture in it, and a picture that failed to decode, land in
   the same place: an empty plate rather than a broken-image glyph. */
.ds-tile-img.is-blank {
  background:
    repeating-linear-gradient(45deg, var(--surface-2) 0 6px, var(--bg-sunk) 6px 12px);
  object-fit: none;
}
.ds-tile-tick {
  position: absolute; top: 6px; left: 6px; z-index: 1;
  width: 16px; height: 16px; cursor: pointer;
  /* The tick sits on the photograph, which may be any colour at all. */
  accent-color: var(--accent);
  box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.45); border-radius: 3px;
}
.ds-tile-name {
  font-size: 0.68rem; color: var(--text-dim);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.ds-tile-meta { font-size: 0.64rem; color: var(--text-mute); font-family: var(--mono); }
/* Already imported: dimmed rather than hidden, the same signal the row list
   gives, so the two views agree about what "have" looks like. */
.ds-tile-have .ds-tile-img { opacity: 0.45; }
.ds-thumbs-toggle { font-size: 0.71rem; white-space: nowrap; }

/* Quick View Modal */
#ds-quickview-modal {
  position: fixed;
  top: 0;
  left: 0;
  width: 100vw;
  height: 100vh;
  background: rgba(0, 0, 0, 0.75);
  display: flex;
  justify-content: center;
  align-items: center;
  z-index: 10000;
}
.ds-quickview-content {
  position: relative;
  max-width: 90%;
  max-height: 90%;
  display: flex;
  justify-content: center;
  align-items: center;
  background: var(--surface-1);
  padding: 32px;
  border-radius: var(--radius-md);
  border: 1px solid var(--line);
  box-shadow: 0 4px 20px rgba(0,0,0,0.5);
}
.ds-quickview-img-wrapper {
  display: block;
  transition: transform 0.2s ease-in-out;
}
.ds-quickview-img {
  width: 600px;
  max-width: 100%;
  max-height: 80vh;
  object-fit: contain;
  display: block;
  border-radius: var(--radius-sm);
}
.ds-quickview-close {
  position: absolute;
  top: -12px;
  right: -12px;
  background: var(--surface-2);
  color: var(--text);
  border: 1px solid var(--line);
  border-radius: 50%;
  width: 28px;
  height: 28px;
  font-size: 1.2rem;
  line-height: 1;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  box-shadow: 0 2px 8px rgba(0,0,0,0.3);
  transition: background 0.15s ease, color 0.15s ease;
  z-index: 10001;
}
.ds-quickview-close:hover {
  background: var(--surface-3);
  color: var(--text-bright);
}
.ds-quickview-rotate {
  position: absolute;
  top: -12px;
  right: 24px;
  background: var(--surface-2);
  color: var(--text);
  border: 1px solid var(--line);
  border-radius: 50%;
  width: 28px;
  height: 28px;
  font-size: 1.1rem;
  line-height: 1;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  box-shadow: 0 2px 8px rgba(0,0,0,0.3);
  transition: background 0.15s ease, color 0.15s ease;
  z-index: 10001;
}
.ds-quickview-rotate:hover {
  background: var(--surface-3);
  color: var(--text-bright);
}
.ds-rotate-90 {
  transform: rotate(90deg);
}
.ds-rotate-180 {
  transform: rotate(180deg);
}
.ds-rotate-270 {
  transform: rotate(270deg);
}

/* Quick View Navigation Arrows */
.ds-quickview-nav {
  position: absolute;
  top: 50%;
  transform: translateY(-50%);
  background: rgba(0, 0, 0, 0.4);
  color: #fff;
  border: 1px solid var(--line);
  border-radius: 50%;
  width: 44px;
  height: 44px;
  font-size: 1.5rem;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  z-index: 10002;
  transition: background 0.15s ease, color 0.15s ease, opacity 0.15s ease;
  user-select: none;
}
.ds-quickview-nav:hover {
  background: rgba(0, 0, 0, 0.7);
  color: var(--text-bright);
}
.ds-quickview-prev {
  left: 32px;
}
.ds-quickview-next {
  right: 32px;
}

/* Storage ring slice colours.
   Red for used and green for free is the pairing people ask for and the one
   red-green colourblindness cannot separate: the panel's own --danger/--ok
   score ΔE 2.2 for deuteranopia, where 8 is the floor. These steps are chosen
   for the separation that survives CVD, which is lightness rather than hue --
   dark red against a light green measures 17.2 (dark) and 19.2 (light), both
   comfortably clear. The written "Used"/"Free" labels beside the swatches are
   what make the chart legible when the hues collapse entirely; they are not
   decoration and should not be removed.
   Verified with the dataviz palette validator, not by eye. */
:root { --ds-used: #da3633; --ds-free: #56d364; }
body.theme-light { --ds-used: #a02725; --ds-free: #4cae4f; }

/* ---- storage ring ----
   The percentage is the number that gets read, so it sits in the middle of the
   ring rather than in the key; the key carries the quantities it is made of.
   Text stays in text tokens throughout -- the coloured dots beside the labels
   are what carry identity, never the words themselves. */
.ds-donut { display: flex; align-items: center; gap: 16px; margin-top: 8px; }
.ds-donut-ring { position: relative; flex: none; width: 88px; height: 88px; }
.ds-donut-svg { display: block; }
.ds-donut-mid {
  position: absolute; inset: 0;
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  line-height: 1.1; pointer-events: none;
}
/* Proportional figures, not tabular: this is a standalone value, and tabular
   digits give every glyph a zero's width, which reads loose at this size. */
.ds-donut-mid b { font-size: 1.05rem; font-weight: 600; color: var(--text); }
.ds-donut-mid span { font-size: 0.62rem; color: var(--text-mute); }

.ds-donut-key { display: grid; gap: 3px; font-size: 0.71rem; }
.ds-donut-row { display: flex; align-items: center; gap: 7px; }
.ds-donut-dot { width: 8px; height: 8px; border-radius: 2px; flex: none; }
.ds-donut-what { color: var(--text-dim); min-width: 58px; }
.ds-donut-val { color: var(--text); font-family: var(--mono); font-size: 0.7rem; }
/* Capacity is context for the two above it, not a third slice -- no swatch, and
   set back a step so the eye does not read it as part of the split. */
.ds-donut-total { color: var(--text-mute); padding-left: 15px; margin-top: 2px; }
.ds-donut-total .ds-donut-val { color: var(--text-mute); }
/* Where the figures came from. Quiet, but on screen: the question it answers
   ("why doesn't this match my phone?") is asked while looking at the numbers. */
.ds-donut-src { font-size: 0.64rem; color: var(--text-mute); padding-left: 15px; margin-top: 3px; }

/* ---- pairing by QR ----
   The code is the subject, so it gets the room: a white plate under it because
   a QR is read by contrast and the dark theme's surface would fight the
   scanner, and the explanation sits beside it rather than under it so both are
   on screen while somebody is holding a phone up to the monitor. */
.ds-pair { margin: 10px 0 0; }
.ds-pair:empty { display: none; }
.ds-pair-box {
  display: flex; gap: 16px; align-items: flex-start; flex-wrap: wrap;
  padding: 14px; border: 1px solid var(--line); border-radius: var(--radius);
  background: var(--bg-sunk);
}
.ds-pair-qr {
  flex: none; width: 220px; height: 220px; display: block;
  background: #fff; padding: 8px; border-radius: 8px;
}
.ds-pair-side { flex: 1; min-width: 220px; }
.ds-pair-state { font-size: 0.8rem; font-weight: 600; color: var(--text); margin-bottom: 4px; }
.ds-pair-msg { font-size: 0.73rem; line-height: 1.55; color: var(--text-dim); }
.ds-pair-steps {
  margin: 0 0 8px; padding-left: 18px;
  font-size: 0.73rem; line-height: 1.6; color: var(--text-dim);
}
.ds-pair-steps strong { color: var(--text); font-weight: 600; }
/* The one sentence that decides whether this works. Marked as a warning
   because the failure it prevents looks like a broken QR rather than a wrong
   scanner, and somebody who reads it that way stops trying. */
.ds-pair-warn {
  font-size: 0.72rem; line-height: 1.55; color: var(--text-dim);
  border-left: 2px solid var(--warn); padding: 2px 0 2px 9px; margin-bottom: 6px;
}
.ds-pair-warn em { color: var(--text); font-style: normal; font-family: var(--mono); font-size: 0.95em; }
/* The second route, given the weight of an offer rather than a footnote: for
   anyone whose phone keeps handing the code to the wrong reader, it is the one
   that works. */
.ds-pair-alt {
  font-size: 0.72rem; line-height: 1.55; color: var(--text-dim);
  border-left: 2px solid var(--accent); padding: 2px 0 2px 9px; margin-bottom: 6px;
}
.ds-pair-alt strong { color: var(--text); font-weight: 600; }
.ds-pair-note { font-size: 0.69rem; line-height: 1.5; color: var(--text-mute); }
.ds-pair-ttl { font-size: 0.7rem; color: var(--text-mute); margin-top: 6px; }
.ds-pair-wait { font-size: 0.73rem; color: var(--text-dim); padding: 10px 0; }

@media (max-width: 760px) {
  /* One column, and the code no larger than the width it has: a QR scaled past
     the viewport is a QR with its corners cut off, which no scanner reads. */
  .ds-pair-box { gap: 12px; }
  .ds-pair-qr { width: 100%; max-width: 260px; height: auto; aspect-ratio: 1; margin: 0 auto; }
}

/* The Wi-Fi paragraph is a second answer, not a continuation of the first:
   separated so the cable route and the wireless one read as a choice. */
.ds-empty-wifi { margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--line-soft); }
.ds-empty-providers { margin-top: 10px; padding-top: 9px; border-top: 1px solid var(--line-soft); }
.ds-empty-sub { font-size: 0.69rem; color: var(--text-mute); margin-bottom: 4px; }
.ds-empty-providers ul { margin: 0; padding-left: 16px; }
.ds-empty-providers li { font-size: 0.71rem; line-height: 1.5; color: var(--text-mute); }

.ds-wifi {
  margin-top: 8px; padding: 10px 11px;
  border: 1px solid var(--line-soft); border-radius: var(--radius-sm);
  background: var(--surface-2);
}
.ds-wifi-row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-top: 8px; }
.ds-wifi-list { display: flex; flex-direction: column; gap: 6px; margin-top: 9px; }
.ds-wifi-item {
  display: flex; align-items: center; gap: 10px;
  padding: 7px 10px; border: 1px solid var(--line-soft);
  border-radius: var(--radius-sm); background: var(--bg-sunk);
}
.ds-wifi-item-body { flex: 1 1 auto; min-width: 0; }
.ds-wifi-item-name {
  font-size: 0.76rem; color: var(--text);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.ds-wifi-item-meta { font-size: 0.68rem; color: var(--text-mute); }
.ds-wifi-help { margin-top: 8px; font-size: 0.7rem; line-height: 1.55; color: var(--text-mute); }
.ds-wifi-help strong { color: var(--text-dim); font-weight: 600; }
.ds-input-short { min-width: 130px; }

.ds-browser { margin-top: 10px; }
.ds-crumbs { display: flex; align-items: center; gap: 3px; flex-wrap: wrap; margin-bottom: 6px; }
.ds-crumb-sep { color: var(--text-mute); font-size: 0.72rem; }
.ds-photos-toggle { margin-bottom: 6px; }

.ds-entries {
  display: flex; flex-direction: column;
  border: 1px solid var(--line-soft); border-radius: var(--radius-sm);
  background: var(--surface-2); max-height: 260px; overflow-y: auto;
}
/* Select-all for the tree, sitting beside the Photos-only filter it respects. */
.ds-tree-bulk { display: inline-flex; gap: 6px; margin-left: 10px; }

/* A row already included because a folder above it is ticked. Dimmed so the
   eye reads it as a consequence rather than a separate choice, and its box is
   disabled -- clearing it would do nothing, the ancestor is the one to untick. */
.ds-entry-covered { opacity: 0.62; }
.ds-entry-covered input[type="checkbox"] { cursor: not-allowed; }

.ds-entry {
  display: flex; align-items: center; gap: 9px;
  padding: 5px 10px; border-bottom: 1px solid var(--line-soft);
}
.ds-entry:last-child { border-bottom: none; }
.ds-entry input[type="checkbox"] { flex: 0 0 auto; cursor: pointer; margin: 0; }
/* The twisty (expand a folder in place) and its spacer on a file row, so a
   picture's name lines up under a folder's name rather than creeping left. */
.ds-entry-twisty {
  flex: 0 0 auto; width: 16px; height: 16px; padding: 0;
  display: inline-flex; align-items: center; justify-content: center;
  background: none; border: none; cursor: pointer;
  font-size: 0.65rem; color: var(--text-mute); line-height: 1;
}
.ds-entry-twisty:hover { color: var(--accent); }
.ds-entry-twisty-spacer { cursor: default; }
.ds-entry-name {
  flex: 1 1 auto; min-width: 0; font-size: 0.76rem; color: var(--text);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: left;
}
.ds-entry-dir {
  background: none; border: none; padding: 0; cursor: pointer;
  font-family: inherit; color: var(--text);
}
.ds-entry-dir:hover { color: var(--accent); }
.ds-folder { color: var(--text-mute); }
.ds-entry-size {
  flex: 0 0 auto; font-family: var(--mono); font-size: 0.7rem; color: var(--text-mute);
}
.ds-muted { color: var(--text-mute); font-size: 0.73rem; }

.ds-cats { display: flex; gap: 6px; flex-wrap: wrap; }
.ds-cat {
  display: inline-flex; align-items: center; gap: 5px;
  padding: 4px 9px; border: 1px solid var(--line-soft);
  border-radius: 999px; background: var(--surface-2);
  font-size: 0.73rem; color: var(--text-dim); cursor: pointer;
}
.ds-cat input { margin: 0; cursor: pointer; }
.ds-cat-on { border-color: var(--accent); color: var(--text); }

.ds-options { display: flex; gap: 14px; align-items: center; flex-wrap: wrap; margin-top: 12px; }
.ds-opt { display: inline-flex; align-items: center; gap: 6px; font-size: 0.73rem; color: var(--text-dim); cursor: pointer; }
.ds-opt input[type="checkbox"] { margin: 0; cursor: pointer; }
.ds-opt-label { cursor: default; }
.ds-input {
  background: var(--bg-sunk); border: 1px solid var(--line-soft);
  border-radius: var(--radius-sm); color: var(--text);
  padding: 4px 8px; font-size: 0.73rem; font-family: inherit; min-width: 190px;
}
.ds-input:focus { outline: none; border-color: var(--accent); }

/* Preview: what an import would copy, shown before it copies anything. */
.ds-preview {
  margin-top: 12px; padding: 10px 11px;
  border: 1px solid var(--line-soft); border-radius: var(--radius-sm);
  background: var(--surface-2);
}
.ds-preview-head {
  display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap;
  margin-bottom: 7px;
}
.ds-preview-count { font-size: 0.78rem; font-weight: 600; color: var(--text); }
.ds-preview-cats { font-size: 0.71rem; color: var(--text-mute); }
.ds-preview-bulk { margin-left: auto; display: inline-flex; gap: 8px; align-items: center; }
.ds-search { min-width: 150px; padding: 3px 8px; font-size: 0.71rem; }
.ds-preview-note { font-size: 0.71rem; color: var(--text-dim); margin: 5px 0 0; }
.ds-preview-warn { color: var(--warn); }
.ds-preview-list {
  display: flex; flex-direction: column;
  border: 1px solid var(--line-soft); border-radius: var(--radius-sm);
  background: var(--bg-sunk); max-height: 240px; overflow-y: auto;
}
.ds-preview-row {
  display: flex; align-items: center; gap: 9px;
  padding: 4px 9px; border-bottom: 1px solid var(--line-soft);
}
.ds-preview-row:last-child { border-bottom: none; }
.ds-preview-row input[type="checkbox"] { flex: 0 0 auto; margin: 0; cursor: pointer; }
.ds-preview-have { opacity: 0.55; }
.ds-preview-name {
  flex: 1 1 auto; min-width: 0; font-size: 0.74rem; color: var(--text);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* The click-to-rename affordance, shared by a preview row's name and a
   tile's caption -- underline on hover is the one hint that plain text has
   become a control, without a permanent decoration cluttering four hundred
   rows nobody is about to rename. */
.ds-name-edit { cursor: text; border-radius: 3px; }
.ds-name-edit:hover, .ds-name-edit:focus-visible {
  text-decoration: underline dotted; text-underline-offset: 2px;
  outline: none;
}
.ds-name-input {
  flex: 1 1 auto; min-width: 0; width: 100%;
  font: inherit; font-size: 0.74rem; color: var(--text);
  background: var(--surface); border: 1px solid var(--accent);
  border-radius: 3px; padding: 1px 4px; margin: -1px 0;
}
.ds-preview-dir {
  flex: 0 1 auto; max-width: 38%; font-family: var(--mono); font-size: 0.68rem;
  color: var(--text-mute); overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.ds-preview-tag {
  flex: 0 0 auto; font-size: 0.63rem; text-transform: uppercase; letter-spacing: 0.04em;
  color: var(--text-mute); border: 1px solid var(--line-soft);
  border-radius: 999px; padding: 0 6px;
}
.ds-preview-dest { margin-top: 7px; font-family: var(--mono); font-size: 0.68rem; }

.ds-errors {
  margin-top: 10px; padding: 9px 11px;
  border: 1px solid var(--line-soft); border-radius: var(--radius-sm);
  background: var(--surface-2); max-height: 160px; overflow-y: auto;
}
.ds-errors-title { font-size: 0.71rem; font-weight: 600; color: var(--warn); margin-bottom: 4px; }
.ds-errors ul { margin: 0; padding-left: 16px; }
.ds-errors li { font-size: 0.7rem; line-height: 1.5; color: var(--text-dim); font-family: var(--mono); }

/* "Install app" (app-shell.js) has to win against five neighbours that look
   exactly like it, because the whole point of the desktop install is that
   almost nobody goes looking for it. The accent fill comes from .is-primary;
   this adds a ring that expands out of the button.

   It runs a few times and stops. A control that pulses forever in an ops
   console reads as an alarm, and the browser's install affordance is an
   invitation -- one the button can only make once before it becomes noise. */
.act.cp-install-cta { position: relative; }
.act.cp-install-cta::after {
  content: ""; position: absolute; inset: -1px; border-radius: inherit;
  border: 1px solid var(--accent); pointer-events: none;
  animation: cp-install-ring 1.8s ease-out 4;
}
@keyframes cp-install-ring {
  0%   { opacity: .85; transform: scale(1); }
  70%  { opacity: 0; transform: scale(1.35); }
  100% { opacity: 0; transform: scale(1.35); }
}
/* Someone who asked the OS for less motion still needs to see the button —
   the accent fill carries it on its own. */
@media (prefers-reduced-motion: reduce) {
  .act.cp-install-cta::after { animation: none; }
}

/* =========================================================================
   Memory analyzer (app-memory.js) — the Memory section and the Memory tab on
   a service. Both draw the same parts, so they share one set of rules: the
   tiles come from .svc-tile above, and only the table, the bars and the
   toolbar are new.

   Every colour here is a token, so the analyzer follows the theme the operator
   chose rather than pinning a dark palette into a light page.
   ========================================================================= */
.mem-page { display: flex; flex-direction: column; gap: 16px; padding: 4px 2px 10px; min-width: 0; }

/* The toolbar: which environment this is, and the controls that shape the
   table under it. */
.mem-bar {
  display: flex; align-items: center; justify-content: space-between;
  gap: 12px; flex-wrap: wrap;
}
.mem-bar-left { display: flex; align-items: center; gap: 10px; min-width: 0; }
.mem-bar-right { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.mem-env { display: inline-flex; align-items: center; gap: 7px; font-weight: 600; font-size: 14px; }
.mem-note { font-family: var(--mono); font-size: 10.5px; color: var(--text-mute); }
.mem-input {
  background: var(--surface-2); color: var(--text); border: 1px solid var(--line);
  border-radius: 5px; padding: 4px 8px; font-family: var(--font); font-size: 12px; height: 28px;
}
.mem-input:focus { outline: none; border-color: var(--accent); }
.mem-auto {
  display: inline-flex; align-items: center; gap: 5px;
  font-size: 12px; color: var(--text-dim); user-select: none;
}

.mem-tiles { grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); }

/* The host's fill, as one bar the width of the page: the tiles say the
   numbers, this says the shape of them at a glance. */
.mem-meter {
  height: 8px; border-radius: 4px; background: var(--surface-2);
  border: 1px solid var(--line); overflow: hidden;
}
.mem-meter-fill { display: block; height: 100%; background: var(--ok); }
.mem-meter-fill.is-warn { background: var(--warn); }
.mem-meter-fill.is-bad { background: var(--danger); }

.mem-panel {
  background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius);
  padding: 13px 15px; display: flex; flex-direction: column; gap: 11px; min-width: 0;
}
.mem-panel-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; flex-wrap: wrap; }

/* A table heading that carries its own controls. The filter and the sort act
   on the rows directly below them and on nothing else on the page, so they
   travel with the heading rather than sitting in the page bar — where they
   read as though they governed the host tiles and the trend as well.
   Centre-aligned rather than baseline: a 26px input has no baseline worth
   sharing with a 10px label. */
.mem-panel-head-tools { align-items: center; }
.mem-head-left { display: inline-flex; align-items: baseline; gap: 10px; min-width: 0; flex-wrap: wrap; }
.mem-head-tools { display: inline-flex; align-items: center; gap: 6px; }
.mem-input-sm { height: 26px; font-size: 11.5px; padding: 3px 7px; }
.mem-head-tools .mem-input-sm { max-width: 190px; }

/* By-program bars. Name, bar, figure — the bar in the middle so the eye runs
   down one edge to find the outlier. */
.mem-groups { display: flex; flex-direction: column; gap: 6px; }
.mem-group { display: grid; grid-template-columns: minmax(90px, 22%) 1fr auto; align-items: center; gap: 10px; }
.mem-group-name {
  font-size: 12.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.mem-group-name em { font-style: normal; color: var(--text-mute); font-family: var(--mono); font-size: 10.5px; }
.mem-group-bar { height: 9px; border-radius: 4px; background: var(--surface-2); overflow: hidden; }
.mem-group-bar > span { display: block; height: 100%; background: var(--accent); opacity: 0.75; }
.mem-group-val { font-family: var(--mono); font-size: 11.5px; color: var(--text-dim); }

/* The table scrolls inside its own box: a hundred processes must not stretch
   the page sideways on a laptop. */
.mem-table-wrap { overflow-x: auto; max-height: 460px; overflow-y: auto; }
.mem-table { width: 100%; border-collapse: collapse; font-size: 12.5px; }
.mem-table th {
  position: sticky; top: 0; z-index: 1; background: var(--surface);
  text-align: left; font-family: var(--mono); font-size: 10px; letter-spacing: 0.1em;
  color: var(--text-mute); font-weight: 500; padding: 0 10px 7px 0; white-space: nowrap;
}
.mem-table td { padding: 5px 10px 5px 0; border-top: 1px solid var(--line); vertical-align: middle; }
.mem-table tbody tr:hover td { background: var(--surface-2); }
.mem-table .num { text-align: right; white-space: nowrap; }
.mem-table .mono { font-family: var(--mono); font-size: 11.5px; color: var(--text-dim); }
.mem-name { font-weight: 600; max-width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

/* The memory cell carries its own bar behind the figure, so the column reads
   as a chart without becoming one. */
.mem-cell { position: relative; min-width: 96px; }
.mem-cell-bar {
  position: absolute; left: 0; top: 4px; bottom: 4px; border-radius: 3px;
  background: var(--accent); opacity: 0.18;
}
.mem-cell-val { position: relative; font-family: var(--mono); font-size: 11.5px; }

.mem-cmd {
  font-family: var(--mono); font-size: 11px; color: var(--text-mute);
  max-width: 420px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}

/* The trend line reuses the response-time bars from the service pane, in the
   accent colour: this is a level over time, not a health judgement, so the
   green/amber/red of a check would be saying something it does not know. */
/* A fixed height, matching TREND_HEIGHT_PX in app-memory.js: the bars are sized
   in pixels there, because inside a flex item a percentage height resolves
   against a size the flex algorithm has not committed to -- which collapsed
   every bar to the floor and drew a flat line whatever the readings did. The
   two numbers have to agree. */
.svc-chart-bars.mem-trend { height: 64px; min-height: 64px; }
.svc-chart-bars.mem-trend span { background: var(--accent); opacity: 0.55; }
.mem-trend-empty { font-family: var(--mono); font-size: 11px; color: var(--text-mute); padding: 18px 0; }

.mem-empty {
  color: var(--text-mute); font-size: 13px; padding: 22px 4px; text-align: center;
}
.mem-foot { font-family: var(--mono); font-size: 10.5px; color: var(--text-mute); line-height: 1.6; }
.mem-error {
  border: 1px solid var(--danger); border-radius: var(--radius);
  background: rgba(239, 68, 68, 0.08); color: var(--text);
  padding: 11px 14px; font-size: 12.5px; line-height: 1.5;
}

/* A phone shows the columns that decide something -- program, memory, share --
   and drops the forensics. The command line is still one tap away in the
   title, and the section is a reading rather than a workbench on a handset. */
@media (max-width: 720px) {
  /* The process table only. A pod table's first columns are its namespace and
     its name, which are the two things it exists to show. */
  .mem-table-proc th:nth-child(1), .mem-table-proc td:nth-child(1),
  .mem-table-proc th:nth-child(2), .mem-table-proc td:nth-child(2),
  .mem-table-proc th:nth-child(7), .mem-table-proc td:nth-child(7),
  .mem-table-proc th:nth-child(8), .mem-table-proc td:nth-child(8) { display: none; }
  .mem-group { grid-template-columns: minmax(70px, 34%) 1fr auto; }
  .mem-bar-right { width: 100%; }
  .mem-input { flex: 1; min-width: 0; }
  /* Narrow: the heading's controls take their own line under it rather than
     crushing the filter box down to nothing beside the label. */
  .mem-head-tools { width: 100%; }
  .mem-head-tools .mem-input-sm { max-width: none; }
}

/* The kill button, one per row.

   Quiet until the row is hovered or the button is focused: a destructive
   control repeated down eighty rows would otherwise be the loudest thing on a
   page whose job is to be read. It is always in the tab order, so the keyboard
   reaches what the mouse reveals. */
.mem-act { text-align: right; white-space: nowrap; }
.mem-kill {
  background: transparent; border: 1px solid var(--line); border-radius: 5px;
  color: var(--text-mute); font-family: var(--font); font-size: 11px;
  padding: 2px 9px; cursor: pointer; opacity: 0; transition: opacity .12s ease;
}
.mem-table tbody tr:hover .mem-kill,
.mem-kill:focus-visible { opacity: 1; }
.mem-kill:hover { border-color: var(--danger); color: var(--danger); }
/* The escalation is visible before it is pressed: a row that has already been
   asked to stop offers SIGKILL, and that button does not hide. */
.mem-kill.is-force {
  opacity: 1; border-color: var(--danger); color: var(--danger);
  background: rgba(248, 81, 73, 0.08);
}
.mem-kill.is-force:hover { background: rgba(248, 81, 73, 0.18); }

/* What happened when the button was pressed. Shown in the pane itself, because
   toasts can be switched off in Settings and the Console is a different
   section -- and "nothing happened" is what this button must never look like.
   The failure case reuses .mem-error above. */
.mem-killed {
  border: 1px solid var(--line); border-left: 3px solid var(--ok);
  border-radius: var(--radius); background: var(--surface);
  padding: 10px 14px; font-size: 12.5px; line-height: 1.5; color: var(--text-dim);
}

/* A touch screen has no hover to reveal anything, so the button is simply
   there. */
@media (hover: none) {
  .mem-kill { opacity: 1; }
}

/* Heap dumps, in the memory analyzer.

   The Dump button sits beside Kill in a row's action cell and follows the same
   rule: quiet until the row is hovered, so a table of eighty processes is not
   eighty buttons shouting. */
.mem-dump {
  background: transparent; border: 1px solid var(--line); border-radius: 5px;
  color: var(--text-mute); font-family: var(--font); font-size: 11px;
  padding: 2px 9px; margin-right: 6px; cursor: pointer;
  opacity: 0; transition: opacity .12s ease;
}
.mem-table tbody tr:hover .mem-dump,
.mem-dump:focus-visible { opacity: 1; }
.mem-dump:hover { border-color: var(--accent); color: var(--accent); }
@media (hover: none) { .mem-dump { opacity: 1; } }

/* The per-service capture button is not a row action: there is one JVM in a
   container, and one button for it, so it is a normal control. */
.mem-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.act.mem-capture { width: auto; margin: 0; }
.act.mem-capture[disabled] { opacity: 0.6; cursor: default; }

/* Download is a link, not a button: the file is the size of a JVM's heap, and
   the browser's own download machinery is what handles that well. Styled to sit
   beside the Delete button without pretending to be one. */
.mem-dl {
  display: inline-block; color: var(--accent); text-decoration: none;
  font-size: 11px; padding: 2px 9px; margin-right: 6px;
  border: 1px solid var(--accent); border-radius: 5px;
}
.mem-dl:hover { background: rgba(94, 176, 239, 0.12); }

/* A dump's row keeps its buttons visible: there are a handful of them, each one
   matters, and the Download link is the point of the panel. */
.mem-table-dumps .mem-kill, .mem-table-dumps .mem-dump { opacity: 1; }

/* Capture in progress. A full GC plus gigabytes to disk can take minutes, and a
   page that looks frozen for minutes is a page people reload. */
.mem-busy {
  border: 1px solid var(--line); border-left: 3px solid var(--accent);
  border-radius: var(--radius); background: var(--surface-2);
  padding: 10px 14px; font-size: 12.5px; line-height: 1.5; color: var(--text-dim);
}

/* The automatic-dump rule, on a service's Memory tab. One line: the switch, the
   threshold, and the button that arms it — everything else about the rule is
   said in words underneath, because a policy nobody can read is a policy nobody
   should be arming. */
.mem-rule-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.mem-rule-num { width: 96px; }
.act.mem-rule-save { width: auto; margin: 0; }
.act.mem-rule-save[disabled] { opacity: 0.6; cursor: default; }
/* An armed rule is not an alarm — it is a setting that happens to be on — so it
   gets a quiet accent edge rather than a colour that reads as a problem. */
.mem-rule { border-left: 3px solid var(--line); }

/* Reading a dump: the histogram, and the file picker that imports one.

   The class column is the wide one — a fully qualified name is the whole
   identification — and the table scrolls inside its own box like the others. */
.mem-table-hist .mem-name { max-width: 460px; }
.mem-table-hist .mem-cell { min-width: 120px; }

/* The import control is a file input wearing the panel's button. The input
   itself is hidden rather than styled: no two browsers agree on how to style
   one, and the label is what people click anyway. */
.mem-importer { display: inline-flex; align-items: center; cursor: pointer; }
.mem-importer input[type="file"] {
  position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none;
}
.mem-importer .act { width: auto; margin: 0; pointer-events: none; }
.mem-importer:hover .act { border-color: var(--accent); color: var(--accent); }
/* Keyboard users get the same affordance the mouse does. */
.mem-importer input[type="file"]:focus-visible + .act { border-color: var(--accent); color: var(--accent); }

/* =========================================================================
   Backup & restore — the export picker (app-settings-backup.js)

   The export used to be one tick per settings document, so a backup was
   all-or-nothing per module. Each module now lists what is inside it, which
   means the picker holds far more rows than it did: the entry lists are
   folded away until asked for, and the whole thing scrolls in its own box so
   a workspace with two hundred saved requests cannot push the Export button
   off the screen.
   ========================================================================= */
.export-picker-head {
  display: flex; align-items: center; justify-content: space-between;
  gap: 12px; flex-wrap: wrap; margin: 0 0 8px 0;
}
.export-picker-tools { display: inline-flex; align-items: center; gap: 6px; }

/* A text button: the same affordance as the links beside a field, not another
   heavy control competing with Export itself. */
.export-link {
  background: none; border: none; padding: 2px 4px; margin: 0; cursor: pointer;
  font: inherit; font-size: 11.5px; color: var(--accent);
  border-radius: 4px;
}
.export-link:hover { background: var(--surface-2); text-decoration: underline; }
.export-link:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }

.export-doc-block { border-bottom: 1px solid var(--line-soft); padding: 4px 0; }
.export-doc-block:last-of-type { border-bottom: none; }
.export-doc-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.export-doc-label {
  display: inline-flex; align-items: center; gap: 8px; cursor: pointer;
  font-size: 13px; min-width: 0;
}
.export-doc-count {
  font-family: var(--mono); font-size: 10.5px; color: var(--text-mute);
  margin-left: auto; white-space: nowrap;
}
.export-doc-warning { margin: 2px 0 4px 26px; }

/* The entries of one module. Capped rather than unbounded: a long list stays
   a list you scroll inside the module instead of a page you scroll past. */
.export-doc-items {
  margin: 4px 0 6px 26px; padding: 6px 8px;
  border: 1px solid var(--line-soft); border-radius: var(--radius);
  background: var(--surface-2);
  max-height: 240px; overflow-y: auto;
}
.export-item-tools { display: flex; gap: 4px; margin-bottom: 4px; }
.export-item {
  display: flex; align-items: baseline; gap: 8px; padding: 2px 0;
  font-size: 12.5px; cursor: pointer; min-width: 0;
}
.export-item-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.export-item-detail {
  font-family: var(--mono); font-size: 10.5px; color: var(--text-mute);
  margin-left: auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  max-width: 55%;
}
.export-picker-summary { margin: 8px 0 0 0; font-weight: 600; }

/* Why a check is failing, under the address it is failing on. Quiet enough to
   sit inside a tile, loud enough to be the thing you read after "failing". */
.svc-tile-why {
  display: block;
  font-family: var(--mono);
  font-size: 10.5px;
  line-height: 1.45;
  color: var(--danger-text, var(--danger));
  overflow-wrap: anywhere;
}

/* ---------- Debug tab (app-debug.js) ----------
   The snippet to paste into a container, and the three steps that follow it. */
.debug-snippet {
  margin: 8px 0;
  padding: 8px 10px;
  font-family: var(--mono);
  font-size: 11.5px;
  line-height: 1.5;
  color: var(--text);
  background: var(--bg-sunk);
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  overflow-x: auto;
  white-space: pre;
}
.debug-steps {
  margin: 8px 0 0;
  padding-left: 20px;
  font-size: 12px;
  line-height: 1.7;
  color: var(--text-dim);
}
.debug-steps code {
  font-family: var(--mono);
  font-size: 11px;
  padding: 1px 5px;
  background: var(--bg-sunk);
  border: 1px solid var(--line);
  border-radius: 4px;
}
/* Something the panel will not do here, and why. Warn-toned rather than
   danger: nothing is broken, it is simply not on offer. */
.debug-blocked {
  margin-top: 10px;
  padding-left: 10px;
  border-left: 3px solid var(--warn);
  color: var(--warn);
}

/* =========================================================================
   Encode & decode (app-encoder.js) — the developer converter section.

   Two panels side by side, because the whole point of the page is watching
   one turn into the other: a stacked layout puts the result below the fold on
   a laptop the moment the input is more than a few lines, and then the tool
   is a form you submit rather than a thing you watch.

   Every colour is a token, so the page follows the theme the operator chose.
   ========================================================================= */
.enc-page { display: flex; flex-direction: column; gap: 12px; padding: 4px 2px 10px; min-width: 0; }

.enc-bar {
  display: flex; align-items: center; justify-content: space-between;
  gap: 12px; flex-wrap: wrap;
}
.enc-bar-left { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.enc-bar-right { display: flex; align-items: center; gap: 8px; }

/* A segmented control rather than two buttons: encode and decode are one
   choice with two answers, and a pair of separate buttons leaves no way to
   show which answer is currently in force. */
.enc-seg {
  display: inline-flex; background: var(--surface-2);
  border: 1px solid var(--line); border-radius: var(--radius-sm); padding: 2px;
}
.enc-seg-btn {
  font-family: var(--font); font-weight: 500; font-size: 12.5px;
  border: 0; background: transparent; color: var(--text-dim);
  border-radius: 5px; height: 26px; padding: 0 14px; cursor: pointer;
  transition: background .12s, color .12s;
}
.enc-seg-btn:hover { color: var(--text); }
.enc-seg-btn.is-on { background: var(--accent); color: var(--accent-ink); font-weight: 600; }
.enc-seg-sm .enc-seg-btn { height: 22px; padding: 0 10px; font-size: 11.5px; }

/* The one claim on the page worth making in the chrome: this converter does
   not send what you paste into it anywhere. Said quietly — it is a fact about
   the tool, not a badge. */
.enc-privacy {
  display: inline-flex; align-items: center; gap: 6px;
  font-size: 11.5px; color: var(--text-mute);
}
.enc-privacy svg {
  width: 14px; height: 14px; flex: none;
  fill: none; stroke: var(--ok); stroke-width: 1.9;
  stroke-linecap: round; stroke-linejoin: round;
}

.enc-hint { font-family: var(--mono); font-size: 11px; color: var(--text-mute); }

.enc-opts { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
.enc-opt {
  display: inline-flex; align-items: center; gap: 7px;
  font-size: 12px; color: var(--text-dim); user-select: none;
}
.enc-opt-check { cursor: pointer; }
.enc-opt-label { color: var(--text-mute); }
.enc-opt select { height: 26px; font-size: 11.5px; padding: 0 7px; }

.enc-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; min-width: 0; }
/* One column below the width where two panels stop being readable, which is
   well above the mobile breakpoint — a half-width textarea at 900px wraps
   Base64 every six characters. */
@media (max-width: 1080px) {
  .enc-grid { grid-template-columns: 1fr; }
}

.enc-panel {
  background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius);
  padding: 12px 14px 11px; display: flex; flex-direction: column; gap: 10px; min-width: 0;
}
.enc-panel-head {
  display: flex; align-items: center; justify-content: space-between;
  gap: 10px; flex-wrap: wrap;
}
.enc-panel-title { margin: 0; font-size: 13px; font-weight: 600; color: var(--text); }
.enc-panel-tools { display: inline-flex; align-items: center; gap: 6px; flex-wrap: wrap; }
/* The buttons in a panel head sit on one line with the title; .act's own
   bottom margin is for a wrapping toolbar and only pushes them off it here. */
.enc-panel-tools .act { margin: 0; height: 28px; }
.enc-panel-tools .act:disabled { opacity: .45; cursor: default; }
.enc-panel-tools .act:disabled:hover { color: var(--text-dim); border-color: var(--line); }

/* Both boxes are the same height whatever is in them, so the two panels stay
   level and the page does not resize under the cursor as you type. */
.enc-text {
  width: 100%; box-sizing: border-box; min-height: 260px; resize: vertical;
  font-family: var(--mono); font-size: 12.5px; line-height: 1.55;
  white-space: pre-wrap; word-break: break-all;
}

.enc-file-input { display: none; }
.enc-drop {
  min-height: 260px; border: 1px dashed var(--line); border-radius: var(--radius-sm);
  background: var(--surface-2); cursor: pointer;
  display: flex; align-items: center; justify-content: center; text-align: center;
  padding: 20px; transition: border-color .12s, background .12s;
}
.enc-drop:hover, .enc-drop.is-over { border-color: var(--accent); background: var(--bg-sunk); }
.enc-drop-inner { display: flex; flex-direction: column; gap: 5px; min-width: 0; }
.enc-drop-name {
  font-size: 13px; font-weight: 600; color: var(--text);
  overflow: hidden; text-overflow: ellipsis;
}
.enc-drop-sub { font-size: 11.5px; color: var(--text-mute); }

/* ---------- the two-state drop target ----------
   Every tool in the drawer that takes a file on its own -- image, icons, PDF,
   convert -- rather than opposite a textarea.

   The plain .enc-drop above is 260px tall because in the encoder it sits
   beside a 260px .enc-text and has to match it. Nothing else in the drawer
   sits beside a textarea, so nothing else should pay 260px of empty box for
   the resemblance: these sit above the preview, the icon grid or the page
   grid you actually came to look at.

   EMPTY it is the primary call to action and gets a band with room to read.
   LOADED it has done its job, so it collapses to a single row -- name, size,
   and the word for what pressing it does -- and gives the height back to the
   thing below it. It stays a drop target and a picker in both states.

   Note the box-sizing: .enc-drop is content-box, so a min-height here without
   it is silently that number plus 40px of padding plus the borders -- which
   is how 32px of text came to occupy 162px. */
.pdf-drop, .img-drop {
  box-sizing: border-box; min-height: 84px; padding: 14px;
  transition: min-height .12s, padding .12s, border-color .12s, background .12s;
}
.pdf-drop.is-loaded, .img-drop.is-loaded {
  min-height: 0; padding: 8px 12px; text-align: left;
  border-style: solid; justify-content: flex-start;
}
.pdf-drop.is-loaded .enc-drop-inner, .img-drop.is-loaded .enc-drop-inner {
  flex-direction: row; align-items: baseline; gap: 9px; width: 100%;
}
.pdf-drop.is-loaded .enc-drop-name, .img-drop.is-loaded .enc-drop-name {
  font-size: 12.5px; flex: none;
}
.pdf-drop.is-loaded .enc-drop-sub, .img-drop.is-loaded .enc-drop-sub {
  font-family: var(--mono); font-size: 10.5px;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* The affordance the collapsed row would otherwise lose: once it stops
   looking like a drop target it has to say that it still is one. */
.pdf-drop-swap {
  margin-left: auto; flex: none; font-size: 11px; color: var(--accent);
}

.enc-foot { font-family: var(--mono); font-size: 10.5px; color: var(--text-mute); min-height: 14px; }

/* A decode that produced bytes rather than text. The card says what it looks
   like, shows it if it is an image, and dumps the head of it — enough to tell
   "this worked, it is a PNG" from "this is noise" without downloading it. */
.enc-binary {
  min-height: 260px; border: 1px solid var(--line); border-radius: var(--radius-sm);
  background: var(--surface-2); padding: 12px 13px;
  display: flex; flex-direction: column; gap: 9px; min-width: 0;
}
.enc-binary-head { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; }
.enc-binary-kind { font-size: 13px; font-weight: 600; color: var(--text); }
.enc-binary-size { font-family: var(--mono); font-size: 11px; color: var(--text-mute); }
.enc-binary-note { font-size: 11.5px; color: var(--text-dim); }
.enc-binary-img {
  max-width: 100%; max-height: 220px; align-self: flex-start;
  border: 1px solid var(--line); border-radius: 6px; background: var(--bg-sunk);
}
/* The dump scrolls inside its own box: sixteen bytes a line is wider than
   half a laptop, and the page itself must never scroll sideways. */
.enc-hexdump {
  margin: 0; overflow: auto; max-height: 220px;
  font-family: var(--mono); font-size: 11px; line-height: 1.5;
  color: var(--text-dim); white-space: pre;
}

/* What went wrong, in the output panel rather than as a toast: the operator is
   already looking here, and the message names a position in the input they
   are about to go and fix. */
.enc-error {
  border-left: 3px solid var(--danger); padding: 6px 0 6px 10px;
  font-size: 12px; color: var(--danger);
}

/* =========================================================================
   Utilities (app-utilities.js) — the frame the small developer tools sit in.

   A tab strip and a body. Deliberately not one nav row per tool: the rail
   already overflows a short window at eighteen rows, and the tools are a
   drawer of unrelated calculators rather than eighteen more features.
   ========================================================================= */
.utils-page { display: flex; flex-direction: column; gap: 14px; min-width: 0; height: 100%; }

/* The strip scrolls sideways rather than wrapping: a second row of tabs moves
   the tool itself down the page every time one is added. Scrolling is the
   last resort, though, not the plan -- see .is-tight below and fitTabs() in
   app-utilities.js. A tab nobody can see is a tool nobody knows exists, and
   the strip hides its own scrollbar, so an overflowing strip is silent. */
.utils-tabs {
  display: flex; gap: 8px; overflow-x: auto; padding-bottom: 2px;
  border-bottom: 1px solid var(--line); scrollbar-width: none;
}
.utils-tabs::-webkit-scrollbar { height: 0; }
.utils-tab {
  display: flex; flex-direction: column; align-items: flex-start; gap: 2px;
  flex: none; cursor: pointer; text-align: left;
  background: transparent; border: 0; border-bottom: 2px solid transparent;
  padding: 7px 12px 9px; margin-bottom: -1px;
  font-family: var(--font); color: var(--text-dim);
  transition: color .12s, border-color .12s;
}
.utils-tab:hover { color: var(--text); }
.utils-tab.is-on { color: var(--text); border-bottom-color: var(--accent); }
/* Never two lines: a tab that wraps makes the strip taller than the one
   beside it and the underline stops lining up. */
.utils-tab-label { font-size: 13px; font-weight: 600; white-space: nowrap; }
/* The one-line "what is this for", so a drawer of eight tools can be read
   without opening each one. */
.utils-tab-blurb { font-size: 11px; color: var(--text-mute); white-space: nowrap; }
/* The blurbs are what goes when the tabs stop fitting -- they are the part
   you can do without, and the names are not. Measured rather than guessed at
   a breakpoint: what decides this is the strip's own width, which the rail
   being collapsed changes as much as the window being resized. Six tools with
   blurbs want ~1600px of window; that is most screens, and the two newest
   tools were off the right-hand edge on all of them. */
.utils-tabs.is-tight .utils-tab-blurb { display: none; }
/* And if even the names overflow, say so: a fade at the edge the strip
   continues past, since the scrollbar is hidden. */
.utils-tabs.is-scrolled-start {
  -webkit-mask-image: linear-gradient(to right, transparent, #000 24px);
  mask-image: linear-gradient(to right, transparent, #000 24px);
}
.utils-tabs.is-scrolled-end {
  -webkit-mask-image: linear-gradient(to left, transparent, #000 24px);
  mask-image: linear-gradient(to left, transparent, #000 24px);
}
.utils-tabs.is-scrolled-start.is-scrolled-end {
  -webkit-mask-image: linear-gradient(to right, transparent, #000 24px, #000 calc(100% - 24px), transparent);
  mask-image: linear-gradient(to right, transparent, #000 24px, #000 calc(100% - 24px), transparent);
}

.utils-body { flex: 1; min-height: 0; min-width: 0; }
.utils-pane { min-width: 0; }
.utils-empty, .utils-mount-error {
  color: var(--text-mute); font-size: 12.5px; padding: 18px 2px;
}
.utils-mount-error { color: var(--danger); }

/* =========================================================================
   JWT decoder (app-jwt.js).

   Reads a token; never verifies one. The signature panel says so in words --
   see the note there and the file header. Nothing in this stylesheet should
   ever give the signature block an ok-coloured treatment: green next to an
   unchecked signature is the whole misreading this tool has to avoid.
   ========================================================================= */
.jwt-page { display: flex; flex-direction: column; gap: 12px; padding: 4px 2px 10px; min-width: 0; }

.jwt-bar {
  display: flex; align-items: center; justify-content: space-between;
  gap: 12px; flex-wrap: wrap;
}
.jwt-bar-left { display: flex; align-items: center; gap: 10px; }
.jwt-bar-right { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.jwt-bar-title { font-size: 13px; font-weight: 600; color: var(--text); }
.jwt-bar-right .act { margin: 0; height: 28px; }

.jwt-input {
  width: 100%; box-sizing: border-box; min-height: 120px; resize: vertical;
  font-family: var(--mono); font-size: 12px; line-height: 1.5;
  white-space: pre-wrap; word-break: break-all;
}

.jwt-out { display: flex; flex-direction: column; gap: 12px; min-width: 0; }

/* The headline row: can this be used right now, and what is it. */
.jwt-status { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.jwt-chip {
  display: inline-flex; align-items: baseline; gap: 6px;
  border: 1px solid var(--line); background: var(--surface-2);
  border-radius: 999px; padding: 4px 12px; max-width: 100%;
}
.jwt-chip-label {
  font-family: var(--mono); font-size: 10px; letter-spacing: .08em;
  text-transform: uppercase; color: var(--text-mute); flex: none;
}
.jwt-chip-value {
  font-size: 12px; color: var(--text-dim);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.jwt-chip.is-ok { border-color: rgba(63,185,80,.32); background: rgba(63,185,80,.10); }
.jwt-chip.is-ok .jwt-chip-label, .jwt-chip.is-ok .jwt-chip-value { color: var(--ok); }
.jwt-chip.is-warn { border-color: rgba(210,153,34,.32); background: rgba(210,153,34,.10); }
.jwt-chip.is-warn .jwt-chip-label, .jwt-chip.is-warn .jwt-chip-value { color: var(--warn); }
.jwt-chip.is-bad { border-color: rgba(248,81,73,.32); background: rgba(248,81,73,.10); }
.jwt-chip.is-bad .jwt-chip-label, .jwt-chip.is-bad .jwt-chip-value { color: var(--danger); }

.jwt-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; min-width: 0; }
@media (max-width: 1080px) {
  .jwt-grid { grid-template-columns: 1fr; }
}

.jwt-panel {
  background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius);
  padding: 11px 13px 12px; display: flex; flex-direction: column; gap: 9px; min-width: 0;
}
.jwt-panel-head {
  display: flex; align-items: center; justify-content: space-between; gap: 10px;
}
.jwt-panel-title { margin: 0; font-size: 12.5px; font-weight: 600; color: var(--text); }
.jwt-panel-head .act { margin: 0; height: 26px; font-size: 11.5px; }

/* JSON scrolls inside its own box; a token with fifty roles in it must not
   stretch the page sideways. */
.jwt-json {
  margin: 0; overflow: auto; max-height: 300px;
  font-family: var(--mono); font-size: 11.5px; line-height: 1.55;
  color: var(--text-dim); white-space: pre-wrap; word-break: break-word;
}

.jwt-table { width: 100%; border-collapse: collapse; font-size: 12.5px; }
.jwt-table th {
  text-align: left; font-family: var(--mono); font-size: 10px; letter-spacing: .1em;
  color: var(--text-mute); font-weight: 500; padding: 0 12px 7px 0; white-space: nowrap;
}
.jwt-table td { padding: 6px 12px 6px 0; border-top: 1px solid var(--line); vertical-align: top; }
.jwt-claim-key { font-family: var(--mono); font-size: 11.5px; color: var(--text); white-space: nowrap; }
/* Long values (a resource_access map, a fifty-role list) wrap inside the cell
   instead of widening the table past the panel. */
.jwt-claim-val { color: var(--text-dim); word-break: break-word; max-width: 620px; }
.jwt-claim-rel { font-family: var(--mono); font-size: 10.5px; color: var(--text-mute); margin-top: 2px; }
.jwt-claim-note { color: var(--text-mute); font-size: 11.5px; }

/* Warn-toned, never ok-toned: nothing is wrong, but nothing was checked. */
.jwt-sig-note {
  border-left: 3px solid var(--warn); padding: 4px 0 4px 10px;
  font-size: 11.5px; color: var(--warn);
}
.jwt-sig-value { max-height: 120px; color: var(--text-mute); }

/* =========================================================================
   Image tools (app-image.js) — resize, rotate, convert, compress.

   Controls on the left, the canvas that is about to be encoded on the right.
   The canvas is the preview: what you see is literally the pixels the
   download will contain, so there is no second rendering path to disagree
   with the first.
   ========================================================================= */
.img-page { display: flex; flex-direction: column; gap: 12px; padding: 4px 2px 10px; min-width: 0; }
/* .img-drop's own sizing is the drawer's shared two-state drop target, up
   beside .enc-drop. It used to be `min-height: 120px` here, which on a
   content-box element with 20px padding meant a 162px box holding two lines
   of text -- and it stayed 162px after the image was loaded, above the
   preview that was the reason for loading it. */
.img-busy { font-size: 12px; color: var(--text-mute); padding: 4px 2px; }

.img-work { min-width: 0; }
.img-grid { display: grid; grid-template-columns: minmax(300px, 380px) 1fr; gap: 12px; min-width: 0; }
@media (max-width: 1080px) {
  .img-grid { grid-template-columns: 1fr; }
}

.img-controls { gap: 6px; }
/* The group headings inside the controls panel: smaller than a panel title,
   and spaced away from the row above rather than from the one below. */
.img-controls .jwt-panel-title { margin-top: 8px; font-size: 11px; letter-spacing: .04em;
  text-transform: uppercase; color: var(--text-mute); }
.img-controls .jwt-panel-title:first-child { margin-top: 0; }
.img-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.img-row .act { margin: 0; height: 28px; }
.img-label { font-family: var(--mono); font-size: 10.5px; color: var(--text-mute); }
.img-num { width: 86px; height: 28px; font-size: 12px; }
.img-range { flex: 1; min-width: 110px; accent-color: var(--accent); height: 28px; padding: 0; }
.img-rot { font-family: var(--mono); font-size: 11.5px; color: var(--text-dim); margin-left: auto; }
.act.is-on { border-color: var(--accent); color: var(--accent); }
.img-note {
  margin-top: 10px; padding-left: 10px; border-left: 3px solid var(--line);
  font-size: 11px; line-height: 1.55; color: var(--text-mute);
}

.img-preview { min-width: 0; }
/* Checkerboard behind the canvas so transparency reads as transparency rather
   than as whatever the panel background happens to be -- which is the whole
   question when converting a PNG to JPEG. */
.img-canvas {
  max-width: 100%; max-height: 460px; align-self: flex-start;
  border: 1px solid var(--line); border-radius: 6px;
  background-color: var(--bg-sunk);
  background-image:
    linear-gradient(45deg, var(--line-soft) 25%, transparent 25%, transparent 75%, var(--line-soft) 75%),
    linear-gradient(45deg, var(--line-soft) 25%, transparent 25%, transparent 75%, var(--line-soft) 75%);
  background-size: 16px 16px;
  background-position: 0 0, 8px 8px;
  object-fit: contain;
}

.img-stats { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
.img-stat { display: inline-flex; align-items: baseline; gap: 7px; }
.img-stat-label {
  font-family: var(--mono); font-size: 10px; letter-spacing: .08em;
  text-transform: uppercase; color: var(--text-mute);
}
.img-stat-value { font-family: var(--mono); font-size: 11.5px; color: var(--text-dim); }
.img-delta { font-size: 11.5px; font-weight: 600; color: var(--text-mute); }
.img-delta.is-good { color: var(--ok); }
/* Warn-toned, not danger-toned: a bigger file is a legitimate outcome of the
   settings chosen, not an error. */
.img-delta.is-bad { color: var(--warn); }

/* Something true about this particular image that the controls do not hint at
   -- an animated GIF losing its animation to a single-frame canvas. Warn, not
   danger: the conversion works, it just does less than expected. */
.img-warning {
  border-left: 3px solid var(--warn); padding: 6px 0 6px 10px;
  font-size: 11.5px; line-height: 1.55; color: var(--warn);
}

/* Background keying controls (app-image.js) and the icon generator
   (app-icons.js). Both reuse the image tool's control column, so only what is
   genuinely new is here. */
.img-color {
  width: 34px; height: 28px; padding: 2px; cursor: pointer;
  background: var(--surface-2); border: 1px solid var(--line);
  border-radius: var(--radius-sm);
}
.img-bg-opts { display: flex; flex-direction: column; gap: 6px; }
/* The eyedropper is armed: the canvas is a colour picker until it is used, and
   the cursor has to say so or the next click looks like it did nothing. */
.img-canvas.is-picking { cursor: crosshair; border-color: var(--accent); }

/* =========================================================================
   Icons & favicons (app-icons.js).
   ========================================================================= */
.ico-page { display: flex; flex-direction: column; gap: 12px; padding: 4px 2px 10px; min-width: 0; }
/* .img-note's own top margin is for sitting under a row of controls; here the
   column's gap has already spaced it. */
.ico-page > .img-note { margin-top: 0; }
.ico-work { min-width: 0; }

.ico-group { align-items: flex-start; gap: 8px; }
.ico-group-label { display: block; font-size: 12px; color: var(--text-dim); }
.ico-group-note { display: block; font-size: 10.5px; color: var(--text-mute); line-height: 1.45; }

.ico-results {
  display: grid; grid-template-columns: repeat(auto-fill, minmax(132px, 1fr));
  gap: 10px; min-width: 0;
}
.ico-cell {
  display: flex; flex-direction: column; align-items: center; gap: 6px;
  padding: 10px 8px; border: 1px solid var(--line); border-radius: var(--radius-sm);
  background: var(--surface-2); min-width: 0;
}
.ico-cell .act { margin: 0; height: 24px; font-size: 11px; }
/* A checkerboard, because a transparent icon on a flat panel is
   indistinguishable from a white one -- which is the thing being checked. */
.ico-frame {
  display: flex; align-items: center; justify-content: center;
  width: 100%; height: 104px; border-radius: 6px;
  background-color: var(--bg-sunk);
  background-image:
    linear-gradient(45deg, var(--line-soft) 25%, transparent 25%, transparent 75%, var(--line-soft) 75%),
    linear-gradient(45deg, var(--line-soft) 25%, transparent 25%, transparent 75%, var(--line-soft) 75%);
  background-size: 12px 12px;
  background-position: 0 0, 6px 6px;
}
/* Nearest-neighbour: a 16px favicon scaled up smoothly looks better than it
   is, and looking better than it is defeats the preview. */
.ico-img { image-rendering: pixelated; }
.ico-name {
  font-family: var(--mono); font-size: 10.5px; color: var(--text-dim);
  overflow: hidden; text-overflow: ellipsis; max-width: 100%; white-space: nowrap;
}
.ico-meta { font-family: var(--mono); font-size: 10px; color: var(--text-mute); }

/* =========================================================================
   PDF tools (app-pdf.js) — merge, organise, compress.

   One tab with three modes inside it rather than three tabs: the drawer
   exists so the sidebar does not grow a row per tool, and the tab strip is
   under the same pressure one level down.

   The thumbnail grid is the part with a constraint behind it. Pages render
   into a <canvas>, never an <img>, because the panel's img-src is
   "'self' data:" and a blob: URL would render nothing while saying so only in
   the console. Rotation is a CSS transform on that canvas — the page is not
   re-rendered to turn it, and the bytes are only rotated on save.
   ========================================================================= */
.pdf-page { display: flex; flex-direction: column; gap: 12px; padding: 4px 2px 10px; min-width: 0; }
.pdf-modes { align-self: flex-start; }

/* The drop target is the drawer's shared two-state one -- see .pdf-drop up
   beside .enc-drop, which app-image.js and app-icons.js share. */
.pdf-pane { display: flex; flex-direction: column; gap: 12px; min-width: 0; }
.pdf-work { display: flex; flex-direction: column; gap: 12px; min-width: 0; }

.pdf-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.pdf-row .act { margin: 0; height: 28px; }
.pdf-field { display: inline-flex; align-items: center; gap: 6px; }
.pdf-select { width: auto; padding: 0 8px; }
.pdf-toolbar { padding-bottom: 2px; border-bottom: 1px solid var(--line); }

/* Anything that cannot act yet says so by looking spent rather than by
   disappearing: a toolbar whose buttons come and go as a selection changes is
   a toolbar nobody can aim at. */
.pdf-page .act[disabled] { opacity: .45; cursor: default; }

.pdf-busy { font-size: 12px; color: var(--text-mute); padding: 4px 2px; }
.pdf-engine { font-size: 12px; color: var(--text-mute); padding: 4px 2px; }
.pdf-engine .act { margin-left: 10px; height: 24px; font-size: 11.5px; }

/* ---------- merge: the input list ----------
   Numbered, because the number is the answer to "what order will these come
   out in" and the order is the only thing this screen is really for. */
.pdf-inputs { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
.pdf-input {
  display: flex; align-items: center; gap: 8px; min-width: 0;
  padding: 7px 9px; border: 1px solid var(--line); border-radius: var(--radius-sm);
  background: var(--surface-2);
}
.pdf-input:hover { border-color: var(--line-strong, var(--accent)); }
.pdf-grip { color: var(--text-mute); cursor: grab; font-size: 13px; flex: none; user-select: none; }
.pdf-input-num {
  font-family: var(--mono); font-size: 11px; color: var(--accent);
  min-width: 18px; text-align: right; flex: none;
}
.pdf-input-meta { flex: 1; min-width: 0; }
.pdf-input-name {
  font-size: 12.5px; font-weight: 600; color: var(--text);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.pdf-input-sub { font-family: var(--mono); font-size: 10.5px; color: var(--text-mute); }
.pdf-range { width: 116px; height: 26px; font-size: 12px; flex: none; }
.pdf-icon-btn { width: 28px; padding: 0; justify-content: center; flex: none; }

/* ---------- organise: the page grid ---------- */
.pdf-pages {
  display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
  gap: 10px; min-width: 0;
}
.pdf-cell {
  display: flex; flex-direction: column; align-items: center; gap: 6px;
  padding: 8px 6px; border: 1px solid var(--line); border-radius: var(--radius-sm);
  background: var(--surface-2); cursor: pointer; min-width: 0;
  transition: border-color .12s, background .12s;
}
.pdf-cell:hover { border-color: var(--accent); }
.pdf-cell.is-on { border-color: var(--accent); background: var(--bg-sunk); }
.pdf-cell.is-over { border-color: var(--accent); border-style: dashed; }

/* A fixed frame, so a grid of mixed page sizes stays a grid. The canvas is
   scaled to fit inside it and rotated in place. */
.pdf-thumb {
  display: flex; align-items: center; justify-content: center;
  width: 100%; height: 160px; border-radius: 5px; overflow: hidden;
  /* White, not a theme token: a page is white, and a dark panel behind a
     transparent-margined render would make the page look like it has one. */
  background: #fff;
}
.pdf-thumb-canvas { display: block; transition: transform .12s; }
.pdf-thumb-wait { font-family: var(--mono); font-size: 11px; color: #9aa0a6; }

/* Four controls in a 140px cell, so they are narrower here than the 28px the
   merge rows use. Below the breakpoint the cells are 118px and they narrow
   again -- a bar that wraps to two lines makes every cell in the grid taller
   for the sake of one button. */
.pdf-cell-bar { display: flex; align-items: center; gap: 4px; }
.pdf-cell-bar .act { margin: 0; height: 22px; font-size: 11px; }
.pdf-cell-bar .pdf-icon-btn { width: 26px; }
.pdf-cell-bar .act svg { width: 12px; height: 12px; }
.pdf-cell-label {
  display: flex; align-items: baseline; gap: 6px; max-width: 100%;
  font-family: var(--mono); font-size: 10.5px; color: var(--text-mute);
}
.pdf-cell-num { color: var(--text-dim); font-weight: 600; }
.pdf-cell-rot { color: var(--accent); }
.pdf-cell-src { color: var(--text-mute); }

.pdf-split { gap: 8px; }

/* ---------- the result, and the compression report ---------- */
/* One line -- stamp, name, size, what it contains, Preview, Download.
   flex-direction is set back to row explicitly because .jwt-panel, which this
   also carries for its frame, is a column; and the padding is tighter than
   that panel's, because this is a row of controls rather than a panel with
   content in it. */
.pdf-output {
  display: flex; flex-direction: row; align-items: center;
  gap: 12px; flex-wrap: wrap; padding: 9px 12px;
}
.pdf-output-head { display: flex; align-items: baseline; gap: 8px; min-width: 0; }
.pdf-output-name {
  font-size: 13px; font-weight: 600; color: var(--text);
  overflow: hidden; text-overflow: ellipsis;
}
.pdf-output-size, .pdf-output-sub {
  font-family: var(--mono); font-size: 11px; color: var(--text-mute);
}
.pdf-output .act { margin: 0; height: 28px; }

.pdf-report { gap: 8px; }
.pdf-report-list {
  margin: 0; padding-left: 16px;
  font-size: 12px; line-height: 1.6; color: var(--text-dim);
}

/* ---------- the small first-page stamps ----------
   On a merge row and on the result card. White behind them for the same
   reason the grid frame is white: a page is white, and a dark panel showing
   through a page's margins reads as a page that has one. */
.pdf-row-thumb, .pdf-card-thumb {
  flex: none; padding: 0; cursor: pointer; overflow: hidden;
  display: flex; align-items: center; justify-content: center;
  border: 1px solid var(--line); border-radius: 3px; background: #fff;
  transition: border-color .12s;
}
.pdf-row-thumb { width: 34px; height: 44px; }
.pdf-card-thumb { width: 42px; height: 54px; border-radius: 4px; }
.pdf-row-thumb:hover, .pdf-card-thumb:hover { border-color: var(--accent); }
.pdf-row-thumb canvas, .pdf-card-thumb canvas { display: block; }
.pdf-row-thumb-wait {
  font-family: var(--mono); font-size: 8.5px; letter-spacing: .06em; color: #9aa0a6;
}
.pdf-output-meta { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }

/* ---------- the preview overlay ----------
   Follows app-devicesync.js's quickview so the panel has one way of showing
   something full size, but the page is a <canvas>: img-src is "'self' data:",
   and pdf.js paints to a canvas natively anyway. */
.pdf-quickview {
  position: fixed; inset: 0; z-index: 10000;
  display: flex; align-items: center; justify-content: center;
  background: rgba(0, 0, 0, .68);
}
.pdf-quickview-content {
  position: relative; display: flex; flex-direction: column; gap: 10px;
  max-width: 94vw; max-height: 92vh; padding: 14px 16px 12px;
  background: var(--surface); border: 1px solid var(--line);
  border-radius: var(--radius); box-shadow: 0 4px 24px rgba(0, 0, 0, .5);
}
.pdf-quickview-head {
  display: flex; align-items: center; justify-content: space-between;
  gap: 14px; min-width: 0;
}
.pdf-quickview-title {
  font-size: 12.5px; font-weight: 600; color: var(--text);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* The page itself, on white and centred. It scrolls inside its own box
   rather than pushing the dialog past the window on a tall page. */
.pdf-quickview-stage {
  display: flex; align-items: center; justify-content: center;
  min-width: 240px; min-height: 240px; overflow: auto;
  border-radius: 4px; background: #fff;
}
.pdf-quickview-canvas { display: block; }
.pdf-quickview-wait { font-family: var(--mono); font-size: 11px; color: #9aa0a6; }
.pdf-quickview-stage .utils-mount-error { padding: 18px; }
.pdf-quickview-foot {
  display: flex; align-items: center; justify-content: center; gap: 10px;
}
.pdf-quickview-foot .act { margin: 0; height: 26px; }
.pdf-quickview-count {
  font-family: var(--mono); font-size: 11px; color: var(--text-mute);
  min-width: 116px; text-align: center;
}
.pdf-quickview-close {
  position: absolute; top: -12px; right: -12px;
  width: 28px; height: 28px; border-radius: 50%;
  display: flex; align-items: center; justify-content: center;
  font-size: 1.2rem; line-height: 1; cursor: pointer;
  background: var(--surface-2); color: var(--text);
  border: 1px solid var(--line); box-shadow: 0 2px 8px rgba(0, 0, 0, .3);
  transition: background .15s, color .15s;
}
.pdf-quickview-close:hover { background: var(--bg-sunk); border-color: var(--accent); }

@media (max-width: 760px) {
  .pdf-input { flex-wrap: wrap; }
  .pdf-range { width: 100%; }
  .pdf-quickview-content { max-width: 98vw; padding: 12px 10px 10px; }
  /* Narrower cells, same frame height: the frame is what app-pdf.js scales
     each canvas against (THUMB_H), so shrinking it here would clip renders
     rather than fit them. */
  .pdf-pages { grid-template-columns: repeat(auto-fill, minmax(118px, 1fr)); }
  .pdf-cell-bar { gap: 3px; }
  .pdf-cell-bar .pdf-icon-btn { width: 22px; }
}

/* =========================================================================
   Convert (app-convert.js) — text, PDF and Word in either direction.

   The layout IS the argument the tool makes: a source at the top, a box of
   text you can read and edit in the middle, a target on the left. What
   crosses between these formats is the words, and a screen built around the
   words says that better than a disclaimer under a button.

   Options on the left and the text on the right, matching app-image.js —
   controls one side, the thing being worked on the other — so the two tools
   do not disagree about where a control panel lives.
   ========================================================================= */
.cv-page { display: flex; flex-direction: column; gap: 12px; padding: 4px 2px 10px; min-width: 0; }
.cv-grid { display: grid; grid-template-columns: minmax(260px, 320px) 1fr; gap: 12px; min-width: 0; }
.cv-targets {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  background: none;
  border: none;
  padding: 0;
  width: 100%;
  box-sizing: border-box;
}

.cv-targets .enc-seg-btn {
  flex: 1 1 calc(33.33% - 6px);
  min-width: 78px;
  height: 28px;
  padding: 0 8px;
  font-size: 11.5px;
  white-space: nowrap;
  border-radius: 5px;
  border: 1px solid var(--line);
  background: var(--surface-2);
  display: inline-flex;
  align-items: center;
  justify-content: center;
  box-sizing: border-box;
}

.cv-targets .enc-seg-btn:hover {
  background: var(--line);
  color: var(--text);
}

.cv-targets .enc-seg-btn.is-on {
  background: var(--accent);
  color: var(--accent-ink);
  border-color: var(--accent);
  font-weight: 600;
}
.cv-layout { display: flex; flex-direction: column; gap: 6px; }
.cv-num { width: 62px; }
.cv-actions { margin-top: 2px; }

.cv-text-panel { min-width: 0; }
/* Taller than the encoder's box and wrapping on words rather than anywhere:
   this is prose being read, not base64 being checked, and break-all would
   chop it mid-word. */
.cv-area {
  min-height: 340px;
  font-size: 12.5px; line-height: 1.6;
  word-break: normal; overflow-wrap: anywhere;
}

/* What was lost on the way through, in words. Not styled as a warning: none
   of it is a failure, all of it is the tool being straight about what a
   format change costs. */
.cv-notes {
  margin: 4px 0 0; padding-left: 16px;
  font-size: 11.5px; line-height: 1.55; color: var(--text-mute);
}
.cv-notes li + li { margin-top: 4px; }

.cv-output { gap: 12px; }

@media (max-width: 900px) {
  .cv-grid { grid-template-columns: 1fr; }
  /* The text first on a narrow screen: it is the thing being looked at, and
     a column of options above it would push it off the fold. */
  .cv-text-panel { order: -1; }
  .cv-area { min-height: 240px; }
}

/* =========================================================================
   Audio tools (app-audio.js) — trim, convert, resample, normalise.

   The waveform is the control, not an illustration: the reason to open this
   tool is almost always "cut the first nine seconds off", and that is a thing
   you point at rather than a number you know. So it gets the full width and
   the top of the page, with the options and the result underneath it —
   unlike app-image.js and app-convert.js, whose subject is a preview you look
   at and whose controls therefore sit beside it.

   Every colour in the canvas is read from these tokens at draw time (see
   drawWave), so a waveform painted for one theme is not invisible in another.
   ========================================================================= */
.aud-page { display: flex; flex-direction: column; gap: 12px; padding: 4px 2px 10px; min-width: 0; }
.aud-work { display: flex; flex-direction: column; gap: 12px; min-width: 0; }
.aud-grid { display: grid; grid-template-columns: minmax(280px, 360px) 1fr; gap: 12px; min-width: 0; }
/* One column where a 280px control panel stops leaving the result anything
   worth having — the same width the image tool folds at, for the same
   reason. */
@media (max-width: 1080px) {
  .aud-grid { grid-template-columns: 1fr; }
}

.aud-selection { gap: 8px; }
/* Fixed height rather than an aspect ratio: a waveform is read across, and
   150px is as much vertical as a peak envelope has anything to say with.
   The canvas is sized in device pixels by drawWave; this is its CSS box. */
.aud-wave {
  width: 100%; height: 150px; display: block;
  background: var(--bg-sunk); border: 1px solid var(--line);
  border-radius: var(--radius-sm); cursor: text; touch-action: none;
}
/* The waveform is focusable (Space plays, arrows move the playhead, [ and ]
   set the marks), so it has to show when it holds focus — a keyboard user
   pressing Space with no idea where it will land is worse than no shortcut. */
.aud-wave:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }

/* Transport. One row read left to right: the three things that can be played,
   then the clock, then the two buttons that take a mark FROM that clock. The
   marks are pushed to the far end because they are the destination of the
   gesture rather than another way to start it. */
.aud-transport {
  display: flex; align-items: center; flex-wrap: wrap; gap: 0 2px;
  padding-top: 2px;
}
.aud-tbtn { height: 28px; font-size: 11.5px; padding: 0 10px; margin: 0 4px 4px 0; }
.aud-tbtn.is-on {
  background: var(--accent); border-color: var(--accent);
  color: var(--accent-ink); font-weight: 600;
}
/* Tabular figures and a fixed slot: this number changes sixty times a second
   while something plays, and digits of different widths make the whole row
   twitch. It is also the number someone is reading off to find a cut, so it
   is the largest mono text on the panel rather than a footnote. */
.aud-clock {
  font-family: var(--mono); font-size: 13px; font-variant-numeric: tabular-nums;
  color: var(--text-dim); margin: 0 10px 4px 10px; white-space: nowrap;
}
.aud-clock.is-live { color: var(--accent); }
.aud-hover {
  font-family: var(--mono); font-size: 11px; font-variant-numeric: tabular-nums;
  color: var(--text-mute); min-width: 74px; margin-bottom: 4px; white-space: nowrap;
}
.aud-marks { margin-left: auto; display: flex; }
@media (max-width: 720px) {
  /* Wrapped onto its own line, the auto margin would strand the two mark
     buttons against the right edge away from everything else. */
  .aud-marks { margin-left: 0; }
}

.aud-times { gap: 8px; }
/* Wide enough for m:ss.mmm and monospaced, so the digits do not shuffle
   sideways while a selection is being dragged. */
.aud-time { width: 104px; font-family: var(--mono); font-size: 11.5px; }
.aud-sel { margin-left: auto; font-family: var(--mono); font-size: 11px; color: var(--text-mute); }

.aud-controls { gap: 6px; }
.aud-controls select { height: 28px; font-size: 12px; padding: 0 7px; min-width: 116px; }
.aud-peak { font-family: var(--mono); font-size: 11px; color: var(--text-mute); padding: 1px 0 3px; }
/* Prose rather than a reading, so it wraps and is not monospaced: what a
   boost preset does needs a sentence, and the makeup figure inside it is the
   part that explains the result. */
.aud-boost-note { font-family: var(--font); line-height: 1.5; padding-bottom: 5px; }

.aud-out { align-self: flex-start; gap: 10px; }
.aud-stats { row-gap: 8px; }
.aud-notes { display: flex; flex-direction: column; gap: 7px; }
/* The things a decode/re-encode round trip costs, said next to the button
   that does it rather than in a footnote. Muted, because most of them are
   facts about the operation rather than warnings — the clipping one is the
   exception and says so in colour. */
.aud-note {
  font-size: 11.5px; line-height: 1.5; color: var(--text-mute);
  border-left: 2px solid var(--line); padding-left: 9px;
}
.aud-note.is-bad { color: var(--warn); border-left-color: var(--warn); }
