/* ===========================================================================
 * CloudLabs portal — the app-* layer.
 *
 * Everything visual now comes from the CloudTeam Design System, vendored in
 * css/cloudteam-ds.css and loaded BEFORE this file. What remains here is only
 * composition and app-specific chrome with NO design-system counterpart.
 *
 * RULES
 *  1. Never restyle a .ct-* element from this file. app.css loads last, so it
 *     would silently beat the DS (that is how the topbar hamburger ended up
 *     visible on desktop). The one deliberate exception is the re-assert at the
 *     bottom, which restores a DS rule that source order had broken.
 *  2. Use --ct-* tokens directly. The old --cl-* bridge is gone with the .cl-*
 *     components it served.
 *  3. js-* classes are behaviour hooks for wwwroot/js — never style them, so
 *     restyling can never break a script.
 * =========================================================================== */

/* --- Layout composition ------------------------------------------------- */

/* Page width for the public/anonymous layout, which is a reading column rather
   than the app shell's full-bleed grid. */
.app-container {
  width: 100%;
  max-width: var(--ct-content-max);
  margin-inline: auto;
  padding-inline: var(--ct-space-4);
}

/* Public (anonymous) surfaces stack topbar / content / footer down the viewport.
   The operator shell does NOT — .ct-app-shell owns 100dvh and scrolls its main. */
body.ct-public { display: flex; flex-direction: column; min-height: 100vh; }
.ct-public__main { flex: 1 0 auto; padding-block: var(--ct-space-8); }
.ct-public__main:focus { outline: none; }

/* Sections keep apart. The DS section head has a bottom margin only, so one that FOLLOWS other content
   (a list, a card, a status line) sat flush against it — the alerts tab's three sections, a group's two
   lab lists. A head after something now gets the room the home page's sections have (.app-page-block,
   below, which says the same thing explicitly). :where() keeps this at one class, so a page that sets its
   own spacing still wins; the tab bar and the page headline already leave room after themselves. */
.ct-detailview__section-head:where(:not(:first-child)) { margin-block-start: var(--ct-space-6); }
:where(.ct-tabs, .ct-headline, .ct-detailview__header) + .ct-detailview__section-head { margin-block-start: 0; }
/* More than one action in a section head (a lab's Export usage + Add participants): the DS head spreads
   its children with space-between, so loose buttons would float apart — group them at the end. */
.app-section-actions { display: flex; flex-wrap: wrap; align-items: center; gap: var(--ct-space-2); }
/* Stacked panels on one tab — a group's synced labs over all its labs. */
.ct-detailview__panel + .ct-detailview__panel { margin-block-start: var(--ct-space-8); }
/* A sub-heading inside a form (the schedule editor's "Login hours"): a new part of the form, not a field. */
.app-order-subhead {
  margin: var(--ct-space-8) 0 var(--ct-space-1);
  font-size: var(--ct-text-lg); font-weight: var(--ct-weight-semibold); color: var(--ct-text-strong);
}

.app-page-block { margin-top: var(--ct-space-6); }
/* Heading above a self-surfaced table/list, where the DS table needs no card. */
.app-section-title { font-size: var(--ct-text-xl); margin: 0 0 var(--ct-space-3); }
/* Grid of DS stat cards (usage report / compute-time history). */
.app-stat-grid {
    display: grid;
    grid-template-columns: repeat(2, minmax(0, 1fr));
    gap: var(--ct-space-6);
}
@media (max-width: 47.999rem) {
    /* One column on a phone: two stat tiles side by side leave no room for the
       meter track to read as a proportion. */
    .app-stat-grid { grid-template-columns: minmax(0, 1fr); }
}
.app-stack { display: flex; flex-direction: column; gap: var(--ct-space-4); }

/* A .ct-card used as a plain panel: the DS pads content via .ct-card__body, so a
   card holding a bare form/section needs the padding supplying. Additive only —
   .ct-card sets no padding of its own. */
.app-panel { padding: var(--ct-space-6); }
.app-panel > h2:first-child, .app-panel > h1:first-child { margin-top: 0; }
.app-panel-grid {
  display: grid; grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr));
  gap: var(--ct-space-4); margin: var(--ct-space-5) 0;
}

/* Page header row (title + actions). The DS .ct-headline covers the common case;
   this is the leftover inline variant used inside cards. */
.app-page-head {
  display: flex; align-items: center; justify-content: space-between;
  gap: var(--ct-space-4); flex-wrap: wrap;
}

/* --- Text utilities ----------------------------------------------------- */
/* The DS deliberately ships no muted-text utility (context picks .ct-hint,
   .ct-card__desc, .ct-list__meta …). These two cover the in-table / inline cases
   those don't fit. */
.app-muted { color: var(--ct-text-muted); }
.app-lead { font-size: var(--ct-text-lg); color: var(--ct-text-muted); }
.app-hidden { display: none !important; }

/* --- Forms -------------------------------------------------------------- */
.app-auth-card, .app-form-card { max-width: 32rem; }
.app-form-actions { display: flex; gap: var(--ct-space-3); margin-top: var(--ct-space-4); flex-wrap: wrap; }
.app-field-grid { display: grid; grid-template-columns: 1fr 1fr; gap: var(--ct-space-4); }
@media (max-width: 40rem) { .app-field-grid { grid-template-columns: 1fr; } }
.app-sso { margin-top: var(--ct-space-4); padding-top: var(--ct-space-4); border-top: 1px solid var(--ct-border); }

/* Stacked fields need spacing between them; .ct-field only handles its own inner gap.
   Inside the two-column grid the grid's own gap does it, so the sibling margin must not
   apply there — otherwise the second column sits lower than the first. */
.ct-field + .ct-field, .app-field-grid + .ct-field, .ct-field + .app-field-grid { margin-top: var(--ct-space-4); }
.app-field-grid > .ct-field + .ct-field { margin-top: 0; }
/* Same inside the DS's paired row and field stack: those own their own spacing
   (a grid gap, and the hairline rule between groups), so the legacy sibling
   margin would push the second column out of alignment. */
.ct-form-fields__row > .ct-field + .ct-field,
.ct-form-fields > .ct-field + .ct-field { margin-top: 0; }

/* ASP.NET renders the validation summary container even when the model is valid, which as a
   .ct-alert would paint an empty red box on every clean form. This is the framework's own
   "nothing to show" class. */
.validation-summary-valid { display: none; }

/* A row of checkboxes/switches (e.g. the admin flags on the user form). */
.app-checks { display: flex; flex-wrap: wrap; gap: var(--ct-space-2) var(--ct-space-5); margin: var(--ct-space-3) 0; }

/* --- Tables / rows ------------------------------------------------------ */
.app-toolbar { display: flex; align-items: center; gap: var(--ct-space-3); margin-bottom: var(--ct-space-4); }
.app-search { width: min(20rem, 60vw); }
/* Row actions. NOTE the two cases: on a <td> this must NOT set `display`, because that
   replaces `table-cell` and drops the cell out of the row's layout — the browser wraps it in
   an anonymous cell, and its border-bottom then sits at a different height from its siblings',
   so the rule between rows visibly breaks at that column. Spacing comes from a sibling margin
   instead, which needs no flex context. Anywhere else (a card, a definition row) flex is fine. */
