/* The brand palette — the six identity colors, and the only place their hex
   codes are written. Everything below is a semantic name pointing at one of
   these, so re-tinting the brand is an edit here rather than a search for
   literals. Names follow the brand sheet; the parenthesised ones are what
   that sheet calls them.
   The `-rgb` triplets exist because a hex custom property cannot carry an
   alpha channel — a tint of a brand color is written
   `rgb(var(--nm-brand-white-rgb) / 0.14)`. Only the colors that are actually
   used at partial opacity have one — the red triplet is currently unused, kept
   because the hex it mirrors still is (the error state, the low band). */
:root {
  --nm-brand-black: #000000;   /* Rich Black */
  --nm-brand-white: #FBF3EF;   /* Parchment */
  --nm-brand-yellow: #DFBA51;  /* Ecru */
  --nm-brand-green: #B2D26E;
  --nm-brand-blue: #74B1C1;
  --nm-brand-red: #E04C24;

  --nm-brand-black-rgb: 0 0 0;
  --nm-brand-white-rgb: 251 243 239;
  --nm-brand-blue-rgb: 116 177 193;
  --nm-brand-red-rgb: 224 76 36;
}

/* Dark is the default: an unset data-theme means dark, and `light` is the
   opt-in the toggle writes. Both themes are the same warm palette — the
   dark one is not a separate design, just the other end of it. */
:root {
  color-scheme: dark;
  /* Aeonik is declared first to mirror the reference site's intent, but it is
     not served there (or here) so UI text resolves to Inter — exactly what the
     live workbench renders. */
  font-family: Aeonik, Inter, ui-sans-serif, system-ui, -apple-system,
    "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
  /* DM Mono drives the logo, the small uppercase labels, and code/inputs. */
  --mono: "DM Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
    monospace;

  /* Surfaces. Black page, warm greys raised above it — the values are the
     UI study's (_misc/BioM3_UX/), converted from oklch. Note the direction:
     every surface sits *above* the page rather than recessed below it. */
  --bg: var(--nm-brand-black);         /* page */
  --bg-raised: rgb(16, 13, 11);        /* cards / panels */
  --bg-input: rgb(34, 30, 29);         /* fields */

  /* Neutral hairlines, not accent-tinted. On a black page a tinted border
     outlines every panel in the accent color; white at low alpha divides
     without drawing attention to the division.

     The study's own value is 12%, which is 1.27:1 against black -- invisible.
     It gets away with that because it separates regions by whitespace and has
     three sparse routes to do it in; our tool pages are fieldsets, tables and
     nested <details> that need an actual edge. 22% reads as a hairline at
     1.8:1 without becoming a grey cage (3:1 would need 36%). */
  --border: rgba(255, 255, 255, 0.22);
  --border-strong: rgba(255, 255, 255, 0.32);
  --border-hover: rgba(255, 255, 255, 0.32);

  /* Ink */
  --text: var(--nm-brand-white);
  /* Vector brand marks draw in this. Tracks --text so a wordmark flips with
     the theme for the same reason the PNG logo is inverted on dark: the art is
     one colour and the page underneath is what changes. */
  --logo: var(--text);
  --text-muted: rgb(157, 151, 148);

  /* Blue accent — links, active-nav, quota fill, chat prompt.
     The exact brand hex: on black it reaches 8.8:1, so it needs none of
     the lightening the warm near-black page used to require.
     --accent-ink is what goes *on* a solid accent fill. Here the accent is
     a light blue, so that is the page black; the light theme inverts it. */
  --accent: var(--nm-brand-blue);
  --accent-hover: rgb(150, 200, 213);
  --accent-soft: rgb(var(--nm-brand-blue-rgb) / 0.18);
  --accent-ink: var(--nm-brand-black);

  /* Cool blue-grey focus ring + sage highlight */
  --ring: rgb(var(--nm-brand-blue-rgb) / 0.7);
  --ring-soft: rgb(var(--nm-brand-blue-rgb) / 0.4);
  --sage: var(--nm-brand-green);

  /* Brand accent rotation — cycled across nav items and prompt chips so the
     chrome carries the palette rather than one accent. On black these clear
     3:1 as-is, so this theme uses them unmodified; the light theme darkens
     three of them, which is why those are literals there and variables here.

     Slot 1 was the brand terracotta and is now the brand blue: orange as an
     accent is what this UI is moving away from. That leaves blue in two of
     the four slots, which is deliberate rather than an oversight — the
     alternative was recolouring a slot to something outside the brand sheet.
     Terracotta is still the ERROR colour's neighbourhood and still the light
     theme's hairline, both of which are meanings rather than decoration. */
  --accent-1: var(--nm-brand-blue);
  --accent-2: var(--nm-brand-green);
  --accent-3: var(--nm-brand-blue);
  --accent-4: var(--nm-brand-yellow);

  /* Plot series — the projection's two sets.
     NOT the accent rotation: that is chrome, cycled for variety, and two of its
     four slots are the same blue. These carry meaning, so they are chosen for
     separation and checked rather than picked.

     Figure/ground, not two peer series. The baseline is 59,893 corpus points
     drawn as a density cloud; the overlay is a few hundred generated ones. So
     this is the "highlight one, gray the rest" pattern — the ground is
     deliberately near-neutral and the overlay carries the only hue.

     Validated with the dataviz palette checker against each theme's own surface
     (never an automatic flip of the other):
       dark  on #000000 — normal ΔE 21.5, tritan 19.5, both in L-band, both ≥3:1
       paper on #FBF3EF — normal ΔE 17.2, tritan 15.9, both in L-band, both ≥3:1
     Floors are 15 (normal) and 8 (CVD), so both clear with room. The checker
     also flags the ground for low chroma, which is the point of it: a ground
     that reads as a hue competes with the set the plot exists to show.

     Warm against a cool grey rather than the brand green, which is what this
     started as: green-on-blue-grey measured tritan ΔE 2.8, i.e. one indistinct
     cloud for a tritanope. Terracotta is unavailable — it is the error colour,
     and a red data series in an app where red means broken is a trap. */
  --plot-set-baseline: #5A6472;
  --plot-set-overlay: #B08E30;

  /* Colouring by an annotation column — species, taxonomy, prompt, split.
     TEN slots, and the first three are not arbitrary: they are the only three
     that clear the all-pairs floors, so they are ordered first and
     `assignSeries` ranks levels by point count — which means slots 1-3 always
     go to the three largest levels and the weak slots go to the smallest.
     The picture degrades where fewest points live.

     Slots 4-10 do NOT clear the floors, and that is known rather than
     overlooked. Measured with the dataviz validator, --pairs all, each theme
     against its own surface:
       the first three — dark CVD ΔE 9.4 / normal 20.9; paper 9.2 / 24.0
       all ten        — dark CVD ΔE 1.6 / normal 7.1; paper 3.2 / 7.1
     Floors are 8 (CVD) and 15 (normal vision). The worst pair is INSIDE the
     design system's own eight (#e66767 vs #d95926, #d55181 vs #199e70), so no
     choice of a ninth and tenth hue lifts it — a search over 7,718 in-gamut
     candidates in the lightness band leaves it exactly where it was, and on
     parchment no candidate clears the band and contrast gates at all.

     So two of any ten levels are indistinguishable, and the documented relief
     is what carries identity instead: the legend ships visible labels AND
     counts, the tooltip names the value on hover, the detail panel names it on
     click. The hue groups; it does not identify. Ten is a product decision
     taken with these numbers in view — MAX_LEVELS in project.js is the one
     edit that dials it back.

     Slots 1-8 are the design system's documented categorical order, unchanged
     (re-stepping a documented palette is off the table by its own rule).
     Slots 9-10 were chosen to maximise their minimum distance to all eight:
     slot 9 is clean (min ΔE 18.9 normal / 11.9 CVD on dark), slot 10 is the
     weak one and takes the smallest level of the ten. */
  --plot-cat-1: #3987e5;
  --plot-cat-2: #d95926;
  --plot-cat-3: #199e70;
  --plot-cat-4: #c98500;
  --plot-cat-5: #d55181;
  --plot-cat-6: #008300;
  --plot-cat-7: #9085e9;
  --plot-cat-8: #e66767;
  --plot-cat-9: #8636af;
  --plot-cat-10: #835b00;

  /* A NUMERIC annotation is a position on a line, not a set of categories.
     `sequence_length` has 1,356 distinct values; ranking them by count and
     naming ten is not an answer to any question. So a column whose values are
     numbers gets a sequential ramp instead — one hue, light to dark, five
     equal-width bins.

     One hue on purpose: a rainbow ramp invents boundaries the data does not
     have, and lightness is the only channel that reads as ordered without a
     legend. Validated with the dataviz validator's ordinal checks against this
     page's own surface — monotone lightness, every adjacent gap >= 0.06 L, and
     the light end at 3.17:1 on black. Steps are the design system's blue ramp;
     nothing here is a fresh colour. */
  --plot-num-1: #b7d3f6;
  --plot-num-2: #86b6ef;
  --plot-num-3: #5598e7;
  --plot-num-4: #2a78d6;
  --plot-num-5: #1c5cab;
  /* The tail and the blanks. Neutral on purpose — neither is a category, and a
     hue would claim they were. */
  --plot-cat-other: #6E7886;
  --plot-cat-none: #454C57;

  /* The composer's ring. On black the study rings it in near-solid cream
     rather than a border tint, which is what makes it the one thing on an
     empty landing page that asks to be used. */
  --composer-border: rgb(var(--nm-brand-white-rgb) / 0.8);

  /* Light "ink" on dark */
  --btn-bg: rgb(244, 234, 227);
  --btn-text: var(--nm-brand-black);

  --error: rgb(248, 113, 113);
  --error-soft: rgba(248, 113, 113, 0.12);
  --link: var(--nm-brand-blue);

  /* Theme-aware overlay tints (replace hardcoded rgba in component rules).
     Cream-tinted rather than pure white, matching the warmth of the raised
     surfaces above. */
  --overlay: rgb(var(--nm-brand-white-rgb) / 0.07);
  --overlay-strong: rgb(var(--nm-brand-white-rgb) / 0.14);

  --radius: 8px;
  --radius-lg: 14px;

  /* The chat composer's line box. A shared value because three things have to
     agree on it: the textarea, the `>` marker beside it, and app.js, which
     multiplies it by CHAT_INPUT_MAX_LINES to find the height at which the
     field stops growing and starts scrolling. Unitless so that multiplication
     is well defined against the field's own font-size. */
  --chat-input-line-height: 1.45;
}

/* Light theme — the warm "paper" tones of the same palette (same fonts) */
:root[data-theme="light"] {
  color-scheme: light;

  --bg: var(--nm-brand-white);
  --bg-raised: rgb(244, 234, 227);
  --bg-input: rgb(234, 223, 214);

  /* Paper's blue. Brand blue is a pale cyan — 2.2:1 on parchment, so this end
     of the palette uses a darkened version of it rather than the raw hex. It
     was written as a literal in four places; it is a token because the borders
     below now tint with it too, and five copies of one colour is four too many.
     Not in the brand block at the top: that block is the brand sheet's own
     hexes, and this is a derivation paper needs and dark does not. */
  --paper-blue-rgb: 44 108 128;

  /* Section hairlines. These were a terracotta tint — `--nm-brand-red-rgb`,
     which is #E04C24 — and at 18% on parchment that reads as orange around
     every panel on the page. Blue instead.
     The alphas are unchanged, which only works because it is the DARKENED blue:
     the raw brand hex at 18% lands around 1.14:1 against parchment and would
     have quietly removed the borders rather than recolouring them. This is
     about 1.28:1, matching the terracotta it replaces. */
  --border: rgb(var(--paper-blue-rgb) / 0.18);
  --border-strong: rgb(var(--paper-blue-rgb) / 0.32);
  --border-hover: rgb(var(--paper-blue-rgb) / 0.32);

  --text: var(--nm-brand-black);
  --text-muted: rgb(var(--nm-brand-black-rgb) / 0.72);

  --accent: rgb(var(--paper-blue-rgb));
  --accent-hover: rgb(30, 84, 101);
  --accent-soft: rgb(var(--nm-brand-blue-rgb) / 0.16);
  --accent-ink: var(--nm-brand-white);

  --ring: rgb(var(--nm-brand-blue-rgb) / 0.6);
  --ring-soft: rgb(var(--nm-brand-blue-rgb) / 0.4);

  /* The darkened blue, not the raw brand hex: on parchment that is 2.2:1 and
     an accent nobody can see is not an accent. Same value as slot 3 — see the
     note in the dark block. */
  --accent-1: rgb(var(--paper-blue-rgb));
  --accent-2: rgb(94, 130, 42);
  --accent-3: rgb(var(--paper-blue-rgb));
  --accent-4: rgb(148, 113, 20);

  /* Plot series on parchment. Stepped for this surface, not derived from the
     dark values — see the note in the dark block. */
  --plot-set-baseline: #918B84;
  --plot-set-overlay: #7E5A00;

  /* Stepped for parchment, not flipped from the dark values. Same ten slots
     and the same measured caveat — see the note in the dark block. */
  --plot-cat-1: #2a78d6;
  --plot-cat-2: #eb6834;
  --plot-cat-3: #1baf7a;
  --plot-cat-4: #eda100;
  --plot-cat-5: #e87ba4;
  --plot-cat-6: #008300;
  --plot-cat-7: #4a3aa7;
  --plot-cat-8: #e34948;
  --plot-cat-9: #ac40ab;
  --plot-cat-10: #885716;

  /* The same ramp stepped for parchment, NOT flipped from the dark values. It
     starts four steps darker because the dark end of the light ramp is what
     parchment eats: #cde2fb measures 1.21:1 here, i.e. invisible, and #86b6ef
     still only 1.93:1 against a 2:1 floor. These five clear every ordinal
     check on this surface. */
  --plot-num-1: #6da7ec;
  --plot-num-2: #3987e5;
  --plot-num-3: #256abf;
  --plot-num-4: #184f95;
  --plot-num-5: #0d366b;
  --plot-cat-other: #8B857E;
  --plot-cat-none: #B8B2AC;

  /* The composer ring, blue like the hairlines. The note this replaces said
     paper keeps a terracotta ring because "a cream one would vanish here" —
     true, and an argument against cream rather than against blue. Paper's
     darkened blue at 32% is about 1.57:1 on parchment, slightly *more* visible
     than the terracotta was, so the ring did not have to be warm to be seen. */
  --composer-border: rgb(var(--paper-blue-rgb) / 0.32);

  /* Ink primary button on paper */
  --btn-bg: var(--nm-brand-black);
  --btn-text: var(--nm-brand-white);

  --error: rgb(192, 57, 43);
  --error-soft: rgba(192, 57, 43, 0.1);
  --link: rgb(var(--paper-blue-rgb));

  --overlay: rgb(var(--nm-brand-black-rgb) / 0.05);
  --overlay-strong: rgb(var(--nm-brand-black-rgb) / 0.08);
}
* { box-sizing: border-box; }
body {
  margin: 0;
  min-height: 100vh;
  background: var(--bg);
  color: var(--text);
}
[hidden] { display: none !important; }
a { color: var(--link); }

/* ---------- Logged-out: centered login ---------- */
.auth-screen {
  min-height: 100vh;
  display: grid;
  place-items: center;
  padding: 2rem;
}
.auth-card {
  max-width: 28rem;
  text-align: center;
}
.auth-card h1 {
  font-size: clamp(2rem, 5vw, 2.5rem);
  margin: 0 0 0.5rem;
  font-weight: 500;
  letter-spacing: -0.025em;
}
.auth-brand {
  margin: 0 0 1rem;
}
.auth-logo {
  width: 100%;
  max-width: 18rem;
  height: auto;
}
/* Logo art is pure black on transparent, so it needs flipping on the (now
   default) dark background and leaving alone on paper. */
.auth-logo {
  filter: invert(1);
}
:root[data-theme="light"] .auth-logo {
  filter: none;
}
.auth-card p {
  margin: 0.25rem 0 1.5rem;
  color: var(--text-muted);
  line-height: 1.5;
}

/* ---------- Buttons ---------- */
button {
  background: var(--btn-bg);
  color: var(--btn-text);
  border: none;
  padding: 0.55rem 1.1rem;
  border-radius: var(--radius);
  font-size: 0.95rem;
  font-weight: 600;
  cursor: pointer;
  font-family: inherit;
  transition: transform 0.15s ease, box-shadow 0.15s ease,
    background 0.15s ease, border-color 0.15s ease;
}
button:hover {
  transform: translateY(-2px);
  box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.12);
}
button:active { transform: translateY(0); box-shadow: none; }
button:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--ring); }
button:disabled {
  opacity: 0.5;
  cursor: not-allowed;
  transform: none;
  box-shadow: none;
}
button.secondary {
  background: var(--bg-input);
  border: 1px solid var(--border);
  color: var(--text);
  font-weight: 500;
}
button.secondary:hover { border-color: var(--border-hover); }

/* ---------- App shell (logged in) ---------- */
.app {
  display: grid;
  grid-template-areas:
    "header header"
    "sidebar main";
  grid-template-columns: 240px 1fr;
  grid-template-rows: auto 1fr;
  height: 100vh;
  transition: grid-template-columns 180ms ease;
}
/* Collapsing the nav is what gives the chat its full width on the home
   route. Column goes to zero rather than the sidebar going display:none,
   so the transition has something to animate. */
.app.sidebar-collapsed { grid-template-columns: 0 1fr; }
.app.sidebar-collapsed .app-sidebar {
  overflow: hidden;
  padding-left: 0;
  padding-right: 0;
}
/* Header and sidebar sit directly on the page background — no raised bar,
   no dividing rules. The content column is what the eye should land on. */
.app-header {
  grid-area: header;
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 0.75rem 1.25rem;
  background: transparent;
}
.app-header .sidebar-toggle {
  background: transparent;
  border: none;
  color: var(--text-muted);
  font-size: 0.9rem;
  line-height: 1;
  padding: 0.3rem 0.4rem;
  /* Cancel the button's own left padding so the glyph lines up with the
     logo's left edge rather than sitting indented under it. */
  margin-left: -0.4rem;
  border-radius: 4px;
  cursor: pointer;
  transition: transform 150ms ease, color 120ms ease;
}
.app-header .sidebar-toggle:hover {
  color: var(--text);
  background: var(--overlay-strong);
  box-shadow: none;
}
.app.sidebar-collapsed .app-header .sidebar-toggle { transform: rotate(-90deg); }
.app-header .header-left {
  display: flex;
  /* The toggle sits under the logo: it controls the sidebar below it, and
     beside the logo it read as part of the wordmark. */
  flex-direction: column;
  align-items: flex-start;
  gap: 0.2rem;
}
.app-header .brand-link {
  text-decoration: none;
  color: inherit;
}
.app-header .brand {
  display: inline-flex;
  align-items: center;
  gap: 0.6rem;
}
.app-header .brand-logo {
  display: block;
  height: 26px;
  width: auto;
}
.app-header .brand-logo {
  filter: invert(1);
}
:root[data-theme="light"] .app-header .brand-logo {
  filter: none;
}

/* Org branding. Both marks are in the markup; exactly one is shown, chosen by
   the data-brand attribute site/js/brand.js puts on <html>. Default (no
   attribute) is Natural Machine, so an unknown or absent org gets the portal's
   own mark rather than nothing. */
.wm {
  display: none;
  align-items: center;
  gap: 0.4em;
  font-family: var(--mono);
  font-size: 0.85rem;
  letter-spacing: 0.14em;
  line-height: 1;
  color: var(--text);
  white-space: nowrap;
}
.wm svg {
  height: 0.9em;
  width: auto;
  flex: none;
}
:root[data-brand="ranganathan"] .app-header .brand-logo {
  display: none;
}
:root[data-brand="ranganathan"] .app-header .wm {
  display: inline-flex;
}
.app-header .profile {
  display: flex;
  align-items: center;
  gap: 0.75rem;
  font-size: 0.9rem;
}
.app-header .profile .who { color: var(--text-muted); }
.app-header .profile .who strong { color: var(--text); font-weight: 500; }
/* Address and active org stack at the right end of the header. */
.app-header .profile .identity {
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  gap: 0.1rem;
  line-height: 1.35;
}
.app-header .profile .who.org { font-size: 0.85rem; }
.app-header .profile .org-select {
  font: inherit;
  color: var(--text);
  background: var(--overlay-strong);
  border: 1px solid var(--border);
  border-radius: 4px;
  padding: 0.05rem 0.25rem;
  max-width: 16rem;
}
.app-header .profile .org-select:disabled { opacity: 0.6; }
.app-header .profile .settings-cog {
  color: var(--text-muted);
  text-decoration: none;
  font-size: 1.1rem;
  line-height: 1;
  padding: 0.3rem 0.5rem;
  border-radius: 4px;
}
.app-header .profile .settings-cog:hover {
  color: var(--text);
  background: var(--overlay-strong);
}

/* ---------- Settings page layout ---------- */
.settings {
  display: grid;
  grid-template-columns: 180px 1fr;
  gap: 1.5rem;
  max-width: 64rem;
}
.settings-sidenav { display: flex; flex-direction: column; }
.settings-sidenav .section-label {
  padding: 0.5rem 0 0.5rem 0.75rem;
  font-family: var(--mono);
  font-size: 0.68rem;
  text-transform: uppercase;
  letter-spacing: 0.16em;
  color: var(--text-muted);
}
.settings-sidenav a {
  display: block;
  padding: 0.45rem 0.75rem;
  color: var(--text-muted);
  text-decoration: none;
  font-size: 0.9rem;
  border-left: 3px solid transparent;
}
.settings-sidenav a:hover {
  color: var(--text);
  background: var(--overlay);
}
.settings-sidenav a.active {
  color: var(--text);
  background: var(--accent-soft);
  border-left-color: var(--accent);
}
.settings-content { max-width: 44rem; }
.settings-content h2 { margin-top: 0; }

/* Admin link in settings sub-nav is hidden unless the user is in admins */
.settings-nav-admin { display: none; }
body.is-admin .settings-nav-admin { display: block; }
.settings-nav-system { display: none; }
body.is-system-admin .settings-nav-system { display: block; }

/* The org checkboxes on a machine's row. Wrapped rather than one per line:
   a machine can serve several orgs and the whole set has to be readable at a
   glance, since the point of the page is comparing it against the grants. */
label.inline {
  display: inline-flex;
  align-items: center;
  gap: 0.3em;
  margin-right: 0.75em;
  white-space: nowrap;
}

/* Account description list */
.account-dl {
  display: grid;
  grid-template-columns: max-content 1fr;
  gap: 0.5rem 1rem;
  margin: 1rem 0;
}
.account-dl dt {
  color: var(--text-muted);
  font-family: var(--mono);
  font-size: 0.72rem;
  text-transform: uppercase;
  letter-spacing: 0.12em;
  align-self: center;
}
.account-dl dd { margin: 0; word-break: break-all; }

/* Globus endpoints — one card per machine. The form controls repeat the
   .workflow input rules rather than reusing them, because those are scoped
   under .workflow and settings pages are not workflows. Kept in sync by
   hand; if a third surface needs them, hoist to a shared .form-field. */
.endpoint-list {
  display: flex;
  flex-direction: column;
  gap: 1rem;
  margin-top: 1.25rem;
}
.endpoint-card {
  padding: 1rem 1.1rem 1.1rem;
  background: var(--bg-raised);
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
}
.endpoint-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 1rem;
}
.endpoint-head h3 { margin: 0; font-size: 1rem; }
.endpoint-saved { font-size: 0.75rem; white-space: nowrap; }
.endpoint-card > .hint { margin: 0.35rem 0 0; }
.endpoint-card .field {
  display: block;
  margin-top: 0.75rem;
  font-size: 0.85rem;
  color: var(--text-muted);
}
.endpoint-card input[type="text"] {
  width: 100%;
  margin-top: 0.25rem;
  background: var(--bg-input);
  color: var(--text);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 0.6rem;
  font-family: var(--mono);
  font-size: 0.85rem;
}
.endpoint-card input[type="text"]:focus {
  outline: none;
  border-color: var(--border-strong);
  box-shadow: 0 0 0 2px var(--ring-soft);
}
.endpoint-card .actions {
  margin-top: 1rem;
  display: flex;
  gap: 0.5rem;
  flex-wrap: wrap;
}
.endpoint-status {
  margin-top: 0.85rem;
  padding: 0.55rem 0.7rem;
  border: 1px solid var(--border);
  border-left-width: 3px;
  border-radius: var(--radius);
  background: var(--bg);
  font-size: 0.85rem;
  line-height: 1.5;
}
/* Three outcomes, three edges: green ran, yellow queued, red failed. Drawn
   from the existing palette accents so both themes are covered already. */
.endpoint-status.ok { border-left-color: var(--accent-2); }
.endpoint-status.pending { border-left-color: var(--accent-4); }
.endpoint-status.error {
  border-color: var(--error);
  color: var(--error);
  background: var(--error-soft);
}

/* Transparent, but not unbounded: the nav keeps a right edge so the content
   column reads as a column. Losing the raised fill was the point; losing the
   boundary with it left the two regions running together. */
.app-sidebar {
  grid-area: sidebar;
  background: transparent;
  border-right: 1px solid var(--border);
  padding: 1rem 0;
  overflow-y: auto;
  /* Column so the footer can be pushed to the bottom with margin-top: auto. */
  display: flex;
  flex-direction: column;
}
/* Collapsed, the edge would sit against the window with nothing behind it. */
.app.sidebar-collapsed .app-sidebar { border-right: none; }
.app-sidebar .section-label {
  padding: 0.5rem 1rem;
  font-family: var(--mono);
  font-size: 0.62rem;
  text-transform: uppercase;
  letter-spacing: 0.18em;
  color: var(--text-muted);
}
.app-sidebar nav a {
  display: block;
  padding: 0.5rem 1rem 0.5rem 1.5rem;
  color: var(--text);
  text-decoration: none;
  font-size: 0.9rem;
  border-left: 3px solid transparent;
  transition: color 120ms ease;
}
/* Hover tints the label rather than filling the row. Each nav block cycles
   the four brand accents so the palette shows up in the chrome; --nav-accent
   is set per <nav> below and inherited by the links inside it. */
.app-sidebar nav a:hover,
.app-sidebar summary.nav-group-label:hover {
  color: var(--nav-accent, var(--accent));
  background: transparent;
}
.app-sidebar nav:nth-of-type(4n + 1) { --nav-accent: var(--accent-1); }
.app-sidebar nav:nth-of-type(4n + 2) { --nav-accent: var(--accent-2); }
.app-sidebar nav:nth-of-type(4n + 3) { --nav-accent: var(--accent-3); }
.app-sidebar nav:nth-of-type(4n + 4) { --nav-accent: var(--accent-4); }
.app-sidebar nav .nav-empty {
  display: block;
  padding: 0.5rem 1rem 0.5rem 1.5rem;
  color: var(--text-muted);
  font-size: 0.85rem;
  font-style: italic;
}
/* Marked with weight and a rule, not a filled block — a tinted slab was
   legible against the old raised sidebar but is the loudest thing on the
   page now that the sidebar is transparent. */
.app-sidebar nav a.active {
  background: transparent;
  border-left-color: var(--accent);
  color: var(--text);
  font-weight: 600;
}
.app-sidebar .nav-group-label {
  padding: 0.4rem 1rem 0.15rem 1.5rem;
  font-size: 0.82rem;
  font-weight: 500;
  color: var(--text);
  margin-top: 0.25rem;
}
.app-sidebar summary.nav-group-label {
  position: relative;
  cursor: pointer;
  list-style: none;
  user-select: none;
}
.app-sidebar summary.nav-group-label::-webkit-details-marker { display: none; }
.app-sidebar summary.nav-group-label::before {
  content: "›";
  position: absolute;
  left: 0.55rem;
  top: 50%;
  transform: translateY(-55%);
  color: var(--text-muted);
  font-size: 1rem;
  line-height: 1;
  transition: transform 150ms ease;
}
.app-sidebar details.nav-group[open] > summary.nav-group-label::before {
  transform: translateY(-55%) rotate(90deg);
}
.app-sidebar nav a.nav-child {
  padding-left: 2.5rem;
  font-size: 0.85rem;
  color: var(--text-muted);
}
.app-sidebar nav a.nav-child.active { color: var(--text); }

/* ---------- Collapsible top-level section (Tools & Pipelines) ---------- */
/* The accent cycling above counts <nav> per parent, and this section's nav is
   nested one level deeper — so both it and Data would land on --accent-3.
   Pin them explicitly; these rules follow the nth-of-type ones and win on
   source order. */
.app-sidebar details.nav-section > nav { --nav-accent: var(--accent-3); }
.app-sidebar > nav.nav-data { --nav-accent: var(--accent-4); }

.app-sidebar summary.nav-section-label {
  position: relative;
  cursor: pointer;
  list-style: none;
  user-select: none;
  /* Room for the disclosure caret, which sits where the tool groups' own
     carets do so the two levels line up. */
  padding-left: 1.5rem;
}
.app-sidebar summary.nav-section-label::-webkit-details-marker { display: none; }
.app-sidebar summary.nav-section-label:hover { color: var(--text); }
.app-sidebar summary.nav-section-label::before {
  content: "›";
  position: absolute;
  left: 0.55rem;
  top: 50%;
  transform: translateY(-55%);
  font-size: 1rem;
  line-height: 1;
  letter-spacing: normal;
  transition: transform 150ms ease;
}
.app-sidebar details.nav-section[open] > summary.nav-section-label::before {
  transform: translateY(-55%) rotate(90deg);
}

/* ---------- Sidebar footer + "Learn more" popover ---------- */
.sidebar-footer {
  margin-top: auto;
  padding: 1rem 1rem 0.25rem 1.5rem;
}
.learn-more-btn {
  background: transparent;
  border: 1px solid var(--border);
  color: var(--text-muted);
  font-size: 0.82rem;
  padding: 0.4rem 0.75rem;
  border-radius: var(--radius);
  cursor: pointer;
}
.learn-more-btn:hover {
  color: var(--text);
  border-color: var(--border-strong);
  background: var(--overlay);
  box-shadow: none;
}
/* Fixed, not absolute: the sidebar scrolls, and an absolutely positioned
   panel inside it would be clipped by that overflow. app.js positions it
   against the button. */
.learn-more-panel {
  position: fixed;
  z-index: 60;
  display: flex;
  flex-direction: column;
  min-width: 12rem;
  padding: 0.4rem 0;
  background: var(--bg-raised);
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  box-shadow: 0 8px 24px rgb(0 0 0 / 0.28);
}
.learn-more-panel[hidden] { display: none; }
/* The report entry is a <button> — the form is a dialog, not a destination —
   so both element types are styled as one list. */
.learn-more-panel a,
.learn-more-panel button {
  padding: 0.45rem 0.9rem;
  color: var(--text);
  text-decoration: none;
  font-size: 0.88rem;
}
.learn-more-panel button {
  background: none;
  border: 0;
  border-radius: 0;
  text-align: left;
  font: inherit;
  font-size: 0.88rem;
  cursor: pointer;
}
.learn-more-panel a:hover,
.learn-more-panel button:hover {
  color: var(--accent);
  background: var(--overlay);
}

/* Report form. Scoped like the upload modal above it — .field carries no
   layout of its own, each dialog gives it one. */
#feedbackForm .field { display: block; margin-top: 0.6rem; }
#feedbackForm input[type="text"],
#feedbackForm select,
#feedbackForm textarea {
  width: 100%;
  margin-top: 0.25rem;
  background: var(--bg-input);
  color: var(--text);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 0.5rem;
  font-family: inherit;
  font-size: 0.9rem;
}
#feedbackForm textarea { resize: vertical; }
#feedbackForm input[type="text"]:focus,
#feedbackForm select:focus,
#feedbackForm textarea:focus {
  outline: none;
  border-color: var(--ring);
  box-shadow: 0 0 0 2px var(--ring-soft);
}
.feedback-intro,
.feedback-note {
  margin: 0.35rem 0 0;
  font-size: 0.8rem;
  color: var(--text-muted);
}
.feedback-note { margin-top: 0.6rem; }
.feedback-done-line { margin: 0.35rem 0 0; font-size: 0.92rem; }
.feedback-actions {
  display: flex;
  gap: 0.5rem;
  margin-top: 0.9rem;
}
.feedback-status {
  margin-top: 0.7rem;
  font-size: 0.82rem;
  color: var(--text-muted);
}
.feedback-status.is-error { color: var(--error); }

.app-main {
  grid-area: main;
  padding: 1.5rem 2rem;
  overflow-y: auto;
  /* Column so #appContent can be handed the full remaining height. Ordinary
     pages ignore it and lay out from the top as before; a workflow needs a
     definite height to snap-scroll against. */
  display: flex;
  flex-direction: column;
}
#appContent {
  flex: 1 1 auto;
  min-height: 0;
}
.app-main h2 {
  margin: 0 0 0.5rem;
  font-size: 1.25rem;
  font-weight: 500;
  letter-spacing: -0.015em;
}
.app-main .hint {
  margin: 0 0 1rem;
  color: var(--text-muted);
  font-size: 0.9rem;
  line-height: 1.5;
}

/* Welcome card on the home view */
.welcome {
  max-width: 36rem;
  padding: 1.5rem;
  background: var(--bg-raised);
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
}
.welcome h2 { margin-top: 0; }
.welcome ul { padding-left: 1.25rem; color: var(--text-muted); }
.welcome ul li { margin: 0.4rem 0; }

/* Home view: My Jobs + Reference Data tables */
.home-section {
  max-width: 56rem;
  margin-top: 2rem;
}
.home-section h2 {
  margin: 0 0 0.5rem;
  font-size: 1.1rem;
}
.data-table {
  width: 100%;
  border-collapse: collapse;
  background: var(--bg-raised);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  overflow: hidden;
  font-size: 0.9rem;
}
.data-table th,
.data-table td {
  padding: 0.5rem 0.75rem;
  text-align: left;
  border-bottom: 1px solid var(--border);
}
.data-table th {
  background: var(--overlay);
  color: var(--text-muted);
  font-family: var(--mono);
  font-weight: 500;
  font-size: 0.72rem;
  text-transform: uppercase;
  letter-spacing: 0.12em;
}
.data-table tr:last-child td { border-bottom: none; }
.data-table .muted { color: var(--text-muted); }
.data-table .mono {
  font-family: var(--mono);
  font-size: 0.85rem;
}
.data-table .empty {
  text-align: center;
  color: var(--text-muted);
  padding: 1rem;
}

/* Global-data directory tree (My Data → Global data). Folders are native
   collapsible <details>; files are flex rows. Nested levels are indented
   with a guide border. */
.data-tree {
  background: var(--bg-raised);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 0.35rem 0.5rem;
  font-size: 0.9rem;
}
.data-tree .empty {
  text-align: center;
  color: var(--text-muted);
  padding: 1rem;
}
.data-tree-dir > summary {
  cursor: pointer;
  list-style: none;
  padding: 0.3rem 0.4rem;
  font-family: var(--mono);
  font-size: 0.75rem;
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: var(--text-muted);
  border-radius: 4px;
  user-select: none;
}
.data-tree-dir > summary::-webkit-details-marker { display: none; }
.data-tree-dir > summary::before {
  content: "▸";
  display: inline-block;
  width: 1em;
  color: var(--accent);
  transition: transform 0.12s ease;
}
.data-tree-dir[open] > summary::before { transform: rotate(90deg); }
.data-tree-dir > summary:hover { background: var(--overlay); }
/* Indent + guide line for everything nested under a folder. */
.data-tree-dir > *:not(summary) {
  margin-left: 1em;
  padding-left: 0.4rem;
  border-left: 1px solid var(--border);
}
.data-tree-file {
  display: flex;
  align-items: center;
  gap: 0.75rem;
  padding: 0.3rem 0.4rem;
  border-radius: 4px;
}
.data-tree-file:hover { background: var(--overlay); }
.data-tree-name { flex: 1 1 auto; overflow-wrap: anywhere; }
.data-tree-size { flex: 0 0 auto; font-size: 0.8rem; }
.data-tree-dl { flex: 0 0 auto; }