.app-row-actions { display: flex; align-items: center; gap: var(--ct-space-3); flex-wrap: wrap; }
td.app-row-actions {
    display: table-cell;
    white-space: nowrap;
}
td.app-row-actions > * + * { margin-inline-start: var(--ct-space-3); }
.app-editing > td { background: var(--ct-surface-sunken); }

/* Inline htmx add/edit forms inside a table or card. */
.app-inline-form {
  display: flex; flex-wrap: wrap; align-items: flex-end; gap: var(--ct-space-2);
  margin-top: var(--ct-space-3); padding: var(--ct-space-3);
  border: 1px dashed var(--ct-border); border-radius: var(--ct-radius-md);
  background: var(--ct-surface);
}
/* The label is the strip's flex item; the control fills it. Sizing must live on the LABEL:
   the input's own row shorthand (below) is written for direct children, and inside this COLUMN
   flexbox its 12rem flex-basis becomes HEIGHT — which shipped as a ~190px-tall text box on the
   outline's add-exercise form. */
.app-inline-form label {
  display: flex; flex-direction: column; gap: var(--ct-space-1);
  flex: 0 1 12rem; min-width: 8rem;
}
.app-inline-form label .ct-input, .app-inline-form label .ct-select {
  flex: 0 0 auto; width: 100%; min-width: 0;
}
/* DS controls are width:100% by design ("lay fields out in a grid with your spacing tokens"),
   so an inline strip has to constrain them or every field claims its own row. Composition
   inside a portal-owned container, not a restyle of the component itself. */
.app-inline-form .ct-input, .app-inline-form .ct-select {
  width: auto; min-width: 8rem; flex: 0 1 12rem;
}
.app-inline-form .ct-input[type="number"] { flex: 0 0 6rem; min-width: 0; }
.app-inline-form .ct-input[type="date"] { flex: 0 0 10rem; }

/* Nested machine → disks sub-panel (Labs details). */
.app-disk-host > td { padding: 0; border-bottom: 0; }
.app-subpanel {
  padding: var(--ct-space-3) var(--ct-space-4) var(--ct-space-4);
  margin: 0 0 var(--ct-space-2) var(--ct-space-6);
  background: var(--ct-surface-sunken);
  border: 1px solid var(--ct-border);
  border-inline-start: 3px solid var(--ct-primary);
  border-radius: var(--ct-radius-md);
}
.app-subpanel__head { display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--ct-space-2); }
.app-table--sub th, .app-table--sub td { padding: var(--ct-space-2) var(--ct-space-3); }

/* --- Course outline (Labs details) -------------------------------------- */
/* --- The course outline editor -------------------------------------------
   The list itself is the DS reorder component. What is here is the editing chrome the component
   deliberately leaves to the app: rename, delete, and the add forms. */

/* Rename is a disclosure rather than a dialog: the row already says what is being renamed. */
.app-outline-rename { display: inline-block; }
.app-outline-rename > summary { list-style: none; cursor: pointer; }
.app-outline-rename > summary::-webkit-details-marker { display: none; }
.app-outline-rename[open] > .app-inline-form { margin-top: var(--ct-space-2); }

/* Module and exercise titles are short lines, but not 12rem short: the add and rename strips give
   them room. The buttons there are md, the inputs' own height, so the strip's bottom edge lines up. */
#js-outline-host .app-inline-form label { flex: 0 1 28rem; }

/* A step is a paragraph, so it is edited where it is read rather than in a separate field — and it
   takes the row's whole width. The LABEL is the flex item; the textarea fills it. */
.app-step-edit { display: flex; align-items: flex-start; gap: var(--ct-space-2); margin: 0; }
.app-step-edit > label { flex: 1 1 auto; min-width: 0; display: block; }
.app-step-edit .ct-textarea { width: 100%; }
.app-step-add > label { flex: 1 1 32rem; max-width: none; }
/* Narrow screens: the row's handle, number, moves and delete leave a step no room beside them, so the
   step takes a line of its own under them, and Save wraps under the text. */
@media (max-width: 40rem) {
  #js-step-list > .ct-reorder__item > .ct-reorder__main { grid-column: 1 / -1; grid-row: 2; }
  .app-step-edit { flex-wrap: wrap; }
  .app-step-edit > label { flex-basis: 100%; }
}

/* The bulk import sits under the editor and is visibly a different job. */
.app-outline-import {
  display: flex; flex-direction: column; gap: var(--ct-space-4);
  margin-top: var(--ct-space-6);
  padding-top: var(--ct-space-5);
  border-top: 1px solid var(--ct-border);
}
.app-outline-import .ct-alert .ct-btn { margin-top: var(--ct-space-3); }
.app-outline-import__actions { display: flex; justify-content: flex-end; margin-top: var(--ct-space-4); }

/* The backend's last error on a disabled template: a TaskWorker message with paths and ids, so it
   keeps its line breaks and wraps instead of widening the page. */
.app-template-error {
  margin: var(--ct-space-2) 0 var(--ct-space-3);
  font-family: var(--ct-font-mono);
  font-size: var(--ct-text-sm);
  white-space: pre-wrap;
  overflow-wrap: anywhere;
}

/* --- Logo drop zone (company branding) ---------------------------------- */
.app-dropzone {
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  gap: var(--ct-space-3); min-height: 11rem; padding: var(--ct-space-6);
  text-align: center; cursor: pointer;
  border: 2px dashed var(--ct-border); border-radius: var(--ct-radius-md);
  background: var(--ct-surface-sunken);
  transition: border-color var(--ct-dur-fast) var(--ct-ease-out),
              background var(--ct-dur-fast) var(--ct-ease-out);
}
.app-dropzone:hover, .app-dropzone.js-dropzone-over { border-color: var(--ct-primary); background: var(--ct-surface); }
.app-dropzone__preview { max-width: min(100%, 18rem); max-height: 8rem; object-fit: contain; }
.app-dropzone__prompt { margin: 0; display: flex; flex-direction: column; gap: 0.2rem; }
.app-dropzone__actions { display: flex; gap: var(--ct-space-3); }

/* --- Long-form content pages (about / SLA / troubleshooting …) ---------- */
.app-prose { max-width: 46rem; }
.app-prose h2 { margin-top: var(--ct-space-6); font-size: var(--ct-text-xl); }
.app-prose p { margin: var(--ct-space-3) 0; }
.app-prose ol, .app-prose ul { padding-left: 1.3rem; }
.app-prose li { margin: 0.3rem 0; }

/* --- Online help (/{culture}/help) -------------------------------------- */
.app-help h2[id], .app-help h3[id] { scroll-margin-top: var(--ct-space-6); }
.app-help__back { margin: 0 0 var(--ct-space-2); }
.app-help__toc {
  margin: var(--ct-space-4) 0 var(--ct-space-6); padding: var(--ct-space-4);
  background: var(--ct-surface); border: 1px solid var(--ct-border); border-radius: var(--ct-radius-lg);
}
.app-help .app-help__toc-title { margin: 0 0 var(--ct-space-2); font-size: var(--ct-text-base); }
.app-help__toc ul { margin: 0; }
.app-help__cards { list-style: none; padding-left: 0; margin-top: var(--ct-space-3); }
.app-help .ct-table-wrap { margin: var(--ct-space-3) 0; }
.app-help kbd {
  padding: 0.05rem 0.35rem; font: inherit; font-size: 0.9em;
  border: 1px solid var(--ct-border); border-bottom-width: 2px; border-radius: var(--ct-radius-sm);
  background: var(--ct-surface);
}
.app-help__links { margin: 0; padding-left: 1.1rem; }
.app-help__links li { margin: 0.25rem 0; }