/* My Jobs: toolbar above table (count + page-size selector) */
.data-page .data-toolbar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.75rem;
  margin: 0 0 0.5rem;
  font-size: 0.85rem;
  color: var(--text-muted);
}
.data-page .data-toolbar label { color: var(--text-muted); }
.data-page .data-toolbar select {
  background: var(--bg-input);
  color: var(--text);
  border: 1px solid var(--border);
  border-radius: 4px;
  padding: 0.2rem 0.4rem;
  margin-left: 0.4rem;
  font-size: 0.85rem;
  font-family: inherit;
}
.data-page .data-toolbar-right {
  display: flex;
  align-items: center;
  gap: 0.5rem;
}
.data-page .data-toolbar button.pager-btn {
  background: var(--bg-input);
  color: var(--text);
  border: 1px solid var(--border);
  border-radius: 4px;
  padding: 0.2rem 0.6rem;
  font-size: 0.85rem;
  font-family: inherit;
  cursor: pointer;
}
.data-page .data-toolbar button.pager-btn:hover:not(:disabled) {
  background: var(--bg-raised);
}
.data-page .data-toolbar button.pager-btn:disabled {
  opacity: 0.4;
  cursor: not-allowed;
}

/* My Jobs: clickable rows + preview pane */
.data-table tr.clickable { cursor: pointer; }
.data-table tr.clickable:hover { background: var(--overlay); }
.data-table tr.clickable.active {
  background: var(--accent-soft);
}
.data-page { max-width: 64rem; }
.data-page h3 {
  margin: 0 0 0.5rem;
  font-size: 1rem;
  font-weight: 500;
  color: var(--text);
}
/* My Jobs: the table, the preview and the live log.
 *
 * Measured against the CONTAINER, not the viewport. The first version of this
 * used a media query at 1100px and squeezed both regions on an ordinary laptop,
 * because the space available here is the viewport minus the 240px sidebar —
 * and minus the chat drawer when that is open. A media query cannot see either.
 * At 1440px that left nine columns sharing ~714px. A container query measures
 * what the grid actually gets, so collapsing the sidebar widens the table
 * instead of doing nothing.
 *
 * Three arrangements, in order of how much room there is. The DOM order is
 * fixed (table, preview, log) and only the areas move, so the log is never
 * re-mounted — it polls, and remounting would restart the poll and discard the
 * output already read. */
.jobs-page {
  container-type: inline-size;
  container-name: jobs;
}
/* Two caps stand between this page and the width it needs, and lifting only one
   is why the first attempt stayed cramped: `.data-page` caps at 64rem and
   `.home-section` at 56rem. Both are right for a reading column and wrong for a
   table beside a log — at 64rem there is no arrangement of nine columns and a
   log that is not a squeeze, whatever the viewport is doing.
   Still capped, just generously: past ~110rem the table stops being easier to
   read and starts being harder, because the eye has to travel the whole row. */
.data-page.jobs-page { max-width: 110rem; }
.data-page.jobs-page .home-section { max-width: none; }

/* Narrowest: stacked, and the table is capped so the log below it is reachable
   without scrolling past fifty rows. This is the fallback arrangement — table,
   then the previewed job's details, then its log. */
.jobs-layout {
  display: grid;
  gap: 1.25rem;
  align-items: start;
  grid-template-areas:
    "table"
    "preview"
    "log";
}
.jobs-main { grid-area: table; min-width: 0; }
.jobs-preview { grid-area: preview; }
.jobs-log { grid-area: log; }

.jobs-table-scroll {
  max-height: 24rem;
  overflow: auto;
  border-radius: var(--radius);
}
/* `.data-table` sets `overflow: hidden` for its own rounded corners, and that
   makes the table its own scrollport — the sticky header below would then stick
   to a box that never scrolls, which is to say not at all. The wrapper owns the
   clipping now; the table stops competing for it. */
.jobs-table-scroll .data-table { overflow: visible; }
/* The header has to survive the scroll, or a capped table is a grid of numbers
   with nothing naming the columns.
   Opaque, in two layers: `--overlay` is translucent by design, so a sticky
   header painted with it alone would have rows sliding visibly underneath.
   Compositing it over `--bg-raised` reproduces exactly what a non-sticky header
   looks like against the table it sits on. */
.jobs-table-scroll thead th {
  position: sticky;
  top: 0;
  z-index: 1;
  background-color: var(--bg-raised);
  background-image: linear-gradient(var(--overlay), var(--overlay));
}

/* Enough room for the log beside the table, which spans both rows so it runs
   the full height of the table and the preview together.
   The log takes 22rem here, leaving the table roughly a tenth more than an
   even-handed split would. It was 17rem — a fifth more for the table — which
   was the right trade while the default page was ten rows and the table was
   the thing you scanned. At five rows the table needs less and the log, which
   is the part you actually sit and read, needs more. */
@container jobs (min-width: 62rem) {
  .jobs-layout {
    grid-template-columns: minmax(0, 1fr) minmax(19rem, 22rem);
    grid-template-areas:
      "table   log"
      "preview log";
  }
  /* Side by side the page scrolls, so the table needs no height cap. Its
     horizontal scroll stays: at the bottom of this tier seven columns and a
     22rem log do not both fit, and scrolling a table sideways is better than
     crushing it. */
  .jobs-table-scroll { max-height: none; }
}

/* Past this there is enough for both, so the log stops paying for the table.
   A log line is long — a timestamp, a level and a message — and even 22rem
   wraps a good many of them. */
@container jobs (min-width: 84rem) {
  .jobs-layout { grid-template-columns: minmax(0, 1fr) minmax(22rem, 28rem); }
}

/* Compact rows, scoped to this table alone. `.data-table` is shared with My
   Data, where the rows are few and the roominess is right; here the list sits
   above a preview and a log, and every row of padding pushes both down. Trims
   about a fifth off the height of a page of jobs. */
.jobs-table th,
.jobs-table td {
  padding: 0.3rem 0.6rem;
}
.jobs-table td { font-size: 0.85rem; }

.job-preview {
  padding: 1rem 1.25rem;
  background: var(--bg-raised);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  font-size: 0.9rem;
  min-height: 6rem;
}
.job-preview.muted { color: var(--text-muted); }
.job-preview.error { border-color: var(--error); color: var(--error); }

/* ---- Live job log (site/js/joblog.js) ------------------------------------
   One component at two sizes: the pane beside the My Jobs table, and the
   compact widget on any page showing a submitted job. */
.joblog {
  background: var(--bg-raised);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  overflow: hidden;
  display: flex;
  flex-direction: column;
}
.joblog-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 0.75rem;
  padding: 0.5rem 0.75rem;
  background: var(--overlay);
  border-bottom: 1px solid var(--border);
}
.joblog-head-left {
  display: flex;
  align-items: baseline;
  gap: 0.4rem;
  min-width: 0;
}
/* The disclosure. Sized to the title beside it rather than to a button: it sits
   in a header strip half a line tall, and any of the app's button padding turns
   that strip into two rows. */
.joblog-toggle {
  background: none;
  border: 0;
  padding: 0;
  margin: 0;
  cursor: pointer;
  font-size: 0.7rem;
  line-height: 1;
  color: var(--text-muted);
}
.joblog-toggle:hover { color: var(--text); }
/* Collapsed hides the OUTPUT and only the output — the head keeps the state
   word and the foot keeps the download, which is the whole point of collapsing
   rather than unmounting. The border would otherwise be left underlining
   nothing when the foot is empty too (a job still running has no download). */
.joblog.is-collapsed .joblog-body { display: none; }
.joblog.is-collapsed .joblog-head { border-bottom: 0; }
.joblog-title {
  font-family: var(--mono);
  font-size: 0.72rem;
  text-transform: uppercase;
  letter-spacing: 0.12em;
  color: var(--text-muted);
}
.joblog-state {
  font-family: var(--mono);
  font-size: 0.7rem;
  color: var(--text-muted);
}
/* A dot rather than a spinner: the tick is every few seconds, and something
   animating continuously implies a liveness the poll does not have. */
.joblog-state.is-live::before {
  content: "●";
  color: var(--nm-brand-green);
  margin-right: 0.35rem;
}
.joblog-state.is-warn { color: var(--error); }
.joblog-body {
  margin: 0;
  padding: 0.65rem 0.75rem;
  font-family: var(--mono);
  font-size: 0.74rem;
  line-height: 1.5;
  color: var(--text);
  /* pre-wrap, not pre: a stack trace has no wrapping of its own and a log pane
     that scrolls sideways is a log pane nobody reads. */
  white-space: pre-wrap;
  word-break: break-word;
  overflow-y: auto;
  height: 26rem;
  scrollbar-width: thin;
}
.joblog-compact .joblog-body { height: 11rem; }
.joblog-body:empty::after {
  content: "No output yet.";
  color: var(--text-muted);
}
.joblog-foot:not(:empty) {
  padding: 0.5rem 0.75rem;
  border-top: 1px solid var(--border);
}
.joblog-download {
  font-size: 0.75rem;
  padding: 0.3rem 0.6rem;
}
.preview-dl {
  display: grid;
  grid-template-columns: max-content 1fr;
  gap: 0.35rem 1rem;
  margin: 0;
}
.preview-dl dt {
  color: var(--text-muted);
  font-family: var(--mono);
  font-size: 0.72rem;
  text-transform: uppercase;
  letter-spacing: 0.12em;
  align-self: center;
}
.preview-dl dd {
  margin: 0;
  word-break: break-all;
}
.preview-dl .error-text { color: var(--error); }

/* My Data: section header with action button */
.home-section .section-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.75rem;
  margin: 0 0 0.5rem;
}
.home-section .section-head h3 { margin: 0; }
.home-section .section-head button { padding: 0.35rem 0.9rem; font-size: 0.85rem; }
.home-section .section-foot {
  display: flex;
  justify-content: flex-start;
  margin: 0.75rem 0 0;
}
.home-section .section-foot button { padding: 0.35rem 0.9rem; font-size: 0.85rem; }
.data-table .delete-link {
  color: var(--error);
  text-decoration: none;
}
.data-table .delete-link:hover { text-decoration: underline; }

/* Upload modal form layout */
#dataUploadForm .field { display: block; margin-top: 0.6rem; }
#dataUploadForm input[type="text"],
#dataUploadForm input[type="file"],
#dataUploadForm select,
#dataUploadForm textarea {
  width: 100%;
  margin-top: 0.25rem;
  background: var(--bg-input);
  color: var(--text);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 0.5rem;
  font-size: 0.9rem;
}
#dataUploadForm textarea { resize: vertical; }
#dataUploadForm input[type="text"]:focus,
#dataUploadForm select:focus,
#dataUploadForm textarea:focus {
  outline: none;
  border-color: var(--ring);
  box-shadow: 0 0 0 2px var(--ring-soft);
}

/* Quota meter on My Data */
.quota-meter {
  margin: 0.5rem 0 0.75rem;
}
.quota-meter-track {
  height: 6px;
  background: var(--bg-input);
  border: 1px solid var(--border);
  border-radius: 4px;
  overflow: hidden;
}
.quota-meter-fill {
  height: 100%;
  background: var(--accent);
  transition: width 0.3s ease;
}
.quota-meter-fill.near-full { background: var(--error); }
.quota-meter-label {
  font-size: 0.78rem;
  margin-top: 0.25rem;
}

/* Tool / workflow view */
.workflow {
  max-width: 48rem;
}
.workflow .field {
  display: block;
  margin-top: 0.75rem;
  font-size: 0.85rem;
  color: var(--text-muted);
}
.workflow textarea,
.workflow input[type="text"],
.workflow input[type="number"],
.workflow input[type="file"],
.workflow select {
  width: 100%;
  margin-top: 0.25rem;
  background: var(--bg-input);
  color: var(--text);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 0.6rem;
  font-family: var(--mono);
  font-size: 0.85rem;
}
.workflow textarea:focus,
.workflow input[type="text"]:focus,
.workflow input[type="number"]:focus,
.workflow select:focus {
  outline: none;
  border-color: var(--ring);
  box-shadow: 0 0 0 2px var(--ring-soft);
}
.workflow input:disabled,
.workflow select:disabled,
.workflow textarea:disabled {
  opacity: 0.55;
  cursor: not-allowed;
}
.workflow textarea {
  min-height: 12rem;
  resize: vertical;
}
.workflow .field-row {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr));
  gap: 0.6rem;
  margin-top: 0.75rem;
}
.workflow .field-row .field { margin-top: 0; }
.workflow .arg-group {
  margin: 1rem 0 0;
  padding: 0.6rem 1rem 1rem;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--bg-raised);
}
.workflow .arg-group > legend {
  padding: 0 0.4rem;
  font-size: 0.85rem;
  font-weight: 500;
  color: var(--text);
  letter-spacing: 0.01em;
}
.workflow .arg-group .field:first-of-type { margin-top: 0.25rem; }
.workflow .arg-group .field-row:first-of-type { margin-top: 0.25rem; }
.workflow .arg-group.is-inactive .field,
.workflow .arg-group.is-inactive .field-row { opacity: 0.5; }
.workflow .arg-group .field-checkbox {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  margin-top: 0.6rem;
  font-size: 0.85rem;
  color: var(--text);
}
.workflow .arg-group .field-checkbox input { margin: 0; }
/* Field picker (the dataset-build tools). The field list runs to ~28 entries,
   so it is laid out in columns and grouped: a single flat column would push
   the submit button off the screen and give no sense of what relates to what. */
.workflow .field-picker {
  margin-top: 0.5rem;
}
.workflow .field-picker-actions {
  display: flex;
  gap: 0.75rem;
  flex-wrap: wrap;
  margin-bottom: 0.5rem;
}
.workflow .field-picker-group {
  margin-top: 0.75rem;
  padding-top: 0.5rem;
  border-top: 1px solid var(--border);
}
.workflow .field-picker-heading {
  font-size: 0.75rem;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--text-muted);
}
.workflow .field-picker-group .field-checkbox {
  display: inline-flex;
  width: 15rem;
  margin-right: 0.75rem;
  vertical-align: top;
}
.workflow .link-button {
  background: none;
  border: none;
  padding: 0;
  font: inherit;
  font-size: 0.8rem;
  color: var(--text-muted);
  text-decoration: underline;
  cursor: pointer;
}
.workflow .link-button:hover { color: var(--text); }

.workflow details.advanced {
  margin-top: 1rem;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 0.5rem 0.75rem;
  background: var(--bg-raised);
}
.workflow details.advanced > summary {
  cursor: pointer;
  font-size: 0.85rem;
  color: var(--text-muted);
}
.workflow details.advanced[open] > summary { margin-bottom: 0.25rem; }
.workflow .actions {
  margin-top: 1rem;
  display: flex;
  gap: 0.5rem;
  flex-wrap: wrap;
}
.workflow .status {
  margin-top: 1rem;
  padding: 0.6rem 0.75rem;
  background: var(--bg-raised);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  font-size: 0.9rem;
  line-height: 1.5;
}
.workflow .status.error {
  border-color: var(--error);
  color: var(--error);
}

/* Upload control: a <label> styled as a button (it drives a hidden file input,
   which a real <button> cannot do), plus the notice line that replaces the
   textarea once a file is attached. */
.workflow .upload-btn {
  display: inline-block;
  padding: 0.55rem 1.1rem;
  border: 1px solid var(--border);
  border-radius: 6px;
  background: transparent;
  color: var(--text);
  cursor: pointer;
  font-size: 0.95rem;
}
.workflow .upload-btn:hover { border-color: var(--border-hover); }
/* A previewed upload: readable and scrollable, but not editable. Deliberately
   NOT the greyed :disabled treatment — the whole point is that you can read
   it. The tint plus the notice line above carry the "this is the file, not
   your typing" signal. */
.workflow textarea.is-preview {
  background: var(--bg-raised);
  cursor: default;
}
.workflow .upload-notice {
  margin-top: 0.5rem;
  padding: 0.5rem 0.75rem;
  background: var(--bg-raised);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  font-size: 0.85rem;
  color: var(--text-muted);
}
.workflow .upload-notice strong {
  color: var(--text);
  font-weight: 500;
  word-break: break-all;
}
.workflow .upload-notice.error {
  border-color: var(--error);
  color: var(--error);
}
.workflow .upload-notice .link-btn {
  padding: 0;
  border: none;
  background: none;
  box-shadow: none;
  color: var(--accent);
  font-size: inherit;
  text-decoration: underline;
  cursor: pointer;
}
.workflow .upload-notice .link-btn:hover { transform: none; }

/* Resumed-jobs strip on the SCA tool */
#myscaResumedJobs {
  margin-top: 1rem;
  display: flex;
  flex-direction: column;
  gap: 0.4rem;
}
#myscaResumedJobs:empty { display: none; }
.resumed-job {
  padding: 0.5rem 0.75rem;
  background: var(--bg-raised);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  font-size: 0.85rem;
}
.resumed-job .label {
  color: var(--text-muted);
  font-size: 0.75rem;
  margin-bottom: 0.15rem;
}
/* The log widget sits under the status line, inset so the row still reads as
   one job. Its own border would double up against the row's, so it loses it. */
.resumed-job-log { margin-top: 0.5rem; }
.resumed-job-log .joblog {
  border-color: var(--border);
  background: var(--bg);
}

/* Reference-picker dialog */
.picker-dialog {
  background: var(--bg-raised);
  color: var(--text);
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  padding: 1.25rem;
  max-width: 32rem;
  width: 90vw;
}
.picker-dialog::backdrop { background: rgba(0, 0, 0, 0.6); }
.picker-dialog h3 { margin: 0 0 0.5rem; }

/* Structure preview -> Mol* detail view -----------------------------------
   The preview (3Dmol) carries a button that opens the same structures in Mol*.
   Both preview hosts — .wb-viewer in the workbench and .align-viewer on the
   align page — are already position:relative, so the button anchors to them
   without either view needing to change. */
.viewer-detail-btn {
  position: absolute;
  top: 0.5rem;
  right: 0.5rem;
  z-index: 2;                 /* above the 3Dmol canvas */
  padding: 0.3rem 0.6rem;
  font-size: 0.8rem;
  line-height: 1;
  color: var(--text);
  background: var(--bg-raised);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  cursor: pointer;
  opacity: 0.85;
}
.viewer-detail-btn:hover {
  opacity: 1;
  border-color: var(--accent);
}

/* Deliberately NOT .picker-dialog: that one is capped at 32rem, which is a
   sensible width for a file picker and useless for a molecular viewer. */
.molstar-dialog {
  width: 92vw;
  height: 88vh;
  max-width: none;
  padding: 0;
  overflow: hidden;
  color: var(--text);
  background: var(--bg-raised);
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
}
/* Guarded on [open]. An unguarded `display: flex` here would beat the UA's
   `dialog:not([open]) { display: none }` and leave the dialog on screen while
   closed. */
.molstar-dialog[open] {
  display: flex;
  flex-direction: column;
}
.molstar-dialog::backdrop { background: rgba(0, 0, 0, 0.6); }

.molstar-dialog-bar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
  padding: 0.6rem 0.9rem;
  border-bottom: 1px solid var(--border);
}
.molstar-dialog-title { font-weight: 600; }
.molstar-dialog-close {
  padding: 0.3rem 0.7rem;
  color: var(--text);
  background: transparent;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  cursor: pointer;
}
.molstar-dialog-close:hover { border-color: var(--accent); }

/* Mol* positions its own UI absolutely inside this host, so it needs a
   positioned parent with a real measured height — hence flex:1 plus the
   min-height:0 that stops a flex item from refusing to shrink. */
.molstar-dialog-body {
  flex: 1;
  min-height: 0;
  position: relative;
}
.molstar-dialog-status {
  display: flex;
  gap: 1rem;
  padding: 0.5rem 0.9rem;
  border-top: 1px solid var(--border);
}
.molstar-dialog-key {
  display: inline-flex;
  align-items: center;
  gap: 0.4rem;
}
.molstar-dialog-key::before {
  content: "";
  width: 0.75rem;
  height: 0.75rem;
  border-radius: 2px;
  background: var(--key-color);
}

/* MCP key reveal — the plaintext value shown once after generation. */
.mcp-key-reveal {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  margin: 0.75rem 0;
  padding: 0.6rem 0.75rem;
  background: var(--bg-input);
  border: 1px solid var(--border);
  border-radius: var(--radius);
}
.mcp-key-reveal code {
  flex: 1;
  font-family: var(--mono);
  font-size: 0.82rem;
  word-break: break-all;
  color: var(--text);
}
.mcp-key-reveal button {
  flex: 0 0 auto;
}
.picker-list {
  margin-top: 0.5rem;
  max-height: 60vh;
  overflow-y: auto;
  display: flex;
  flex-direction: column;
  gap: 0.4rem;
}
.picker-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.75rem;
  padding: 0.6rem 0.75rem;
  background: var(--bg);
  border: 1px solid var(--border);
  border-radius: var(--radius);
}
.picker-row .meta { font-size: 0.8rem; color: var(--text-muted); }
.picker-row button { padding: 0.35rem 0.7rem; font-size: 0.85rem; }
.picker-empty {
  text-align: center;
  color: var(--text-muted);
  padding: 1rem;
}

/* Inline "no job selected" row for the prior-job picker (Core tool) */
.prior-job-row {
  display: flex;
  align-items: center;
  gap: 0.75rem;
  margin-top: 0.25rem;
  padding: 0.5rem 0.75rem;
  background: var(--bg-input);
  border: 1px solid var(--border);
  border-radius: var(--radius);
}
.prior-job-row #myscaPriorJobDisplay {
  flex: 1;
  font-size: 0.85rem;
}
.prior-job-row button { padding: 0.35rem 0.7rem; font-size: 0.85rem; }

/* ---------- Home view: inline chat ---------- */
/* Shared by the home route and the thermostability workbench. Only the
   home partial carries #homeChat and the `.empty` landing state; the
   workbench keeps its own intro paragraph as a direct child, which is
   why the hero/suggestion hiding below is scoped to .home-chat-hero and
   .home-suggestions rather than to .home-chat-intro. */
/* .home-chat's height:100% only resolves if its parent has a definite
   height, and #appContent is auto. Establish it here — scoped to the chat
   views — rather than changing the height chain for the other ~30 views.
   Without this the log never scrolls and the composer drifts down the page
   instead of staying pinned. */
#appContent:has(> .home-chat) { height: 100%; }

.home-chat {
  display: flex;
  flex-direction: column;
  height: 100%;
  gap: 0.75rem;
}
.home-chat-intro { margin: 0; }
.chat-host {
  flex: 1 1 auto;
  display: flex;
  min-height: 0;
  overflow: hidden;
}

/* --- Landing state --- */
.home-chat-hero,
.home-suggestions { display: none; }

.home-chat.empty {
  justify-content: center;
  gap: 1.75rem;
  /* Sit the stack slightly above true center — optically centered rather
     than measured, and it leaves room for the transcript to grow down. */
  padding-bottom: 8vh;
}
.home-chat.empty .home-chat-hero { display: flex; }
.home-chat.empty .home-suggestions { display: flex; }
.home-chat.empty .chat-host { flex: 0 0 auto; overflow: visible; }
/* Nothing in the log yet, so the scroll region would only add dead space. */
.home-chat.empty .chat-log { display: none; }

.home-chat-hero {
  flex-direction: column;
  align-items: center;
  gap: 1.1rem;
}
.brand-mark {
  height: 2.5rem;
  width: auto;
  color: var(--text);
}
.wm-hero {
  font-size: 1.25rem;
  letter-spacing: 0.16em;
}
:root[data-brand="ranganathan"] .home-chat-hero .brand-mark {
  display: none;
}
:root[data-brand="ranganathan"] .home-chat-hero .wm-hero {
  display: inline-flex;
}
.home-chat-hero .home-chat-intro {
  max-width: 30rem;
  text-align: center;
}

.home-suggestions {
  flex-wrap: wrap;
  justify-content: center;
  gap: 0.5rem;
}
.home-suggestions .suggestion {
  background: transparent;
  color: var(--text-muted);
  border: 1px solid var(--border);
  border-radius: 999px;
  padding: 0.4rem 0.95rem;
  font-family: var(--mono);
  font-size: 0.65rem;
  font-weight: 400;
  text-transform: uppercase;
  letter-spacing: 0.14em;
  transition: color 120ms ease, border-color 120ms ease;
}
.home-suggestions .suggestion:hover {
  color: var(--chip);
  border-color: var(--chip);
  background: transparent;
  transform: none;
  box-shadow: none;
}
.home-suggestions .suggestion:nth-child(4n + 1) { --chip: var(--accent-1); }
.home-suggestions .suggestion:nth-child(4n + 2) { --chip: var(--accent-2); }
.home-suggestions .suggestion:nth-child(4n + 3) { --chip: var(--accent-3); }
.home-suggestions .suggestion:nth-child(4n + 4) { --chip: var(--accent-4); }

/* When #chatPanel is relocated into .chat-host, drop the drawer styling
 * (fixed position, slide-in transform, left border + shadow) and let it
 * fill the container as a normal flex child. */
.chat-panel.chat-inline {
  position: relative;
  top: auto;
  right: auto;
  bottom: auto;
  width: 100%;
  max-width: none;
  background: transparent;
  border-left: none;
  box-shadow: none;
  transform: none !important;
  transition: none;
  flex: 1 1 auto;
}
/* Inline, the transcript is a reading column rather than a full-bleed
   panel — the composer tracks the same measure so the two stay aligned. */
.chat-panel.chat-inline .chat-log,
.chat-panel.chat-inline .chat-shell,
.chat-panel.chat-inline .chat-hint,
.chat-panel.chat-inline .chat-panel-header {
  width: 100%;
  max-width: 44rem;
  margin-left: auto;
  margin-right: auto;
}
.chat-panel.chat-inline .chat-panel-header {
  background: transparent;
  border-bottom: none;
  justify-content: flex-end;
}
/* Inline, the page *is* the assistant — the drawer's title only earns its
   space when the panel is floating over another view. */
.chat-panel.chat-inline .chat-panel-title { display: none; }
/* ...except in history mode, where it's the only label on the list. */
.chat-panel.chat-inline:has(#chatHistoryView:not([hidden])) .chat-panel-title {
  display: block;
}
/* Landing state has one visible affordance: the composer — the header's
   controls act on a conversation that doesn't exist yet. History mode is
   the exception, and syncHomeChatState drops `.empty` for it so the back
   button stays reachable. */
.home-chat.empty .chat-panel-header { display: none; }
.home-chat.empty .chat-shell { margin-bottom: 0; }

/* ---------- Assistant chat panel (right-side drawer) ---------- */
.chat-toggle {
  position: fixed;
  bottom: 1.25rem;
  right: 1.25rem;
  background: var(--accent);
  color: var(--accent-ink);
  border: none;
  border-radius: 999px;
  padding: 0.6rem 1.1rem;
  font-size: 0.85rem;
  font-weight: 500;
  cursor: pointer;
  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.35);
  z-index: 49;
}
.chat-toggle:hover { background: var(--accent-hover); }

.chat-panel {
  position: fixed;
  top: 0;
  right: 0;
  bottom: 0;
  width: min(380px, 100vw);
  background: var(--bg);
  border-left: 1px solid var(--border);
  display: flex;
  flex-direction: column;
  z-index: 50;
  box-shadow: -6px 0 18px rgba(0, 0, 0, 0.35);
  transform: translateX(100%);
  transition: transform 200ms ease;
}
.chat-panel.open { transform: translateX(0); }

.chat-panel-header {
  display: flex;
  align-items: center;
  gap: 0.25rem;
  padding: 0.6rem 0.75rem;
  border-bottom: 1px solid var(--border);
  background: var(--bg-raised);
  flex: 0 0 auto;
}
.chat-panel-title {
  flex: 1;
  font-weight: 600;
  font-size: 0.85rem;
  color: var(--text);
}
.chat-icon-btn {
  background: transparent;
  border: none;
  color: var(--text-muted);
  cursor: pointer;
  font-size: 0.95rem;
  padding: 0.2rem 0.45rem;
  line-height: 1;
  border-radius: 4px;
}
.chat-icon-btn:hover {
  color: var(--text);
  background: var(--overlay-strong);
  transform: none;
  box-shadow: none;
}

/* The composer grows with its content, so it is a pill only while it is one
   line tall. `align-items: flex-end` keeps the `>` sitting with the last line
   rather than floating to the vertical middle of a tall box, and app.js sets
   .is-multiline once the field has wrapped, which relaxes the radius — a
   999px corner on a six-line box reads as a lozenge, not a text field. */
.chat-shell {
  display: flex;
  align-items: flex-end;
  gap: 0.6rem;
  margin: 0.5rem 0.75rem;
  background: var(--bg-raised);
  border: 2px solid var(--composer-border);
  border-radius: 999px;
  padding: 0.65rem 1.1rem;
  font-family: var(--mono);
  flex: 0 0 auto;
  transition: border-color 140ms ease, box-shadow 140ms ease,
              border-radius 140ms ease;
}
.chat-shell.is-multiline { border-radius: 1.1rem; }
.chat-shell:focus-within {
  border-color: var(--accent);
  box-shadow: 0 2px 12px var(--overlay-strong);
}
.chat-shell .prompt {
  color: var(--accent);
  user-select: none;
  /* Matches the textarea's own line box, so the marker and the first line of
     text sit on the same baseline at every height. */
  line-height: var(--chat-input-line-height);
}
.chat-shell textarea {
  flex: 1;
  background: transparent;
  color: var(--text);
  border: none;
  outline: none;
  font-family: inherit;
  font-size: 0.85rem;
  line-height: var(--chat-input-line-height);
  min-width: 0;
  /* Height is owned by app.js, which measures scrollHeight. `resize: none`
     because a drag handle would fight that, and `overflow-y: hidden` so no
     scrollbar shows until the cap is reached — app.js flips it to `auto`. */
  resize: none;
  overflow-y: hidden;
  padding: 0;
  margin: 0;
  display: block;
}
.chat-shell textarea::placeholder { color: var(--text-muted); }

/* A turn is running. The field stays live — typing the next question while
   reading the answer is what people do, and only SENDING has to wait — so this
   is the whole of the affordance: the caret marker goes quiet, saying the
   composer is not the thing being waited on. Deliberately not a dimmed or
   greyed field, which reads as disabled and is the state this replaced. */
.chat-shell.is-busy .prompt {
  color: var(--text-muted);
  opacity: 0.6;
}
.chat-blocked {
  margin: -0.15rem 0.75rem 0.6rem;
  font-size: 0.72rem;
  color: var(--text-muted);
  flex: 0 0 auto;
}
.chat-blocked[hidden] { display: none; }

.chat-hint {
  margin: 0 0.75rem 0.75rem;
  font-size: 0.72rem;
  color: var(--text-muted);
  text-align: left;
  flex: 0 0 auto;
}
/* No hint text -> collapse it entirely (no empty-gap above the input). */
.chat-hint:empty {
  display: none;
}

.chat-log {
  flex: 1 1 auto;
  overflow-y: auto;
  display: flex;
  flex-direction: column;
  gap: 0.4rem;
  padding: 0.75rem;
  min-height: 0;
}