/* --- Home dashboard quick-links ---------------------------------------- */
.app-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr)); gap: var(--ct-space-4); margin-top: var(--ct-space-6); }
.app-cards a {
  display: block; padding: var(--ct-space-4);
  background: var(--ct-surface); border: 1px solid var(--ct-border);
  border-radius: var(--ct-radius-lg); box-shadow: var(--ct-shadow-sm);
  text-decoration: none; color: var(--ct-text-body);
}
.app-cards a:hover { border-color: var(--ct-primary); }
.app-cards strong { display: block; margin-bottom: 0.2rem; color: var(--ct-text-strong); }
.app-cards span { font-size: var(--ct-text-sm); color: var(--ct-text-muted); }

/* --- Language selector wrapper ----------------------------------------- */
/* Holds the DS LanguageSelector plus its <noscript> submit button on one line. */
.app-lang-form { display: inline-flex; align-items: center; gap: var(--ct-space-2); margin: 0; }

/* --- Labelled accessibility trigger in a top bar ------------------------
   The DS trigger is either a full-width sidebar row or a bare icon square. The "bar" variant of
   _A11yPanel keeps the icon AND the label, sized to its content instead of stretching. */
.app-a11y-trigger--bar { width: auto; }

/* --- Student lab-access page --------------------------------------------
   Layout is the DS public shell (--wide, so the column matches the signed-in shell's content
   measure). What is here is the page inside it: a two-column split, the identity block, and the
   console's reserved frame. */

.app-lab__headline { margin-block-end: var(--ct-space-6); }

/* Lab on the left, the clock and who provided it on the right. The aside is fixed-width rather
   than a fraction: an address does not get more readable with more room, and the console does.
   24rem is what the countdown needs — four two-digit groups plus separators — not a round number. */
.app-lab__grid {
  display: grid;
  grid-template-columns: minmax(0, 1fr) 24rem;
  gap: var(--ct-space-6);
  align-items: start;
}
@media (max-width: 64rem) {
  /* The console is the reason for the page, so it stays first when the columns stack. */
  .app-lab__grid { grid-template-columns: minmax(0, 1fr); }
}

.app-lab__main, .app-lab__aside { display: flex; flex-direction: column; gap: var(--ct-space-4); min-inline-size: 0; }

/* --- Which lab, whose, how long ------------------------------------------ */
.app-lab__facts { display: flex; flex-direction: column; gap: var(--ct-space-1); }
.app-lab__name {
  margin: 0;
  font-size: var(--ct-text-2xl);
  font-weight: var(--ct-weight-bold);
  line-height: var(--ct-leading-tight);
  color: var(--ct-text-strong);
}
.app-lab__template { margin: 0; color: var(--ct-text-muted); }
.app-lab__who { margin: var(--ct-space-2) 0 0; font-weight: var(--ct-weight-medium); }

/* The consequence text sits inside the DS countdown, below the status line. */
.app-countdown__note { margin: 0; font-size: var(--ct-text-sm); color: var(--ct-text-muted); }
.ct-countdown--urgent .app-countdown__note {
  color: var(--ct-danger-text);
  font-weight: var(--ct-weight-semibold);
}

/* The clock in a 24rem aside: four groups of two 2.5rem cards, three separators and the gaps
   between them come to roughly 21rem, which fits the card's content box. The DS wraps the groups
   rather than shrinking them if it ever does not, so a tighter column degrades legibly. */
.app-lab__clock { --ct-countdown-size: 2.5rem; display: flex; flex-direction: column; gap: var(--ct-space-4); }

.app-lab__flexi {
  display: flex; align-items: baseline; gap: var(--ct-space-2);
  margin: 0;
  padding-block-start: var(--ct-space-4);
  border-block-start: 1px solid var(--ct-border);
  font-size: var(--ct-text-sm);
  color: var(--ct-text-muted);
}
.app-lab__flexi strong { font-size: var(--ct-text-lg); color: var(--ct-text-strong); }

/* --- Who provided it ----------------------------------------------------- */
.app-lab__provider-logo { display: block; max-block-size: 44px; max-inline-size: 100%; inline-size: auto; }
.app-lab__provider-label {
  margin: 0 0 var(--ct-space-2);
  font-size: var(--ct-text-sm);
  font-weight: var(--ct-weight-semibold);
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--ct-text-muted);
}
.app-lab__provider-address {
  margin: var(--ct-space-3) 0 0;
  font-style: normal;
  font-size: var(--ct-text-sm);
  line-height: var(--ct-leading-relaxed);
  color: var(--ct-text-muted);
}
.app-lab__provider-address a { color: inherit; }

/* --- The login schedule -------------------------------------------------- */
.app-schedule__title { margin: 0; font-size: var(--ct-text-md); }
.app-schedule__note { margin: var(--ct-space-1) 0 var(--ct-space-3); font-size: var(--ct-text-sm); color: var(--ct-text-muted); }

.app-loginhours { width: 100%; border-collapse: collapse; }
.app-loginhours th {
  text-align: left; font-weight: var(--ct-weight-regular); color: var(--ct-text-muted);
  padding: var(--ct-space-1) var(--ct-space-4) var(--ct-space-1) 0;
}
.app-loginhours td { font-weight: var(--ct-weight-semibold); font-variant-numeric: tabular-nums; }

.app-lab-unavailable { border-inline-start: 4px solid var(--ct-danger-500); }
.app-unavailable-title { color: var(--ct-danger-text); }

/* --- In-browser console -------------------------------------------------- */
/* The frame is the same size idle or live, so pressing Connect fills a space that was already
   there instead of shoving the page around. */
.app-console { display: flex; flex-direction: column; }
.app-console__head {
  display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between;
  gap: var(--ct-space-3); margin-bottom: var(--ct-space-3);
}
.app-console__title { margin: 0; font-size: var(--ct-text-lg); }
/* Controls only, so they sit at the end rather than opposite a heading that is not there. */
.app-console__head--actions { justify-content: flex-end; }
.app-console__target { color: var(--ct-text-muted); font-weight: var(--ct-weight-regular); }
.app-console__actions { display: flex; flex-wrap: wrap; gap: var(--ct-space-2); }
.app-console__hint { margin: 0 0 var(--ct-space-3); color: var(--ct-text-muted); font-size: var(--ct-text-sm); }
.app-console__error { color: var(--ct-danger-text); font-weight: var(--ct-weight-semibold); margin: 0 0 var(--ct-space-2); }
.app-console__busy { color: var(--ct-text-muted); font-size: var(--ct-text-sm); }