/* ---------- Past-conversations view (history mode) ---------- */
.chat-history {
  flex: 1 1 auto;
  overflow-y: auto;
  display: flex;
  flex-direction: column;
  min-height: 0;
}
.chat-history-status {
  padding: 0.75rem;
  font-size: 0.78rem;
  color: var(--text-muted);
  text-align: center;
}
.chat-history-list {
  list-style: none;
  margin: 0;
  padding: 0;
}
.chat-history-list li {
  padding: 0.6rem 0.85rem;
  border-bottom: 1px solid var(--border);
  cursor: pointer;
  display: flex;
  flex-direction: column;
  gap: 0.2rem;
  font-size: 0.82rem;
}
.chat-history-list li:hover { background: var(--overlay); }
.chat-history-list li.active { background: var(--accent-soft); }
.chat-history-preview {
  color: var(--text);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.chat-history-meta {
  font-size: 0.7rem;
  color: var(--text-muted);
}
.chat-message {
  padding: 0.5rem 0.75rem;
  border-radius: var(--radius);
  border: 1px solid var(--border);
  font-size: 0.85rem;
  line-height: 1.45;
  white-space: pre-wrap;
  word-wrap: break-word;
  max-width: 90%;
}
.chat-message.user {
  background: var(--bg-input);
  border-radius: var(--radius-lg);
  align-self: flex-end;
  max-width: 80%;
}
/* The assistant gets no bubble. Its turns are long rendered markdown —
   headings, lists, tables — and a card around that reads as a quotation
   of the page rather than the page itself. Only the user's turns are
   short enough to want a container, which is also what distinguishes the
   two speakers now that the assistant has no frame. */
.chat-message.assistant {
  background: transparent;
  border: none;
  padding: 0.25rem 0;
  align-self: stretch;
  max-width: 100%;
  /* assistant turns hold rendered markdown (real block elements), so
     the pre-wrap used for plain-text user/error bubbles would add stray
     gaps between blocks. Let the elements own their own spacing. */
  white-space: normal;
}
/* ---- Rendered-markdown elements inside assistant bubbles ---- */
.chat-message.assistant > :first-child { margin-top: 0; }
.chat-message.assistant > :last-child { margin-bottom: 0; }
.chat-message.assistant p { margin: 0.5rem 0; }
.chat-message.assistant ul,
.chat-message.assistant ol {
  margin: 0.5rem 0;
  padding-left: 1.4rem;
}
.chat-message.assistant li { margin: 0.15rem 0; }
/* Plots the agent generates. A matplotlib figure is far wider than this
   column, and an unconstrained <img> widened the whole chat log into a
   horizontal scroller. Scale to the column instead; the image is wrapped in a
   link to the full-size original, which is also how you save it. */
.chat-message.assistant img {
  display: block;
  max-width: 100%;
  height: auto;
  margin: 0.5rem 0;
  border-radius: 6px;
  border: 1px solid var(--border);
  /* Plots are rendered on white; keep that readable in the dark theme rather
     than leaving a bright rectangle floating on a dark panel. */
  background: #fff;
}
.chat-message.assistant a:has(> img) {
  display: inline-block;
  max-width: 100%;
  cursor: zoom-in;
}
.chat-message.assistant a:has(> img):hover img { border-color: var(--accent); }
.chat-message.assistant li > ul,
.chat-message.assistant li > ol { margin: 0.15rem 0; }
.chat-message.assistant h1,
.chat-message.assistant h2,
.chat-message.assistant h3,
.chat-message.assistant h4 {
  margin: 0.75rem 0 0.35rem;
  line-height: 1.25;
  font-weight: 600;
}
.chat-message.assistant h1 { font-size: 1.15rem; }
.chat-message.assistant h2 { font-size: 1.05rem; }
.chat-message.assistant h3 { font-size: 0.95rem; }
.chat-message.assistant h4 { font-size: 0.88rem; }
.chat-message.assistant blockquote {
  margin: 0.5rem 0;
  padding: 0.1rem 0.75rem;
  border-left: 3px solid var(--border-strong);
  color: var(--text-muted);
}
.chat-message.assistant hr {
  border: none;
  border-top: 1px solid var(--border);
  margin: 0.75rem 0;
}
.chat-message.assistant table {
  border-collapse: collapse;
  margin: 0.5rem 0;
  font-size: 0.8rem;
  display: block;
  overflow-x: auto;
}
.chat-message.assistant th,
.chat-message.assistant td {
  border: 1px solid var(--border);
  padding: 0.3rem 0.55rem;
  text-align: left;
}
.chat-message.assistant th {
  background: var(--bg-input);
  font-weight: 600;
}
.chat-message.error {
  background: var(--error-soft);
  border-color: var(--error);
  color: var(--error);
  align-self: flex-start;
}
/* Why a turn stopped, when it stopped for a reason of ours rather than
   because the model was finished — the round cap, the clock, a turn already
   running. Deliberately not styled as an assistant message: this is the
   portal talking, and the previous version of it was written into the
   transcript in the model's voice, which left Claude apologising on the next
   turn for words it had never said. */
.chat-message.notice {
  background: transparent;
  border: 1px dashed var(--border-strong);
  color: var(--text-muted);
  font-size: 0.8rem;
  align-self: flex-start;
}
.chat-message pre {
  background: var(--bg-input);
  border: 1px solid var(--border);
  border-radius: 4px;
  padding: 0.5rem 0.65rem;
  margin: 0.35rem 0;
  overflow-x: auto;
  font-size: 0.78rem;
  white-space: pre;
}
.chat-message code {
  font-family: var(--mono);
  font-size: 0.85em;
}
.chat-message :not(pre) > code {
  background: var(--bg-input);
  padding: 0.05em 0.3em;
  border-radius: 3px;
}
.chat-message pre code { display: block; font-size: inherit; }
.chat-message a {
  color: var(--link);
  text-decoration: underline;
}
.chat-message strong { font-weight: 600; }
.chat-message em { font-style: italic; }
/* Tool calls are a collapsed <details> disclosure: de-emphasized by
   default so the prose answer leads, with the full JSON one click away. */
.chat-tool {
  align-self: flex-start;
  max-width: 90%;
  font-size: 0.72rem;
  color: var(--text-muted);
  border-left: 2px solid var(--border-strong);
  margin: 0.05rem 0;
}
.chat-tool > summary {
  cursor: pointer;
  list-style: none;
  padding: 0.15rem 0.5rem;
  font-family: var(--mono);
  user-select: none;
}
.chat-tool > summary::-webkit-details-marker { display: none; }
.chat-tool > summary:hover { color: var(--text); }
.chat-tool[open] { border-left-color: var(--accent); }
.chat-tool.error { border-left-color: var(--error); }
.chat-tool.error > summary { color: var(--error); }
.chat-tool > pre {
  margin: 0.1rem 0 0.3rem;
  padding: 0.35rem 0.6rem;
  background: var(--bg-input);
  border-radius: 4px;
  font-family: var(--mono);
  font-size: 0.7rem;
  white-space: pre-wrap;
  word-wrap: break-word;
  overflow-x: auto;
}
/* A file a tool read, shown verbatim.
   Deliberately NOT styled like .chat-tool: that is a debugging affordance,
   collapsed and dimmed, and burying a file's contents in it is what let the
   assistant's retelling of a protein sequence be the only version anyone read.
   This is the authoritative copy, so it is open, full contrast, and monospace
   at a size you can actually check a residue against. */
.chat-file {
  align-self: flex-start;
  max-width: 100%;
  width: 100%;
  margin: 0.1rem 0 0.4rem;
  border: 1px solid var(--border);
  border-radius: 6px;
  overflow: hidden;
}
.chat-file-head {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  padding: 0.3rem 0.5rem;
  background: var(--bg-input);
  border-bottom: 1px solid var(--border);
  font-size: 0.7rem;
}
.chat-file-path {
  font-family: var(--mono);
  color: var(--text);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
/* Pushes the action to the right and is the first thing to give up room. */
.chat-file-meta {
  color: var(--text-muted);
  margin-right: auto;
  white-space: nowrap;
}
.chat-file-copy {
  flex: none;
  padding: 0.1rem 0.5rem;
  font-size: 0.7rem;
  font-family: inherit;
  color: var(--text-muted);
  background: transparent;
  border: 1px solid var(--border-strong);
  border-radius: 4px;
  cursor: pointer;
  text-decoration: none;
}
.chat-file-copy:hover { color: var(--text); border-color: var(--accent); }
/* `pre`, not `pre-wrap`: a wrapped sequence invites counting a residue into
   the wrong column, and a soft-wrapped line copies with the wrap in it on
   some browsers. Long lines scroll sideways instead. */
.chat-file-body {
  margin: 0;
  padding: 0.45rem 0.6rem;
  max-height: 22rem;
  overflow: auto;
  background: var(--bg);
  font-family: var(--mono);
  font-size: 0.72rem;
  line-height: 1.5;
  white-space: pre;
  tab-size: 4;
}
.chat-file-note {
  padding: 0.35rem 0.6rem;
  font-size: 0.7rem;
  color: var(--text-muted);
}

.chat-pending {
  align-self: flex-start;
  font-size: 0.8rem;
  font-style: italic;
  color: var(--text-muted);
}

/* Thinking indicator — the two brand solids, pulsing out of phase and
   cycling the palette. Replaces the italic "Thinking…" while a turn is in
   flight; the plain .chat-pending text is still used for the (much
   shorter) conversation-load wait. */
.chat-thinking {
  align-self: flex-start;
  display: flex;
  align-items: center;
  gap: 0.7rem;                 /* marks -> label */
  padding: 0.35rem 0;
}
.chat-thinking-marks {
  display: flex;
  align-items: center;
  gap: 0.35rem;                /* circle -> hexagon; half the label gap */
}
/* Box sized to the shape at REST, not at the pulse's peak. A box sized for
   the peak leaves ~0.36rem of empty box on each side for most of the cycle,
   and a flex gap cannot reach inside a box to remove it — so the marks read
   as far apart no matter how tight the gap gets. The peak is carried by the
   keyframes' scale instead, which overflow: visible lets escape the box.
   Box and scales are paired: 1.1rem x 0.8 and 1.1rem x 1.45 render the same
   sizes the old 1.6rem x 0.55 and x 1 did. Change one, change the other. */
.chat-thinking svg {
  height: 1.1rem;
  width: 1.1rem;
  overflow: visible;
}
.chat-thinking .mark-a,
.chat-thinking .mark-b {
  transform-origin: center;
  transform-box: fill-box;
}
.chat-thinking .mark-a { animation: nm-pulse-a 1.8s ease-in-out infinite; }
.chat-thinking .mark-b { animation: nm-pulse-b 1.8s ease-in-out infinite; }
.chat-thinking .thinking-label {
  font-family: var(--mono);
  font-size: 0.62rem;
  text-transform: uppercase;
  letter-spacing: 0.18em;
  color: var(--text-muted);
}

/* Rest 0.8 / peak 1.45 against a 1.1rem box — see .chat-thinking svg. These
   are the old 0.55 / 1 rescaled by 1.6/1.1, so the rendered sizes are
   unchanged; only the empty box around them is gone. */
@keyframes nm-pulse-a {
  0%, 100% { transform: scale(0.8);  fill: var(--accent-1); }
  25%      { transform: scale(1.45); fill: var(--accent-4); }
  50%      { transform: scale(0.8);  fill: var(--accent-2); }
  75%      { transform: scale(0.8);  fill: var(--accent-3); }
}
@keyframes nm-pulse-b {
  0%, 100% { transform: scale(0.8);  fill: var(--accent-3); }
  25%      { transform: scale(0.8);  fill: var(--accent-2); }
  50%      { transform: scale(0.8);  fill: var(--accent-4); }
  75%      { transform: scale(1.45); fill: var(--accent-1); }
}
@media (prefers-reduced-motion: reduce) {
  .chat-thinking .mark-a,
  .chat-thinking .mark-b {
    animation: none;
    fill: var(--text-muted);
  }
}

.error-banner {
  margin: 1rem auto;
  max-width: 36rem;
  padding: 0.6rem 0.75rem;
  background: var(--error-soft);
  border: 1px solid var(--error);
  color: var(--error);
  border-radius: var(--radius);
  font-size: 0.9rem;
  text-align: center;
}

.srOnly {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip: rect(0 0 0 0);
  border: 0;
}

/* ---------- Workflow runner ---------- */
/* The shape every workflow page uses: steps stacked vertically, screens laid
   out horizontally within a step, both on CSS scroll-snap so a swipe and a
   button press settle on the same stops. The controls are injected by
   js/workflows/_runner.js — the view file carries content only. */
.wf {
  position: relative;
  height: 100%;
  /* Pages and screens are sized against this box, so it must not scroll
     itself; the two inner scrollers do all of it. */
  overflow: hidden;
  background: var(--bg-raised);
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
}
.wf-pages {
  height: 100%;
  /* Steps advance by Back/Next (and PageUp/PageDown), never by wheel or swipe.
     A workflow is a sequence, and a flick that lands three steps on from where
     you were reading is not navigation — it also made the `advanceable` gate
     meaningless, since Next could be greyed out while scrolling walked straight
     past it.

     `hidden`, NOT `clip`: an overflow-hidden box is still scrollable
     programmatically, which is the whole mechanism here — _runner.js's
     scrollTo() still moves it and scroll-snap still settles it. `clip` would
     freeze the container on step 1 with no way out. */
  overflow-y: hidden;
  /* Kept for the horizontal axis, which is still user-scrollable: without this,
     scrolling past the last screen hands the leftover delta to .app-main and
     the whole workflow drifts inside its frame. */
  overscroll-behavior: contain;
  scroll-snap-type: y mandatory;
  scroll-behavior: smooth;
  scrollbar-width: none;
}
.wf-page {
  height: 100%;
  scroll-snap-align: start;
  /* A fast flick would otherwise carry through several steps at once; a
     workflow is meant to be read one step at a time. */
  scroll-snap-stop: always;
  display: flex;
  overflow-x: auto;
  overscroll-behavior-x: contain;
  scroll-snap-type: x mandatory;
  scroll-behavior: smooth;
  scrollbar-width: none;
}
.wf-pages::-webkit-scrollbar,
.wf-page::-webkit-scrollbar { width: 0; height: 0; }
.wf-screen {
  flex: 0 0 100%;
  height: 100%;
  scroll-snap-align: start;
  scroll-snap-stop: always;
  overflow-y: auto;
  display: flex;
  flex-direction: column;
  align-items: center;
  /* `safe` keeps a screen taller than the frame reachable — plain centering
     would push its top out of the scrollport where nothing can reach it. The
     unprefixed line is the fallback for browsers that drop the safe keyword. */
  justify-content: center;
  justify-content: safe center;
  /* Clearance for the controls, which float above every screen. */
  padding: 3.75rem 4.25rem 5.25rem;
}
.wf-body { max-width: 40rem; }
.wf-body h2 {
  font-size: 1.5rem;
  margin-bottom: 0.75rem;
}
.wf-eyebrow {
  margin: 0 0 0.35rem;
  font-family: var(--mono);
  font-size: 0.62rem;
  text-transform: uppercase;
  letter-spacing: 0.18em;
  color: var(--accent);
}

/* Controls float over the screens; the layer itself must stay transparent to
   pointer events or it would swallow the wheel and swipe gestures. */
.wf-controls {
  position: absolute;
  inset: 0;
  pointer-events: none;
}
.wf-controls > * { pointer-events: auto; }
.wf-counter {
  position: absolute;
  top: 1rem;
  right: 1.25rem;
  font-family: var(--mono);
  font-size: 0.62rem;
  text-transform: uppercase;
  letter-spacing: 0.16em;
  color: var(--text-muted);
}

/* A debug aid, in the corner opposite the step counter and dressed like it:
   same type, same baseline, so it reads as chrome rather than as one of the
   screen's controls. Off on every mount — it lifts the gate that keeps a
   workflow a sequence, so it should look available and unremarkable, not
   inviting. */
.wf-scroll-toggle {
  position: absolute;
  top: 1rem;
  left: 1.25rem;
  padding: 0.15rem 0.45rem;
  background: none;
  border: 1px solid transparent;
  border-radius: var(--radius);
  font-family: var(--mono);
  font-size: 0.62rem;
  text-transform: uppercase;
  letter-spacing: 0.16em;
  color: var(--text-muted);
}
.wf-scroll-toggle:hover {
  border-color: var(--border);
  color: var(--text);
  transform: none;
  box-shadow: none;
}
.wf-scroll-toggle.selected {
  border-color: var(--accent);
  color: var(--accent);
}

/* What the toggle buys: the wheel moves between steps again. Snapping stays on,
   so it still lands on a step rather than halfway between two. */
.wf.wf-scroll-free .wf-pages { overflow-y: auto; }

/* Back / Next. The transform is positioning, so every state that the global
   button rules re-transform has to restate it. */
.wf-nav {
  position: absolute;
  left: 50%;
  transform: translateX(-50%);
  display: inline-flex;
  align-items: center;
  gap: 0.45rem;
  padding: 0.4rem 1rem;
  background: var(--bg-input);
  border: 1px solid var(--border);
  border-radius: 999px;
  color: var(--text);
  font-size: 0.82rem;
  font-weight: 500;
}
.wf-nav:hover,
.wf-nav:active {
  transform: translateX(-50%);
  color: var(--accent);
  border-color: var(--border-strong);
  box-shadow: none;
}
.wf-nav[hidden] { display: none; }
.wf-up { top: 0.85rem; }
.wf-down { bottom: 1rem; }
.wf-caret { font-size: 0.55rem; line-height: 1; color: var(--text-muted); }
.wf-nav:hover .wf-caret { color: var(--accent); }

.wf-edge {
  position: absolute;
  top: 50%;
  transform: translateY(-50%);
  width: 2.3rem;
  height: 2.3rem;
  padding: 0;
  display: grid;
  place-items: center;
  background: transparent;
  border: 1px solid var(--border);
  border-radius: 50%;
  color: var(--text);
  font-size: 1.15rem;
  font-weight: 400;
  line-height: 1;
}
.wf-edge:hover,
.wf-edge:active { transform: translateY(-50%); box-shadow: none; }
.wf-edge:hover:not(:disabled) {
  color: var(--accent);
  border-color: var(--border-strong);
  background: var(--overlay);
}
.wf-edge:disabled { opacity: 0.22; cursor: default; }
.wf-edge[hidden] { display: none; }
.wf-left { left: 1rem; }
.wf-right { right: 1rem; }

/* Screen position within the current step. Sits above the Next control. */
.wf-dots {
  position: absolute;
  bottom: 3.35rem;
  left: 50%;
  transform: translateX(-50%);
  display: flex;
  gap: 0.45rem;
}
.wf-dots[hidden] { display: none; }
.wf-dot {
  width: 0.45rem;
  height: 0.45rem;
  padding: 0;
  border: none;
  border-radius: 50%;
  background: var(--border-strong);
}
.wf-dot:hover,
.wf-dot:active { transform: none; box-shadow: none; background: var(--text-muted); }
.wf-dot.active,
.wf-dot.active:hover { background: var(--accent); }

@media (prefers-reduced-motion: reduce) {
  .wf-pages,
  .wf-page { scroll-behavior: auto; }
}

/* Workflow screens that hold a form rather than prose. The tool pages' own
   field styles are scoped under .workflow, which a workflow screen is not, so
   the ones a step actually uses are restated here against .wf-body. */
.wf-body-wide { max-width: 52rem; width: 100%; }
.wf-columns {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 1.25rem;
  align-items: start;
  margin-top: 1rem;
}
@media (max-width: 40rem) {
  .wf-columns { grid-template-columns: 1fr; }
}
.wf-body .field {
  display: block;
  margin-top: 0.75rem;
  font-size: 0.85rem;
  color: var(--text-muted);
}
.wf-body .field-row {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
  gap: 0.75rem;
  margin-top: 0.75rem;
}
.wf-body .field-row .field { margin-top: 0; }
.wf-body textarea,
.wf-body input[type="text"],
.wf-body input[type="number"],
.wf-body select {
  width: 100%;
  margin-top: 0.25rem;
  background: var(--bg-input);
  color: var(--text);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 0.55rem 0.6rem;
  font-family: var(--mono);
  font-size: 0.85rem;
}
.wf-body select { padding-right: 0.75rem; } /* moves chevron in from edge of the box */
.wf-body textarea { resize: vertical; }
.wf-body textarea:focus,
.wf-body input:focus,
.wf-body select:focus {
  outline: none;
  border-color: var(--border-strong);
  box-shadow: 0 0 0 2px var(--ring-soft);
}
.wf-body .arg-group {
  margin-top: 1.25rem;
  padding: 0.9rem 1rem 1rem;
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
}
.wf-body .arg-group > legend,
.wf-body .arg-group > summary {
  padding: 0 0.4rem;
  font-family: var(--mono);
  font-size: 0.62rem;
  text-transform: uppercase;
  letter-spacing: 0.16em;
  color: var(--text-muted);
}
.wf-body details.wf-advanced > summary { cursor: pointer; }

/* The intro's one call to action. Inherits the button look at a size that
   suits a screen with nothing else on it. */
.wf-cta {
  display: block;
  margin: 1.75rem auto 0;
  padding: 0.9rem 2.2rem;
  font-size: 1.05rem;
}

/* Action rows. Buttons sit in the flow of the screen, clear of the runner's
   own controls, which are pinned to the frame. */
.wf-actions {
  display: flex;
  flex-wrap: wrap;
  gap: 0.6rem;
  margin-top: 1.25rem;
}
.wf-actions-stacked { flex-direction: column; align-items: flex-start; }

/* Two routes off one screen, weighted the same. `flex: 1 1 0` rather than
   `auto` so the widths come from the row and not from the labels — "Get
   started" is half the length of "Jump to generation", and sizing to content
   makes a matched pair look like a mistake. The margin reset is for wf-cta,
   which centres itself with `margin: … auto` when it stands alone. */
.wf-actions-pair { margin-top: 1.75rem; }
.wf-actions-pair > button {
  flex: 1 1 0;
  /* Zero, not wf-cta's `1.75rem auto`: the row above provides the separation
     now, and `auto` side margins would fight the equal-width flex basis. */
  margin: 0;
  padding: 0.9rem 1.5rem;
  font-size: 1.05rem;
  text-align: center;
}

/* Offered but not yet available. Greyed like a disabled control, and still
   clickable — the click is what explains it (see intro.js). `not-allowed`
   rather than `default`, because the cursor is the only hint before the press
   that the answer will be a sentence rather than a screen.
   Scoped to buttons: a bare attribute selector would also catch the API
   reference's vendored tab markup, which sets aria-disabled on list items. */
button[aria-disabled="true"] {
  opacity: 0.5;
  cursor: not-allowed;
}
button[aria-disabled="true"]:hover {
  transform: none;
  box-shadow: none;
}

/* Download control under the sequence preview. A plain button toggling a short
   text list — no <details>, so there is no disclosure marker to suppress and no
   per-browser summary styling. The list is in the flow, so opening it shifts
   the status line down rather than floating over it. */
.wf-download {
  margin-top: 1rem;
  text-align: right;
}
.wf-download-options {
  list-style: none;
  margin: 0.5rem 0 0;
  padding: 0;
}
/* Formats read as text. They stay <button>s so they remain keyboard-reachable
   and announce as controls; only the styling is stripped back. */
.wf-download-options button {
  padding: 0.2rem 0;
  background: none;
  color: var(--accent);
  border-radius: 0;
  font-size: 0.88rem;
  font-weight: 400;
}
.wf-download-options button:hover {
  text-decoration: underline;
  transform: none;
  box-shadow: none;
}

/* Fact tables — the "here is what this job was" blocks each step opens with. */
.wf-summary { margin-top: 1rem; }
/* Host for the live job log on a waiting screen. Empty until a job exists, so
   `:not(:empty)` keeps it from reserving a gap on a screen with nothing to
   show — a workflow step is a full viewport page, and blank space in the
   middle of one reads as something failing to load. */
.wf-log:not(:empty) { margin-top: 1rem; }
.wf-facts {
  display: grid;
  grid-template-columns: max-content 1fr;
  gap: 0.35rem 1rem;
  margin: 0;
  font-size: 0.85rem;
}
.wf-facts dt {
  font-family: var(--mono);
  font-size: 0.68rem;
  text-transform: uppercase;
  letter-spacing: 0.12em;
  color: var(--text-muted);
  align-self: center;
}
.wf-facts dd { margin: 0; word-break: break-word; }
/* A fact that is true but probably not what was intended — the generate
   screen's decoder when no override is picked, so the run would sample the
   stock bundle rather than the model just trained. Red because it wants a
   second look, not because anything has gone wrong; --error is the palette's
   only "stop and read this" colour and inventing a second would be worse than
   borrowing it. */
.wf-facts dd.wf-fact-warn { color: var(--error); }

/* Said beside the fact, not only in it: red text answers "is this right?" for
   someone already reading the decoder line, and this answers it for someone
   heading for the Generate button. */
.wf-inline-warning {
  margin: 0.4rem 0 0;
  padding: 0.4rem 0.55rem;
  border: 1px solid var(--error);
  border-left-width: 3px;
  border-radius: var(--radius);
  background: var(--error-soft);
  color: var(--error);
  font-size: 0.8rem;
}

.wf-body .status {
  margin-top: 1rem;
  padding: 0.55rem 0.7rem;
  border: 1px solid var(--border);
  border-left-width: 3px;
  border-radius: var(--radius);
  background: var(--bg);
  font-size: 0.85rem;
  line-height: 1.5;
}
.wf-body .status.ok { border-left-color: var(--accent-2); }
.wf-body .status.error {
  border-color: var(--error);
  color: var(--error);
  background: var(--error-soft);
}

/* A list beside a detail pane — the shape "pick one of these, look at it" takes
   on a workflow screen. Collapses to stacked below the columns breakpoint,
   where side-by-side leaves neither half usable. */
.wf-split {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr);
  gap: 1rem;
  align-items: start;
  margin-top: 1rem;
}
@media (max-width: 40rem) {
  .wf-split { grid-template-columns: 1fr; }
}

/* ---------- the prompt-list control (BioM3 generation, step 6) ---------- */

.wf-prompt-heading {
  margin: 1.5rem 0 0.75rem;
  font-size: 0.95rem;
  font-weight: 500;
}

/* A step with a rail: everything down the middle, one standing list beside it.
   Wider than wf-body-wide because the rail is additive — reusing 52rem for both
   columns would take the width back out of the part doing the work, which is
   the compression this layout exists to remove. */
.wf-body-rail {
  max-width: 70rem;
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(14rem, 19rem);
  grid-template-areas:
    "head head"
    "main side";
  column-gap: 1.75rem;
  align-items: start;
}
.wf-rail-head { grid-area: head; }
.wf-rail-main { grid-area: main; min-width: 0; }
.wf-rail-side {
  grid-area: side;
  min-width: 0;
  /* Sticks to the top of the screen's scrollport while the main column runs
     past it. `align-self: start` matters as much as the sticky: a stretched
     grid item is as tall as its row and has nothing left to stick within. */
  position: sticky;
  top: 0.5rem;
  align-self: start;
  display: flex;
  flex-direction: column;
  min-height: 0;
  /* Bounded by the screen, not by its contents: the rail is what the list
     scrolls *within*. The subtrahend is the screen's own top and bottom padding
     (3.75rem + 5.25rem, clearance for the floating controls) plus the sticky
     offset — without it a long list runs under the Back/Next chrome. */
  max-height: calc(100vh - 10rem);
}
/* The list takes whatever height the rail has left after the count and the
   hint, and scrolls inside it. The base rule's 16rem cap was never reached in
   the old layout — the list shared a grid row with the builder, so the row grew
   to whichever was taller and the list simply got longer. */
.wf-rail-side .wf-prompt-list {
  flex: 1 1 auto;
  max-height: none;
  min-height: 6rem;
}
.wf-rail-side > .wf-rail-title,
.wf-rail-side > .hint,
.wf-rail-side > .wf-prompt-preview { flex: 0 0 auto; }

/* Title and count on one line: the count is a number beside a heading, not a
   sentence restating it. */
.wf-rail-title {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 0.5rem;
  margin-bottom: 0.4rem;
}
.wf-rail-title h3 {
  margin: 0;
  font-size: 0.95rem;
  font-weight: 500;
}

/* The selected prompt, in full and read-only. The list clamps every row to two
   lines, so this is the only place a long caption can be read — and it is a
   div rather than a textarea because editing has exactly one home, the entry
   box on the left. */
.wf-prompt-preview {
  margin-top: 0.5rem;
  padding: 0.5rem 0.6rem;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--bg-input);
  font-size: 0.78rem;
  line-height: 1.5;
  white-space: pre-wrap;
  overflow-wrap: anywhere;
  /* Bounded and scrollable: a prompt has no length limit, and this sits under
     a list that is already competing for the rail's height. */
  max-height: 11rem;
  overflow-y: auto;
}
.wf-prompt-preview.is-empty {
  color: var(--text-muted);
  font-style: italic;
  background: none;
  border-style: dashed;
}
/* One column once the rail would be narrower than its own content. The list
   then follows the work rather than sitting beside it, which is the right
   order to read them in. */
@media (max-width: 62rem) {
  .wf-body-rail {
    grid-template-columns: minmax(0, 1fr);
    grid-template-areas: "head" "main" "side";
  }
  .wf-rail-side { position: static; }
}

/* The + sits inside the textarea's bottom-right corner, so the gesture is
   "write here, then commit it" rather than two unrelated controls. */
.wf-prompt-entry { position: relative; }
.wf-prompt-entry textarea { margin-top: 0; }
.wf-prompt-add {
  position: absolute;
  right: 0.55rem;
  bottom: 0.55rem;
  width: 1.9rem;
  height: 1.9rem;
  padding: 0;
  border-radius: 50%;
  font-size: 1.1rem;
  line-height: 1;
  display: grid;
  place-items: center;
}

.wf-prompt-count {
  /* No bottom margin: it sits on the title's baseline now, not above the list
     as its own line. */
  margin: 0;
  font-family: var(--mono);
  font-size: 0.62rem;
  text-transform: uppercase;
  letter-spacing: 0.16em;
  color: var(--text-muted);
}

.wf-prompt-list,
.wf-seq-list {
  list-style: none;
  margin: 0;
  padding: 0;
  /* Scrolls rather than growing: n is unbounded and the screen is not. */
  max-height: 16rem;
  overflow-y: auto;
  border: 1px solid var(--border);
  border-radius: var(--radius);
}
.wf-prompt-list:empty { border-style: dashed; min-height: 4rem; }

.wf-prompt-row {
  display: flex;
  align-items: flex-start;
  gap: 0.3rem;
  /* Separated rather than merely divided. Two prompts are two-line clamps of
     similar-looking text, and a 1px hairline between them read as one block:
     the gap and the card edge are what make the boundary legible at a glance. */
  margin: 0.3rem;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--bg-input);
}
.wf-prompt-row:hover { border-color: var(--border-hover); }
.wf-prompt-row.editing {
  background: var(--ring-soft);
  border-color: var(--accent);
}

/* Buttons, not divs: selecting a prompt to edit and removing one are both
   actions, and both need to be reachable from the keyboard. */
.wf-prompt-body,
.wf-seq-row {
  flex: 1 1 auto;
  min-width: 0;
  text-align: left;
  background: none;
  border: 0;
  border-radius: 0;
  padding: 0.5rem 0.6rem;
  font-size: 0.8rem;
  color: var(--text);
  box-shadow: none;
}
.wf-prompt-body {
  /* Two lines of the prompt, so a list of ten stays scannable. */
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}
.wf-prompt-body:hover,
.wf-seq-row:hover { background: var(--bg-input); transform: none; }

.wf-prompt-remove {
  flex: 0 0 auto;
  background: none;
  border: 0;
  box-shadow: none;
  padding: 0.5rem 0.6rem;
  font-size: 1rem;
  line-height: 1;
  color: var(--text-muted);
}
.wf-prompt-remove:hover {
  color: var(--error);
  background: none;
  transform: none;
}

/* ---------- the projection's set list (step 9) ----------
   Same card as a prompt row, but stacked: the head names the set and the row
   under it carries that set's own settings. A prompt is one string and fits on
   a line; a set is a thing plus three decisions about it. */

.wf-set-add { margin-top: 0.75rem; }

.wf-set-row {
  margin: 0.3rem;
  padding: 0.5rem 0.6rem 0.6rem;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--bg-input);
}
.wf-set-row:hover { border-color: var(--border-hover); }

.wf-set-head {
  display: flex;
  align-items: baseline;
  gap: 0.5rem;
}
/* The name takes the room; the kind sits beside it and the remove button is
   pushed to the far edge, where it is in every other list on these screens. */
.wf-set-head > strong { flex: 0 1 auto; min-width: 0; word-break: break-word; }
.wf-set-head > .hint { flex: 1 1 auto; margin: 0; font-size: 0.75rem; }
.wf-set-head > .wf-prompt-remove { margin: -0.35rem -0.35rem 0 0; }

/* Tighter than a form's: these repeat once per row, and a full-height field
   row per set would push the button off the screen at four sets. */
.wf-set-row .field-row { margin-top: 0.4rem; gap: 0.5rem; }
.wf-set-row .field { font-size: 0.75rem; }
.wf-set-row .field.checkbox { align-self: end; padding-bottom: 0.5rem; }

/* ---------- generated-sequence browser (step 8, screen 1) ---------- */

.wf-seq-list { max-height: 22rem; }
.wf-seq-list li { border-bottom: 1px solid var(--border); }
.wf-seq-list li:last-child { border-bottom: 0; }
.wf-seq-row {
  display: flex;
  justify-content: space-between;
  gap: 0.6rem;
  width: 100%;
}
.wf-seq-row.selected,
.wf-seq-list li .wf-seq-row.selected { background: var(--ring-soft); }
.wf-seq-name { font-family: var(--mono); font-size: 0.78rem; }

.wf-seq-preview {
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 0.8rem 0.9rem;
}
/* The prompt behind the selected sequence, as a row of the facts list. It is
   longer than the other values, so it is set apart by a rule rather than by the
   margin it used to carry as a standalone note — a top margin here would drop
   it below the `<dt>` it is meant to sit beside. */
.wf-seq-prompt {
  padding-left: 0.6rem;
  border-left: 2px solid var(--border);
  white-space: pre-wrap;
}
/* Residues wrap on any character — a 300-mer has no spaces to break at, and
   without this the pane scrolls sideways forever. */
.wf-seq-residues {
  /* Bottom, not top: the block sits above the facts now. */
  margin: 0 0 0.7rem;
  padding: 0.55rem 0.6rem;
  max-height: 10rem;
  overflow-y: auto;
  background: var(--bg-input);
  border-radius: var(--radius);
  font-family: var(--mono);
  font-size: 0.72rem;
  line-height: 1.6;
  white-space: pre-wrap;
  overflow-wrap: anywhere;
}

/* An indeterminate bar. Deliberately not a percentage: a job reports its state,
   not its progress, so a filling bar would be showing elapsed time dressed up
   as completion. */
.wf-progress {
  margin-top: 1rem;
  height: 3px;
  border-radius: 999px;
  background: var(--border);
  overflow: hidden;
}
.wf-progress-fill {
  width: 35%;
  height: 100%;
  border-radius: inherit;
  background: var(--accent);
  animation: wf-progress-slide 1.4s ease-in-out infinite;
}
@keyframes wf-progress-slide {
  0% { transform: translateX(-100%); }
  100% { transform: translateX(340%); }
}
@media (prefers-reduced-motion: reduce) {
  .wf-progress-fill { animation: none; width: 100%; opacity: 0.5; }
}
/* Queued behind something else — the automatic chain holds alignment until
   folding lands. Present, so the screen does not read as idle; still, so it
   does not claim a job is running that has not been submitted. */
.wf-progress.is-waiting .wf-progress-fill {
  animation: none;
  transform: none;
  opacity: 0.55;
}

/* ---------- results widget (js/results/) ---------- */
/* Global rather than scoped into a partial the way .wb-* is: the same widget
   mounts on six workflow screens, and a copy of these rules per partial is six
   places for one of them to fall behind. */

.rw-host:empty { display: none; }
.rw-host { margin-top: 1.25rem; }

/* The summary and, for a grouped kind, the collapse-all control beside it. */
.rw-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 0.75rem;
}
.rw-summary {
  margin: 0;
  font-family: var(--mono);
  font-size: 0.62rem;
  text-transform: uppercase;
  letter-spacing: 0.16em;
  color: var(--text-muted);
}
.rw-fold-all {
  flex: none;
  padding: 0;
  border: 0;
  background: none;
  cursor: pointer;
  font: inherit;
  font-family: var(--mono);
  font-size: 0.62rem;
  text-transform: uppercase;
  letter-spacing: 0.16em;
  color: var(--text-muted);
}

.rw-fold-all:hover { color: var(--text); }
.rw-fold-all:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.rw-note { margin: 0.4rem 0 0; }
.rw-note.error { color: var(--error); }

/* The detail side carries a plot, a fact list and a 3D canvas, so it gets the
   larger share — the reverse of the sequence browser, where the list is what
   is being read. */
.rw-split { grid-template-columns: minmax(0, 0.8fr) minmax(0, 1.4fr); }

/* The candidate table. The scroller is this box and the table inside it is its
   natural height, which is what lets `thead` stay put — a sticky header inside
   a scrolling `<table>` element does not work, only inside a scrolling
   ancestor. */
.rw-list {
  max-height: 24rem;
  overflow-y: auto;
  border: 1px solid var(--border);
  border-radius: var(--radius);
}
.rw-table {
  width: 100%;
  border-collapse: collapse;
  font-size: 0.75rem;
  /* 0.4rem padding twice + 0.9rem line-height + the 1px bottom border. */
  --rw-head-h: calc(1.7rem + 1px);
}
/* Sticky lives on the cells, not the row: `position: sticky` on a `<tr>` is
   ignored in Safari, which would drop the header the moment the list scrolled. */
.rw-table thead th {
  position: sticky;
  top: 0;
  z-index: 2;
  text-align: left;
  /* padding + line-height are pinned rather than left to the font, because the
     group headers below stick to the bottom edge of this row and there is no
     way to ask CSS how tall it ended up. --rw-head-h is that sum. */
  padding: 0.4rem 0.6rem;
  line-height: 0.9rem;
  font-family: var(--mono);
  font-size: 0.62rem;
  font-weight: 400;
  text-transform: uppercase;
  letter-spacing: 0.1em;
  color: var(--text-muted);
  white-space: nowrap;
  /* Opaque, for the same reason the jobs table's header is: --overlay is
     translucent, and rows would slide visibly underneath it. */
  background-color: var(--bg-raised);
  background-image: linear-gradient(var(--overlay), var(--overlay));
  border-bottom: 1px solid var(--border);
}
.rw-table td {
  padding: 0.4rem 0.6rem;
  border-bottom: 1px solid var(--border);
  /* The first column is an id and the rest are numbers; only the id may wrap. */
  white-space: nowrap;
}
.rw-table td:first-child { overflow-wrap: anywhere; white-space: normal; }
.rw-table tbody tr:last-child td { border-bottom: 0; }
/* Numbers compare down a column only when they share a font and an edge. */
.rw-num { text-align: right; font-variant-numeric: tabular-nums; }
/* Beating `.rw-table thead th { text-align: left }`, which is more specific
   than `.rw-num` on its own and would leave a numeric header over its column's
   far edge. */