/* A remote desktop is landscape and never square, so the frame takes the width it is given and
   derives its height from it. A fixed height made it far taller than any session it would ever
   show, and the desktop inside then letterboxed itself into the middle of a tall black box.

   16:9 is the shape of the screens these labs actually run at. The viewport cap is a backstop for
   a very wide monitor, where 16:9 of a 1000px column would otherwise push the connect button below
   the fold. */
.app-console__frame {
  position: relative;
  aspect-ratio: 16 / 9;
  max-block-size: 80vh;
  border: 1px solid var(--ct-border);
  border-radius: var(--ct-radius-md);
  overflow: hidden;
  background: var(--ct-neutral-900, #101828);
}
/* The client's drawing surface. touch-action: none is what lets Guacamole's touch handling see
   gestures at all — without it the browser claims them for scrolling. Scoped here, never wider:
   the page around the console must keep scrolling normally. */
.app-console__viewport {
  position: relative;
  width: 100%;
  height: 100%;
  overflow: hidden;
  touch-action: none;
  user-select: none;
  cursor: default;
}
/* The focus ring marks the capture boundary: while it shows, keys go to the desktop. Inset so it
   survives the frame's overflow: hidden. */
.app-console__viewport:focus-visible {
  outline: none;
  box-shadow: inset 0 0 0 2px var(--ct-primary);
}

/* Why the desktop just disappeared — rendered by the idle panel when a session ends. */
.app-console__ended {
  margin: 0 0 var(--ct-space-3);
  font-weight: var(--ct-weight-semibold);
  color: var(--ct-text-strong);
}

/* Full screen: the frame becomes the whole surface, so the ratio and the rounding both go away —
   the desktop should fill the screen, not sit 16:9 in the middle of it. */
.app-console__frame:fullscreen {
  aspect-ratio: auto;
  block-size: 100%;
  max-block-size: none;
  border: 0;
  border-radius: 0;
}

/* Idle: the same rectangle, but a surface rather than a black hole with a button on it. */
.app-console__frame--idle {
  display: grid;
  place-items: center;
  padding: var(--ct-space-6);
  background: var(--ct-surface-sunken);
  border-style: dashed;
}
.app-console__idle-body {
  display: flex; flex-direction: column; align-items: center; gap: var(--ct-space-4);
  max-inline-size: 30rem; text-align: center;
}
.app-console__idle-lede { margin: 0; color: var(--ct-text-muted); }

.app-connect-list { display: flex; flex-wrap: wrap; justify-content: center; gap: var(--ct-space-2); }
.app-connect-form { margin: 0; }

/* On a phone 16:9 of a ~350px column is only ~200px tall, which is not a usable desktop. Below
   the stacking breakpoint the ratio gives way to a height that is worth having; the desktop
   letterboxes inside it, which is the lesser problem. */
@media (max-width: 48rem) {
  .app-console__frame { aspect-ratio: auto; block-size: min(70vh, 32rem); }
}

/* --- htmx ---------------------------------------------------------------- */
.htmx-indicator { opacity: 0; transition: opacity 120ms ease-in; }
.htmx-request .htmx-indicator, .htmx-request.htmx-indicator { opacity: 1; }

/* --- Sidebar collapse ---------------------------------------------------- */
/* The pre-paint script marks <html> before the shell exists in the DOM, so the
   rail's width is right on the very first frame rather than snapping after JS. */
.cl-rail-collapsed .ct-app-shell { grid-template-columns: var(--ct-sidebar-width-collapsed) minmax(0, 1fr); }

/* Company logo preview + the aside's own action rows (the sticky bar's Save
   belongs to the details form, so the logo forms carry their own buttons). */
.app-logo-preview {
    display: block;
    max-width: 100%;
    max-height: 120px;
    margin-bottom: var(--ct-space-4);
    border-radius: var(--ct-radius-md);
    background: var(--ct-surface-sunken);
}
.app-aside-actions { display: flex; justify-content: flex-end; margin-top: var(--ct-space-4); }

/* htmx activity spinner for a ct-listview search: pinned inside the input's own
   wrap at the trailing edge, mirroring the DS's leading search icon, so it adds
   no height to the field. */
.app-search-spinner {
    position: absolute;
    right: 12px;
    top: 50%;
    transform: translateY(-50%);
    color: var(--ct-text-subtle);
    pointer-events: none;
}

/* A tabbed page that is NOT a DS DetailView (no record — e.g. Participants, which is
   just two lists under one nav entry). The DS spaces a tab strip with
   `.ct-detailview > .ct-tabs`, a direct-child selector, so a strip outside that wrapper
   gets no bottom margin at all and its content collides with the rail. This reuses the
   same --ct-space-6 rather than inventing a second spacing value. */
.app-tabbed-page > .ct-tabs {
    margin-block-end: var(--ct-space-6);
}

/* Generate-lab: the three input methods. Panels are hidden rather than removed, so switching tabs
   never discards half-typed input or a file already chosen. */
.app-method-panel {
    padding-block-start: var(--ct-space-5);
    display: flex;
    flex-direction: column;
    gap: var(--ct-space-4);
}

.app-method-panel[hidden] {
    display: none;
}

/* A field that is shown but not editable: same vertical rhythm as an input so a mixed row still
   lines up, without looking like a control somebody could type into. */
.app-readonly-value {
    margin: 0;
    padding-block: var(--ct-space-2);
    font-weight: 600;
}

/* At-a-glance facts beside a page's title — status, dates, how many people. Pushed to the end of
   .ct-pagehead (which is already a flex row) so the title keeps the left edge. Wraps onto its own
   line on a narrow screen rather than crushing the title. */
.app-pagehead-facts {
    display: flex;
    flex-wrap: wrap;
    gap: var(--ct-space-5);
    margin: 0;
    margin-inline-start: auto;
}

.app-pagehead-facts dt {
    font-size: var(--ct-text-xs);
    color: var(--ct-text-muted);
    text-transform: uppercase;
    letter-spacing: 0.04em;
}

.app-pagehead-facts dd {
    margin: 0;
    font-weight: 600;
}

@media (max-width: 48rem) {
    .app-pagehead-facts { margin-inline-start: 0; }
}

/* A checkbox whose explanation reads on the same line as its label, rather than under the form.
   baseline alignment so the smaller hint text sits on the label's baseline; it wraps under on a
   narrow screen rather than squeezing the label. */
.app-check-inline {
    display: flex;
    align-items: baseline;
    gap: var(--ct-space-2);
    flex-wrap: wrap;
}

.app-check-inline > .ct-hint {
    margin: 0;
}

/* Actions only — the trainer checkbox used to sit here too. The row is right-aligned as a GROUP:
   pushing just the last button over (margin-inline-start: auto) left Cancel stranded on the far
   side of the row, away from the button it belongs next to. */
.app-participant-add__actions {
    display: flex;
    align-items: center;
    justify-content: flex-end;
    gap: var(--ct-space-4);
    flex-wrap: wrap;
}

/* Live matches under a search box: one full-width button per match. Shared by the participant
   email box and the USOS teacher box, so picking a person looks the same wherever it happens. */
.app-suggestions {
    margin: var(--ct-space-2) 0 0;
    padding: 0;
    list-style: none;
    border: 1px solid var(--ct-border);
    border-radius: var(--ct-radius-md);
    overflow: hidden;
}

.app-suggestion {
    display: flex;
    flex-direction: column;
    align-items: flex-start;
    gap: 2px;
    inline-size: 100%;
    min-height: var(--ct-target-min);
    padding: var(--ct-space-2) var(--ct-space-3);
    text-align: start;
    background: var(--ct-surface);
    border: 0;
    color: var(--ct-text-body);
    cursor: pointer;
}

.app-suggestions li + li .app-suggestion { border-top: 1px solid var(--ct-border); }
.app-suggestion:hover { background: var(--ct-surface-hover); }
.app-suggestion:focus-visible { outline: none; box-shadow: var(--ct-ring); }

/* A teacher's USOS groups: a checkbox per group, stacked. */
.app-usos-groups {
    display: flex;
    flex-direction: column;
    gap: var(--ct-space-2);
    margin: 0;
    padding: 0;
    list-style: none;
}

/* The password-mode choice on the new-user form: a fieldset for the group semantics a radio pair
   needs, with the browser's fieldset chrome removed — the DS draws groups with cards, not inset
   borders. Each option's hint indents to sit under its label text, not under the radio. */
.app-invite-choice {
    margin: 0;
    padding: 0;
    border: 0;
}

.app-invite-choice legend {
    padding: 0;
    margin-bottom: var(--ct-space-2);
}

.app-invite-choice .ct-hint {
    margin: 0 0 var(--ct-space-3);
    padding-inline-start: calc(1rem + var(--ct-space-2));
}

/* Hours and minutes read as one value, so they sit on one line with their units. */
.app-duration {
    display: flex;
    align-items: center;
    gap: var(--ct-space-2);
}

.app-duration .ct-input {
    width: 5rem;
}

/* The organisation's logo in the sidebar brand slot. Contained, not cropped: a wordmark and a
   square badge both have to sit in the same box without being cut off. */
.app-brand-logo {
    inline-size: 100%;
    block-size: 100%;
    object-fit: contain;
}

/* Email template placeholders: a token and what it stands for, insertable on click. */
.app-placeholders {
    display: flex;
    flex-wrap: wrap;
    gap: var(--ct-space-2);
    margin: 0;
    padding: 0;
    list-style: none;
}

.app-placeholder {
    display: flex;
    flex-direction: column;
    align-items: flex-start;
    gap: 2px;
    /* The 2.5.8 target floor — these are small, so they are the easiest place to fall under it. */
    min-height: var(--ct-target-min);
    padding: var(--ct-space-2) var(--ct-space-3);
    text-align: start;
    cursor: grab;
    background: var(--ct-surface-sunken);
    border: 1px solid var(--ct-border);
    border-radius: var(--ct-radius-md);
    color: var(--ct-text-body);
}

.app-placeholder:hover { background: var(--ct-surface-hover); }
.app-placeholder:focus-visible { outline: none; box-shadow: var(--ct-ring); }
.app-placeholder code { font-family: var(--ct-font-mono); font-size: var(--ct-text-xs); }
.app-placeholder__desc { font-size: var(--ct-text-xs); color: var(--ct-text-muted); }

/* A critical alert's card: the border says it before the badge is read. */
.app-alert--critical {
    border-color: var(--ct-danger, #b3261e);
}

/* Which way a figure moved since the previous reading. The glyph is decoration over an sr-only
   sentence; colour follows meaning for the usage metrics (rising usage is the hazard), and the
   neutral variant is for counts where a rise is just a fact. */
.app-trend {
    font-size: var(--ct-text-xs);
    font-weight: 600;
    margin-inline-start: var(--ct-space-1);
}

.app-trend--up { color: var(--ct-danger, #b3261e); }
.app-trend--down { color: var(--ct-success, #1e7d46); }
.app-trend--flat,
.app-trend--neutral { color: var(--ct-text-muted); }

/* Usage-history charts: server-rendered SVG, stretched to the card. */
.app-chart-grid {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(20rem, 1fr));
    gap: var(--ct-space-4);
}

.app-chart-card { padding: var(--ct-space-4); }

.app-chart-figure { margin: 0; }

.app-chart {
    display: block;
    inline-size: 100%;
    block-size: 9rem;
    background: var(--ct-surface-sunken);
    border-radius: var(--ct-radius-sm);
}

.app-chart__grid { stroke: var(--ct-border); stroke-width: 1; }

.app-chart__area { fill: var(--ct-primary, #1e547d); opacity: 0.12; }

.app-chart__line {
    stroke: var(--ct-primary, #1e547d);
    stroke-width: 2;
    /* The viewBox stretches; without this the stroke stretches with it and fat verticals appear. */
    vector-effect: non-scaling-stroke;
    stroke-linejoin: round;
    stroke-linecap: round;
}

.app-chart__ticks {
    display: flex;
    justify-content: space-between;
    font-size: var(--ct-text-xs);
    color: var(--ct-text-muted);
    margin-block-start: var(--ct-space-1);
}

.app-range-picker { display: flex; gap: var(--ct-space-2); }

/* Dashboard stat cards double as links; the card look stays, the underline goes. */
.app-dash-stats { margin-block: var(--ct-space-4) var(--ct-space-6); }

a.app-dash-stat {
    text-decoration: none;
    color: inherit;
}

a.app-dash-stat:hover { border-color: var(--ct-primary, #1e547d); }

/* An error page's single statement: the status as a watermark, an icon, one sentence of what
   happened and one of what to do. Centred in whichever layout hosts it — the operator shell for a
   signed-in wrong turn, the public column for everyone else. */
.app-error {
    display: flex;
    flex-direction: column;
    align-items: center;
    text-align: center;
    gap: var(--ct-space-3);
    padding: var(--ct-space-12) var(--ct-space-6);
    margin-inline: auto;
    max-width: 34rem;
}

.app-error__code {
    margin: 0;
    font-size: clamp(4rem, 12vw, 6.5rem);
    font-weight: 800;
    line-height: 1;
    letter-spacing: -0.04em;
    color: var(--ct-surface-sunken);
    -webkit-text-stroke: 2px var(--ct-border);
}

.app-error__icon {
    width: 56px;
    height: 56px;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    border-radius: var(--ct-radius-lg);
    background: var(--ct-surface-sunken);
    color: var(--ct-text-subtle);
    border: 1px solid var(--ct-border);
    margin-block-start: calc(-1 * var(--ct-space-6));
}

.app-error__icon svg { width: 28px; height: 28px; }

.app-error__title {
    margin: 0;
    font-size: var(--ct-text-xl);
    font-weight: var(--ct-weight-semibold);
    color: var(--ct-text-strong);
}

.app-error__desc { margin: 0; color: var(--ct-text-muted); }

.app-error__actions { margin-block-start: var(--ct-space-3); }

.app-error__ref {
    margin-block-start: var(--ct-space-5);
    font-size: var(--ct-text-xs);
    color: var(--ct-text-subtle);
}

/* The alert-subscription form: two questions, each a row of choices. */
.app-sub-fieldset { border: 0; margin: 0; padding: 0; }
.app-sub-fieldset > legend { padding: 0; margin-block-end: var(--ct-space-2); }

.app-sub-choices {
    display: flex;
    flex-wrap: wrap;
    gap: var(--ct-space-2) var(--ct-space-5);
}

/* A template's language version: one tab panel per language. Hidden, not removed, so the form always
   posts every language. */
.app-template-lang {
    padding-block-start: var(--ct-space-4);
    display: flex;
    flex-direction: column;
    gap: var(--ct-space-4);
}

.app-template-lang[hidden] { display: none; }
.app-template-lang .ct-field + .ct-field { margin-block-start: 0; }

/* One weekly window: day picker, then the range shown twice — the slider as the picture, the
   time fields under its ends as the precise way in. Stacked so they read as one value. */
.app-hour-list {
    list-style: none;
    margin: 0 0 var(--ct-space-4);
    padding: 0;
    display: flex;
    flex-direction: column;
    gap: var(--ct-space-3);
}

.app-hour-row {
    display: flex;
    align-items: flex-start;
    gap: var(--ct-space-5);
    padding: var(--ct-space-3) var(--ct-space-4);
    border: 1px solid var(--ct-border);
    border-radius: var(--ct-radius-md);
    background: var(--ct-surface);
}

.app-hour-day { flex: 0 0 11rem; }
.app-hour-day .ct-select { width: 100%; }

.app-hour-range { flex: 1 1 auto; min-width: 0; }

.app-hour-times {
    display: flex;
    justify-content: space-between;
    gap: var(--ct-space-3);
    flex-wrap: wrap;
}

.app-hour-time {
    display: flex;
    align-items: center;
    gap: var(--ct-space-2);
    font-size: var(--ct-text-sm);
    color: var(--ct-text-muted);
}

.app-hour-time .ct-input { width: 8.5rem; }

.app-hour-row .js-remove-hour { margin-top: var(--ct-space-2); }

@media (max-width: 40rem) {
    .app-hour-row { flex-wrap: wrap; }
    .app-hour-day { flex-basis: 100%; }
}

/* Login hours: a two-handle time range. Two native range inputs stacked over one track — native,
   so each handle keeps its keyboard behaviour and its announced value. */
.app-timerange {
    position: relative;
    padding-block: var(--ct-space-3);
}

.app-timerange__track {
    position: absolute;
    inset-inline: 0;
    inset-block-start: 50%;
    block-size: 6px;
    transform: translateY(-50%);
    border-radius: var(--ct-radius-pill);
    background: var(--ct-surface-hover);
}

.app-timerange__track > span {
    position: absolute;
    inset-block: 0;
    border-radius: inherit;
    background: var(--ct-primary-action);
}

/* Both inputs occupy the same box; only their thumbs are hit-testable, so the lower one stays
   reachable. Without pointer-events: none on the track the top input would swallow every click. */
.app-timerange__handle {
    position: absolute;
    inset-inline: 0;
    inset-block-start: 50%;
    transform: translateY(-50%);
    inline-size: 100%;
    margin: 0;
    background: none;
    pointer-events: none;
    appearance: none;
    -webkit-appearance: none;
}

.app-timerange__handle::-webkit-slider-thumb {
    pointer-events: auto;
    -webkit-appearance: none;
    /* 20px visual, but the input's own focus ring and the row padding keep the target at 44px. */
    inline-size: 20px;
    block-size: 20px;
    border-radius: 50%;
    border: 2px solid var(--ct-surface);
    background: var(--ct-primary-action);
    box-shadow: var(--ct-shadow-xs);
    cursor: grab;
}

.app-timerange__handle::-moz-range-thumb {
    pointer-events: auto;
    inline-size: 20px;
    block-size: 20px;
    border-radius: 50%;
    border: 2px solid var(--ct-surface);
    background: var(--ct-primary-action);
    cursor: grab;
}

.app-timerange__handle:focus-visible { outline: none; }
.app-timerange__handle:focus-visible::-webkit-slider-thumb { box-shadow: var(--ct-ring); }
.app-timerange__handle:focus-visible::-moz-range-thumb { box-shadow: var(--ct-ring); }

/* The slider is a pointer convenience; below sm the row is already cramped and the time fields
   alone are the better control. */
@media (max-width: 40rem) {
    .app-timerange { display: none; }
}


/* The accessibility panel's no-JS action row.
 * The DS requires .ct-a11y-panel__actions to be a SIBLING of the scrolling __body — that is the
 * whole point of the three-region structure, and it is why it never needs stickiness or a z-index.
 * Wrapping it in <noscript> would otherwise interpose a flex item between the two, so the noscript
 * is taken out of the box tree and the __actions div itself becomes the flex child.
 * ONLY with scripting off. With scripting on, a <noscript>'s content is not parsed: it is one raw
 * text node, which the UA keeps hidden with display: none. Overriding that unconditionally printed
 * the button's markup as text at the foot of the panel. */
@media (scripting: none) {
    .ct-a11y-panel > noscript { display: contents; }
}

/* A creation-time value shown as a fact on the participant edit page — same type as an input's
   content, without pretending to be one. A disabled input would be announced as "dimmed" and excluded
   from tabbing anyway; plain text with the group's one explanation above it says more with less. */
.app-locked-value {
  margin: 0;
  min-height: var(--ct-target-min);
  display: flex;
  align-items: center;
  color: var(--ct-text-strong);
  font-size: var(--ct-text-sm);
}

/* Groups: one tree, indented by depth. The indent is a sized spacer rather than padding on the cell,
   so the cell keeps the table's own padding and the link stays the hit target. */
.app-group-name__indent {
  display: inline-block;
  inline-size: calc(var(--depth, 0) * var(--ct-space-6));
}

/* A group page's cards under the member list: side by side when there is room. */
.app-group-panels {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 22rem), 1fr));
  gap: var(--ct-space-4);
  margin-top: var(--ct-space-6);
  align-items: start;
}

.app-plain-list {
  list-style: none;
  margin: 0 0 var(--ct-space-4);
  padding: 0;
  display: grid;
  gap: var(--ct-space-2);
}

/* The participants' bulk bar: the actions, then the sentence that says what they act on. */
.app-bulkbar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--ct-space-2) var(--ct-space-3);
  margin-block: var(--ct-space-3);
}

.app-bulkbar__status {
  margin: 0;
  color: var(--ct-text-muted);
  font-size: var(--ct-text-sm);
}

.app-bulkbar__status--nudge {
  color: var(--ct-text-strong);
  font-weight: 600;
}

/* aria-disabled, not disabled: focusable and announced, but visibly not ready. */
.ct-btn[aria-disabled="true"] {
  opacity: 0.55;
  cursor: not-allowed;
}

.app-select-col {
  inline-size: 2.5rem;
}

/* A tooltip on an ENABLED control (never on a disabled one — see the bulk bar). Shown on hover and
   on keyboard focus; the bubble sits inside the wrapper, so moving the pointer onto it keeps it open,
   and tooltip.js closes it on Escape (WCAG 1.4.13). */
.app-tooltip {
  position: relative;
  display: inline-flex;
}

.app-tooltip__bubble {
  position: absolute;
  inset-block-start: calc(100% + var(--ct-space-2));
  inset-inline-end: 0;
  z-index: 20;
  inline-size: max-content;
  max-inline-size: 18rem;
  padding: var(--ct-space-2) var(--ct-space-3);
  border: 1px solid var(--ct-border);
  border-radius: var(--ct-radius-md);
  background: var(--ct-surface);
  box-shadow: var(--ct-shadow-md);
  color: var(--ct-text-body);
  font-size: var(--ct-text-sm);
  font-weight: 400;
  line-height: 1.4;
  white-space: normal;
  visibility: hidden;
  opacity: 0;
  transition: opacity 120ms ease;
}

.app-tooltip:hover .app-tooltip__bubble,
.app-tooltip:focus-within .app-tooltip__bubble {
  visibility: visible;
  opacity: 1;
}

.app-tooltip.is-dismissed .app-tooltip__bubble {
  visibility: hidden;
  opacity: 0;
}

@media (prefers-reduced-motion: reduce) {
  .app-tooltip__bubble { transition: none; }
}

/* A row whose state changed under a poll (row-changes.js): a lab-page participant gone from
 * Pending to Ready, or a labs-list row whose status or environments moved.
 *
 * Two layers, so the meaning never rests on the animation alone:
 *   - .is-row-changed: the row's badge ("Just now" / "Updated" — text, so not colour alone, 1.4.1)
 *     and a thin success bar at the row's start. Kept for a minute, across polls.
 *   - .is-row-changed-flash: a soft success tint that fades out over a few seconds, once.
 * Under reduced motion — the OS setting or the accessibility panel's — the tint does not fade: it
 * is simply there, statically, for as long as the badge is, and goes with it. The DS's global
 * reduced-motion rule would otherwise cut the fade to 0.01 ms and the tint would never be seen. */
.app-row-changed-badge { margin-inline-start: var(--ct-space-2); }

.ct-table tr.is-row-changed > td:first-child { box-shadow: inset 3px 0 0 var(--ct-success-500); }

.ct-table tr.is-row-changed-flash > td { animation: app-row-changed 4s ease-out 1; }

@keyframes app-row-changed {
  from { background-color: var(--ct-success-bg); }
  to   { background-color: transparent; }
}

@media (prefers-reduced-motion: reduce) {
  .ct-table tr.is-row-changed-flash > td { animation: none; }
  .ct-table tr.is-row-changed > td { background-color: var(--ct-success-bg); }
}
[data-reduced-motion="reduce"] .ct-table tr.is-row-changed-flash > td { animation: none; }
[data-reduced-motion="reduce"] .ct-table tr.is-row-changed > td { background-color: var(--ct-success-bg); }

/* High contrast's success background is the surface's near-black, so the tint vanishes there; the
   bar and an outlined badge carry it instead. */
[data-mode="high-contrast"] .app-row-changed-badge { border: 1px solid currentColor; }
[data-mode="high-contrast"] .ct-table tr.is-row-changed > td:first-child { box-shadow: inset 4px 0 0 var(--ct-success-text); }

/* A checkbox in the second column of a field grid, beside a select: pushed down past where the
   select's label is, so the box sits level with the control it qualifies rather than its label.
   Stacked on narrow screens, it simply follows. */
.app-field-grid__check { padding-top: calc(var(--ct-text-sm) * 1.5 + var(--ct-space-2)); }
@media (max-width: 40rem) { .app-field-grid__check { padding-top: 0; } }
.app-field-grid__check > .ct-hint { display: block; margin-top: var(--ct-space-1); }

/* A block of fields under its own caption inside a card — the synced group's settings. */
.app-fieldset { border: 0; margin: var(--ct-space-4) 0 0; padding: 0; min-inline-size: 0; }
.app-fieldset > legend { padding: 0; margin-bottom: var(--ct-space-3); font-weight: 600; }

/* A record's place in a tree, above its title: each level a link, separated by a chevron drawn in CSS
   so a screen reader reads the list, not the punctuation. */
.app-breadcrumb { display: flex; flex-wrap: wrap; gap: var(--ct-space-1) var(--ct-space-2); list-style: none; margin: 0 0 var(--ct-space-1); padding: 0; color: var(--ct-text-muted); font-size: var(--ct-text-sm); }
.app-breadcrumb li + li::before { content: "›"; margin-inline-end: var(--ct-space-2); color: var(--ct-text-subtle); }
.app-breadcrumb [aria-current="page"] { color: var(--ct-text-strong); }

/* Audit log: a record's JSON details, readable in a narrow cell without widening the table. */
.app-audit-details {
    max-width: 48ch;
    max-height: 24rem;
    overflow: auto;
    white-space: pre-wrap;
    word-break: break-word;
    font-size: var(--ct-font-size-sm, 0.8125rem);
}

/* A short qualifier inside a field label ("Search (address, target…)"): part of the label's text, so
   screen readers read it with the name, but quieter than the name itself. */
.app-label-hint { font-weight: var(--ct-weight-regular, 400); color: var(--ct-text-muted); }

/* --- The Trainer Tool (a trainer's access page) -------------------------- */
.app-trainer-tool { display: flex; flex-direction: column; gap: var(--ct-space-3); }
.app-trainer-tool__title { margin: 0; font-size: var(--ct-text-md); }
.app-trainer-tool__note { margin: 0; font-size: var(--ct-text-sm); color: var(--ct-text-muted); }
.app-trainer-tool__state { margin: 0; font-weight: var(--ct-weight-semibold); }
.app-trainer-tool__actions { display: flex; flex-wrap: wrap; gap: var(--ct-space-2); }
.app-trainer-tool__pairing { display: flex; flex-direction: column; align-items: flex-start; gap: var(--ct-space-2); }
/* Read off the screen and typed into another program, so it gets the type that tells 8 from B. */
.app-trainer-tool__code {
  margin: 0;
  font-family: var(--ct-font-mono);
  font-size: var(--ct-text-xl);
  font-weight: var(--ct-weight-semibold);
  letter-spacing: 0.12em;
  color: var(--ct-text-strong);
}
.app-trainer-tool__code { display: inline-flex; align-items: center; gap: var(--ct-space-2); }
.app-trainer-tool__code .js-copy { letter-spacing: normal; }
.app-trainer-tool__code .js-copy.is-copied { color: var(--ct-success-text, var(--ct-success)); }
.app-trainer-tool__download { margin: 0; font-size: var(--ct-text-sm); }


/* --- Lab instructions: plain text steps, and the printable document ----------------------------
   A step is plain text everywhere (portal, CustomDesktop, the printed copy): its line breaks are
   kept here rather than by markup stored in the data. */
.app-step-text { white-space: pre-line; overflow-wrap: anywhere; }

/* _LayoutPrint: a document, not an app. Light regardless of the reader's mode — it is meant for paper. */
.app-print { background: #fff; color: #1a1a1a; }
.app-print main { max-width: 52rem; margin: 0 auto; padding: var(--ct-space-6) var(--ct-space-4); }
.app-print__toolbar { display: flex; flex-wrap: wrap; gap: var(--ct-space-3); margin-bottom: var(--ct-space-6); }
.app-print__empty { color: #555; }
.app-instructions { font-size: 11pt; line-height: 1.5; }
.app-instructions__head { border-bottom: 1px solid #ccc; padding-bottom: var(--ct-space-3); margin-bottom: var(--ct-space-5); }
.app-instructions__title { margin: 0; font-size: 1.75rem; }
.app-instructions__subtitle { margin: var(--ct-space-1) 0 0; color: #555; }
.app-instructions__module > h2 { font-size: 1.35rem; margin: var(--ct-space-6) 0 var(--ct-space-3); }
.app-instructions__exercise > h3 { font-size: 1.1rem; margin: var(--ct-space-4) 0 var(--ct-space-2); }
.app-instructions__steps { margin: 0 0 var(--ct-space-3); padding-left: 1.75rem; }
.app-instructions__steps > li { margin-bottom: var(--ct-space-2); }

@media print {
    @page { margin: 18mm 16mm; }
    .app-print__toolbar { display: none; }
    .app-print main { max-width: none; padding: 0; }
    /* A heading is never the last line on a page, and a step is not split across two. */
    .app-instructions h2, .app-instructions h3 { break-after: avoid; page-break-after: avoid; }
    .app-instructions__steps > li { break-inside: avoid; page-break-inside: avoid; }
    .app-instructions__module + .app-instructions__module { break-before: auto; }
}

/* Cards one under another (a trainer's students page): the DS card has no outer margin of its own. */
.app-panel-stack { display: flex; flex-direction: column; gap: var(--ct-space-4); }

/* ===================== Portal notice (Organisation → Notice) =====================
 * A band above the footer: the funder's strip of marks, then the sentence. Side by side when there is room,
 * stacked on a phone. The image keeps its proportions and never overflows the column. */
.app-notice {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--ct-space-3) var(--ct-space-5);
  padding-block: var(--ct-space-4);
  border-block-start: 1px solid var(--ct-border);
}
.app-notice__image { display: block; max-block-size: 72px; max-inline-size: 100%; inline-size: auto; }
.app-notice__text {
  flex: 1 1 18rem;
  margin: 0;
  font-size: var(--ct-text-sm);
  color: var(--ct-text-muted);
  white-space: pre-line;
}
/* Lab access page: inside the footer band, which draws the top rule and holds the footer to the page's measure —
 * so the notice takes the footer's box (same measure, centred, no rule of its own) and the footer gets the rule
 * between them. Otherwise the notice runs wider than the footer below it. */
.ct-public-shell__footer .app-notice {
  inline-size: 100%;
  max-inline-size: var(--_measure);
  margin-inline: auto;
  border-block-start: 0;
}
.ct-public-shell__footer .app-notice + .ct-footer { border-block-start: 1px solid var(--ct-border); }
/* The signed-out column is narrow: the notice sits under the settings strip, image above the text. */
.ct-auth__col .app-notice { flex-direction: column; align-items: flex-start; }
.ct-auth__col .app-notice__text { flex: none; }
/* Funders' marks are drawn for a white ground (and their rules usually ask for one): kept white in dark mode. */
.app-notice__image { background: #fff; padding: var(--ct-space-1); border-radius: var(--ct-radius-sm); }

/* Signed-out shell, branded panel: the pitch (logo, headline, features) sits in the vertical middle of the panel and
 * the copyright line stays at the foot. The design system lays the panel out with `space-between`, which pins the
 * pitch to the top and leaves the lower half of a tall window empty. Portal-local on purpose: cloudteam-ds.css is
 * vendored and not hand-edited. Auto block margins on the first child split the free space above and below it. */
@media (min-width: 64rem) {
  .ct-auth__panel { justify-content: flex-start; }
  .ct-auth__panel > :first-child { margin-block: auto; }
}

/* Accessibility panel must stay inside the window.
 *
 * The DS anchors the popover under its trigger (top: anchor(bottom); left: anchor(left)) and offers
 * `flip-block, flip-inline` as fallbacks. Those are tried ONE AT A TIME, and the trigger sits at the right end of the
 * settings strip, so below-and-left runs off the right edge, above-and-left still does, and below-and-right runs off
 * the bottom: nothing fits, the first placement wins and the panel is clipped (seen on dev.cloudlabs.eu). The
 * combination "above AND right-aligned" is the one that fits, so it is added as a candidate.
 *
 * Where no anchored placement fits at all (a short or narrow window, or a trigger in the middle of a tall one: the
 * panel is up to 40rem tall) the LAST candidate is a sheet centred in the window and never larger than it. The
 * browser tries the candidates in order and keeps the first that fits, so this depends on the real position of the
 * trigger, not on a guessed window size. The three regions inside (header, scrolling body, footer) are unchanged, so
 * a short window scrolls the body, not the page.
 *
 * `position: fixed` so the sheet is placed against the window, not the page (the DS makes the popover `absolute`,
 * which would put it at the top of a scrolled page). Literal 1rem in @position-try: var() is not usable there. */
@position-try --ct-a11y-sheet {
  inset: 1rem;
  width: auto;
  max-width: 24rem;
  height: fit-content;
  max-height: calc(100svh - 2rem);
  margin: auto;
}
.ct-a11y-panel:popover-open {
  position: fixed;
  position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline, --ct-a11y-sheet;
}

/* Footer: tighter. The DS gives the footer 16px above and below its row, and the shell's own 32px bottom padding then
 * sits under it, so the text floated 57px above the bottom of the window in a 69px-tall footer (measured). The shell's
 * bottom padding is cut to 8px and the footer's to 8px: 25px from the bottom, 53px tall. The language control keeps its
 * 36px height (the DS minimum touch target), which is what sets the footer's height. The breathing room between the
 * page and the footer's rule is the body's own padding (DS-DEVIATIONS 8) and is untouched. */
.ct-app-shell__main { padding-block-end: var(--ct-space-2); }
.ct-footer { padding-block: var(--ct-space-2); }

/* The licence/updates banner sits directly above the page inside .ct-app-shell__body. Pages carry no leading margin
 * by design (see DS-DEVIATIONS 8 for the trailing one), so without its own the banner touched the page title.
 *
 * Record pages (form/detail views) start with .ct-pagebar, which pulls itself UP by --ct-space-8 (negative margin, so
 * it bleeds to the top of the padded column) on the assumption that nothing precedes it. With the banner above, that
 * pull put the opaque bar over the banner and cut off its last lines. The banner therefore gives that distance back on
 * pages that have a bar, and keeps the plain gap on all others. Measured: -32px before, 24px after. */
#license-banner { margin-block-end: var(--ct-space-6); }
.ct-app-shell__body:has(.ct-pagebar) > #license-banner {
  margin-block-end: calc(var(--ct-space-6) + var(--ct-space-8));
}

/* Signed-out screens: the footer sits under the credential grid, so the grid fills the rest of the viewport. */
.app-auth-shell { display: flex; flex-direction: column; min-block-size: 100svh; }
.app-auth-shell > .ct-auth { flex: 1 1 auto; min-block-size: 0; }
.app-auth-shell__footer { padding-inline: var(--ct-space-6); background: var(--ct-surface); }
.app-auth-shell__footer .ct-footer__lang { display: none; }