.rw-table thead th.rw-num { text-align: right; }
.rw-table td.mono { font-family: var(--mono); font-size: 0.72rem; }

.rw-row { cursor: pointer; }
.rw-row:hover td { background: var(--bg-input); }
.rw-row.selected td { background: var(--ring-soft); }
.rw-row:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }

/* Group header inside the candidate table — BLAST's query sequences. Sticky
   below the column header, so both the field names and the query a hit belongs
   to stay on screen while scrolling a table that can run to a few hundred rows.
   `z-index` under the thead's, or a group would scroll over the header it is
   meant to sit beneath. */
.rw-table tbody th {
  position: sticky;
  top: var(--rw-head-h);
  z-index: 1;
  padding: 0.35rem 0.6rem;
  background-color: var(--bg-raised);
  background-image: linear-gradient(var(--overlay), var(--overlay));
  border-bottom: 1px solid var(--border);
}
/* The flex box is a child of the cell, not the cell itself: `display: flex` on
   a `<th>` takes it out of table layout, and the browser then wraps it in an
   anonymous cell that ignores the colspan. */
.rw-group-inner {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 0.75rem;
}
.rw-group-name {
  font-family: var(--mono);
  font-size: 0.72rem;
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: var(--text-muted);
  overflow: hidden;
  text-overflow: ellipsis;
}
.rw-group-inner .meta { white-space: nowrap; font-size: 0.7rem; }

/* The group header is the control that folds its rows away. */
.rw-group-toggle { cursor: pointer; }
.rw-group-toggle:hover .rw-group-name { color: var(--text); }
.rw-group-toggle:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.rw-group-chevron {
  flex: none;
  margin-right: 0.4rem;
  font-size: 0.6rem;
  color: var(--text-muted);
  transition: transform 0.12s ease;
  display: inline-block;
}
.rw-group-toggle[aria-expanded="false"] .rw-group-chevron { transform: rotate(-90deg); }
/* The chevron and the name are one unit on the left; the count stays right. */
.rw-group-inner > .rw-group-chevron + .rw-group-name { margin-right: auto; }
/* `[hidden]` alone loses to the `display: table-row` a table row gets by
   default in some engines, and a folded group that stays on screen is worse
   than no control at all. */
.rw-table tbody tr[hidden] { display: none; }

.rw-detail { min-width: 0; }

/* The BLAST pairwise block. Everything about it is load-bearing: the three
   lines are aligned by COLUMN, so the font must be monospace, the text must not
   wrap, and the box scrolls sideways instead. A wrapped alignment is not an
   alignment. */
.rw-alignment { margin-top: 0.9rem; }
.rw-alignment[hidden] { display: none; }
.rw-alignment-stats {
  margin: 0 0 0.4rem;
  font-size: 0.72rem;
  color: var(--text-muted);
}
.rw-alignment-body {
  margin: 0;
  padding: 0.6rem 0.7rem;
  max-height: 16rem;
  overflow: auto;
  background: var(--bg-input);
  border-radius: var(--radius);
  font-family: var(--mono);
  font-size: 0.68rem;
  line-height: 1.45;
  white-space: pre;
  /* `anywhere` is inherited from further up and would break the columns. */
  overflow-wrap: normal;
  word-break: normal;
}
.rw-plot-svg {
  display: block;
  width: 100%;
  height: auto;
  overflow: visible;
}
.rw-gridline { stroke: var(--border); stroke-width: 1; }
.rw-tick, .rw-axis-label {
  fill: var(--text-muted);
  font-family: var(--mono);
  font-size: 9px;
}
.rw-line { fill: none; stroke: var(--accent); stroke-width: 1.5; }
.rw-cursor { stroke: var(--text-muted); stroke-width: 1; stroke-dasharray: 2 3; }
.rw-cursor[hidden] { display: none; }
.rw-mark {
  fill: var(--mark, var(--accent));
  fill-opacity: 0.55;
  stroke: var(--mark, var(--accent));
  cursor: pointer;
}
.rw-mark:hover { fill-opacity: 0.9; }
/* Selected by a ring in the page's own text colour, never by a fill: the
   point's colour IS its protein, and recolouring the one you clicked hides the
   thing you clicked to check. */
.rw-mark.is-selected {
  fill-opacity: 1;
  stroke: var(--text);
  stroke-width: 2.5;
}

/* Score bands — pLDDT and TM-score on one red → yellow → green scale. Faint,
   because this is the backdrop a trace is read against, not a thing to look at.
   Marks are deliberately NOT coloured by band. */
.rw-band { opacity: 0.14; }
.rw-band-low { fill: var(--nm-brand-red); }
.rw-band-mid { fill: var(--nm-brand-yellow); }
.rw-band-high { fill: var(--nm-brand-green); }
/* Named in the plot, not in a legend beside it — a legend swatch would read as
   the marks' colour. Dimmer than a tick: the bands are context. */
.rw-band-label {
  fill: var(--text-muted);
  font-family: var(--mono);
  font-size: 8px;
  opacity: 0.75;
}

.rw-readout {
  margin: 0.2rem 0 0;
  min-height: 1.1rem;
  font-size: 0.7rem;
  color: var(--text-muted);
}
.rw-facts { margin-top: 0.6rem; }

/* An explicit height, always: 3Dmol sizes its canvas from the container, and
   an auto-height one collapses to zero and renders nothing with no error. */
.rw-viewer {
  height: 260px;
  margin-top: 0.7rem;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  position: relative;
  overflow: hidden;
}
.rw-viewer[hidden] { display: none; }

.rw-legend {
  display: flex;
  flex-wrap: wrap;
  gap: 0.8rem;
  margin-top: 0.4rem;
  font-size: 0.72rem;
  color: var(--text-muted);
}
.rw-legend-item { display: inline-flex; align-items: center; gap: 0.35rem; }
.rw-swatch {
  width: 0.6rem;
  height: 0.6rem;
  border-radius: 2px;
  display: inline-block;
}
/* ---------- blast, read per design ---------- */
/* The plot is what this screen is for, so it takes the room. */
.rw[data-kind$="-by-prompt"] .rw-split {
  grid-template-columns: minmax(0, 0.85fr) minmax(0, 1.6fr);
}
/* One line per design. The generic rule lets the first column wrap, which is
   right for an accession and wrong for "Prompt 2 · replicate 3". */
.rw[data-kind$="-by-prompt"] .rw-table td:first-child { white-space: nowrap; }
.rw[data-kind$="-by-prompt"] .rw-detail { border: 0; padding: 0; }
.rw[data-kind$="-by-prompt"] .rw-plot {
  background: var(--nm-brand-white);
  border-radius: var(--radius);
  padding: 0.6rem 0.5rem;
  --text: var(--nm-brand-black);
  --text-muted: rgb(var(--nm-brand-black-rgb) / 0.55);
  --border: rgb(var(--nm-brand-black-rgb) / 0.15);
}
/* Inherits the re-pointed tokens above, so only the plate needs restating. */
.rw[data-kind$="-by-prompt"] .rw-plot-legend {
  background: rgb(var(--nm-brand-white-rgb) / 0.92);
}
.rw-plot { position: relative; }

/* ---------- The plot pager ---------- */
/* One panel per plot, sliding horizontally, so a kind can carry several without
   them competing for the detail column. Same mechanics as the workflow runner:
   CSS scroll-snap, so a swipe and a dot click settle on the same stops. */
.rw-plots { position: relative; }
.rw-plot-track {
  display: flex;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  scroll-behavior: smooth;
  scrollbar-width: none;
  /* Load-bearing. This track sits inside .wf-page, which is ITSELF a horizontal
     snap scroller: without containment a swipe past the last plot hands the
     leftover delta up and slides the workflow to the next screen. */
  overscroll-behavior-x: contain;
}
.rw-plot-track::-webkit-scrollbar { width: 0; height: 0; }
.rw-plot-panel {
  flex: 0 0 100%;
  /* Without this a wide plot sets the flex base and the panels stop being one
     scrollport wide, which breaks every index * clientWidth in the pager. */
  min-width: 0;
  scroll-snap-align: start;
  scroll-snap-stop: always;
}
.rw-plot-title {
  margin: 0 0 0.35rem;
  font-family: var(--mono);
  font-size: 0.65rem;
  text-transform: uppercase;
  letter-spacing: 0.12em;
  color: var(--text-muted);
}
.rw-plot-title[hidden] { display: none; }

/* Over the track's vertical middle, clear of the plot area's left gutter. */
.rw-plot-edge {
  position: absolute;
  top: 45%;
  padding: 0.1rem 0.45rem;
  font-size: 1rem;
  line-height: 1;
  background: var(--overlay);
  color: var(--text-muted);
  border: 1px solid var(--border);
  border-radius: var(--radius);
}
.rw-plot-edge:hover:not(:disabled) {
  color: var(--text);
  background: var(--overlay-strong);
  transform: none;
  box-shadow: none;
}
.rw-plot-edge:disabled { opacity: 0.25; }
.rw-plot-prev { left: -0.4rem; }
.rw-plot-next { right: -0.4rem; }

.rw-plot-dots {
  display: flex;
  justify-content: center;
  gap: 0.45rem;
  margin-top: 0.3rem;
}
.rw-plot-dot {
  width: 0.45rem;
  height: 0.45rem;
  padding: 0;
  border: none;
  border-radius: 50%;
  background: var(--border-strong);
}
.rw-plot-dot:hover,
.rw-plot-dot:active {
  transform: none;
  box-shadow: none;
  background: var(--text-muted);
}
.rw-plot-dot.active,
.rw-plot-dot.active:hover { background: var(--accent); }

@media (prefers-reduced-motion: reduce) {
  .rw-plot-track { scroll-behavior: auto; }
}
/* Under the plot, not on it. Absolutely positioned it sat over the top-right
   corner, which on all three of these plots is where the good results are —
   high identity, high pLDDT, high TM. Beside it was worse: as a flex sibling
   it took 11rem off the width of the thing it labelled. A wrapped row
   underneath costs one line of height and covers nothing. */
.rw-plot-legend {
  flex-direction: row;
  flex-wrap: wrap;
  gap: 0.3rem 0.8rem;
  margin: 0.45rem 0 0;
  padding: 0;
}

/* The one control on this page that starts work, so it reads as a control
   rather than as a footnote. */
.workflow details.wf-run-more { border: 0; padding: 0; background: none; }
.workflow details.wf-run-more > summary {
  display: inline-block;
  list-style: none;
  background: var(--bg-input);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 0.55rem 1.1rem;
  color: var(--text);
}
.workflow details.wf-run-more > summary::-webkit-details-marker { display: none; }
.workflow details.wf-run-more[open] > summary { margin-bottom: 0.75rem; }

/* ---------- API reference (/api-docs.html) ---------- */
/* Its own document, not a route — see the comment in api-docs.html. Redoc
   ships its own stylesheet and is themed from JS (js/api-docs.js); what is left
   here is the frame it renders inside. */

/* Redoc's request/response panel stays dark in BOTH themes, which is why these
   three are defined here and never restated in the light theme. Redoc
   syntax-highlights its samples with a fixed palette built for a dark block —
   pale greens and blues that wash out to nothing on paper. Its own default
   right panel is dark for the same reason. Values are the dark theme's
   --bg-raised, --bg-input and --text. */
:root {
  --api-docs-panel: rgb(16, 13, 11);
  --api-docs-code: rgb(34, 30, 29);
  --api-docs-panel-ink: #fbf3ef;
}
.api-docs-body {
  height: 100vh;
  display: flex;
  flex-direction: column;
  overflow: hidden;
}
.api-docs-head {
  flex: 0 0 auto;
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 1rem;
  padding: 1rem 1.5rem 0.75rem;
  border-bottom: 1px solid var(--border);
}
.api-docs-head h1 {
  margin: 0 0 0.25rem;
  font-size: 1.25rem;
  font-weight: 500;
  letter-spacing: -0.015em;
}
.api-docs-head .hint {
  margin: 0;
  color: var(--text-muted);
  font-size: 0.9rem;
}
.api-docs-head .hint.api-docs-error { color: var(--error); }
.api-docs-back { text-decoration: none; }
.api-docs-back:hover { text-decoration: underline; }
.api-docs-sep { color: var(--text-muted); }
.api-docs-actions {
  flex: 0 0 auto;
  display: flex;
  align-items: center;
  gap: 0.6rem;
}
.api-docs-raw {
  padding: 0.35rem 0.9rem;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  font-size: 0.85rem;
  text-decoration: none;
  white-space: nowrap;
}
.api-docs-raw:hover { border-color: var(--border-hover); background: var(--overlay); }

/* Redoc's sidebar is `position: sticky`, so it needs a scroll container. Giving
   it this one rather than the document keeps the page header in place while the
   reference scrolls under it. */
.api-docs-host {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
}

/* Redoc fills the selected sample tab with `rightPanel.textColor` as its
   BACKGROUND and leaves the label at `colors.text.primary`. That inverts
   correctly only when the right panel is dark under light page text, which is
   its default; here both tokens are the page's cream, so the label goes
   invisible. Restoring the intended contrast is a color, not a redesign.

   Scoped to `.react-tabs__tab` on purpose: the response-code tabs (202/400/…)
   carry `tab-success`/`tab-error` instead and are transparent-backed, so they
   need the light label they already have. */
.api-docs-host .react-tabs__tab.react-tabs__tab--selected {
  color: var(--api-docs-panel);
}

/* ── Caption label grid ─────────────────────────────────────────────────
   The BioM3 prompt builder (site/js/caption/). Rows of LABEL + value that
   compose into one caption line, plus the two remote fills and a preview of
   exactly what will be submitted. Theme tokens only — this has to read in
   both palettes like everything else. */
.cap-panel {
  border: 1px solid var(--border);
  border-radius: 6px;
  padding: 0.5rem 0.75rem;
  margin-bottom: 1rem;
  background: var(--bg-raised);
}
.cap-panel > summary {
  cursor: pointer;
  font-weight: 600;
  padding: 0.15rem 0;
}
.cap-panel > .hint { margin: 0.5rem 0 0.75rem; }

/* The three ways in, as tabs. A row of buttons rather than a <select>: there
   are exactly three, they are the screen's primary choice, and a dropdown hides
   two thirds of the answer to "how else could I do this?" — which is what the
   old single collapsed panel did and what this replaces. */
.cap-tabs {
  display: flex;
  gap: 0.25rem;
  flex-wrap: wrap;
  border-bottom: 1px solid var(--border);
  padding-bottom: 0.4rem;
}
.cap-tab {
  padding: 0.3rem 0.7rem;
  border: 1px solid transparent;
  border-radius: var(--radius);
  background: none;
  color: var(--text-muted);
  font-size: 0.85rem;
}
.cap-tab:hover:not(:disabled) { background: var(--overlay); color: var(--text); }
.cap-tab.selected {
  background: var(--overlay);
  border-color: var(--border);
  color: var(--text);
  font-weight: 500;
}
.cap-tab:disabled { opacity: 0.4; cursor: default; }
.cap-view-hint { margin: 0.5rem 0 0.6rem; }
.cap-panel-body { display: flex; flex-direction: column; gap: 0.5rem; }
/* The Build button ends every view, in the same place in all three, so the
   gesture does not move when the fields above it do — and it lands between the
   box you type into and the box its result appears in. */
.cap-build-row { display: flex; margin-top: 0.15rem; }
.cap-build { flex: 0 0 auto; }
/* A lookup field is a direct child of the panel now that the button is not
   beside it. The panel is a flex COLUMN, where the `flex: 1 1 14rem` that made
   it share a row would set its height instead — a 14rem-tall accession box. */
.cap-panel-body > .cap-source-input {
  flex: 0 0 auto;
  width: 100%;
  box-sizing: border-box;
}

.cap-rows { display: flex; flex-direction: column; gap: 0.4rem; }
.cap-row {
  display: flex;
  gap: 0.4rem;
  align-items: center;
  flex-wrap: wrap;
}
.cap-label,
.cap-custom-label {
  flex: 0 0 15rem;
  min-width: 9rem;
  background: var(--bg-input);
  color: var(--text);
  border: 1px solid var(--border);
  border-radius: 4px;
  padding: 0.3rem 0.4rem;
}
.cap-custom-label { letter-spacing: 0.02em; }
/* The value is the part people type into, so it takes the room left over. */
.cap-value {
  flex: 1 1 18rem;
  min-width: 12rem;
  background: var(--bg-input);
  color: var(--text);
  border: 1px solid var(--border);
  border-radius: 4px;
  padding: 0.3rem 0.4rem;
}
.cap-row-controls { display: flex; gap: 0.15rem; }
.cap-move,
.cap-remove,
.cap-uncustom {
  background: transparent;
  color: var(--text-muted);
  border: 1px solid var(--border);
  border-radius: 4px;
  padding: 0.2rem 0.45rem;
  line-height: 1;
  cursor: pointer;
}
.cap-move:hover:not(:disabled),
.cap-remove:hover,
.cap-uncustom:hover {
  color: var(--text);
  border-color: var(--border-hover);
}
.cap-move:disabled { opacity: 0.35; cursor: default; }

.cap-actions {
  display: flex;
  gap: 0.4rem;
  flex-wrap: wrap;
  margin-top: 0.6rem;
}
/* Dressed like the row's own up/down/x controls, because that is what they are
   — housekeeping on the grid. As filled buttons they carried the same weight as
   Generate, so the eye landed on "Clear" and "Reset order" before the thing
   that actually starts a run. */
.cap-actions button {
  background: transparent;
  color: var(--text-muted);
  border: 1px solid var(--border);
  border-radius: 4px;
  padding: 0.25rem 0.55rem;
  font-size: 0.78rem;
  font-weight: 400;
  box-shadow: none;
}
.cap-actions button:hover {
  color: var(--text);
  border-color: var(--border-hover);
  background: var(--overlay);
  transform: none;
  box-shadow: none;
}

.cap-sources {
  margin-top: 0.85rem;
  padding-top: 0.75rem;
  border-top: 1px solid var(--border);
  display: flex;
  flex-direction: column;
  gap: 0.4rem;
}
.cap-source { display: flex; gap: 0.4rem; align-items: center; flex-wrap: wrap; }
.cap-source-label { flex: 0 0 11rem; color: var(--text-muted); }
.cap-source-input {
  flex: 1 1 14rem;
  min-width: 10rem;
  background: var(--bg-input);
  color: var(--text);
  border: 1px solid var(--border);
  border-radius: 4px;
  padding: 0.3rem 0.4rem;
}
.cap-status { margin: 0.25rem 0 0; color: var(--text-muted); }
.cap-status.error { color: var(--nm-brand-red, crimson); }

.cap-preview-box {
  margin-top: 0.85rem;
  padding-top: 0.75rem;
  border-top: 1px solid var(--border);
}
.cap-preview-label {
  display: block;
  color: var(--text-muted);
  font-size: 0.85em;
  margin-bottom: 0.25rem;
}
.cap-preview {
  margin: 0;
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  font-size: 0.9em;
  /* A caption is one long line; wrapping it is the only way to read it. */
  overflow-wrap: anywhere;
  white-space: pre-wrap;
}
.cap-preview-empty { color: var(--text-muted); font-style: italic; }

/* The validated-prompt list. Scrolls in its own right rather than growing the
   panel: this sits above the entry box on a step that already has settings, a
   builder and a prompt rail on it, and a dozen curated captions unrolled would
   push the box they are meant to fill off the bottom of the screen. */
.cap-examples {
  display: flex;
  flex-direction: column;
  gap: 0.35rem;
  max-height: 16rem;
  overflow-y: auto;
  scrollbar-width: thin;
}
.cap-example {
  display: flex;
  flex-direction: column;
  gap: 0.2rem;
  width: 100%;
  text-align: left;
  padding: 0.45rem 0.55rem;
  background: var(--bg-input);
  color: var(--text);
  border: 1px solid var(--border);
  border-radius: 4px;
  cursor: pointer;
}
.cap-example:hover { border-color: var(--text-muted); }
.cap-example.selected {
  border-color: var(--nm-brand-green);
  background: var(--accent-soft);
}
.cap-example-name { font-weight: 500; }
/* Two lines, like the prompt rail's rows: enough to tell two captions apart,
   and the full text is legible in the entry box the moment it is used. */
.cap-example-text {
  color: var(--text-muted);
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  font-size: 0.85em;
  overflow-wrap: anywhere;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}
.cap-example-tags { display: flex; gap: 0.3rem; flex-wrap: wrap; }
.cap-example-tag {
  font-size: 0.7rem;
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: var(--text-muted);
  border: 1px solid var(--border);
  border-radius: 999px;
  padding: 0.05rem 0.4rem;
}

/* Step 9's plot: two layers in one coordinate frame. The canvas holds every
   point and the SVG holds the axes and the selectable subset, so they are
   stacked rather than side by side and both are sized by the same box. */
/* What step 7 queued off the back of a finished generation run. One line per
   event, appended as they happen. */
.wf-queued {
  margin: 0.75rem 0 0;
  padding-left: 1.1rem;
  font-size: 0.9em;
  color: var(--text-muted);
}
.wf-queued li { margin: 0.15rem 0; }

/* The picture, and one point beside it.
   A WRAPPING flex row rather than a grid. The grid kept the panel's width when
   the plot was dragged, which fixed the overlap and cost horizontal resize
   entirely: the plot was clamped to a track it could never exceed. Here the
   plot's own width sizes its column, and the panel drops below it once there is
   no longer room for both — so dragging wider works, and nothing overlaps. */
/* The plot area now fills the main column of the step's head/main/side grid —
   the panel beside it is a real sibling column (.wf-rail-side), not a share of
   this one. So there is no inner layout left to do here. */
.wf-plot { display: block; }

.wf-plot-stack {
  position: relative;
  /* Fills the column. Width is the grid's to decide now that the panel has a
     column of its own — a plot narrower than the status bar above it was the
     whole complaint, and an inline width here is what caused it. Resizing stays,
     vertically: how TALL the plot should be is still a judgement about the data
     (a wide flat cloud wants a short box), while how wide is now answered. */
  inline-size: auto;
  block-size: 480px;
  min-block-size: 220px;
  resize: vertical;
  overflow: hidden;
  border: 1px solid var(--border);
  border-radius: 4px;
  /* Grab is the resting state: the whole surface pans. The script swaps it for
     a pointer over a point and for grabbing mid-drag. */
  cursor: grab;
}
/* The canvas sizes itself in device pixels and sets its own CSS box, so it is
   pinned rather than stretched — stretching is what turned a 2-unit dot into a
   4px blob the first time round. */
.wf-plot-stack canvas { position: absolute; inset: 0; display: block; }
/* Axes sit above the points and must not eat the pointer: every interaction
   belongs to the canvas underneath. */
.wf-plot-axes {
  position: absolute;
  inset: 0;
  pointer-events: none;
}
.wf-plot-axes svg { width: 100%; height: 100%; display: block; }

/* The hover readout. Positioned inline by the script and flipped near the
   edges, so it never leaves the box the user just sized. */
.wf-plot-tip {
  position: absolute;
  z-index: 2;
  pointer-events: none;
  max-width: 15rem;
  padding: 0.35rem 0.5rem;
  border: 1px solid var(--border);
  border-radius: 4px;
  background: var(--bg-raised);
  color: var(--text);
  font-size: 0.8em;
  line-height: 1.35;
  white-space: pre-line;
  box-shadow: 0 2px 8px rgb(0 0 0 / 0.35);
}

/* One row above the plot, which is where a filter belongs. */
.wf-plot-controls {
  display: flex;
  align-items: center;
  gap: 0.75rem;
  flex-wrap: wrap;
  margin-bottom: 0.5rem;
}
.wf-plot-controls .field.inline {
  display: flex;
  align-items: center;
  gap: 0.4rem;
  margin: 0;
}
/* A rail that starts at the PLOT rather than at the top of the form.

   The plain `.wf-body-rail` puts the side column beside `main`, which on this
   screen means beside the method selector — the panel floated a screen and a
   half above the picture it describes. So the plot gets a row of its own and
   the rail shares it: same row, `align-items: start` from the base rule, hence
   the same top edge.

   The form deliberately does NOT span both columns. It is a form, and 70rem of
   line length is worse than the empty half-row beside it. */
.wf-body-rail-plot {
  grid-template-areas:
    "head head"
    "main ."
    "plot side";
}
.wf-body-rail-plot > .wf-plot { grid-area: plot; min-width: 0; }
/* Must match the base rule's breakpoint exactly. Between the two the base sets
   one column while this still declared two, and a template whose areas and
   columns disagree is invalid — the whole grid falls back to auto placement. */
@media (max-width: 62rem) {
  .wf-body-rail-plot {
    grid-template-areas: "head" "main" "plot" "side";
  }
}

/* The tabs stay put and the open panel scrolls beneath them, so the rail itself
   must not also scroll — two nested scrollers means the tab strip slides away
   just as you reach for it. */
#b3projRail { overflow: hidden; }

/* The side column carries two boxes — what the picture is made of, over what
   one point is — and scrolls as one. `.wf-rail-side` already bounds itself to
   the screen and sticks; this only says how its two children share that. */
.wf-rail-side > .cap-tabs { flex: 0 0 auto; }
.wf-rail-side > .wf-plot-sets,
.wf-rail-side > .wf-plot-detail {
  flex: 1 1 auto;
  min-height: 0;
  /* The panels are plain now; the rail is the box. Not sticky either — the
     rail it lives in already is, and a sticky child of a sticky bounded box
     has nothing left to move within. */
  position: static;
  padding: 0;
  border: 0;
  background: none;
  margin: 0;
  max-height: none;
  overflow-y: auto;
}

/* Datasets and Point are TABS of one box now, so the frame belongs to the rail
   and neither panel draws its own — two nested borders read as two boxes, which
   is what tabbing them was meant to stop. */
#b3projRail {
  padding: 0.75rem;
  border: 1px solid var(--border);
  border-radius: 4px;
  background: var(--bg-raised);
}
#b3projRail > .cap-tabs { margin-bottom: 0.5rem; }

/* The sets in the fit. Scrolled rather than allowed to run: MAX_SETS is eight
   and a generation run's name is long, so a few rows is the common case and the
   cap is what stops an eight-set fit pushing the annotations off the panel. */
.wf-set-list {
  list-style: none;
  margin: 0 0 0.5rem;
  padding: 0;
  max-height: 9rem;
  overflow-y: auto;
}
.wf-set-row {
  display: flex;
  align-items: center;
  gap: 0.4rem;
  margin: 0;
  padding: 0.15rem 0.25rem;
  border: 1px solid transparent;
  border-radius: 3px;
  min-width: 0;
}
.wf-set-row.selected { border-color: var(--border); background: var(--bg); }
.wf-set-row input { flex: none; margin: 0; }
/* The name is the select target and takes the rest of the row, so the click
   lands anywhere along it rather than only on the text. */
.wf-set-name {
  flex: 1 1 auto;
  min-width: 0;
  text-align: left;
  padding: 0.15rem 0.2rem;
  border: 0;
  background: none;
  color: var(--text);
  font: inherit;
  font-size: 0.9em;
  cursor: pointer;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.wf-set-row:hover { background: var(--bg); }
.wf-set-row.selected .wf-set-name { font-weight: 600; }
.wf-plot-sets { min-width: 0; }
.wf-plot-sets .field { margin: 0.5rem 0 0; }
/* How the selected dataset is drawn. Folded away by default — it is the second
   question, and open by default it would push the annotation list off. */
.wf-set-options {
  margin-top: 0.75rem;
  border-top: 1px solid var(--border);
  padding-top: 0.5rem;
}
.wf-set-options > summary {
  cursor: pointer;
  font-size: 0.9em;
  color: var(--text-muted);
}
.wf-set-options .field { margin: 0.5rem 0 0; }
.wf-set-options input[type="range"],
.wf-set-options select { width: 100%; box-sizing: border-box; }
.wf-set-options .hint { margin: 0.5rem 0 0; }

.wf-ann-box {
  margin-top: 0.75rem;
  border-top: 1px solid var(--border);
  padding-top: 0.75rem;
}
.wf-ann-search { width: 100%; box-sizing: border-box; }
/* Capped and scrolled rather than allowed to run: a curator can add columns
   without a deploy, so the number of rows is not bounded by anything the
   markup knows. */
.wf-ann-list {
  list-style: none;
  margin: 0.5rem 0;
  padding: 0;
  max-height: 14rem;
  overflow-y: auto;
}
.wf-ann-list li { margin: 0; }
/* Full-width rows, so the hit target is the row and not the word in it. */
.wf-ann-row {
  display: block;
  width: 100%;
  text-align: left;
  padding: 0.3rem 0.45rem;
  border: 1px solid transparent;
  border-radius: 3px;
  background: none;
  color: var(--text);
  font: inherit;
  cursor: pointer;
}
.wf-ann-row:hover { background: var(--bg); }

/* The value picker. Capped and scrolled for the same reason the column list is:
   `species` has 1,356 values and the search box is how you reach the rest. */
.wf-val-box {
  margin-top: 0.75rem;
  border-top: 1px solid var(--border);
  padding-top: 0.5rem;
}
.wf-val-box .field { margin: 0 0 0.5rem; }
.wf-val-list {
  list-style: none;
  margin: 0.5rem 0;
  padding: 0;
  max-height: 13rem;
  overflow-y: auto;
}
.wf-val-list li { margin: 0; }
/* The row is the hit target, and it stays one line — a species name is long
   and the count must not be pushed off by it. */
.wf-val-row {
  display: flex;
  align-items: center;
  gap: 0.4rem;
  padding: 0.2rem 0.3rem;
  border-radius: 3px;
  cursor: pointer;
  font-size: 0.9em;
  min-width: 0;
}
.wf-val-row:hover { background: var(--bg); }
.wf-val-row input { flex: none; margin: 0; }
.wf-val-row span {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  min-width: 0;
}
/* A full palette is why the empty boxes go inert; say so by looking inert. */
.wf-val-row:has(input:disabled) { opacity: 0.55; cursor: default; }
.wf-ann-row.selected {
  border-color: var(--border);
  background: var(--bg);
  font-weight: 600;
}

.wf-plot-detail {
  flex: 0 1 auto;
  /* Its own box, so it reads as a separate view rather than as an annotation
     floating beside the chart. Sticky, because the plot is tall and the thing
     you just clicked should stay on screen while you scroll it. */
  position: sticky;
  top: 1rem;
  min-width: 0;
  padding: 0.75rem;
  border: 1px solid var(--border);
  border-radius: 4px;
  background: var(--bg-raised);
  max-height: 80vh;
  overflow-y: auto;
}
.wf-plot-detail-head {
  display: flex; justify-content: space-between; align-items: center; gap: 0.5rem;
}
.wf-plot-detail-nav {
  display: flex; align-items: center; gap: 0.5rem; margin: 0.5rem 0;
}
/* A sequence is one long token; wrapping it is the only way it fits a panel. */
.wf-plot-seq {
  white-space: pre-wrap; word-break: break-all;
  font-size: 0.8em; max-height: 8em; overflow-y: auto; margin: 0.5rem 0;
}
.wf-plot-caption { font-size: 0.9em; max-height: 14em; overflow-y: auto; }
/* The projection plot draws its points on canvas, so there are no per-mark
   rules here any more. What is left in SVG is the axes plus two marks — the
   hovered point and the selected one — and those are outlines over a dot the
   canvas already painted, never the thing that carries the colour.

   Radius is set as an ATTRIBUTE by the script, deliberately. `r` as a CSS
   property is a geometry-property feature, and this file already said why not
   to lean on it for meaning: "a selection that only shows in some browsers is
   worse than one drawn a different colour". */
.rw-mark-cursor {
  fill: none;
  stroke-width: 2;
  pointer-events: none;
}
.rw-mark-cursor.is-hovered { stroke: var(--text); stroke-opacity: 0.75; }
.rw-mark-cursor.is-selected { stroke: var(--sage); stroke-width: 2.5; }

/* The legend. Present whenever a run has two sets — identity is never carried
   by colour alone, and the swatch is beside the words rather than replacing
   them. */
/* One row per level, scrolled rather than allowed to run. Several sets each
   carrying up to MAX_LEVELS levels plus a fold and a blank is comfortably forty
   rows, and a legend taller than the plot pushes the picture off the screen —
   which is the thing a legend exists to serve. */
.wf-plot-legend {
  display: flex;
  flex-direction: column;
  gap: 0.25rem;
  margin: 0.5rem 0 0;
  font-size: 0.9em;
  color: var(--text-muted);
  max-height: 11rem;
  overflow-y: auto;
}
/* Each row stays ONE line: the swatch, then the name elided at the row's edge.
   A wrapping legend row is how five hundred characters of caption became six
   lines of it. The full value is on the row's `title`. */
.wf-plot-legend li {
  display: flex;
  align-items: center;
  gap: 0.4rem;
  min-width: 0;
}
.wf-plot-legend li > span:last-child {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  min-width: 0;
}
.wf-plot-legend .swatch {
  width: 0.7rem;
  height: 0.7rem;
  border-radius: 50%;
  background: var(--swatch);
  flex: none;
}

/* Compute-host admin: the one-time enrollment code.
   Given weight on purpose — it is shown once and never retrievable, so a
   reader who skims past it has lost it. */
.callout {
  border: 1px solid var(--border-strong);
  border-radius: 6px;
  padding: 0.85rem 1rem;
  margin: 1rem 0;
  background: rgb(var(--nm-brand-blue-rgb) / 0.07);
}
.callout > p { margin: 0 0 0.5rem; }
.callout > p:last-child { margin-bottom: 0; }
.enrollment-code {
  font-family: var(--mono);
  font-size: 1.15rem;
  letter-spacing: 0.08em;
  user-select: all;            /* one click selects the whole code */
  word-break: break-all;
}


/* The one command that installs a worker. Pre-wrapped rather than scrolling:
   it is meant to be read before it is run — it mounts the Docker socket — and a
   command you have to scroll sideways to see the end of is one people paste
   without reading. */
.install-command {
  white-space: pre-wrap;
  word-break: break-all;
  padding: 0.75em;
  border-radius: 6px;
  background: var(--surface-2, rgba(127, 127, 127, 0.12));
  font-size: 0.85em;
  line-height: 1.5;
  margin: 0.5em 0;
}


/* What a machine needs before it can be registered. Collapsed by default: an
   admin registering their fifth machine does not need it, and one registering
   their first does — a <details> serves both without making either read past
   the other. */
.setup-notes {
  margin: 0.5em 0 1em;
  padding: 0.6em 0.9em;
  border-radius: 6px;
  background: var(--surface-2, rgba(127, 127, 127, 0.1));
  font-size: 0.9em;
}
.setup-notes > summary {
  cursor: pointer;
  font-weight: 600;
}
.setup-notes ul { margin: 0.6em 0; padding-left: 1.2em; }
.setup-notes li { margin: 0.35em 0; }
.setup-notes code {
  word-break: break-all;
  font-size: 0.9em;
}
