/* ==========================================================================
   Silica Solutions - monochrome business dashboard

   Strictly black, white and grey, warm-tinted so nothing is pure #000 / #fff.
   With hue removed, emphasis is carried by weight, size, lightness and
   spacing instead.

   Motion is entrance-only and runs once per data change: marks arrive in the
   direction their data is read, then stop. Filter changes re-render in place
   without replaying. Hover and focus keep short transitions as feedback.
   ========================================================================== */

:root {
  --page: #f4f3f0;
  --surface: #fdfcfa;

  /* Calendar grid metrics.
     Declared here rather than on `.timegrid` because the loading placeholder
     that stands in for the grid is not inside it, and the two have to agree to
     the pixel or the card resizes when the bookings land. `--timegrid-head-h`
     is the weekday strip: 8px + 9px padding around a 25px date disc, a 13px
     label and the 2px between them, plus its bottom border. */
  --timegrid-body-h: 620px;
  --timegrid-head-h: 58px;
  --timegrid-h: calc(var(--timegrid-body-h) + var(--timegrid-head-h));

  /* Tall table scrollers, and the sticky header inside them. Shared with the
     loading placeholder that stands in for the rows, for the same reason. */
  --table-tall-h: 560px;
  --record-head-h: 38px;

  --ink-900: #171512;
  --ink-800: #2b2823;
  --ink-700: #423e37;
  --ink-600: #5c574e;
  --ink-500: #7a746a;
  --ink-400: #8b857a;
  --ink-300: #bdb8ae;
  --ink-200: #d0cbc0;
  --ink-150: #e6e2d9;
  --ink-100: #efece5;

  --border: rgba(23, 21, 18, 0.11);
  --border-strong: rgba(23, 21, 18, 0.2);

  --radius-sm: 4.5px;
  --radius: 4.5px;
  --radius-lg: 4.5px;

  --shadow-sm: 0 1px 2px rgba(23, 21, 18, 0.05);
  --shadow: 0 1px 3px rgba(23, 21, 18, 0.06), 0 8px 20px -10px rgba(23, 21, 18, 0.13);

  --font: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;

  --scrollbar-thumb: var(--ink-300);
  --scrollbar-thumb-hover: var(--ink-400);
  --scrollbar-track: var(--ink-100);

  /* Gap between the tab row and the panel it opens. Declared here because two
     rules have to agree on it: the tab row's bottom margin, and the negative
     offset the panel's bleed uses to reach back up to the track's base edge. */
  --tabs-gap: 18px;
}

*,
*::before,
*::after {
  box-sizing: border-box;
}

html {
  -webkit-text-size-adjust: 100%;
}

* {
  -webkit-tap-highlight-color: transparent;
}

::selection {
  background: var(--ink-200);
  color: var(--ink-900);
}

/* --- Scrollbars ----------------------------------------------------------- */

/* Keeps form controls and native UI on the light theme, and stops the browser
   drawing a dark default scrollbar when the OS is in dark mode. */
html {
  color-scheme: light;

  /* The gutter reserved below belongs to `html`, not `body`, and `body` is
     what carries the page tint - so the reserved strip was painted with the
     browser's own canvas colour. It read as a white band down the right edge,
     which only became obvious against a modal's dimmed backdrop, since that is
     when the strip is the one light thing on screen. Painting `html` puts the
     page colour under the gutter as well. */
  background: var(--page);
}

/* The white strip down the right edge is fixed by the `html` background
   above, and nothing else is needed for it.
 *
 * Worth recording why, because the obvious fixes are all wrong. The strip is
 * the gutter `scrollbar-gutter: stable` reserves on `html`: when a dialog sets
 * `body { overflow: hidden }` the scrollbar goes but the reserved band stays,
 * and it was painted with the browser canvas because `body` carried the page
 * tint and `html` had none. The overlay was never failing to cover it -
 * `position: fixed` with `inset: 0` resolves against the viewport, so the
 * backdrop already spanned the full width and was painting over a band that
 * had no colour under it.
 *
 * So: do NOT give the overlay `width: 100vw` (that is wider than the viewport
 * when a scrollbar is present, and introduces the sideways shift the gutter
 * exists to prevent), and do NOT drop the gutter while a dialog is open (the
 * layout then jumps by the width of the bar on open and again on close). */

/* Firefox and any other engine implementing the standard properties. */
html {
  /* The gutter is reserved whether or not the page currently overflows.

     Without this, a short page has no scrollbar and a long one does, so moving
     between them changes the width of the viewport - and every centred or
     percentage-width thing on the page jumps sideways by the width of the bar.
     The same shift happens within a single page the moment content grows past
     one screen. Reserving the space means the layout is the same width in both
     states, so nothing moves.

     `stable` rather than `stable both-edges`: the bar sits on one side, and
     reserving a matching strip on the other would trade the shift for a
     permanent empty margin. Overlay scrollbars (most phones, and macOS unless
     "always show" is set) take no space to begin with, and the property is
     correctly a no-op there. */
  scrollbar-gutter: stable;

  scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
  scrollbar-width: thin;
}

/* WebKit/Blink. The track is drawn flat against the page tint and the thumb
   picks up the same greys as the borders, so it reads as part of the surface
   rather than as chrome sitting on top of it. */
::-webkit-scrollbar {
  width: 10px;
  height: 10px;
}

::-webkit-scrollbar-track {
  background: var(--scrollbar-track);
}

::-webkit-scrollbar-thumb {
  background: var(--scrollbar-thumb);
  border: 2px solid var(--scrollbar-track);
  border-radius: 999px;
}

::-webkit-scrollbar-thumb:hover {
  background: var(--scrollbar-thumb-hover);
}

/* Without this the intersection of a horizontal and vertical bar falls back to
   the UA's default grey patch. */
::-webkit-scrollbar-corner {
  background: var(--scrollbar-track);
}

/* The table scroller sits on --surface rather than --page, so its track is
   matched to the panel it lives in. */
.table-wrap {
  scrollbar-color: var(--scrollbar-thumb) var(--surface);
}

.table-wrap::-webkit-scrollbar-track,
.table-wrap::-webkit-scrollbar-corner {
  background: var(--surface);
}

.table-wrap::-webkit-scrollbar-thumb {
  border-color: var(--surface);
}

body {
  margin: 0;
  background: var(--page);
  color: var(--ink-900);
  font-family: var(--font);
  font-size: 14px;
  line-height: 1.5;
  -webkit-font-smoothing: antialiased;
}

h1,
h2,
h3,
p {
  margin: 0;
}

button {
  font: inherit;
  color: inherit;
}

:focus-visible {
  outline: 2px solid var(--ink-900);
  outline-offset: 2px;
  border-radius: 4.5px;
}

/* Chart hit-targets are large, often-transparent SVG shapes (the pointer
   overlay, per-row/column/cell/slice groups). The default outline above would
   draw a solid box around the whole shape instead of a subtle cue, so they
   opt out and rely on their own focus treatment (e.g. .chart-row:focus-visible).
   `tabindex` on a non-native-interactive element (a plain SVG <g>) means
   Chromium and Firefox cannot tell a keyboard focus from a pointer one, so
   `:focus-visible` also matches on a plain mouse click here - not just
   `:focus`, so both are covered. */
/* The scrub overlay owns horizontal gestures inside the plot. Without this the
   browser can claim a drag for page scrolling partway through, pointermove
   stops arriving, and the readout freezes under a finger that is still moving.
   `pan-y` keeps vertical scrolling working, so the page is never trapped. */
.chart-overlay {
  touch-action: pan-y;
}

.chart-overlay:focus,
.chart-overlay:focus-visible,
.chart-row:focus,
.chart-row:focus-visible,
.chart-column:focus,
.chart-column:focus-visible,
.chart-cell:focus,
.chart-cell:focus-visible,
.chart-slice:focus,
.chart-slice:focus-visible {
  outline: none;
}

.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

.skip-link {
  position: absolute;
  left: -9999px;
  top: 12px;
  z-index: 100;
  padding: 10px 16px;
  background: var(--surface);
  border: 1px solid var(--border-strong);
  border-radius: var(--radius-sm);
  box-shadow: var(--shadow);
}

.skip-link:focus {
  left: 16px;
}

/* --- Page transitions -----------------------------------------------------
   Moving between the dashboard and the settings page is one workspace changing
   view, not two documents being fetched - so the navigation is animated rather
   than left as the browser's white flash.

   These are cross-document view transitions, which are pure CSS: both
   documents opt in with `@view-transition`, and the browser snapshots the old
   page, swaps in the new one and animates between them. Chrome 126+ and
   Safari 18.2+ run it; anywhere else the at-rule is ignored and navigation is
   simply instant, which is exactly the old behaviour and no worse for it.

   The chrome is deliberately excluded from the movement. The header and the
   dashboard switcher are identical on every page, so animating them would mean
   fading one copy of the same control out while an identical one fades in -
   visible work for no change. Naming them holds each one still while only
   `main` moves, which is what sells the pages as one shell.

   This is also why the switcher is a sibling of `main` rather than its first
   child. Inside `main` it was part of the `page` snapshot, so it left and
   re-entered on every navigation - the nav bar visibly reappearing under a
   header that had not moved. Lifted out and named, it stays put like the
   header, and the only thing that travels is the view below it.
   ========================================================================== */

@view-transition {
  navigation: auto;
}

/* Each name must be unique per page, and these elements exist once. The shared
   name across both documents is what pairs them into a single, still group.

   `view-transition-name` creates a stacking context, which is not free here:
   the account menu hangs out of this header and is positioned above the page
   with `z-index: 40`, and that number can only compete inside whatever context
   contains it. Trapped in the header, the menu was painted beneath the tab
   strip and the cards below - visible, but only its top few rows could be
   clicked, because page content was sitting on top of the rest of it.

   So the header is given a stacking position of its own, above the content
   that follows it in the document. The menu's own z-index then orders it
   within the header, and the header as a whole sits above the page. */
.topbar {
  position: relative;
  z-index: 30;
  view-transition-name: topbar;
}

/* Held still for the same reason as the header, and it has to sit above the
   page for the same reason too: its own stacking context would otherwise let
   the incoming `main` snapshot paint over the track during the swap. */
.switcher {
  position: relative;
  z-index: 20;
  view-transition-name: switcher;
}

main {
  view-transition-name: page;
}

/* The auth header is a different object from the workspace one: no live-state
   block, no account menu, and a different height. Pairing those two would ask
   the browser to morph one into the other, which draws attention to a
   difference the reader has no reason to study.

   But the auth headers are identical to *each other*. Sign in, create account
   and the signed-in page carry the same brand block at the same size, so they
   get a name of their own - shared across those three documents and shared
   with nothing else. Moving between them now holds the header perfectly still
   while only the card beneath it travels, which is what makes the three pages
   read as one surface rather than as three documents that happen to look
   alike. Leaving it unnamed cross-faded the header along with the page: the
   logo and wordmark visibly dipped and came back on every step of signing in.

   The name differs from `topbar`, so an auth page and a workspace page are
   still unpaired and still cross-fade - which is the behaviour the paragraph
   above wants for signing in and out. */
.topbar--auth {
  view-transition-name: topbar-auth;
}

/* The root does not animate at all, and that is the single biggest thing
   keeping this smooth.

   Left to its default it cross-fades a full-page snapshot - the entire
   dashboard, every SVG chart in it - against another one. Two full-viewport
   layers being composited with changing opacity is the expensive part, and it
   is what made the transition stutter on the way in.

   It was also the source of the white flash. During the swap, both the old and
   new root layers are on screen at once; while the incoming page's charts have
   not painted yet, the reader sees two page backgrounds stacked and lightening
   through each other. Holding the root perfectly still removes both problems
   at once - the backgrounds are identical on every page here, so there is
   nothing about the root worth animating in the first place.

   As with the header, only the incoming layer is painted. Two opaque snapshots
   held on top of each other are composited together rather than one simply
   covering the other, so anything common to both pages was drawn twice and
   came out darker than it should be. */
::view-transition-old(root) {
  animation: none;
  opacity: 0;
}

::view-transition-new(root) {
  animation: none;
  opacity: 1;
  mix-blend-mode: normal;
}

/* Same for the header: it is the same element in the same place on both pages,
   so it holds rather than fades.

   Only the incoming copy is painted. Showing both at full opacity stacks two
   opaque snapshots of the same header, and because the default blend mode
   composites them together, the shared text and rules darkened as though the
   header had been printed twice - which is exactly what was happening. Hiding
   the outgoing copy and holding the new one at full strength leaves one
   header on screen for the whole navigation. */
::view-transition-old(topbar) {
  animation: none;
  opacity: 0;
}

::view-transition-new(topbar) {
  animation: none;
  opacity: 1;
  mix-blend-mode: normal;
}

/* And the dashboard switcher, on identical terms - one copy on screen, held at
   full strength, for the whole navigation. The only difference between the two
   pages is which link carries the pill, and that is a paint the incoming
   snapshot already has. */
::view-transition-old(switcher) {
  animation: none;
  opacity: 0;
}

::view-transition-new(switcher) {
  animation: none;
  opacity: 1;
  mix-blend-mode: normal;
}

/* The only thing that actually moves.

   The two halves are sequenced rather than cross-faded. A cross-fade means two
   full pages of text are on screen together at partial opacity, and no amount
   of easing makes overlapping paragraphs look like anything but a double
   exposure - that is what made the earlier version feel cheap. So the outgoing
   page is gone in 120ms, on an ease-in that accelerates it away, and the
   incoming one does not begin until it has left. Nothing ever overlaps.

   That buys the arrival room to be deliberate. It comes in over 300ms on
   ease-out-quart - the same curve as the tab underline and the heading swap -
   with a touch more travel than before, because a movement that is alone on
   screen can afford to be seen. Ease-out front-loads the distance, so it reads
   as arriving well before it technically stops, and the whole navigation is
   over inside 400ms. The result is one page giving way to the next rather than
   two competing for the same space.

   Only `opacity` and `transform` are animated, so both halves run entirely on
   the compositor: measured at zero dropped frames through the transition. */
::view-transition-old(page) {
  animation: page-leave 0.12s cubic-bezier(0.55, 0, 1, 0.45) both;
}

::view-transition-new(page) {
  animation: page-enter 0.3s 0.1s cubic-bezier(0.25, 1, 0.5, 1) both;
}

/* The snapshots are stacked, and blending them is what produced the washed-out
   doubled text. `isolation: auto` lets the incoming one paint over the
   outgoing one instead of mixing with it. */
::view-transition-image-pair(page) {
  isolation: auto;
}

/* Each snapshot keeps its own height and is pinned to the top of the group
   rather than stretched to fill it. Without this the shorter of the two pages
   is scaled up to the taller one's box, which distorts the whole page for the
   length of the transition - text and charts stretched vertically, then
   snapping back at the end. Anchoring at the top instead means both pages
   start from the same edge, which is where the reader is looking. */
::view-transition-old(page),
::view-transition-new(page) {
  height: auto;
  object-fit: none;
  object-position: top left;
}

/* The group is the box the pair is drawn inside, and by default it animates
   from the old page's size to the new one's. These two pages are nowhere near
   the same height - the dashboard's main is around 1500px against the settings
   page's 780px - so that default resizes a container by hundreds of pixels
   mid-navigation, and whichever snapshot is shorter than the box leaves the
   page background showing through the difference.

   Holding the group still removes the resize entirely. Each snapshot is then
   just drawn at its own size and faded, which is all this transition ever
   wanted the group to do. */
::view-transition-group(page) {
  animation: none;
}

/* Leaves by lifting and receding slightly - a page being set down, not slid
   off. The scale is fractional on purpose: at 0.994 it registers as depth
   rather than as a zoom, which would be a louder effect than this page wants. */
@keyframes page-leave {
  to {
    opacity: 0;
    transform: translateY(-8px) scale(0.994);
  }
}

/* Arrives by rising into place from very slightly back. Same fractional scale,
   so the two halves are mirror images of one gesture. */
@keyframes page-enter {
  from {
    opacity: 0;
    transform: translateY(10px) scale(0.994);
  }
}

/* --- Shell --------------------------------------------------------------- */

.shell {
  max-width: 1300px;
  margin: 0 auto;
  padding: 0 24px 64px;
}

.topbar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
  padding: 18px 0;
  margin-bottom: 22px;
  border-bottom: 1px solid var(--border);
  flex-wrap: wrap;
}

.brand {
  display: flex;
  align-items: center;
  gap: 11px;
}

/* The mark is the logo drawn as a mask rather than an <img> so it inherits the
   surrounding text colour - that keeps it legible against any surface, which a
   flat black SVG would not. */
/* The club mark, at its own colour rather than `currentColor`.

   Every other glyph in this system is monochrome line art masked to ink, but
   this one is a printed emblem - gold and maroon on the letterhead, the vests,
   the door decal - and forcing it flat would make it stop being recognisable as
   the club's mark rather than make it fit the palette. It gets the one
   exception: a plain image, full colour, at the size the reference reserves for
   the brand slot. */
.brand__mark {
  display: grid;
  place-items: center;
  width: 34px;
  height: 34px;
  flex: none;
}

.brand__mark img {
  width: 100%;
  height: 100%;
  object-fit: contain;
}

.brand__name {
  font-size: 15px;
  font-weight: 600;
  letter-spacing: -0.01em;
}

.brand__sub {
  font-size: 12px;
  color: var(--ink-400);
}

.topbar__right {
  display: flex;
  align-items: center;
  gap: 18px;
}

/* --- Profile menu ---------------------------------------------------------
   The account control sits at the far right of the header, where it is looked
   for. Closed, it is a quiet row - avatar, name, chevron - with no border, so
   it does not compete with the filter buttons a line below it. Open, it becomes
   a proper surface: the same card treatment as every panel on the page, so the
   menu reads as part of the dashboard rather than as browser chrome. */

.profile {
  position: relative;
}

.profile__trigger {
  display: inline-flex;
  align-items: center;
  gap: 9px;
  padding: 5px 9px 5px 5px;
  background: transparent;
  border: 1px solid transparent;
  border-radius: 4.5px;
  cursor: pointer;
  text-align: left;
  transition: background 0.12s linear, border-color 0.12s linear;
}

.profile__trigger:hover {
  background: var(--ink-100);
  border-color: var(--border);
}

.profile__trigger[aria-expanded='true'] {
  background: var(--ink-100);
  border-color: var(--border-strong);
}

/* The account disc.

   The resting fill is the light plate, not the ink one. It used to be
   `--ink-900`, which meant black was what showed in every gap between the
   three states this disc moves through - and there is always a gap. The
   shimmer is dropped the instant the disc is known to be filled, but a
   portrait is not painted until it has decoded, so between those two moments
   the disc fell back to this rule and flashed a black circle. On a page with
   two discs it flashed twice.

   Ink is the backdrop for INITIALS specifically, so it is painted by the rule
   that shows them (`[data-avatar-initials]`) rather than sitting underneath
   every state as a default. Light is the right resting colour for all the
   others: it is what the shimmer fades from, what a decoded photo sits on, and
   what an empty disc should look like while it waits.

   `color` stays light because it is only ever seen against the ink plate -
   the initials state re-declares neither, and the shimmer state overrides the
   colour to transparent so the fallback letters do not show through it. */
.profile__avatar {
  display: grid;
  place-items: center;
  flex-shrink: 0;
  width: 30px;
  height: 30px;
  border-radius: 50%;
  background: var(--ink-100);
  color: var(--surface);
  font-size: 11.5px;
  font-weight: 600;
  letter-spacing: 0.02em;
}

/* Initials, and the dark plate they are written on. Set by the same code that
   writes the letters, so the plate and its text arrive together and neither is
   ever on screen without the other. */
.profile__avatar[data-avatar-initials],
.hero-id__avatar[data-avatar-initials] {
  background: var(--ink-900);
  color: var(--surface);
}

.profile__avatar--lg {
  width: 38px;
  height: 38px;
  font-size: 13px;
}

.profile__id {
  display: flex;
  flex-direction: column;
  line-height: 1.25;
  min-width: 0;
}

.profile__name {
  font-size: 13px;
  font-weight: 600;
  letter-spacing: -0.01em;
}

.profile__role,
.profile__mail {
  font-size: 11.5px;
  color: var(--ink-400);
}

.profile__mail {
  margin-top: 2px;
}

/* The card's identity block, set to the same measure as the trigger's.

   `.profile__id` - the name and role beside the avatar in the topbar - pins
   `line-height: 1.25`. The menu card's equivalent block is a plain wrapper
   with none, so it inherited the body's 1.5: the same 11.5px text rendered
   17.25px tall there against 14.4px in the trigger, which read as the role
   being a larger, looser line than the email directly above it rather than
   the matched pair they are.

   Set on the block rather than on `.profile__role` so the name, the email and
   the role all sit on one rhythm, which is what makes them read as one stack. */
.profile__card > div {
  line-height: 1.25;
}

/* Rotates to point up while the menu is open, so the control states which way
   it will move next rather than only that it is a menu. */
.profile__chevron {
  flex-shrink: 0;
  color: var(--ink-400);
  transition: transform 0.18s cubic-bezier(0.22, 0.61, 0.36, 1);
}

.profile__trigger[aria-expanded='true'] .profile__chevron {
  transform: rotate(180deg);
}

/* Anchored to the right edge of the trigger so it can never overhang the
   viewport on the side it opens towards. */
.profile__menu {
  position: absolute;
  top: calc(100% + 8px);
  right: 0;
  z-index: 40;
  width: 244px;
  padding: 6px;
  background: var(--surface);
  border: 1px solid var(--border-strong);
  border-radius: var(--radius);
  box-shadow: var(--shadow);

  /* Grows out of the corner it is pinned to, matching the tooltip's entrance. */
  transform-origin: 100% 0;
  animation: profile-menu-in 0.18s cubic-bezier(0.22, 0.61, 0.36, 1);
}

@keyframes profile-menu-in {
  from {
    opacity: 0;
    transform: scale(0.97) translateY(-4px);
  }
}

.profile__card {
  display: flex;
  align-items: center;
  gap: 11px;
  padding: 10px 10px 12px;
}

.profile__group {
  padding-top: 5px;
  border-top: 1px solid var(--border);
}

/* Items are a mix of <button> and <a> - whichever is right for what the item
   does - so this resets both to the same row. */
.profile__item {
  display: flex;
  align-items: center;
  gap: 10px;
  width: 100%;
  padding: 8px 10px;
  background: transparent;
  border: 0;
  border-radius: 4.5px;
  font: inherit;
  font-size: 13px;
  color: var(--ink-700);
  text-align: left;
  text-decoration: none;
  cursor: pointer;
  transition: background 0.12s linear, color 0.12s linear;
}

.profile__item svg {
  flex-shrink: 0;
  color: var(--ink-400);
  transition: color 0.12s linear;
}

.profile__item:hover,
.profile__item:focus-visible {
  background: var(--ink-100);
  color: var(--ink-900);
}

.profile__item:hover svg,
.profile__item:focus-visible svg {
  color: var(--ink-900);
}

/* The item naming the page already open. It keeps its hover treatment, because
   it is still focusable and still a real target - it simply has nowhere to go,
   and the dot says so without greying the label into something that looks
   broken. */
.profile__item[aria-current='page'] {
  color: var(--ink-900);
  font-weight: 550;
  cursor: default;
}

.profile__item[aria-current='page']::after {
  content: '';
  width: 5px;
  height: 5px;
  margin-left: auto;
  border-radius: 50%;
  background: var(--ink-900);
}

.profile__group--last {
  margin-top: 5px;
}

/* --- Dashboard tabs -------------------------------------------------------
   A section index ruled into the page, not a container sitting on top of it.

   Two earlier versions were both wrong, in opposite directions. A bare
   underline was unreadable: a row of grey words under a heading looks like a
   caption, and a 2px rule under one of them is not enough to say the row is
   pressable. The fix for that was an enclosed track with the open tab cut
   through it - legible, but it imported a browser-chrome / file-folder
   metaphor into a page where nothing else has depth. Every other surface here
   is flat and ruled, so the one element pretending to be a physical object was
   the thing that read as out of place, sitting between a plain heading and a
   grid of flat-bordered cards.

   This keeps the flatness and solves the affordance problem with contrast
   instead of elevation, which is the same thing the rest of the page does:
   a full-width rule anchors the row so it is structure rather than stray text,
   the labels sit at a real ink step below the heading, and the selected one
   takes near-black ink, a weight jump and a 2px marker sitting on the rule.
   Nothing is enclosed, nothing is lifted, and the active tab is the darkest
   thing in the row - which is how the rest of this system marks emphasis. */

.tabs {
  position: relative;
  display: flex;
  /* The tab's horizontal padding, and therefore how far the active marker is
     inset from the button box at each end. One value so the label, the hover
     stub and the marker cannot drift apart across breakpoints - `slideInk`
     reads it back off `.tabs__ink`'s computed `left`. */
  --tab-inset: 14px;
  /* Thickness of the active marker and its hover stub. Heavy enough to read as
     a deliberate mark rather than a hairline that happens to be under a word. */
  --tab-marker: 3px;
  margin-bottom: var(--tabs-gap);
  /* The rule the whole row sits on, drawn across the full content column
     rather than stopping where the labels end. It is what makes this a band of
     the page instead of four words floating under the title, and it is the
     line the active marker lands on. */
  border-bottom: 1px solid var(--border-strong);
}

.tabs__list {
  position: relative;
  display: flex;
  /* Shrinks and scrolls when the tabs outrun a narrow column. */
  flex: 0 1 auto;
  min-width: 0;
  gap: 2px;
  /* Horizontal only. `overflow-x: auto` promotes the other axis from `visible`
     to `auto`, so anything hanging below the content box makes the strip
     vertically scrollable too - and a trackpad gesture over the tabs then
     nudged the labels around inside their own track.

     The marker and hover stub used to hang below the box, which was exactly
     enough to do it. Rather than clip that away, the box is given room for it:
     the padding below matches the marker's weight so the marker sits *inside*
     the scrollable area and there is no vertical overflow left to scroll.

     The tab buttons lose the same amount off their own bottom padding, so the
     strip's height is unchanged - this is a redistribution, not an addition.

     `top: 1px` then nudges the whole list down onto the row's rule, so the
     marker covers that line while still sitting inside this scroll box. Done
     with an offset rather than a negative margin because `.tabs` is a flex
     container, where a negative margin would also change the layout. */
  top: 1px;
  padding-bottom: var(--tab-marker);
  overflow-x: auto;
  /* No vertical panning even where a touch device would otherwise offer it. */
  touch-action: pan-x;
  scrollbar-width: none;
}

.tabs__list::-webkit-scrollbar {
  display: none;
}

/* Generous vertical padding is doing real work here: it sets the height of the
   band above the rule, which is what gives the row physical presence now that
   there is no track drawing one. The horizontal padding is deliberately
   smaller than the old track's, because without an enclosing box the labels no
   longer need to be held off an edge - only separated from each other. */
.tabs__tab {
  position: relative;
  z-index: 1;
  display: inline-flex;
  align-items: center;
  gap: 8px;
  flex-shrink: 0;
  /* The bottom padding gives back exactly what the list took for the marker, so
     the band keeps the height it had before the marker moved inside the box. */
  padding: 11px var(--tab-inset) calc(13px - var(--tab-marker));
  background: transparent;
  border: 0;
  font-size: 13.5px;
  /* The unselected weight sits one step below the selected one, so the switch
     is carried by weight as well as ink and survives being looked at quickly. */
  font-weight: 500;
  color: var(--ink-500);
  cursor: pointer;
  white-space: nowrap;
  transition: color 0.16s linear;
}

/* Hover is the hint that an unselected tab can be taken. The selected one is
   excluded: the pointer is still resting on it after a click, and a second
   treatment there would only compete with the marker it already has. */
.tabs__tab:not([aria-selected='true']):hover {
  color: var(--ink-900);
}

/* A faint stub of the active marker under the hovered tab - the row's own
   language previewed at low strength, which is what tells the reader the
   marker is a thing that will move here rather than a static decoration.

   The active marker underlines the word alone, so this has to as well, or the
   stub would be wider than the mark that replaces it. CSS cannot measure a text
   node, but it does not need to: the icon and the gap after it are both fixed
   widths, so starting the stub past them lands it on the first glyph. */
.tabs__tab:not([aria-selected='true'])::after {
  content: '';
  position: absolute;
  /* padding + icon (15px) + gap (8px) */
  left: calc(var(--tab-inset) + 23px);
  right: var(--tab-inset);
  /* Reaches down into the list's marker padding so it lands on the same baseline
     as the active marker. The list has exactly this much padding below the tab,
     so the stub fills it without escaping the scroll box. */
  bottom: calc(var(--tab-marker) * -1);
  height: var(--tab-marker);
  background: var(--ink-300);
  border-radius: 999px;
  opacity: 0;
  transition: opacity 0.16s linear;
}

.tabs__tab:not([aria-selected='true']):hover::after {
  opacity: 1;
}

.tabs__tab[aria-selected='true'] {
  color: var(--ink-900);
  font-weight: 600;
}

/* The mark is the bare glyph, with no chip drawn around it - the icon carries
   the tab on its own shape, and its active state rides on ink weight alone,
   which is also the one channel that survives forced-colors. It steps darker
   in three stages (rest, hover, selected) alongside the label, so the icon and
   the word are never at odds about which tab is current. */
.tabs__mark {
  display: grid;
  place-items: center;
  flex-shrink: 0;
  color: var(--ink-400);
  transition: color 0.16s linear;
}

.tabs__mark svg {
  display: block;
}

.tabs__tab:hover .tabs__mark {
  color: var(--ink-700);
}

.tabs__tab[aria-selected='true'] .tabs__mark {
  color: var(--ink-900);
}

@media (max-width: 720px) {
  .tabs {
    --tab-inset: 12px;
  }

  .tabs__tab {
    /* Same marker allowance as the base rule - see `.tabs__tab`. */
    padding: 11px var(--tab-inset) calc(13px - var(--tab-marker));
    gap: 7px;
  }

  /* Follows the tighter gap above - icon (15px) + gap (7px). */
  .tabs__tab:not([aria-selected='true'])::after {
    left: calc(var(--tab-inset) + 22px);
  }
}

/* The active marker, slid and resized to the selected tab by JS rather than
   painted per-tab - the same shared-element technique as `.segmented__pill`,
   so a tab change is one object moving instead of two states swapping.

   It sits on the row's rule rather than inside a track, at full ink and 2px, so
   it overrides the hairline it covers instead of decorating it. `left` is the
   inset `slideInk` reads back out of the stylesheet to work out where a tab's
   marker starts and how wide it runs, and it resolves to the tab's own
   horizontal padding: the marker spans the label, not the whole hit area, which
   is what keeps it reading as an underline for a word rather than a bar under a
   button. */
.tabs__ink {
  position: absolute;
  left: var(--tab-inset);
  /* Sits flush with the base of the list's padded box - never outside it, or the
     overflow would make the strip vertically scrollable again. The list is
     pulled down 1px by its own negative bottom margin instead, which is what
     puts this marker on the row's rule while keeping it inside the scroll box. */
  bottom: 0;
  /* Width is set once in JS and never animated - the bar is scaled instead, so
     the whole movement stays on the compositor. */
  width: 0;
  height: var(--tab-marker);
  background: var(--ink-900);
  /* Rounded ends, which only become visible at this weight - at 2px the caps
     were doing nothing, and at 3px a square end reads as a cut-off bar. */
  border-radius: 999px;
  /* Scaling has to grow from the left edge, because `translateX` positions that
     edge. From the centre the bar would drift by half its own width change. */
  transform-origin: left center;
  pointer-events: none;
  will-change: transform;
}

/* Panels are hidden, never unmounted: returning to a tab keeps the geometry its
   charts were measured against, so nothing is re-laid-out on the way back. */
.panel:focus {
  outline: none;
}

.panel[hidden] {
  display: none !important;
}

/* Entrance for the incoming panel only - the outgoing one is simply hidden, so
   there is never a moment where two panels are both taking up space. */
.panel.is-entering {
  animation: panel-in 0.3s cubic-bezier(0.22, 0.61, 0.36, 1);
}

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

/* --- Page head + filters -------------------------------------------------- */

.page-head {
  display: flex;
  align-items: flex-end;
  justify-content: space-between;
  gap: 20px;
  margin-bottom: 20px;
  flex-wrap: wrap;
}

.page-head h1 {
  font-size: 23px;
  font-weight: 600;
  letter-spacing: -0.022em;
  /* The swap animation below moves the text; the box must not move with it. */
  will-change: transform, opacity;
}

/* The view title is the one piece of copy that names the panel now on screen,
   so it changes in step with the underline rather than snapping to new text a
   frame ahead of the panel it describes.

   Two halves of one swap: the outgoing wording leaves upward while the incoming
   wording arrives from below, both on the strip's easing and inside its 260ms,
   so the underline, the panel and the heading read as a single movement. The
   travel is 5px and the letter-spacing tightens by a hair on the way in - just
   enough to feel typeset into place, not enough to look like it bounced. */
.page-head h1.is-swapping-out {
  animation: view-title-out 0.13s cubic-bezier(0.4, 0, 1, 1) forwards;
}

.page-head h1.is-swapping-in {
  animation: view-title-in 0.28s cubic-bezier(0.22, 0.61, 0.36, 1);
}

@keyframes view-title-out {
  to {
    opacity: 0;
    transform: translateY(-5px);
  }
}

@keyframes view-title-in {
  from {
    opacity: 0;
    transform: translateY(5px);
    letter-spacing: -0.004em;
  }
}

.page-head p {
  color: var(--ink-500);
  font-size: 13.5px;
  margin-top: 3px;
}

.controls {
  display: flex;
  align-items: center;
  gap: 10px;
  flex-wrap: wrap;
}

.segmented {
  position: relative;
  display: inline-flex;
  padding: 3px;
  background: var(--ink-100);
  border-radius: 4.5px;
  gap: 2px;
}

.segmented button {
  position: relative;
  z-index: 1;
  border: 0;
  background: transparent;
  padding: 6px 13px;
  border-radius: 3.5px;
  font-size: 13px;
  font-weight: 500;
  color: var(--ink-500);
  cursor: pointer;
  /* The pill now carries the background - only the label colour animates here. */
  transition: color 0.16s linear;
}

.segmented button:hover {
  color: var(--ink-900);
}

.segmented button[aria-pressed='true'] {
  color: var(--ink-900);
}

/* One shared pill, slid and resized to the pressed button by JS rather than
   painted per-button - see `createSegmented` in ui.js. Sitting behind the
   button labels (z-index above), it is what turns a selection change into
   motion instead of a swap.
   Unlike `.switcher__pill` - which only ever sets its position once, from
   `aria-current` at load, because the page it marks never changes without a
   navigation - this pill genuinely moves between two live positions inside
   one session, so it is the one of the pair that actually needs a transition
   to slide rather than jump.
   Held at a fixed base width and scaled with `transform` rather than having
   `width` itself animated - the same reasoning `.tabs__ink` documents:
   animating `width` forces a layout recalculation on every frame, which is
   what a slide feels like it seizes up partway through on a slower device
   rather than actually running the full transition. `transform` alone stays
   on the compositor regardless of how heavy the rest of the page is. Skipped
   entirely under reduced motion, where it jumps straight to the new spot. */
.segmented__pill {
  position: absolute;
  top: 3px;
  left: 3px;
  height: calc(100% - 6px);
  width: 100px;
  transform-origin: left center;
  background: var(--surface);
  border-radius: 3.5px;
  box-shadow: var(--shadow-sm);
  pointer-events: none;
  will-change: transform;
  transition: transform 0.22s cubic-bezier(0.22, 0.61, 0.36, 1);
}

/* Before JS places it, the pill would sit at the far left with zero width. */
.segmented__pill:not(.is-ready) {
  opacity: 0;
}

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

/* The workspace role switch sits in the topbar next to the account menu
   rather than above a card, so it takes a slightly smaller button than the
   default `.segmented` to sit comfortably at that row's height. */
.segmented--role {
  flex-shrink: 0;
}

/* "Staff" and "Fitness" are different lengths, and a two-segment control whose
   halves are visibly unequal reads as broken rather than as two options. A
   shared floor evens them up without stretching the longer one. */
.segmented--role button {
  min-width: 68px;
  padding: 6px 11px;
  font-size: 12.5px;
  white-space: nowrap;
}

/* Hidden until the profile lookup says this account can actually switch. An
   account without the permission must never see a control that would fail. */
.segmented--role[hidden] {
  display: none;
}

/* Matches the profile trigger's own breakpoint (see `.profile__id` above) -
   the two controls sit side by side, so they give up their words at the same
   width rather than one crowding the other right before it does. */
@media (max-width: 860px) {
  .segmented--role button {
    min-width: 60px;
    padding: 6px 9px;
  }
}

.ghost-button {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  padding: 7px 13px;
  background: var(--surface);
  border: 1px solid var(--border-strong);
  border-radius: 4.5px;
  font-size: 13px;
  font-weight: 500;
  cursor: pointer;
  transition: background 0.12s linear, border-color 0.12s linear;
}

.ghost-button:hover {
  background: var(--ink-100);
  border-color: var(--ink-400);
}

.ghost-button[aria-pressed='true'] {
  background: var(--ink-900);
  border-color: var(--ink-900);
  color: var(--surface);
}

/* An anchor styled as a ghost button still needs the type reset an inline
   element does not inherit. */
a.ghost-button {
  text-decoration: none;
  color: inherit;
}

/* The one filled button per view - the primary commit. Exactly one, or the
   emphasis it is carrying stops meaning anything. */
.solid-button {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  padding: 8px 15px;
  background: var(--ink-900);
  border: 1px solid var(--ink-900);
  border-radius: 4.5px;
  font-size: 13px;
  font-weight: 500;
  color: var(--surface);
  cursor: pointer;
  transition: background 0.12s linear, border-color 0.12s linear;
}

.solid-button:hover {
  background: var(--ink-800);
  border-color: var(--ink-800);
}

.solid-button--inline {
  align-self: flex-start;
  margin-top: 4px;
}

/* Destructive actions are ringed rather than filled: they must be findable,
   but never the most prominent thing in the card. With no hue in this system,
   the weight of the border is what marks them out. */
.ghost-button--danger {
  border-color: var(--ink-700);
  color: var(--ink-900);
}

.ghost-button--danger:hover {
  background: var(--ink-900);
  border-color: var(--ink-900);
  color: var(--surface);
}

/* --- Cards ---------------------------------------------------------------- */

.card {
  display: flex;
  flex-direction: column;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  box-shadow: var(--shadow-sm);
}

.card__head {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 14px;
  padding: 17px 20px 0;
  flex-wrap: wrap;
}

.card__title {
  font-size: 14.5px;
  font-weight: 600;
  letter-spacing: -0.01em;
}

/* --- Card count badge -------------------------------------------------------
   The same pill the tab strip uses for a live count, brought down onto a card
   title. Every list on the landing page had a count and nowhere to put it - it
   was either buried as "5 of 6" prose in the sub-line or left off entirely -
   so a header with real content in it read the same as an empty one at a
   glance. The badge is what makes a title look like it has five things behind
   it before a single row has loaded. */
.card__count {
  display: inline-grid;
  place-items: center;
  min-width: 19px;
  height: 17px;
  margin-left: 7px;
  padding: 0 6px;
  border-radius: 999px;
  background: var(--ink-150);
  color: var(--ink-600);
  font-size: 10.5px;
  font-weight: 650;
  font-variant-numeric: tabular-nums;
  vertical-align: 2px;
}

.card__count:empty {
  display: none;
}

.card__sub {
  font-size: 12.5px;
  color: var(--ink-400);
  margin-top: 2px;
}

/* The body is a column with a growing chart slot and a pinned foot. When a card
   is stretched by a taller neighbour the extra height is absorbed around the
   chart instead of pooling as dead space beneath the last line of text. */
.card__body {
  flex: 1;
  display: flex;
  flex-direction: column;
  padding: 14px 20px 18px;
}

.card__foot {
  flex: 0 0 auto;
}

/* --- Insight -------------------------------------------------------------- */

.insight {
  margin-top: 14px;
  padding-top: 13px;
  border-top: 1px solid var(--border);
  font-size: 12.5px;
  line-height: 1.6;
  color: var(--ink-600);
}

.insight strong {
  color: var(--ink-900);
  font-weight: 600;
}

/* --- Period summary strip -------------------------------------------------
   Five figures that are read across, not down, so they live in one ruled strip
   rather than five floating tiles. Each cell is the same three-row grid, which
   is what puts every label, every value and every delta on a shared baseline
   no matter how long the label or how tall the number. Hairline rules come
   from a 1px grid gap over a tinted backing, so they stay correct when the
   strip wraps to two or three columns. */

.kpi-card {
  overflow: hidden;
}

/* The dividers between tiles are the parent's own background peeking through a
   1px grid gap, which only draws a clean set of hairlines when every cell in
   the grid is filled. Five tiles fill five columns exactly, but the same five
   tiles wrap to 3 or 2 columns below 1080px, and a tile count that is not a
   multiple of the column count leaves the last row short - one or two grid
   cells with no `.kpi` in them, which is `.kpi-row`'s own ink-toned background
   showing as a solid block instead of a hairline. Painting the card's own
   surface colour there instead means an empty cell blends into the card around
   it - the same thing already happens at every edge of the grid - rather than
   showing a colour that only ever appears when a cell is missing. */
.kpi-row {
  display: grid;
  grid-template-columns: repeat(5, minmax(0, 1fr));
  gap: 1px;
  margin-top: 14px;
  margin-left: 20px;
  margin-right: 20px;
  background: var(--surface);
  border-top: 1px solid var(--ink-150);
}

.kpi {
  display: grid;
  grid-template-rows: auto auto auto;
  align-content: start;
  gap: 7px;
  padding: 16px 20px 18px;
  background: var(--surface);
  box-shadow: -1px 0 0 var(--ink-150);
}

/* The shadow above draws the divider on a tile's own left edge instead of
   relying on the grid gap, so an empty trailing cell simply has no tile and no
   line - not a line drawn nowhere. The leftmost column of every row would
   otherwise carry a line against the card's own edge, which nothing else in
   this system does. */
.kpi-row > .kpi:nth-child(5n + 1) {
  box-shadow: none;
}

@media (max-width: 1080px) {
  .kpi-row > .kpi:nth-child(5n + 1) {
    box-shadow: -1px 0 0 var(--ink-150);
  }

  .kpi-row > .kpi:nth-child(3n + 1) {
    box-shadow: none;
  }
}

@media (max-width: 720px) {
  .kpi-row > .kpi:nth-child(3n + 1) {
    box-shadow: -1px 0 0 var(--ink-150);
  }

  .kpi-row > .kpi:nth-child(2n + 1) {
    box-shadow: none;
  }
}

/* The outer tiles drop their outside gutter so the first label and the last
   sparkline sit on the same vertical line as the card heading above, instead
   of pressing into the card border. Inner edges keep the full 20px, which is
   what the ruled dividers are spaced against. */
.kpi-row > .kpi:first-child {
  padding-left: 0;
}

.kpi-row > .kpi:last-child {
  padding-right: 0;
}

.kpi__label {
  font-size: 12px;
  color: var(--ink-500);
  font-weight: 500;
  letter-spacing: 0.01em;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* Tabular figures here, unlike elsewhere: this number counts up from zero on
   reveal, and proportional digits would make it twitch as the glyphs change. */
.kpi__value {
  font-size: 25px;
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  letter-spacing: -0.028em;
  line-height: 1.1;
}

.kpi__foot {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  min-height: 30px;
}

/* Direction is carried by the glyph and weight, never by hue. */
.delta {
  display: inline-flex;
  align-items: baseline;
  gap: 4px;
  font-size: 12.5px;
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  color: var(--ink-900);
  white-space: nowrap;
}

.delta[data-dir='down'] {
  color: var(--ink-400);
}

.delta__arrow {
  font-size: 9px;
  line-height: 1;
}

.delta__period {
  display: block;
  margin-top: 3px;
  font-size: 11.5px;
  color: var(--ink-400);
  font-weight: 400;
  white-space: nowrap;
}

.kpi__spark {
  display: flex;
  align-items: center;
  flex-shrink: 0;
  height: 30px;
  opacity: 0.75;
}

/* Wrapped to more than one row, the gap between rows needs the same treatment
   the vertical dividers got above: a rule drawn on each tile rather than the
   parent's background showing through the grid gap, so a short last row does
   not turn "no second tile here" into a stray horizontal line floating past
   where the row actually ends. `nth-child(3n+1)` through `(3n+3)` is every tile
   from the second row of a 3-column wrap onward. */
@media (max-width: 1080px) {
  .kpi-row {
    grid-template-columns: repeat(3, minmax(0, 1fr));
  }

  .kpi:nth-child(n + 4) {
    box-shadow: 0 -1px 0 var(--ink-150);
  }

  .kpi-row > .kpi:nth-child(3n + 1):nth-child(n + 4) {
    box-shadow: -1px 0 0 var(--ink-150), 0 -1px 0 var(--ink-150);
  }
}

/* Below 720px five tiles at desktop padding stack into a page-length column -
   each one a label, a 25px value and a foot, at 34px of vertical padding
   alone. Two columns instead of one halves that count of stacked rows, and the
   tighter padding and smaller value size bring each tile down to roughly half
   its desktop height - a landing page's summary strip should be glanced at,
   not scrolled through. */
@media (max-width: 720px) {
  .kpi-row {
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }

  .kpi {
    gap: 4px;
    padding: 11px 12px 12px;
  }

  /* The inner-edge padding above is 12px, not 20px, so the first-child and
     last-child resets that zero out the desktop 20px would leave a lopsided
     8px gap on one side of every tile. Zeroing both edges keeps the grid's 1px
     rules the only thing separating tiles, on every row. */
  .kpi-row > .kpi:first-child,
  .kpi-row > .kpi:last-child {
    padding-left: 12px;
    padding-right: 12px;
  }

  .kpi__label {
    font-size: 11px;
  }

  .kpi__value {
    font-size: 19px;
  }

  .kpi__foot {
    min-height: 0;
  }

  .kpi__spark {
    display: none;
  }

  /* Two columns: every tile from the third one on starts a new row, so it
     needs the horizontal rule the 1080px block draws at its own row length. */
  .kpi:nth-child(n + 3) {
    box-shadow: 0 -1px 0 var(--ink-150);
  }

  .kpi-row > .kpi:nth-child(2n + 1):nth-child(n + 3) {
    box-shadow: -1px 0 0 var(--ink-150), 0 -1px 0 var(--ink-150);
  }
}

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

.grid {
  display: grid;
  gap: 13px;
  margin-bottom: 16px;
}

/* A card standing on its own in a page or panel carries no margin of its own,
   so when a grid follows it the two sit flush. Only direct children qualify -
   a card inside a grid is already spaced by that grid's gap, and a margin here
   would double it. Last-child is left alone so the page bottom stays tight. */
main > .card:not(:last-child),
.panel > .card:not(:last-child) {
  margin-bottom: 13px;
}

/* Bento: hero and donut share the top row, the trading-day profile runs the
   full width beneath them.

   The obvious alternative - stacking hero and rhythm against a full-height
   donut - forces one column to invent height the other does not need, and the
   donut card ends up ringed with the gap that arrangement creates. Here the
   donut sets the row height, the hero chart grows into it, and the seven-column
   rhythm gets the width it actually wants. Nothing is left over. */
.grid--bento {
  grid-template-columns: minmax(0, 2.05fr) minmax(0, 1fr);
  grid-template-areas:
    'hero side'
    'rhythm rhythm';
}

.card--hero {
  grid-area: hero;
}

.card--rhythm {
  grid-area: rhythm;
}

.card--side {
  grid-area: side;
}

/* Donut sits centred in whatever height is left after the key and the insight,
   so a taller left column never leaves a gap under the legend. */
.card--side .card__body {
  justify-content: flex-start;
}

.grid--halves {
  grid-template-columns: repeat(2, minmax(0, 1fr));
}

@media (max-width: 1080px) {
  .grid--halves {
    grid-template-columns: minmax(0, 1fr);
  }

  .grid--bento {
    grid-template-columns: minmax(0, 1fr);
    grid-template-areas:
      'hero'
      'side'
      'rhythm';
  }
}

/* Once the bento stacks, the hero chart is full-width with nothing beside it to
   set its height - and a plot sized for a desktop card becomes most of a phone
   screen for a single figure. Capping it keeps the insight line and the cards
   below on screen together, which is what makes the page scannable rather than
   a sequence of full-height panels.

   A cap rather than a fixed height: the chart measures whatever box it is given,
   so this shortens it without stopping it filling a wider one. */
@media (max-width: 760px) {
  .card--hero .chart {
    max-height: 240px;
    min-height: 180px;
  }
}

@media (max-width: 480px) {
  .card--hero .chart {
    max-height: 200px;
    min-height: 160px;
  }
}

/* --- Settings layout ------------------------------------------------------
   Two columns of cards that each own their natural height, rather than a grid
   row forcing a short card to match a tall one. `align-items: start` is what
   keeps the Regional card from stretching to the height of Identity beside it
   and pooling 200px of empty surface under its last field. */

.grid--settings {
  grid-template-columns: repeat(2, minmax(0, 1fr));
  align-items: start;
}

/* Tables and lists that want the full measure - sessions, members, danger. */
.card--wide {
  grid-column: 1 / -1;
}

.card--danger {
  border-color: var(--border-strong);
}

@media (max-width: 1080px) {
  .grid--settings {
    grid-template-columns: minmax(0, 1fr);
  }
}

/* --- Form fields ----------------------------------------------------------
   Label above input, hint below it, in one column. The label sits on the field
   it names with nothing between them, and the hint is tied to the field by
   proximity rather than by a rule - so a scan down the form reads as a
   sequence of complete units instead of three interleaved columns. */

.field {
  display: flex;
  flex-direction: column;
  gap: 6px;
  margin-bottom: 16px;
}

.field:last-child {
  margin-bottom: 0;
}

.field__label {
  font-size: 12.5px;
  font-weight: 500;
  color: var(--ink-700);
}

.field__input {
  width: 100%;
  padding: 9px 11px;
  background: var(--surface);
  border: 1px solid var(--border-strong);
  border-radius: 4.5px;
  font: inherit;
  font-size: 13px;
  color: var(--ink-900);
  transition: border-color 0.12s linear, box-shadow 0.12s linear;
}

.field__input:hover {
  border-color: var(--ink-400);
}

/* The ring replaces the global focus outline here: on a bordered input an
   offset outline reads as a second, detached box around the field. */
.field__input:focus,
.field__input:focus-visible {
  outline: none;
  border-color: var(--ink-900);
  box-shadow: 0 0 0 3px rgba(23, 21, 18, 0.09);
}

.field__input--area {
  resize: vertical;
  min-height: 72px;
  line-height: 1.55;
}

/* Native select arrow removed for a drawn one, so the control matches the
   inputs above it instead of picking up the platform's own chrome. The SVG is
   inlined as a data URI - the CSP allows data: for images. */
select.field__input {
  appearance: none;
  padding-right: 32px;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%238b857a' stroke-width='2.4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
  background-repeat: no-repeat;
  background-position: right 11px center;
  cursor: pointer;
}

.field__hint {
  font-size: 11.5px;
  line-height: 1.5;
  color: var(--ink-400);
}

.avatar-row {
  display: flex;
  align-items: flex-start;
  gap: 15px;
  padding-bottom: 18px;
  margin-bottom: 18px;
  border-bottom: 1px solid var(--border);
}

.avatar-row__actions {
  display: flex;
  align-items: center;
  gap: 8px;
  flex-wrap: wrap;
}

.avatar-row__actions .field__hint {
  flex-basis: 100%;
}

.profile__avatar--xl {
  width: 54px;
  height: 54px;
  font-size: 17px;
}

/* --- Switch rows ----------------------------------------------------------
   A setting is a sentence plus a state, so the row is exactly that: name and
   consequence on the left, control hard right. Every control lands on the same
   vertical line, which is what lets the eye run down the right edge and read
   the on/off pattern without reading a single label. */

.switch-list {
  display: flex;
  flex-direction: column;
}

.switch-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 18px;
  padding: 13px 0;
  border-bottom: 1px solid var(--border);
}

.switch-row:first-child {
  padding-top: 0;
}

.switch-row:last-child {
  padding-bottom: 0;
  border-bottom: 0;
}

/* Used where the list follows fields rather than opening the card, so it needs
   its own rule above it to separate the two. */
.switch-list--flush {
  margin-top: 16px;
  padding-top: 4px;
  border-top: 1px solid var(--border);
}

.switch-list--flush .switch-row:first-child {
  padding-top: 13px;
}

/* A card whose body is nothing but a switch list has no fields above it to
   separate from, so the flush variant's own rule and top gap are dead space. */
.card--danger .switch-list--flush {
  margin-top: 0;
  padding-top: 0;
  border-top: 0;
}

.card--danger .switch-list--flush .switch-row:first-child {
  padding-top: 0;
}

.switch-row__text {
  display: flex;
  flex-direction: column;
  gap: 3px;
  min-width: 0;
}

.switch-row__name {
  font-size: 13px;
  font-weight: 500;
  color: var(--ink-900);
}

.switch-row__hint {
  font-size: 11.5px;
  line-height: 1.5;
  color: var(--ink-400);
}

.switch {
  position: relative;
  flex-shrink: 0;
  width: 40px;
  height: 23px;
  padding: 0;
  background: var(--ink-200);
  border: 1px solid transparent;
  border-radius: 999px;
  cursor: pointer;
  transition: background 0.18s cubic-bezier(0.22, 0.61, 0.36, 1);
}

.switch[aria-checked='true'] {
  background: var(--ink-900);
}

.switch__thumb {
  position: absolute;
  top: 2px;
  left: 2px;
  width: 17px;
  height: 17px;
  border-radius: 50%;
  background: var(--surface);
  box-shadow: var(--shadow-sm);
  transition: transform 0.18s cubic-bezier(0.22, 0.61, 0.36, 1);
}

.switch[aria-checked='true'] .switch__thumb {
  transform: translateX(17px);
}

/* --- Members + tags ------------------------------------------------------- */

.member-list {
  display: flex;
  flex-direction: column;
}

.member {
  display: flex;
  align-items: center;
  gap: 11px;
  padding: 11px 0;
  border-bottom: 1px solid var(--border);
}

.member:first-child {
  padding-top: 0;
}

.member:last-child {
  padding-bottom: 0;
  border-bottom: 0;
}

.member__text {
  display: flex;
  flex-direction: column;
  gap: 2px;
  min-width: 0;
  margin-right: auto;
}

.member__name {
  font-size: 13px;
  font-weight: 500;
}

.member__mail {
  font-size: 11.5px;
  color: var(--ink-400);
  overflow: hidden;
  text-overflow: ellipsis;
}

.tag {
  flex-shrink: 0;
  padding: 3px 8px;
  border-radius: 999px;
  background: var(--ink-900);
  color: var(--surface);
  font-size: 11px;
  font-weight: 500;
  letter-spacing: 0.01em;
  white-space: nowrap;
}

.tag--quiet {
  background: var(--ink-100);
  color: var(--ink-600);
}

/* --- Save confirmation ----------------------------------------------------
   A live region under the tabs. Empty and out of flow until it has something
   to say, so it never reserves a band of blank page. */

.settings-status:empty {
  display: none;
}

.settings-status {
  padding: 11px 15px;
  margin-bottom: 16px;
  background: var(--surface);
  border: 1px solid var(--border-strong);
  border-radius: var(--radius);
  font-size: 13px;
  animation: panel-in 0.24s cubic-bezier(0.22, 0.61, 0.36, 1);
}

/* --- Toast ------------------------------------------------------------------
   A confirmation that drops from the top of the viewport and clears itself,
   entirely out of the page's own layout flow - see `showStatus` in
   showcase.js. Fixed and centred above everything else, including an open
   profile menu, so it is never mistaken for part of whatever card sits
   beneath it. */

.toast-host {
  position: fixed;
  top: 18px;
  left: 50%;
  transform: translateX(-50%);
  z-index: 90;
  display: flex;
  flex-direction: column;
  align-items: center;
  pointer-events: none;
}

.toast {
  display: flex;
  align-items: center;
  gap: 9px;
  max-width: min(420px, calc(100vw - 32px));
  padding: 11px 16px 11px 13px;
  background: var(--ink-900);
  color: var(--surface);
  border-radius: 8px;
  box-shadow: 0 4px 10px rgba(23, 21, 18, 0.14), 0 14px 32px -12px rgba(23, 21, 18, 0.32);
  font-size: 13px;
  font-weight: 500;
  line-height: 1.4;
  pointer-events: auto;
  animation: toast-in 0.32s cubic-bezier(0.22, 0.61, 0.36, 1);
  transition: opacity 0.18s linear, transform 0.22s cubic-bezier(0.4, 0, 1, 1);
}

.toast.is-leaving {
  opacity: 0;
  transform: translateY(-6px) scale(0.98);
}

.toast__mark {
  display: grid;
  place-items: center;
  flex-shrink: 0;
  width: 20px;
  height: 20px;
  color: var(--surface);
  opacity: 0.85;
}

.toast__text {
  min-width: 0;
}

@keyframes toast-in {
  from {
    opacity: 0;
    transform: translateY(-14px) scale(0.96);
  }
}

@media (max-width: 560px) {
  .toast-host {
    top: 12px;
    left: 16px;
    right: 16px;
    transform: none;
    align-items: stretch;
  }

  .toast {
    max-width: none;
  }
}

@media (prefers-reduced-motion: reduce) {
  .toast {
    animation: none;
    transition: opacity 0.15s linear;
  }

  .toast.is-leaving {
    transform: none;
  }
}

/* --- Key (legend) --------------------------------------------------------- */

.key {
  display: flex;
  align-items: center;
  gap: 4px;
  flex-wrap: wrap;
}

.key__item {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  padding: 4px 9px;
  background: transparent;
  border: 1px solid transparent;
  border-radius: 4.5px;
  font-size: 12.5px;
  color: var(--ink-600);
  cursor: pointer;
  transition: background 0.12s linear, border-color 0.12s linear;
}

.key__item:hover {
  background: var(--ink-100);
  border-color: var(--border);
}

.key__item[aria-pressed='false'] {
  opacity: 0.4;
}

.key__item[aria-pressed='false'] .key__name {
  text-decoration: line-through;
}

.key__item svg {
  display: block;
  flex-shrink: 0;
}

.key--rows {
  flex-direction: column;
  align-items: stretch;
  gap: 0;
  margin-top: 16px;
}

.key--rows .key__item {
  justify-content: space-between;
  width: 100%;
  padding: 7px 8px;
  border-radius: 4.5px;
}

.key__name {
  display: inline-flex;
  align-items: center;
  gap: 9px;
  min-width: 0;
}

.key__swatch {
  width: 10px;
  height: 10px;
  border-radius: 2px;
  flex-shrink: 0;
}

.key__value {
  font-variant-numeric: tabular-nums;
  font-weight: 600;
  color: var(--ink-900);
}

/* The share of the quota, beside the byte figure. Quieter than the bytes: the
   percentage is the shape the ring already shows, the bytes are the part a
   reader needs when deciding what to clear. */
.key__share {
  margin-left: 6px;
  font-weight: 500;
  color: var(--ink-400);
}

/* A prospect's initials, in the slot a file glyph used to occupy. Same box as
   .row-mark so the two columns line up, but tinted and lettered - these rows
   are people, and a column of identical document icons said nothing. */
/* Same ink as the header's account avatar - these are both "a person", and
   two different avatar treatments on one page reads as two different things. */
.row-mark--avatar {
  font-size: 12px;
  font-weight: 650;
  letter-spacing: 0.02em;
  background: var(--ink-900);
  color: var(--surface);
}

/* --- Charts --------------------------------------------------------------- */

/* Every chart slot grows into whatever height its card was given and reports
   that height back to the renderer, so a card stretched by a taller neighbour
   draws a taller chart rather than parking a short one over dead space. The
   min-height is the floor the chart falls back to when nothing stretches it. */
.chart {
  position: relative;
  width: 100%;
  flex: 1 1 auto;
  min-height: 200px;
  display: grid;
  align-content: center;
}

/* The gap between asking for a different range and the new window arriving.

   A range change swaps 30 points for 90, so the path morph has no
   correspondence to tween along and declines - the chart is genuinely redrawn
   rather than bent. Dimming across that request turns the swap into one
   continuous movement instead of a blink, and doubles as the loading state if
   the response is slow. The redraw itself then plays the left-to-right wipe. */
.chart {
  transition: opacity 180ms ease;
}

.chart--swapping {
  opacity: 0.35;
}

/* The dim is decoration, not information - the figures below the chart say the
   same thing. Readers who ask for less motion get the swap with no fade. */
@media (prefers-reduced-motion: reduce) {
  .chart {
    transition: none;
  }

  .chart--swapping {
    opacity: 1;
  }
}

.chart--center {
  place-items: center;
}

/* The hero plot. 300px was inherited from the reference, where this card sat in
   a three-card bento and was held to that height by its neighbours. Here it is
   paired with the storage ring and its card is free to grow, so it settles at
   510px - and reserving the smaller figure meant the chart arrived, measured
   itself, and shoved the whole page down 210px. Reserve what it actually is. */
#metric-chart {
  min-height: 510px;
}

#traffic-chart {
  min-height: 260px;
}

/* Tall enough that, after the mean caption and the two label bands are taken
   out, the columns still have room to be columns. */
#weekday-chart {
  min-height: 250px;
}

#channel-chart {
  min-height: 264px;
}

/* The storage ring.

   Fixed rather than floored, and explicitly not flexible. Every other chart in
   this file grows into whatever height its card was given, which is right when
   a card's height is set by a taller neighbour it has to fill. This one sits
   beside the much taller hero card, so `flex: 1 1 auto` had the empty slot
   stretch to 494px on first paint and then collapse to 300px the moment the
   donut measured itself - a 157px snap under a ring that had not moved. The
   ring is a fixed size, so the box that holds it is one too, and the slack goes
   under the legend where nothing is drawn. */
#storage-chart {
  flex: 0 0 auto;
  height: 300px;
  min-height: 300px;
}

#region-chart,
#product-chart {
  min-height: 240px;
}

/* The funnel is HTML, so it distributes its own slack between the stages. */
#funnel-chart .funnel {
  height: 100%;
  justify-content: space-between;
}

.chart svg {
  display: block;
  overflow: visible;
}

.chart-empty {
  padding: 44px 12px;
  text-align: center;
  color: var(--ink-400);
  font-size: 12.5px;
}

.chart-row,
.chart-column,
.chart-cell {
  cursor: default;
}

.chart-connector,
.chart-dot {
  transition: opacity 0.12s linear, r 0.12s linear;
}

.chart-row:hover .chart-connector {
  opacity: 0.55;
}

.chart-row:hover .chart-dot,
.chart-row:focus-visible .chart-dot {
  r: 7.5;
}

.chart-column,
.chart-cell {
  transition: opacity 0.12s linear;
}

.chart-column:hover,
.chart-cell:hover {
  opacity: 0.78;
}

/* Hover still lightens the slice, because the slice still answers a hover with
   a tooltip. The pointer cursor is the part that promises a click, so it is
   held back for a donut that actually has something to select - here the
   isolating is done from the legend underneath instead. */
.chart-slice {
  transition: opacity 0.12s linear;
}

.chart-slice.is-selectable {
  cursor: pointer;
}

.chart-slice:hover,
.chart-slice:focus-visible {
  opacity: 0.75;
}

.chart-slice.is-dimmed {
  opacity: 0.22;
}

/* --- Tooltip -------------------------------------------------------------- */

/* Positioned entirely through `transform`, which the compositor can apply
   without a layout pass - the tooltip can then track the cursor at frame rate.
   `left`/`top` stay at zero so the transform is the single source of position.

   The outer node owns position and nothing else. The entrance lives on the
   inner `__box`, because a scale on the outer node would be overwritten by the
   next positional write - the two transforms would fight every frame. */
.chart-tooltip {
  position: absolute;
  left: 0;
  top: 0;
  width: max-content;
  pointer-events: none;
  will-change: transform;
  z-index: 5;
}

/* The box grows out of the corner nearest the cursor, so it reads as unfolding
   from the mark rather than materialising over it. `--ox`/`--oy` are set by JS
   to whichever corner the flip logic settled on. */
.chart-tooltip__box {
  min-width: 172px;
  max-width: 260px;
  padding: 9px 11px;
  background: var(--surface);
  border: 1px solid var(--border-strong);
  border-radius: 4.5px;
  box-shadow: var(--shadow);

  transform-origin: var(--ox, 0%) var(--oy, 100%);
  opacity: 0;
  /* Squat and slightly wide at rest - the settle stretches it upright, which
     gives the arrival a bit of body instead of a uniform zoom. */
  transform: scale(1.012, 0.93) translateY(5px);
  transition:
    opacity 0.16s ease,
    transform 0.16s ease;
}

.chart-tooltip.is-visible .chart-tooltip__box {
  opacity: 1;
  transform: scale(1, 1) translateY(0);
  /* Both channels run long and land together. An opacity that finishes well
     before the transform is what makes a popup read as "blinky" - the content
     is fully there while the shape is still moving. */
  transition:
    opacity 0.3s cubic-bezier(0.3, 0.6, 0.3, 1),
    transform 0.42s cubic-bezier(0.18, 0.9, 0.26, 1.02);
}

/* Adopted from a previous render - see `adopt` in charts.js. The tooltip was
   already open, so it must appear at its final size and position with no
   entrance and no row stagger, or the rebuild reads as a glitch. */
.chart-tooltip.is-adopting .chart-tooltip__box,
.chart-tooltip.is-adopting .chart-tooltip__layer > * {
  transition: none !important;
  animation: none !important;
}

/* The rows follow the box in, slowly enough to read as a sequence. */
.chart-tooltip.is-visible .chart-tooltip__layer:not(.is-fading) > * {
  animation: tooltip-row 0.42s cubic-bezier(0.22, 0.85, 0.3, 1) backwards;
}

.chart-tooltip.is-visible .chart-tooltip__layer > *:nth-child(1) { animation-delay: 0.04s; }
.chart-tooltip.is-visible .chart-tooltip__layer > *:nth-child(2) { animation-delay: 0.09s; }
.chart-tooltip.is-visible .chart-tooltip__layer > *:nth-child(3) { animation-delay: 0.14s; }
.chart-tooltip.is-visible .chart-tooltip__layer > *:nth-child(n + 4) { animation-delay: 0.18s; }

@keyframes tooltip-row {
  from {
    opacity: 0;
    transform: translateY(4px);
  }
}

/* --- Swap crossfade ---
   The outgoing copy is stacked over the incoming one and fades out while the
   new content fades in, so the two overlap. Previously the whole box dipped in
   opacity and the text switched underneath, which read as a hard cut. */
/* The box is the positioning context; the ghost overlays the live layer
   exactly, inheriting the same padding box. */
.chart-tooltip__box {
  position: relative;
}

.chart-tooltip__layer--ghost {
  position: absolute;
  left: 11px;
  right: 11px;
  top: 9px;
  opacity: 0;
  pointer-events: none;
}

.chart-tooltip__layer--ghost.is-fading {
  animation: tooltip-ghost-out 0.22s ease-out forwards;
}

@keyframes tooltip-ghost-out {
  from { opacity: 1; }
  to { opacity: 0; }
}

/* The incoming copy rises in over the same window, so the two genuinely
   overlap rather than one replacing the other. */
.chart-tooltip__box:has(.is-fading) .chart-tooltip__layer:not(.chart-tooltip__layer--ghost) {
  animation: tooltip-swap-in 0.22s ease-out;
}

@keyframes tooltip-swap-in {
  from { opacity: 0; }
}

.chart-tooltip__title {
  font-size: 12px;
  font-weight: 600;
  margin-bottom: 6px;
}

.chart-tooltip__row {
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: 12.5px;
  margin-top: 3px;
}

.chart-tooltip__key {
  display: inline-flex;
  flex-shrink: 0;
}

.chart-tooltip__name {
  color: var(--ink-500);
}

.chart-tooltip__value {
  margin-left: auto;
  font-weight: 600;
  font-variant-numeric: tabular-nums;
}

.chart-tooltip__meta {
  margin-top: 6px;
  padding-top: 6px;
  border-top: 1px solid var(--border);
  font-size: 11.5px;
  color: var(--ink-400);
}

/* The payments behind one point on the trend. Sits below the series rows and
   the delta, separated the same way .chart-tooltip__meta is, so the tooltip
   reads as value first, then what the value is made of. */

/* What Stripe took, above the payment list. Separated the same way, so the
   tooltip reads: the lines' values, then the fee out of them, then the payments
   that made them move. */

.chart-tooltip__fees {
  margin-top: 6px;
  padding-top: 6px;
  border-top: 1px solid var(--border);
}

.chart-tooltip__fees-note {
  margin-top: 2px;
  font-size: 11px;
  color: var(--ink-400);
}

.chart-tooltip__payments {
  margin-top: 6px;
  padding-top: 6px;
  border-top: 1px solid var(--border);
}

.chart-tooltip__payments-head {
  font-size: 11px;
  font-weight: 600;
  letter-spacing: 0.02em;
  text-transform: uppercase;
  color: var(--ink-400);
  margin-bottom: 4px;
}

.chart-tooltip__payment {
  display: flex;
  align-items: baseline;
  gap: 10px;
  font-size: 12px;
  margin-top: 2px;
}

.chart-tooltip__payer {
  color: var(--ink-500);
  /* A long name truncates rather than wrapping the tooltip into a column of
     torn text - the amount beside it is the part that must stay readable. */
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  max-width: 190px;
}

.chart-tooltip__paid {
  margin-left: auto;
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}

.chart-tooltip__payment--more {
  color: var(--ink-400);
  font-style: italic;
}

/* --- Funnel --------------------------------------------------------------- */

.funnel {
  display: flex;
  flex-direction: column;
  gap: 13px;
}

.funnel__row {
  padding: 6px 8px;
  margin: -6px -8px;
  border-radius: 4.5px;
  transition: background 0.12s linear;
}

.funnel__row:hover {
  background: var(--ink-100);
}

.funnel__row.is-worst .funnel__drop {
  color: var(--ink-900);
  font-weight: 600;
}

.funnel__head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 12px;
  margin-bottom: 6px;
}

.funnel__name {
  font-size: 13px;
  color: var(--ink-700);
}

.funnel__value {
  font-size: 13px;
  font-weight: 600;
  font-variant-numeric: tabular-nums;
}

.funnel__track {
  height: 12px;
  background: var(--ink-100);
  border-radius: 4.5px;
  overflow: hidden;
}

.funnel__bar {
  height: 100%;
  border-radius: 4.5px;
}

.funnel__meta {
  display: flex;
  justify-content: space-between;
  gap: 12px;
  margin-top: 5px;
  font-size: 11.5px;
  color: var(--ink-400);
}

/* --- Table view ----------------------------------------------------------- */

.table-toggle {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  margin-top: 14px;
  padding: 5px 10px;
  background: transparent;
  border: 1px solid var(--border-strong);
  border-radius: 4.5px;
  font-size: 12px;
  color: var(--ink-500);
  cursor: pointer;
  transition: background 0.12s linear, color 0.12s linear;
}

.table-toggle:hover {
  background: var(--ink-100);
  color: var(--ink-900);
}

.data-table {
  width: 100%;
  border-collapse: collapse;
  font-size: 12.5px;
  margin-top: 8px;
}

.data-table caption {
  text-align: left;
  color: var(--ink-400);
  font-size: 12px;
  padding-bottom: 8px;
}

.data-table th,
.data-table td {
  padding: 7px 10px;
  text-align: right;
  border-bottom: 1px solid var(--border);
}

.data-table th:first-child,
.data-table td:first-child {
  text-align: left;
}

.data-table thead th {
  position: sticky;
  top: 0;
  background: var(--surface);
  color: var(--ink-400);
  font-weight: 500;
  font-size: 11.5px;
  text-transform: uppercase;
  letter-spacing: 0.04em;
}

.data-table td {
  font-variant-numeric: tabular-nums;
}

.table-wrap {
  max-height: 280px;
  overflow: auto;
}

[hidden] {
  display: none !important;
}

/* --- Refetch state -------------------------------------------------------- */

/* Hold the previous render at reduced opacity - never flash a skeleton.
   A skeleton would throw away the figures the reader is looking at and replace
   them with grey boxes, which is a bigger visual event than the update itself.
   Settling to 0.45 says "these numbers are going stale" while keeping them
   legible and, crucially, keeping the layout identical - so when the new data
   lands the marks morph from where they already are.

   The fade in and out is asymmetric on purpose: dimming eases in over 180ms so
   a fast response never strobes, while coming back is instant, because the new
   marks carry their own entrance and a second fade on top would smear it. */
.chart,
.kpi__value,
.funnel,
.insight {
  transition: opacity 0.12s linear;
}

.is-refetching .chart,
.is-refetching .kpi__value,
.is-refetching .funnel,
.is-refetching .insight {
  opacity: 0.45;
  transition: opacity 0.18s ease-in 0.06s;
}

/* Reduced motion keeps the state change, drops the easing. */
@media (prefers-reduced-motion: reduce) {
  .chart,
  .kpi__value,
  .funnel,
  .insight,
  .is-refetching .chart,
  .is-refetching .kpi__value,
  .is-refetching .funnel,
  .is-refetching .insight {
    transition: none;
  }
}

.error-banner {
  padding: 12px 16px;
  margin-bottom: 16px;
  background: var(--surface);
  border: 1px solid var(--ink-900);
  border-radius: var(--radius);
  font-size: 13px;
}


/* The sign-off: the mark, then the line of text, centred under the rule.

   Centred rather than left-aligned because it is not content - it closes the
   page rather than continuing it, and a centred block reads as an end mark
   where a left-aligned one reads as one more row of the layout above. */
.footer-note {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 10px;
  margin-top: 20px;
  padding-top: 18px;
  border-top: 1px solid var(--border);
  font-size: 12px;
  color: var(--ink-400);
  text-align: center;
}

/* Faded, so it sits behind the words rather than competing with them. The
   footer is the quietest thing on the page and the mark has to agree - at full
   strength a logo down here pulls the eye back down the page just as the reader
   reaches the end of it. */
.footer-note__mark {
  width: 34px;
  height: 34px;
  opacity: 0.38;
  /* The asset is a flat mark; on a dark ground it would otherwise sit in its
     own light box. Multiply lets the page colour through the white. */
  mix-blend-mode: multiply;
}

.footer-note__text {
  margin: 0;
  /* The line reads as one sentence at any width rather than breaking after the
     pipe, which would leave "Safeguard Group" stranded on its own line. */
  max-width: 46ch;
  text-wrap: balance;
}

/* Neither the fade nor the blend survives a high-contrast preference: both are
   ways of making something quieter, and that is exactly what the setting asks
   not to happen. */
@media (prefers-contrast: more) {
  .footer-note__mark {
    opacity: 1;
    mix-blend-mode: normal;
  }
}

/* --- Sign in --------------------------------------------------------------
   One card on an otherwise empty page. The dashboard and account views are
   scanned; this one is not - it holds a single decision, so the layout drops
   every competing element and centres what is left on the short axis.

   Everything here is built from the tokens and components already in use:
   .card, .field, .solid-button, .settings-status. The page is a new
   arrangement of the system, not a second system. */

.body--auth {
  min-height: 100vh;
}

.shell--auth {
  display: flex;
  flex-direction: column;
  min-height: 100vh;
  max-width: 1300px;
  padding-bottom: 32px;
}

/* No live-state block and no account menu on this page, so the header has a
   left edge only - the rule under it stays, because it is what ties the page
   to the rest of the workspace. */
.topbar--auth {
  justify-content: flex-start;
  margin-bottom: 0;
}

.auth {
  flex: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  /* Optical centring: a form sitting on the exact vertical middle reads as
     slightly low, so the block is lifted by trimming the bottom padding. */
  padding: 40px 0 56px;
  gap: 0;
}

.auth__card {
  width: 100%;
  max-width: 396px;
  box-shadow: var(--shadow);
}

.auth__head {
  padding: 24px 24px 0;
}

.auth__title {
  font-size: 19px;
  letter-spacing: -0.018em;
}

.auth__body {
  padding: 18px 24px 24px;
  gap: 0;
}

/* The status slot is the form's error and confirmation surface. It keeps the
   settings-status treatment so a message means the same thing on both pages,
   and adds the bottom margin only when it has something to say. */
.auth__body .settings-status {
  margin-bottom: 16px;
}

/* --- Google -------------------------------------------------------------
   Full width and ghost-weight, so it reads as an equal alternative to the
   form rather than as the primary commit - the filled button below is the
   only one of those. The logo is the single place colour is allowed in this
   monochrome system: it is a third-party mark, and redrawing it in grey
   would misrepresent it. */

.oauth-button {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 10px;
  width: 100%;
  padding: 10px 15px;
  background: var(--surface);
  border: 1px solid var(--border-strong);
  border-radius: 4.5px;
  font-size: 13.5px;
  font-weight: 500;
  color: var(--ink-900);
  cursor: pointer;
  transition: background 0.12s linear, border-color 0.12s linear;
}

.oauth-button:hover {
  background: var(--ink-100);
  border-color: var(--ink-400);
}

.oauth-button__mark {
  flex-shrink: 0;
}

/* --- Divider ------------------------------------------------------------ */

.auth__divider {
  display: flex;
  align-items: center;
  gap: 12px;
  margin: 18px 0;
  font-size: 11.5px;
  color: var(--ink-400);
}

.auth__divider::before,
.auth__divider::after {
  content: '';
  flex: 1;
  height: 1px;
  background: var(--border);
}

/* --- Form --------------------------------------------------------------- */

.auth__form {
  display: flex;
  flex-direction: column;
}

/* The label and its forgotten-password link share the row above the input. */
.field__label-row {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 12px;
}

/* Both a real anchor and a <button> carry this class - the demo has no reset or
   signup page, so those two are buttons. The reset here is what makes the
   button indistinguishable from the link beside it. */
.auth__link {
  padding: 0;
  background: none;
  border: 0;
  font-family: inherit;
  font-size: 12px;
  font-weight: 500;
  color: var(--ink-600);
  cursor: pointer;
  text-decoration: underline;
  text-underline-offset: 2px;
  text-decoration-color: var(--ink-200);
  transition: color 0.12s linear, text-decoration-color 0.12s linear;
}

/* In the sentence under the card this sits inline with body copy, so it takes
   that size rather than the 12px it uses as a field-level affordance. */
.auth__alt .auth__link {
  font-size: inherit;
}

.auth__link:hover {
  color: var(--ink-900);
  text-decoration-color: var(--ink-400);
}

/* Wrapper for an input with a control sitting inside its box. */
.field__control {
  position: relative;
  display: flex;
}

/* Room for the reveal button, so a long password never runs under it. */
.field__input--with-affix {
  padding-right: 40px;
}

.field__affix {
  position: absolute;
  top: 50%;
  right: 4px;
  transform: translateY(-50%);
  display: grid;
  place-items: center;
  width: 30px;
  height: 30px;
  padding: 0;
  background: transparent;
  border: 0;
  border-radius: 4.5px;
  color: var(--ink-400);
  cursor: pointer;
  transition: color 0.12s linear, background 0.12s linear;
}

.field__affix:hover {
  color: var(--ink-900);
  background: var(--ink-100);
}

/* --- Validation ---------------------------------------------------------
   With no hue in the system, an error is marked by weight: the field takes
   the strong ink border it would otherwise only get on focus, and the message
   sits directly under it in near-black rather than the hint grey. */

.field__input.is-invalid {
  border-color: var(--ink-900);
  box-shadow: 0 0 0 3px rgba(23, 21, 18, 0.07);
}

.field__error {
  display: flex;
  align-items: flex-start;
  gap: 5px;
  margin-top: 1px;
  font-size: 11.5px;
  line-height: 1.5;
  font-weight: 500;
  color: var(--ink-800);
}

.field__error::before {
  content: '!';
  display: grid;
  place-items: center;
  flex-shrink: 0;
  width: 13px;
  height: 13px;
  margin-top: 2px;
  border-radius: 999px;
  background: var(--ink-900);
  color: var(--surface);
  font-size: 9px;
  font-weight: 700;
}

/* --- Checkbox -----------------------------------------------------------
   The native box is kept in the DOM for keyboard and form semantics and hidden
   behind a drawn one, so the control matches the inputs above it. */

.checkbox {
  display: inline-flex;
  align-items: center;
  gap: 9px;
  cursor: pointer;
  user-select: none;
}

.checkbox__input {
  position: absolute;
  width: 1px;
  height: 1px;
  opacity: 0;
  pointer-events: none;
}

.checkbox__box {
  display: grid;
  place-items: center;
  flex-shrink: 0;
  width: 16px;
  height: 16px;
  background: var(--surface);
  border: 1px solid var(--border-strong);
  border-radius: 4.5px;
  color: var(--surface);
  transition: background 0.12s linear, border-color 0.12s linear;
}

.checkbox__box svg {
  opacity: 0;
  transform: scale(0.7);
  transition: opacity 0.12s linear, transform 0.14s cubic-bezier(0.22, 0.61, 0.36, 1);
}

.checkbox:hover .checkbox__box {
  border-color: var(--ink-400);
}

.checkbox__input:checked + .checkbox__box {
  background: var(--ink-900);
  border-color: var(--ink-900);
}

.checkbox__input:checked + .checkbox__box svg {
  opacity: 1;
  transform: scale(1);
}

/* The visual box carries the focus ring the hidden input cannot show. */
.checkbox__input:focus-visible + .checkbox__box {
  outline: 2px solid var(--ink-900);
  outline-offset: 2px;
}

.checkbox__label {
  font-size: 12.5px;
  color: var(--ink-600);
}

.auth__row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  margin: 2px 0 18px;
}

/* The submit is the width of the fields it commits, so the form reads as one
   block rather than a stack with a small button hanging off the end. */
.solid-button--block {
  width: 100%;
  justify-content: center;
  padding: 10px 15px;
  font-size: 13.5px;
}

.solid-button:disabled {
  background: var(--ink-300);
  border-color: var(--ink-300);
  color: var(--surface);
  cursor: default;
}

/* While the request is in flight the label is replaced by a spinner, so the
   button keeps its width and the row cannot reflow under the pointer. */
.solid-button.is-busy {
  color: transparent;
  position: relative;
}

.solid-button.is-busy::after {
  content: '';
  position: absolute;
  top: 50%;
  left: 50%;
  width: 15px;
  height: 15px;
  margin: -7.5px 0 0 -7.5px;
  border: 2px solid rgba(253, 252, 250, 0.35);
  border-top-color: var(--surface);
  border-radius: 999px;
  animation: button-spin 0.62s linear infinite;
}

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

/* One quiet sentence, not a third section - separated by space rather than a
   rule, which would give it the same weight as the divider above the Google
   button and imply another choice sits below it. */
.auth__alt {
  margin-top: 20px;
  font-size: 12.5px;
  color: var(--ink-500);
  text-align: center;
}

/* Sits under a centred card on an otherwise empty page, so it is centred and
   loses the full-width rule the in-page note carries. */
.footer-note--auth {
  width: 100%;
  max-width: 396px;
  margin-top: 22px;
  padding-top: 0;
  border-top: 0;
  text-align: center;
}

/* Entrance motion is driven from JS via the Web Animations API, which checks
   this preference itself; this is the belt-and-braces pass for anything CSS
   still owns. */
@media (prefers-reduced-motion: reduce) {

  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
  }

  /* View-transition pseudo-elements live outside the document tree, so the
     universal selector above never reaches them - they have to be named. The
     page change still happens, it just arrives rather than travels. */
  ::view-transition-group(*),
  ::view-transition-old(*),
  ::view-transition-new(*) {
    animation: none !important;
  }

  /* Fade only - no travel, no scale. */
  .chart-tooltip__box,
  .chart-tooltip.is-visible .chart-tooltip__box {
    transform: none;
  }

  .profile__menu,
  .panel.is-entering,
  .settings-status,
  .page-head h1.is-swapping-out,
  .page-head h1.is-swapping-in {
    animation: none;
  }

  /* The spinner is the only signal that a submit is in flight, so it is the one
     animation kept here - slowed rather than stopped, since removing it would
     leave a button with a blank face. */
  .solid-button.is-busy::after {
    animation-duration: 1.4s !important;
  }

  /* Belt and braces: the out-half sets its end state with `forwards`, so if the
     animation were ever to run here the heading would be left invisible. */
  .page-head h1 {
    opacity: 1;
    transform: none;
  }
}

/* Touch: the tooltip is the answer to a deliberate tap, not something glimpsed
   in passing, so it is given more room and a heavier surface. `hover: none`
   targets the input device rather than the viewport - a small window on a
   laptop still gets the compact hover treatment. */
@media (hover: none) {

  .chart-tooltip__box {
    min-width: 190px;
    max-width: min(280px, calc(100vw - 48px));
    padding: 11px 13px;
    border-radius: 4.5px;
    /* Lifted further off the card: with no cursor to tie it to a position, the
       depth cue is what separates it from the chart underneath. */
    box-shadow:
      0 2px 6px rgba(23, 21, 18, 0.08),
      0 14px 32px -12px rgba(23, 21, 18, 0.22);
  }

  .chart-tooltip__row {
    font-size: 13.5px;
    margin-top: 5px;
  }

  .chart-tooltip__title {
    font-size: 13px;
    margin-bottom: 7px;
  }

  .chart-tooltip__meta {
    font-size: 12.5px;
    margin-top: 8px;
    padding-top: 8px;
  }

  .chart-tooltip__layer--ghost {
    left: 13px;
    right: 13px;
    top: 11px;
  }
}

@media (forced-colors: active) {

  .card,
  .stat,
  .chart-tooltip__box,
  .profile__menu {
    border: 1px solid CanvasText;
  }

  /* The marker is a background fill, which forced-colors discards - so the
     selected tab is marked with something that survives the mode. */
  .tabs__tab[aria-selected='true'] {
    text-decoration: underline;
    text-underline-offset: 4px;
  }
}

/* Below this the header would wrap the account control onto its own line, so
   the name and role are dropped and the trigger becomes the avatar alone. */
@media (max-width: 860px) {
  .profile__id,
  .profile__chevron {
    display: none;
  }

  .profile__trigger {
    padding: 4px;
  }
}

@media (max-width: 640px) {
  .shell {
    padding: 0 15px 48px;
  }

  .topbar__right {
    gap: 12px;
  }

  .tabs {
    --tab-inset: 11px;
  }

  .tabs__tab {
    /* Same marker allowance as the base rule - see `.tabs__tab`. */
    padding: 10px var(--tab-inset) calc(12px - var(--tab-marker));
    font-size: 13px;
  }

  /* The menu is anchored to the trigger, which is now hard against the right
     edge - clamp the width so it cannot push past the viewport. */
  .profile__menu {
    width: min(244px, calc(100vw - 30px));
  }

  /* A 40px switch beside a two-line label leaves the label about 140px on a
     narrow phone, which breaks it into a ragged stack. Below this the control
     drops to its own line and the text gets the full width. */
  .switch-row {
    align-items: flex-start;
    gap: 12px;
  }

  .avatar-row {
    gap: 12px;
  }

  .field {
    margin-bottom: 14px;
  }

  .page-head h1 {
    font-size: 19px;
  }

  .kpi__value {
    font-size: 21px;
  }

  .kpi {
    padding: 14px 15px 16px;
  }

  /* The strip's inset tracks the card gutter, which tightens here. */
  .kpi-row {
    margin-left: 15px;
    margin-right: 15px;
  }

  .card__head {
    padding: 15px 15px 0;
  }

  .card__body {
    padding: 12px 15px 16px;
  }

  /* The card is the page on a phone, so it takes the full width and drops the
     vertical centring - a short viewport with the keyboard open would otherwise
     push the submit below the fold. */
  .auth {
    justify-content: flex-start;
    padding: 26px 0 40px;
  }

  .auth__card {
    max-width: none;
  }

  .auth__head {
    padding: 20px 18px 0;
  }

  .auth__body {
    padding: 16px 18px 20px;
  }

  .footer-note--auth {
    max-width: none;
  }
}
/* ==========================================================================
   Showcase pages

   Six additional views built from the tokens already declared at the top of
   this file: same page tint, same surface, same 4.5px radius, same hairline
   borders, same monochrome ink ramp. Nothing below introduces a new colour, a
   new radius or a new shadow - every component here is an arrangement of the
   system the dashboard established, which is the whole point of the exercise.
   ========================================================================== */

/* --- Dashboard switcher ---------------------------------------------------
   The control that moves between the seven dashboards, centred under the
   header where a view switcher is looked for.

   The first version of this was a left-aligned row of quiet text links, and it
   failed for one reason: it did not look like a control. A row of grey words
   under a header reads as breadcrumbs or as a footer, so the thing the whole
   demo is built around - moving between dashboards - was the least obvious
   affordance on the page.

   This is enclosed instead. A single tinted track with the segments sitting
   inside it is the one shape in this system that already means "pick one of
   these", because it is what `.segmented` uses for the range filter. Reusing
   that language here is what makes the control self-evident: the reader has
   already learned it one line further down the page.

   Centred, because it is scoped to the whole page rather than to any panel in
   it. Left-aligned it would read as belonging to the heading beneath it. */

.switcher {
  display: flex;
  justify-content: center;
  margin-bottom: 24px;
}

/* The track. Same tint and radius as `.segmented`, sized up so it reads as
   page-level navigation rather than as a filter for the card below it. */
.switcher__track {
  position: relative;
  display: flex;
  align-items: center;
  gap: 2px;
  max-width: 100%;
  padding: 4px;
  background: var(--ink-100);
  border: 1px solid var(--border);
  border-radius: 6px;
  overflow-x: auto;
  scrollbar-width: none;
  touch-action: pan-x;
}

.switcher__track::-webkit-scrollbar {
  display: none;
}

.switcher__link {
  position: relative;
  z-index: 1;
  display: inline-flex;
  align-items: center;
  gap: 7px;
  flex-shrink: 0;
  padding: 7px 13px;
  border-radius: 4.5px;
  font-size: 13px;
  font-weight: 500;
  color: var(--ink-500);
  text-decoration: none;
  white-space: nowrap;
  transition: color 0.14s linear;
}

.switcher__link svg {
  flex-shrink: 0;
  opacity: 0.75;
  transition: opacity 0.14s linear;
}

.switcher__link:hover {
  color: var(--ink-900);
}

.switcher__link:hover svg {
  opacity: 1;
}

/* The open dashboard. The raised pill behind it carries the surface, so this
   only has to take the ink - the same division of labour `.segmented` uses. */
.switcher__link[aria-current='page'] {
  color: var(--ink-900);
  font-weight: 600;
  cursor: default;
}

.switcher__link[aria-current='page'] svg {
  opacity: 1;
}

/* The raised pill marking the current dashboard.
   Painted once behind the labels and positioned from JS, rather than drawn as
   a background on the active link - so the marker is one object that can be
   animated, and so it matches `.segmented__pill` exactly.

   This one only ever sets its position once, from `aria-current` at load -
   the page it marks never changes without a full navigation, so there is
   never a second position to slide to within a session. What it still shares
   with the segmented pill is the entrance: rather than an instant pop the
   moment JS knows where it goes, it fades and eases in the last few pixels of
   its own travel, which is what keeps it feeling like the same family of
   control as the ones that do slide between two live states. */
.switcher__pill {
  position: absolute;
  top: 4px;
  left: 0;
  height: calc(100% - 8px);
  background: var(--surface);
  border-radius: 4.5px;
  box-shadow: var(--shadow-sm);
  pointer-events: none;
  will-change: transform, width, opacity;
  transition: opacity 0.26s linear, transform 0.32s cubic-bezier(0.22, 0.61, 0.36, 1);
}

/* Before JS places it, the pill would sit at the far left over the wrong
   label. It is revealed only once positioned - see `createSwitcher` in ui.js
   for how it eases in from a few pixels short of its landing spot rather
   than appearing already in place. */
.switcher__pill:not(.is-ready) {
  opacity: 0;
}

@media (prefers-reduced-motion: reduce) {
  .switcher__pill {
    transition: opacity 0.15s linear;
  }
}

@media (max-width: 720px) {
  .switcher {
    /* Full-bleed on a phone: the track scrolls, and a centred scroller with
       padding either side hides the fact that there is more to reach. */
    justify-content: flex-start;
    margin-bottom: 20px;
  }

  .switcher__link {
    padding: 7px 11px;
    font-size: 12.5px;
  }
}

/* --- Brand loader ---------------------------------------------------------
   The only placeholder in the system, and it stands in for exactly one kind of
   thing: content that has to be computed before it can be shown - a chart, an
   order total. A grey block would be a claim about a shape nobody knows yet, so
   this shows the product mark instead and says only that work is happening.

   Everything else on these pages waits with nothing. Grey rows standing in for
   rosters and ledgers were tried and removed: against an API that answers in a
   millisecond they were a flicker of scaffolding on the way to content that was
   already there, which reads as a fault rather than as work.

   The mark is drawn as a CSS mask over `currentColor`, the same way the header
   brand is, so it inherits the monochrome palette rather than shipping a second
   copy of the logo with its own fill baked in. */

.brand-loader {
  display: grid;
  place-items: center;
  width: 100%;
  height: 100%;
  min-height: 120px;
  /* Two steps darker than the old `--ink-300`, because the mark is no longer
     painted flat in this colour - it rests at 38% of it and only the sweep
     reaches full. At `--ink-300` that resting state came out near invisible. */
  color: var(--ink-500);
}

/* The mark holds still and light moves across it.

   It is a faceted gem with a lot of fine internal line work, and the earlier
   treatment - scaling it up and down while fading - softened all of that: at
   0.94 the facets land between pixels and the shape reads as a grey blob
   pulsing, which is neither the logo nor a loading state anyone would call
   clean. Holding the geometry fixed keeps every facet on the pixel grid.

   The motion is a highlight sweeping left to right instead. Because the logo is
   a mask over a painted box, the paint can be a moving gradient while the mask
   stays put - so nothing about the shape moves, only what is behind it. That
   reads as a surface catching the light, which is what a gem should do, and it
   never implies a percentage the way a spinner does.

   A spinner was not an option regardless: the mark is radially symmetric, so
   rotating it looks like nothing is happening at all. */
.brand-loader__mark {
  width: 32px;
  height: 32px;
  /* The sweep sits over a dimmed copy of the same colour rather than the whole
     element being faded, so the highlight can reach full strength while the
     resting mark stays quiet. Fading the element instead would dim both and
     flatten the effect back to nothing. */
  background:
    linear-gradient(
        100deg,
        transparent 20%,
        currentColor 38%,
        currentColor 46%,
        transparent 64%
      )
      /* Three times the width of the mark, so the lit band is a narrow pass
         rather than the whole face brightening at once. */
      0 0 / 300% 100% no-repeat,
    linear-gradient(color-mix(in srgb, currentColor 38%, transparent) 0 0);
  -webkit-mask: url('/logo.svg') center / contain no-repeat;
  mask: url('/logo.svg') center / contain no-repeat;
  animation: brand-sweep 1.9s cubic-bezier(0.5, 0, 0.5, 1) infinite;
}

/* Runs from fully off one edge to fully off the other, then holds briefly
   before repeating - a continuous strobe reads as agitation, and the pause is
   what makes it feel unhurried rather than anxious. */
@keyframes brand-sweep {
  0% {
    background-position: 130% 0;
  }
  62%,
  100% {
    background-position: -130% 0;
  }
}

/* --- Handover ---
   What replaces the mark, on the way in.

   The mark holds its slot for a beat and is then swapped for the real content
   in a single frame, which lands as a hard cut - the thing the wait was
   supposed to smooth over happens anyway, at the end. This eases that last
   step: the container is flagged as it is handed to the renderer, so the
   content it draws rises the last few pixels and fades up instead of appearing.

   On the container rather than its children, so one rule covers a table of
   rows, a calendar grid and a chart without any of them knowing about it. */
[data-loading-settled] {
  animation: loading-settle 0.42s cubic-bezier(0.22, 0.61, 0.36, 1) both;
}

/* A `<tbody>` is a table section, and `transform` on one is not reliably
   rendered - Chrome honours it, others drop it or clip the rows against the
   scrolling ancestor. The rows fade without the rise instead, which is the part
   of the effect that was doing the work anyway. */
tbody[data-loading-settled] {
  animation-name: loading-settle-fade;
}

@keyframes loading-settle-fade {
  from {
    opacity: 0;
  }
  to {
    opacity: 1;
  }
}

@keyframes loading-settle {
  from {
    opacity: 0;
    transform: translateY(4px);
  }
  to {
    opacity: 1;
    transform: none;
  }
}

/* Reduced motion gets the mark as a plain solid shape - the sweep dropped
   entirely, not merely paused, since a halted gradient would freeze it
   half-lit. Handover is a cut for the same reason: a reader who has asked for
   no motion should not be given a fade instead. */
@media (prefers-reduced-motion: reduce) {
  .brand-loader__mark {
    background: currentColor;
    opacity: 0.55;
    animation: none;
  }

  [data-loading-settled],
  tbody[data-loading-settled] {
    animation: none;
  }
}

/* --- Skeleton blocks --------------------------------------------------------
   Grey bars in the exact shape of content whose shape is already fixed - the
   KPI strip is always five tiles, a row list holds the row height already
   reserved for it, a mini-week is always a few day columns. Where the mark
   above declines to guess a shape it cannot know, this is the other half of
   the same rule: where the shape *is* known, a block drawn in it is a truer
   placeholder than a mark that says nothing about what is coming.

   `brand-loader--skeleton` sits on the same wrapper the mark uses, so it picks
   up the wrapper's sizing and the shared `[data-loading-settled]` handover for
   free - only the fill changes. */

/* `.brand-loader`'s base rule floors every slot at 120px, sized for the
   smallest chart this system draws. A skeleton's own blocks define the shape
   instead - the KPI strip, the row lists and the mini-week are all taller than
   that floor anyway, but the storage insight is two lines of text reserved at
   under half of it, and the 120px floor overrode that reservation outright:
   the block rendered taller than the space held open for it, and the card
   grew by the difference the instant the skeleton appeared, then snapped back
   the instant real content replaced it. Zeroing the floor here means every
   skeleton is exactly as tall as what it draws, which is what its target
   already reserves for it. */
.brand-loader--skeleton {
  display: block;
  place-items: initial;
  min-height: 0;
  padding: 0;
}

/* Inside `.mini-week` the wrapper would otherwise be that grid's one and only
   item, with `.sk-week` nested a layer deeper than the real `.mini-week__day`
   columns sit and sized to its own shorter content - leaving a bare strip of
   the 308px `.mini-week` already reserves showing beneath it as that
   container's own hairline background. `display: contents` removes the
   wrapper from layout while keeping it in the DOM, so `clearLoading` still
   finds it to tear down, and `.sk-week` becomes the grid's direct child,
   stretching to fill the space `.mini-week` already holds open. */
.mini-week .brand-loader--skeleton {
  display: contents;
}

.sk-block {
  border-radius: 4px;
  background: linear-gradient(
      100deg,
      var(--ink-100) 32%,
      var(--ink-150) 50%,
      var(--ink-100) 68%
    )
    0 0 / 250% 100%;
  animation: sk-shimmer 1.9s ease-in-out infinite;
}

/* The same 1.9s beat the brand mark sweeps on, so a page with both a chart
   loading and a skeleton loading reads as one system breathing rather than two
   different clocks running past each other. */
@keyframes sk-shimmer {
  0% {
    background-position: 120% 0;
  }
  62%,
  100% {
    background-position: -120% 0;
  }
}

/* --- Row list skeleton --- */

.sk-row {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 11px 20px;
  border-bottom: 1px solid var(--border);
}

.sk-row:last-child {
  border-bottom: 0;
}

.sk-row__mark {
  width: 34px;
  height: 34px;
  flex: none;
  border-radius: 7px;
}

.sk-row__text {
  flex: 1 1 auto;
  min-width: 0;
  display: grid;
  gap: 6px;
}

.sk-row__text .sk-block:first-child {
  height: 13px;
  width: var(--sk-title-w, 55%);
}

.sk-row__text .sk-block:last-child {
  height: 10px;
  width: var(--sk-meta-w, 35%);
}

.sk-row__end {
  flex: none;
  min-width: 108px;
  display: grid;
  justify-items: end;
  gap: 6px;
}

.sk-row__end .sk-block:first-child {
  height: 12px;
  width: 46px;
}

.sk-row__end .sk-block:last-child {
  height: 17px;
  width: 72px;
  border-radius: 999px;
}

/* --- Storage legend and insight skeletons ---
   The donut itself keeps the pulsing mark - a part-to-whole split's shape is
   not knowable before its data arrives, which is exactly the case the mark was
   built for. The legend underneath it and the insight sentence below that are
   different: five rows and roughly two lines of prose are both a fixed shape
   this card always has, so they get blocks the same way the KPI strip and the
   row lists do. */

.sk-legend {
  display: flex;
  flex-direction: column;
  gap: 0;
  margin-top: 16px;
}

.sk-legend__row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  padding: 7px 8px;
}

.sk-legend__row .sk-block:first-child {
  height: 12px;
  width: 62%;
}

.sk-legend__row .sk-block:last-child {
  height: 12px;
  width: 44px;
  flex: none;
}

/* No margin, padding or border of its own - `#storage-insight` is the real
   `.insight` paragraph and already carries all three, so a skeleton that
   repeated them stacked its own copy on top of the element's and rendered
   taller than the reservation matching that element. Only the two text lines
   are this block's job; the paragraph around it supplies the rest. */
.sk-insight {
  display: grid;
  gap: 8px;
}

.sk-insight .sk-block:first-child {
  height: 13px;
  width: 92%;
}

.sk-insight .sk-block:last-child {
  height: 13px;
  width: 68%;
}

/* --- Mini-week skeleton ---
   Three columns, matching the day count the real widget most often draws, each
   holding one or two chip-shaped bars - so the placeholder reads as a small
   timetable rather than as three grey rectangles. */

/* One grid level deep once its `brand-loader` wrapper collapses via
   `display: contents` above - `.sk-week` becomes `.mini-week`'s single grid
   item, so it has to fill the height `.mini-week` already reserves rather than
   sizing to its own shorter content and leaving that reservation's own
   background exposed beneath it. */
.sk-week {
  display: grid;
  grid-auto-flow: column;
  grid-auto-columns: minmax(0, 1fr);
  align-self: stretch;
  height: 100%;
  gap: 1px;
  background: var(--border);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  overflow: hidden;
}

.sk-week__day {
  display: flex;
  flex-direction: column;
  background: var(--surface);
  min-width: 0;
}

.sk-week__head {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 6px;
  padding: 11px 4px 10px;
  border-bottom: 1px solid var(--border);
  background: var(--page);
}

.sk-week__head .sk-block:first-child {
  width: 26px;
  height: 9px;
}

.sk-week__head .sk-block:last-child {
  width: 22px;
  height: 22px;
  border-radius: 50%;
}

.sk-week__slots {
  display: flex;
  flex-direction: column;
  gap: 5px;
  flex: 1;
  padding: 7px;
}

.sk-week__chip {
  height: 56px;
  border-radius: 3px;
}

@media (max-width: 480px) {
  .sk-week {
    grid-auto-flow: row;
  }

  .sk-week__day {
    border-bottom: 1px solid var(--border);
  }

  .sk-week__head {
    flex-direction: row;
    justify-content: space-between;
    padding: 9px 10px;
  }
}

/* --- File card skeleton ---
   The library's browse grid is a fixed shape before a single file has loaded -
   always a preview panel over a name, a description line and a footer - so it
   gets a skeleton in that exact shape rather than the generic mark. Laid out
   with the same `.file-grid` this becomes once real cards land, so the count
   and sizing the reader sees while waiting is the count and sizing they get. */

.sk-card {
  display: flex;
  flex-direction: column;
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  overflow: hidden;
}

.sk-card__preview {
  height: 108px;
  border-radius: 0;
}

.sk-card__body {
  display: flex;
  flex-direction: column;
  gap: 8px;
  padding: 12px 14px 14px;
}

.sk-card__body .sk-block:nth-child(1) {
  height: 13px;
  width: 72%;
}

.sk-card__body .sk-block:nth-child(2) {
  height: 11px;
  width: 92%;
}

.sk-card__foot {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  margin-top: 3px;
  padding: 10px 14px;
  border-top: 1px solid var(--border);
}

.sk-card__foot .sk-block:first-child {
  height: 18px;
  width: 76px;
  border-radius: 999px;
}

.sk-card__foot .sk-block:last-child {
  height: 11px;
  width: 44px;
}

@media (prefers-reduced-motion: reduce) {
  .sk-block {
    background: var(--ink-150);
    animation: none;
  }
}

/* --- Toolbar --------------------------------------------------------------
   Search, filters and a primary action above a table. One row, wrapping to
   two on narrow screens, with the search input taking the slack so the
   controls to its right stay hard against the edge they are aligned to. */

.toolbar {
  display: flex;
  align-items: center;
  gap: 9px;
  flex-wrap: wrap;
  padding: 14px 20px;
  border-bottom: 1px solid var(--border);
}

.toolbar__search {
  position: relative;
  flex: 1 1 220px;
  min-width: 0;
  display: flex;
  align-items: center;
}

.toolbar__search svg {
  position: absolute;
  left: 10px;
  color: var(--ink-400);
  pointer-events: none;
}

.toolbar__search .field__input {
  padding-left: 32px;
}

.toolbar__spacer {
  flex: 1 1 auto;
}

.toolbar__count {
  font-size: 12.5px;
  color: var(--ink-500);
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}

/* A select sitting in a toolbar rather than in a form field, so it loses the
   full-width behaviour and takes its own content's width. */
select.toolbar__select {
  width: auto;
  min-width: 120px;
  padding: 7px 30px 7px 11px;
  font-size: 12.5px;
}

/* --- Records table --------------------------------------------------------
   The dashboard's `.data-table` is a figures table: right-aligned, tabular
   numerals, dense. A roster is a different object - it holds people, avatars
   and controls - so it gets its own treatment rather than overloading that one
   with exceptions. Both share the same hairline rules and the same sticky
   header, which is what keeps them recognisably the same family. */

.record-table {
  width: 100%;
  border-collapse: collapse;
  font-size: 13px;
}

.record-table th,
.record-table td {
  padding: 11px 20px;
  text-align: left;
  border-bottom: 1px solid var(--border);
  vertical-align: middle;
}

.record-table thead th {
  position: sticky;
  top: 0;
  z-index: 2;
  background: var(--surface);
  color: var(--ink-400);
  font-weight: 500;
  font-size: 11.5px;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  white-space: nowrap;
}

.record-table tbody tr {
  transition: background 0.12s linear;
}

.record-table tbody tr:hover {
  background: var(--ink-100);
}

.record-table tbody tr:last-child td {
  border-bottom: 0;
}

.record-table .td--num {
  text-align: right;
  font-variant-numeric: tabular-nums;
}

/* A sortable column header. The arrow is drawn only on the column actually
   carrying the sort, so the header row is not a line of identical glyphs the
   reader has to compare to find the active one. */
.record-table th button {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  padding: 0;
  background: none;
  border: 0;
  font: inherit;
  font-size: 11.5px;
  font-weight: 500;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: inherit;
  cursor: pointer;
  transition: color 0.12s linear;
}

.record-table th button:hover {
  color: var(--ink-900);
}

.record-table th[aria-sort] button {
  color: var(--ink-900);
}

.record-table th button::after {
  content: '';
  width: 0;
  height: 0;
  opacity: 0;
  border-left: 3.5px solid transparent;
  border-right: 3.5px solid transparent;
  transition: opacity 0.12s linear;
}

.record-table th[aria-sort='ascending'] button::after {
  opacity: 1;
  border-bottom: 4.5px solid currentColor;
}

.record-table th[aria-sort='descending'] button::after {
  opacity: 1;
  border-top: 4.5px solid currentColor;
}

/* The identity cell: avatar, name, secondary line. Used in the roster, the
   audit log and the session list, so all three read as the same object. */
.identity {
  display: flex;
  align-items: center;
  gap: 10px;
  min-width: 0;
}

.identity__text {
  display: flex;
  flex-direction: column;
  gap: 1px;
  min-width: 0;
}

.identity__name {
  font-weight: 550;
  color: var(--ink-900);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.identity__meta {
  font-size: 11.5px;
  color: var(--ink-400);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* A `<button>` version of the identity cell, for a table row where the file
   itself opens something (the preview modal) - reset to the plain cell's
   look, with the name picking up the underline a link gets on hover/focus so
   the cell reads as clickable without a whole row highlight. */
.identity--link {
  padding: 0;
  background: none;
  border: 0;
  font: inherit;
  text-align: left;
  cursor: pointer;
}

.identity--link:hover .identity__name,
.identity--link:focus-visible .identity__name {
  text-decoration: underline;
  text-underline-offset: 2px;
}

/* Avatars in a table are smaller than the header's, and squared rather than
   round - a round chip at this size reads as a status dot beside text. */
.identity__avatar {
  display: grid;
  place-items: center;
  flex-shrink: 0;
  width: 30px;
  height: 30px;
  border-radius: 4.5px;
  background: var(--ink-900);
  color: var(--surface);
  font-size: 11px;
  font-weight: 600;
  letter-spacing: 0.02em;
}

/* A member who is invited or suspended is not fully present, and the avatar is
   the fastest place to say so - the row still reads normally, but the identity
   is visibly held at a lower weight. */
.identity__avatar--quiet {
  background: var(--ink-200);
  color: var(--ink-600);
}

/* --- Status pills ---------------------------------------------------------
   Four states drawn in one monochrome ramp. With hue unavailable, the ranking
   is carried by fill weight: solid ink for the strongest state, a ringed
   outline for a pending one, a flat tint for a dormant one. */

.pill {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 3px 9px;
  border: 1px solid transparent;
  border-radius: 999px;
  font-size: 11.5px;
  font-weight: 500;
  letter-spacing: 0.01em;
  white-space: nowrap;
}

.pill::before {
  content: '';
  width: 5px;
  height: 5px;
  border-radius: 50%;
  background: currentColor;
  flex-shrink: 0;
}

/* Live, healthy, paid, confirmed. */
.pill--on {
  background: var(--ink-900);
  border-color: var(--ink-900);
  color: var(--surface);
}

/* Awaiting something: invited, pending, open. Ringed rather than filled, so it
   reads as an outline of the solid state it is on its way to becoming. */
.pill--wait {
  background: transparent;
  border-color: var(--border-strong);
  color: var(--ink-700);
}

/* Dormant: suspended, cancelled, revoked. */
.pill--off {
  background: var(--ink-100);
  border-color: transparent;
  color: var(--ink-500);
}

/* Needs attention: failed, denied, critical. Heaviest border in the set, which
   is how the rest of this system marks a destructive or urgent thing. */
.pill--alert {
  background: var(--surface);
  border-color: var(--ink-900);
  color: var(--ink-900);
  font-weight: 600;
}

/* A pill without its leading dot, for labels that are classifications rather
   than states - a role name, an event category. */
.pill--plain::before {
  display: none;
}

/* --- Role select ----------------------------------------------------------
   A native select inside a table cell. Kept native for the keyboard and mobile
   behaviour, restyled to the width of its own content so a column of them does
   not read as a column of identical boxes. */

.role-select {
  width: auto;
  min-width: 104px;
  padding: 5px 28px 5px 9px;
  font-size: 12.5px;
  background-position: right 8px center;
}

.role-select:disabled {
  color: var(--ink-400);
  background-color: var(--ink-100);
  cursor: not-allowed;
}

/* --- Row actions ---------------------------------------------------------- */

.row-actions {
  display: flex;
  align-items: center;
  justify-content: flex-end;
  gap: 4px;
}

.icon-button {
  display: grid;
  place-items: center;
  width: 28px;
  height: 28px;
  padding: 0;
  background: transparent;
  border: 1px solid transparent;
  border-radius: 4.5px;
  color: var(--ink-400);
  cursor: pointer;
  transition: background 0.12s linear, color 0.12s linear, border-color 0.12s linear;
}

.icon-button:hover {
  background: var(--ink-100);
  border-color: var(--border);
  color: var(--ink-900);
}

.icon-button:disabled {
  opacity: 0.35;
  cursor: not-allowed;
}

.icon-button:disabled:hover {
  background: transparent;
  border-color: transparent;
  color: var(--ink-400);
}

/* --- Bulk selection bar ---------------------------------------------------
   Appears in place of the toolbar's normal contents once rows are selected.
   It replaces rather than stacks, so the table never jumps down the page the
   moment a checkbox is ticked. */

.bulk-bar {
  display: flex;
  align-items: center;
  gap: 10px;
  flex-wrap: wrap;
  padding: 10px 20px;
  background: var(--ink-100);
  border-bottom: 1px solid var(--border);
  font-size: 12.5px;
  animation: panel-in 0.2s cubic-bezier(0.22, 0.61, 0.36, 1);
}

.bulk-bar__count {
  font-weight: 600;
  font-variant-numeric: tabular-nums;
}

/* --- Empty state ----------------------------------------------------------
   What a filtered table shows when nothing matches. It states the cause, not
   just the absence - "no results" alone leaves the reader checking whether the
   page is broken or their filter is too narrow. */

.empty-state {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 6px;
  padding: 48px 20px;
  text-align: center;
}

.empty-state__mark {
  display: grid;
  place-items: center;
  width: 38px;
  height: 38px;
  margin-bottom: 4px;
  border-radius: 4.5px;
  background: var(--ink-100);
  color: var(--ink-400);
}

.empty-state__title {
  font-size: 13.5px;
  font-weight: 600;
}

.empty-state__hint {
  font-size: 12.5px;
  color: var(--ink-500);
  max-width: 320px;
  line-height: 1.6;
}

/* The one empty state in this app that is a genuine accomplishment rather
   than an absence - reaching zero unread notifications - so it gets a filled
   dark mark with a check rather than the muted square every other "nothing
   matches" state uses, and a touch more room to feel like a small landing
   rather than a filtered-out table. */
.empty-state--caught-up {
  padding: 56px 20px 60px;
}

.empty-state__mark--done {
  width: 44px;
  height: 44px;
  border-radius: 50%;
  background: var(--ink-900);
  color: var(--surface);
  animation: caught-up-in 0.42s cubic-bezier(0.22, 0.61, 0.36, 1);
}

@keyframes caught-up-in {
  from {
    opacity: 0;
    transform: scale(0.7);
  }
}

@media (prefers-reduced-motion: reduce) {
  .empty-state__mark--done {
    animation: none;
  }
}

/* --- Calendar -------------------------------------------------------------
   A month grid built from a 1px gap over a tinted backing, the same technique
   the KPI strip uses for its hairlines - so the rules stay correct however the
   grid wraps and there is no border-collapse arithmetic to get wrong. */

.calendar {
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  overflow: hidden;
  background: var(--ink-150);
}

.calendar__weekdays {
  display: grid;
  grid-template-columns: repeat(7, minmax(0, 1fr));
  gap: 1px;
  background: var(--ink-150);
}

.calendar__weekday {
  padding: 9px 10px;
  background: var(--surface);
  font-size: 11px;
  font-weight: 500;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  color: var(--ink-400);
}

/* Six rows always, sized rather than left to the content.

   A month covers five or six weeks depending on the weekday it opens on, so a
   grid that sizes to its rows changes height by a whole row - 123px - as the
   reader steps between months, and again on first paint when the loading mark
   gives way to the days. Six is the most any month needs; a five-week month
   carries one row of trailing greyed-out days instead of resizing the card.
   `auto-rows` rather than a fixed track so a day heavy with chips can still
   grow past the floor `.calendar__day` sets. */
.calendar__grid {
  display: grid;
  grid-template-columns: repeat(7, minmax(0, 1fr));
  grid-auto-rows: minmax(122px, auto);
  gap: 1px;
  background: var(--ink-150);
}

.calendar__day {
  display: flex;
  flex-direction: column;
  gap: 5px;
  min-height: 122px;
  padding: 8px;
  background: var(--surface);
  text-align: left;
  border: 0;
  font: inherit;
  cursor: pointer;
  transition: background 0.12s linear;
}

.calendar__day:hover {
  background: var(--ink-100);
}

/* Days spilling in from the neighbouring months. Present, because a month grid
   with holes in it is harder to read than one that runs continuously, but held
   well back so the current month is unambiguous. */
.calendar__day--outside {
  background: var(--page);
}

.calendar__day--outside .calendar__date {
  color: var(--ink-300);
}

/* The selected day owns the ink, matching how the tab strip marks its
   selection - the darkest thing in the group is the current one. */
.calendar__day[aria-pressed='true'] {
  background: var(--ink-900);
}

.calendar__day[aria-pressed='true'] .calendar__date,
.calendar__day[aria-pressed='true'] .calendar__more {
  color: var(--surface);
}

.calendar__day[aria-pressed='true'] .calendar__chip {
  background: rgba(253, 252, 250, 0.16);
  border-color: rgba(253, 252, 250, 0.3);
  color: var(--surface);
}

.calendar__date {
  display: flex;
  align-items: center;
  gap: 6px;
  font-size: 12.5px;
  font-weight: 550;
  font-variant-numeric: tabular-nums;
  color: var(--ink-700);
}

/* Today, marked with a filled disc rather than a ring - a ring would read as a
   selection, which is a different state this grid already uses. */
.calendar__day--today .calendar__date {
  color: var(--ink-900);
}

.calendar__day--today .calendar__date::before {
  content: '';
  width: 5px;
  height: 5px;
  border-radius: 50%;
  background: var(--ink-900);
}

.calendar__day[aria-pressed='true'].calendar__day--today .calendar__date::before {
  background: var(--surface);
}

.calendar__chip {
  display: block;
  width: 100%;
  padding: 3px 6px;
  background: var(--ink-100);
  border: 1px solid transparent;
  border-left: 2px solid var(--ink-700);
  border-radius: 3px;
  font-size: 11px;
  line-height: 1.45;
  color: var(--ink-700);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* A pending booking is ringed rather than filled, matching `.pill--wait`. */
.calendar__chip--pending {
  background: transparent;
  border-color: var(--border-strong);
  border-left-color: var(--ink-400);
}

/* Cancelled bookings stay on the grid - a slot that disappears when it is
   cancelled hides the fact that it was ever booked - but struck through. */
.calendar__chip--cancelled {
  background: transparent;
  border-left-color: var(--ink-200);
  color: var(--ink-400);
  text-decoration: line-through;
}

.calendar__more {
  font-size: 11px;
  color: var(--ink-400);
  padding-left: 2px;
}

.calendar__count {
  margin-left: auto;
  font-size: 10.5px;
  font-weight: 600;
  color: var(--ink-400);
}

/* --- Day agenda -----------------------------------------------------------
   The detail pane beside the month grid. Bookings on the selected day laid out
   against a time rail, so a gap in the day is visible as a gap. */

/* Unlike the grid and the mini month, this card's height is genuinely a
   function of its contents - a day with six bookings is taller than a day with
   one, and it changes as the reader clicks around long after loading is over.
   There is no single height to freeze it at, so the placeholder takes the
   height of the emptiest real state instead: the card grows into its content
   rather than being caught out shrinking from a guess that was too big.

   That emptiest state is the "No bookings" block below, measured at 158.25px -
   32px of padding top and bottom around the mark, title and hint. It was
   written here as 146px, which is where the card's twelve-pixel jump on load
   came from: every real day, empty or not, was taller than the placeholder
   holding its place. */
#cal-agenda[data-loading-slot] .brand-loader {
  min-height: 158.25px;
}

/* And reserved on the empty container too, for the window before the JS has
   run and written that placeholder in - see the note on `.minical:empty`. */
#cal-agenda:empty {
  min-height: 158.25px;
}

.agenda {
  display: flex;
  flex-direction: column;
}

.agenda__item {
  display: grid;
  grid-template-columns: 54px minmax(0, 1fr);
  gap: 12px;
  padding: 12px 0;
  border-bottom: 1px solid var(--border);
}

.agenda__item:first-child {
  padding-top: 0;
}

.agenda__item:last-child {
  padding-bottom: 0;
  border-bottom: 0;
}

.agenda__time {
  font-size: 12.5px;
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  color: var(--ink-900);
}

.agenda__duration {
  display: block;
  margin-top: 2px;
  font-size: 11px;
  font-weight: 400;
  color: var(--ink-400);
}

.agenda__body {
  min-width: 0;
}

.agenda__head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 10px;
  margin-bottom: 3px;
}

.agenda__name {
  font-size: 13px;
  font-weight: 550;
}

.agenda__meta {
  font-size: 11.5px;
  color: var(--ink-400);
  line-height: 1.6;
}

/* --- Checkout -------------------------------------------------------------
   Two columns: the form the reader works through on the left, the order they
   are committing to on the right. The summary sticks, because it is the thing
   being confirmed and it must stay legible while the form is filled in. */

.grid--checkout {
  grid-template-columns: minmax(0, 1.35fr) minmax(0, 1fr);
  align-items: start;
}

@media (max-width: 1080px) {
  .grid--checkout {
    grid-template-columns: minmax(0, 1fr);
  }
}

/* The summary sticks while the form is worked through, so the total and the
   pay button stay on screen however long the form column runs. */
@media (min-width: 1081px) {
  .grid--checkout > .card--summary {
    position: sticky;
    top: 16px;
  }
}

/* --- Line items ----------------------------------------------------------- */

.lines {
  display: flex;
  flex-direction: column;
}

/* The order summary's height, held from the first paint to the last.

   The block is four line items and comes out at 112.5px, and it stays at that
   height in every state the reader can put the page into - each plan, each
   billing cycle, each seat count, at every width from 1440 down to 420. So it
   is a fixed, knowable box, and the placeholder that stands in for it can
   reserve exactly the right one.

   It previously did not. The mark was given a 190px floor in JS, which is 77px
   taller than the summary that replaces it, so the card lurched twice on load:
   down 190px when the placeholder appeared, then back up 77px when the real
   figures landed. Both numbers now come from here instead.

   `:empty` covers the window before the JS has run at all, which is the larger
   half of the problem - the module that writes the placeholder imports two
   further modules first, so the shell paints with this container at zero height
   and everything below it sitting too high. Reserved in the stylesheet, the
   space is correct in the very first frame the browser paints. */
#summary-lines:empty,
#summary-lines[data-loading-slot] .brand-loader {
  min-height: 112.5px;
}

.line {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 14px;
  padding: 11px 0;
  border-bottom: 1px solid var(--border);
}

.line:first-child {
  padding-top: 0;
}

.line__text {
  min-width: 0;
}

.line__name {
  font-size: 13px;
  font-weight: 500;
}

.line__detail {
  margin-top: 2px;
  font-size: 11.5px;
  color: var(--ink-400);
}

.line__amount {
  flex-shrink: 0;
  font-size: 13px;
  font-weight: 550;
  font-variant-numeric: tabular-nums;
}

/* The totals block. Sub-rows are quiet, the final total is the heaviest thing
   in the card - it is the number being agreed to. */
.totals {
  display: flex;
  flex-direction: column;
  gap: 9px;
  padding-top: 14px;
  margin-top: 3px;
  border-top: 1px solid var(--border);
}

/* Empty, this block collapses to nothing and the pay button and its note sit
   125px too high until the figures land, then drop into place - the same late
   shove the summary above it used to make.

   It deliberately carries no loading mark: it is the figure being calculated,
   and the mark in the lines directly above is already saying so. But it still
   has to hold its ground, so the space is reserved while it is empty. 140.75px
   is its measured height, which like the summary's is the same in every plan,
   cycle and payment-method state at every width.

   The border and padding are dropped for that window - with no rows above them
   they would otherwise draw a rule across an empty gap. */
.totals:empty {
  height: 140.75px;
  padding-top: 0;
  border-top: 0;
}

.totals__row {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 14px;
  font-size: 12.5px;
  color: var(--ink-600);
}

.totals__row span:last-child {
  font-variant-numeric: tabular-nums;
  color: var(--ink-900);
}

.totals__row--grand {
  padding-top: 11px;
  margin-top: 2px;
  border-top: 1px solid var(--border);
  font-size: 15px;
  font-weight: 600;
  color: var(--ink-900);
}

.totals__row--grand span:last-child {
  font-size: 19px;
  letter-spacing: -0.02em;
}

/* --- Card entry -----------------------------------------------------------
   A demonstration form. It is deliberately not a real payment field: there is
   no processor iframe behind it and nothing is transmitted, which the note
   under it states plainly rather than leaving to be assumed. */

/* The saved card and the code that confirms it, on one line. They are a single
   question - "charge this card, and prove you hold it" - so they sit together
   rather than stacking into two apparent steps. The card keeps its own width
   and the field takes what is left. */
.saved-row {
  display: flex;
  align-items: flex-start;
  gap: 20px;
  margin-bottom: 18px;
}

/* Inside the row the card's own bottom margin would double the gap. It also
   holds its width rather than flexing: the aspect-ratio is what makes it read
   as a card, and a card that squashes to fit stops looking like one. */
.saved-row .saved-card {
  flex: 0 1 300px;
  margin-bottom: 0;
}

/* The label sits at the card's top edge, so the two read as one row rather than
   as a card with something floating beside it. */
.saved-row .field {
  flex: 1;
  min-width: 0;
}

@media (max-width: 620px) {
  .saved-row {
    flex-direction: column;
    gap: 14px;
  }

  /* Stacked, the card is no longer competing with the field for the row, so it
     goes back to sizing itself rather than filling the column - `flex: 0 1 300px`
     in a column flexbox would stretch it to the full width instead. */
  .saved-row .saved-card {
    flex: 0 0 auto;
    width: 100%;
  }
}

/* The saved card, drawn as a card. It is read-only - every value on it comes
   from what a processor hands back, so there is nothing here to edit and no
   full number to display. */
.saved-card {
  position: relative;
  display: flex;
  flex-direction: column;
  /* ISO/IEC 7810 ID-1 - the proportions of the object in the reader's wallet.
     Capped rather than stretched: a card that fills the column reads as a
     banner, and the whole point of drawing it is that it looks like a card. */
  width: 100%;
  max-width: 340px;
  aspect-ratio: 1.586;
  /* Everything printed on the face is sized against the card's own rendered
     width, not the viewport's. The face shrinks with the column it sits in - on
     a 360px phone it lands near 288px against 340px on desktop - and type held
     at desktop pixels inside a face that has lost 15% of its width closes up the
     padding and throws the proportions out. A container is what lets the chip,
     the number and the footer shed size at the same rate the card does, so it
     reads as the same object photographed smaller rather than a cramped one.

     `cqw` units carry the scaling; the fallbacks on the line above each one keep
     the desktop pixel values for anything without container query support. */
  container-type: inline-size;
  container-name: saved-card;
  padding: 18px 20px;
  padding: clamp(12px, 5.3cqw, 18px) clamp(14px, 5.9cqw, 20px);
  margin-bottom: 18px;
  border-radius: 12px;
  color: #fdfcfa;
  /* Three layers make the surface read as a physical one: a soft radial
     highlight where light would land, a diagonal shift across the face, and the
     base ink underneath. A single flat gradient looks like a coloured div. */
  background:
    radial-gradient(120% 140% at 12% 8%, rgb(255 255 255 / 0.16), transparent 52%),
    linear-gradient(148deg, #2f2b25 0%, #1c1a16 46%, #100f0d 100%);
  /* Contact shadow plus a cast one - the pair is what separates the card from
     the page instead of just darkening under it. Two inset hairlines give the
     edge its lit top and shaded bottom. */
  box-shadow:
    0 1px 2px rgb(23 21 18 / 0.16),
    0 10px 24px -12px rgb(23 21 18 / 0.42),
    inset 0 1px 0 rgb(255 255 255 / 0.13),
    inset 0 -1px 0 rgb(0 0 0 / 0.35);
  overflow: hidden;
  isolation: isolate;
}

/* The sweep of light across the face. Its own layer so it can sit above the
   background but under the content, and clip to the card's radius. */
.saved-card__sheen {
  position: absolute;
  inset: 0;
  z-index: -1;
  background: linear-gradient(
    104deg,
    transparent 32%,
    rgb(255 255 255 / 0.07) 46%,
    rgb(255 255 255 / 0.11) 50%,
    rgb(255 255 255 / 0.04) 55%,
    transparent 68%
  );
  pointer-events: none;
}

.saved-card__top {
  display: flex;
  align-items: center;
  gap: 11px;
  gap: clamp(8px, 3.2cqw, 11px);
}

/* The plate and the contactless mark carry `width`/`height` attributes for the
   no-CSS case; here they scale off the face like everything else on it, holding
   their own aspect ratio so the contact traces stay square to the plate. */
.saved-card__chip {
  flex-shrink: 0;
  width: clamp(26px, 10cqw, 34px);
  height: auto;
  border-radius: 4.5px;
  /* The plate stands slightly proud of the card face. */
  box-shadow: 0 1px 2px rgb(0 0 0 / 0.35);
}

.saved-card__wave {
  flex-shrink: 0;
  width: clamp(14px, 5.3cqw, 18px);
  height: auto;
  color: rgb(255 255 255 / 0.62);
}

/* The number takes the slack, which keeps it optically centred whatever the
   card's rendered height works out to. Set in the system's tabular figures with
   a faint emboss shadow - the letterspacing is what sells it, not a novelty
   face the rest of the page never uses. */
.saved-card__number {
  display: flex;
  align-items: center;
  gap: 11px;
  gap: clamp(7px, 3.2cqw, 11px);
  flex: 1;
  font-size: 18px;
  font-size: clamp(13px, 5.3cqw, 18px);
  font-weight: 500;
  letter-spacing: 0.11em;
  font-variant-numeric: tabular-nums;
  text-shadow: 0 1px 1px rgb(0 0 0 / 0.4);
}

/* The masked groups are drawn as dots rather than set as bullet glyphs: a
   bullet is a narrow character on a text baseline, so it renders small and
   sitting low next to the digits. Fixed dots match the weight of the visible
   four and centre against them. */
.saved-card__mask {
  display: flex;
  align-items: center;
  gap: 4.5px;
  gap: clamp(3px, 1.3cqw, 4.5px);
}

/* The dots stand in for digits, so they track the number's size rather than
   staying fixed - held at 6px they read as too heavy against a 13px last four. */
.saved-card__mask i {
  width: 6px;
  height: 6px;
  width: clamp(4.5px, 1.8cqw, 6px);
  height: clamp(4.5px, 1.8cqw, 6px);
  border-radius: 50%;
  background: currentColor;
  box-shadow: 0 1px 1px rgb(0 0 0 / 0.4);
}

.saved-card__last4 {
  margin-left: 2px;
}

/* Cardholder, expiry and brand on one line. The gap is the first thing to give
   on a narrow face - the three items themselves have to stay on this line,
   because a wrapped footer is the one thing that stops it reading as a card. */
.saved-card__foot {
  display: flex;
  align-items: flex-end;
  gap: 22px;
  gap: clamp(10px, 6.5cqw, 22px);
}

.saved-card__pair {
  display: flex;
  flex-direction: column;
  gap: 3px;
  min-width: 0;
}

/* The expiry is a fixed five characters, so it holds its size and the cardholder
   beside it absorbs whatever the footer is short of. */
.saved-card__pair:nth-of-type(2) {
  flex-shrink: 0;
}

.saved-card__key {
  font-size: 8.5px;
  font-size: clamp(7px, 2.5cqw, 8.5px);
  font-weight: 500;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  white-space: nowrap;
  color: rgb(255 255 255 / 0.48);
}

.saved-card__val {
  font-size: 12.5px;
  font-size: clamp(10.5px, 3.7cqw, 12.5px);
  font-weight: 500;
  letter-spacing: 0.02em;
  font-variant-numeric: tabular-nums;
  text-shadow: 0 1px 1px rgb(0 0 0 / 0.32);
  /* The cardholder is the only value here whose length is not fixed, so it is
     the one that gives when the footer runs out of room - a long name ellipses
     rather than wrapping to a second line or shouldering the brand off the
     trailing edge. The expiry is five characters and never reaches this. */
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
}

/* Pushed to the trailing edge and set in the italic weight the network marks
   share - close enough to read as a brand, drawn in the card's own ink rather
   than reproducing a logo the demo has no licence to use. */
.saved-card__brand {
  margin-left: auto;
  flex-shrink: 0;
  font-size: 17px;
  font-size: clamp(13px, 5cqw, 17px);
  font-weight: 700;
  font-style: italic;
  letter-spacing: -0.01em;
  text-shadow: 0 1px 2px rgb(0 0 0 / 0.4);
}

/* The confirmation code next to a saved card is three digits - a full-width
   input for it looks like it is expecting more. */
.field__input--short {
  max-width: 128px;
}

/* Every way to pay, in one list. Express wallets and card options used to be
   two separate controls with two different behaviours stacked above each other,
   which left the reader deciding which one was actually in charge. They are one
   radiogroup now: everything selects, nothing charges until the single button
   in the summary is pressed. */
.pay-methods {
  display: flex;
  flex-direction: column;
  gap: 8px;
  margin-bottom: 20px;
}

/* The express wallets, on one row above the card list. Two equal targets -
   neither is the recommended one, and sizing them differently would imply
   otherwise. */
.wallets {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 10px;
}

.wallet {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 7px;
  padding: 11px 15px;
  background: var(--ink-900);
  border: 1px solid var(--ink-900);
  border-radius: 4.5px;
  color: var(--surface);
  cursor: pointer;
  transition: background 0.12s linear, border-color 0.12s linear, transform 0.12s ease;
}

.wallet:hover {
  background: var(--ink-800);
  border-color: var(--ink-800);
}

.wallet:active {
  transform: translateY(1px);
}

.wallet:disabled {
  cursor: default;
  opacity: 0.55;
}

.wallet__mark {
  flex-shrink: 0;
}

/* The Google mark keeps its own colours, since a monochrome version of it is
   not the mark anyone recognises - so its button is light. The Apple one is
   drawn in the button's ink and reads as a glyph. */
.wallet[data-wallet='google'] {
  background: var(--surface);
  border-color: var(--border-strong);
  color: var(--ink-900);
}

.wallet[data-wallet='google']:hover {
  background: var(--ink-100);
  border-color: var(--ink-400);
}

.wallet__label {
  font-size: 13.5px;
  font-weight: 600;
  letter-spacing: -0.01em;
}

/* The chosen wallet. The two buttons carry different fills - one dark, one
   light - so the selected state is an offset ring rather than a background
   change, which is the one treatment that reads the same on both. It matches
   the ring `.pay-method[aria-checked]` uses, so a wallet and a card row look
   equally selected. */
.wallet.is-chosen,
.wallet[data-wallet='google'].is-chosen {
  border-color: var(--ink-900);
  box-shadow: 0 0 0 2px var(--surface), 0 0 0 3px var(--ink-900);
}

/* The tick that confirms which one is active, since the ring alone can read as
   a hover state on a two-button row. */
.wallet.is-chosen::after {
  content: '';
  width: 13px;
  height: 13px;
  margin-left: 1px;
  background: currentColor;
  /* A check glyph, masked so it inherits each button's own ink. */
  -webkit-mask: var(--wallet-tick) center / contain no-repeat;
  mask: var(--wallet-tick) center / contain no-repeat;
}

.wallets {
  --wallet-tick: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='3.4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m4 12.5 5.5 5.5L20 7'/%3E%3C/svg%3E");
}

.wallet__note {
  margin-top: 9px;
  font-size: 11px;
  color: var(--ink-400);
  text-align: center;
}

/* A rule with the alternative labelled in it, rather than a bare line the
   reader has to interpret. */
.pay-divider {
  display: flex;
  align-items: center;
  gap: 12px;
  margin: 16px 0 14px;
  font-size: 11px;
  font-weight: 500;
  letter-spacing: 0.04em;
  color: var(--ink-400);
  text-transform: uppercase;
}

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

/* Every row is one grid with fixed tracks, so the radio, the label and the
   brand chip land on the same columns down the list. Flex would let each row
   size its own, which is what left the marks and chips drifting. */
.pay-method {
  display: grid;
  grid-template-columns: 16px minmax(0, 1fr) auto;
  align-items: center;
  gap: 0 12px;
  /* Uniform height whatever the hint's length, so the list reads as a stack of
     equal choices rather than a ragged one. */
  min-height: 58px;
  padding: 11px 14px;
  background: var(--surface);
  border: 1px solid var(--border-strong);
  border-radius: 4.5px;
  cursor: pointer;
  transition: border-color 0.12s linear, background 0.12s linear;
}

.pay-method:hover {
  border-color: var(--ink-400);
}

.pay-method[aria-checked='true'] {
  border-color: var(--ink-900);
  box-shadow: 0 0 0 1px var(--ink-900);
}

/* The radio mark, drawn rather than native so it matches the checkbox already
   in the system. */
.pay-method__mark {
  display: grid;
  place-items: center;
  flex-shrink: 0;
  width: 16px;
  height: 16px;
  border: 1px solid var(--border-strong);
  border-radius: 50%;
  transition: border-color 0.12s linear;
}

.pay-method__mark::after {
  content: '';
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: var(--ink-900);
  transform: scale(0);
  transition: transform 0.14s cubic-bezier(0.22, 0.61, 0.36, 1);
}

.pay-method[aria-checked='true'] .pay-method__mark {
  border-color: var(--ink-900);
}

.pay-method[aria-checked='true'] .pay-method__mark::after {
  transform: scale(1);
}

.pay-method__text {
  display: flex;
  flex-direction: column;
  gap: 3px;
  min-width: 0;
  text-align: left;
}

.pay-method__name {
  font-size: 13px;
  font-weight: 500;
  line-height: 1.3;
}

.pay-method__hint {
  font-size: 11.5px;
  line-height: 1.35;
  color: var(--ink-400);
}

/* Card brand marks are drawn as text in the system's own ink rather than as
   brand logos - a demo has no licence to reproduce those, and a grey wordmark
   is honest about being a placeholder. */
.pay-method__brand {
  justify-self: end;
  flex-shrink: 0;
  /* A fixed floor so BACS, Card and Visa are the same width and the chips form
     a clean right edge down the list instead of stepping in and out. */
  min-width: 46px;
  padding: 3px 7px;
  border: 1px solid var(--border);
  border-radius: 3px;
  font-size: 10.5px;
  font-weight: 600;
  letter-spacing: 0.04em;
  color: var(--ink-500);
  text-align: center;
  text-transform: uppercase;
}

.field-row {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 12px;
}

@media (max-width: 520px) {
  .field-row {
    grid-template-columns: minmax(0, 1fr);
  }
}

/* When a wallet or invoice is selected every field drops out and the form has
   nothing left to show, so its own leading space would read as a gap. */
.pay-form--sparse {
  margin-top: -4px;
}

/* The chosen method, restated beside the button that charges it. The reader
   should never have to scroll back up the form to confirm what they picked. */
/* The commit block.

   Nothing loads here any more. The veil and spinner that used to cover this
   while a new method resolved have moved to the order summary above, which is
   the block whose figures actually change - covering the button meant the one
   control the reader was reaching for went dead and grey on every switch while
   the numbers it referred to sat there unchanged. */
.pay-commit {
  position: relative;
}

/* The commit button, spaced off the totals it follows. */
#pay-submit {
  margin-top: 18px;
}

/* The outcome, inline under the button that caused it. Empty until there is
   something to report, so it takes no space on arrival. */
.pay-result {
  display: none;
}

.pay-result.is-shown {
  display: flex;
  align-items: flex-start;
  gap: 9px;
  padding: 12px 13px;
  margin-top: 12px;
  background: var(--ink-100);
  border: 1px solid var(--border-strong);
  border-radius: 4.5px;
  animation: panel-in 0.24s cubic-bezier(0.22, 0.61, 0.36, 1);
}

/* A filled tick rather than an outline one - this is the page's only success
   state, and it should read at a glance. */
.pay-result__mark {
  display: grid;
  place-items: center;
  flex-shrink: 0;
  width: 20px;
  height: 20px;
  margin-top: 1px;
  background: var(--ink-900);
  border-radius: 50%;
  color: var(--surface);
}

.pay-result__text {
  display: flex;
  flex-direction: column;
  gap: 3px;
  min-width: 0;
}

.pay-result__title {
  font-size: 12.5px;
  font-weight: 600;
  line-height: 1.4;
  color: var(--ink-900);
}

.pay-result__detail {
  font-size: 11.5px;
  line-height: 1.5;
  color: var(--ink-600);
}

/* The demo tag, on its own line at hint weight. Set apart from the sentence so
   the confirmation reads as a confirmation and the caveat is still unmissable. */
.pay-result__demo {
  margin-top: 1px;
  font-size: 10.5px;
  font-weight: 500;
  letter-spacing: 0.03em;
  color: var(--ink-400);
  text-transform: uppercase;
}

/* The reassurance line under the pay button. Not a badge and not a lock icon
   the size of a thumbnail - one sentence at hint weight, which is what a real
   checkout can honestly offer. */
.pay-note {
  display: flex;
  align-items: flex-start;
  gap: 8px;
  margin-top: 14px;
  font-size: 11.5px;
  line-height: 1.55;
  color: var(--ink-400);
}

.pay-note svg {
  flex-shrink: 0;
  margin-top: 1px;
}

/* --- Usage meters ---------------------------------------------------------
   Consumption against a limit. The track is the limit, so a bar near the end
   of its track is legible as "close to the cap" without reading the numbers. */

.meters {
  display: flex;
  flex-direction: column;
  gap: 14px;
}

.meter__head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 12px;
  margin-bottom: 6px;
}

.meter__name {
  font-size: 12.5px;
  color: var(--ink-700);
}

.meter__value {
  font-size: 12px;
  font-variant-numeric: tabular-nums;
  color: var(--ink-500);
}

.meter__track {
  height: 6px;
  background: var(--ink-100);
  border-radius: 999px;
  overflow: hidden;
}

.meter__bar {
  height: 100%;
  background: var(--ink-700);
  border-radius: 999px;
  transform-origin: left center;
}

/* Over 85% of the allowance. Full ink, because this is the one state in the
   list the reader may need to act on. */
.meter__bar--high {
  background: var(--ink-900);
}

/* --- Audit log ------------------------------------------------------------
   A feed, not a table. Each entry is one sentence - who, what, where - with the
   severity carried by a marker in the gutter, so a scan down the left edge
   picks out the critical events without reading a word. */

.log {
  display: flex;
  flex-direction: column;
}

.log__entry {
  display: grid;
  grid-template-columns: 8px minmax(0, 1fr);
  gap: 14px;
  padding: 13px 0;
  border-bottom: 1px solid var(--border);
}

/* An entry backed by a detail payload is a disclosure control, not just a row
   - the cursor and the chevron are what tell the reader there is more here
   before they click, rather than the click either working or silently doing
   nothing. */
.log__entry--expandable {
  cursor: pointer;
}

.log__entry--expandable .log__head {
  gap: 8px;
}

.log__chevron {
  flex-shrink: 0;
  color: var(--ink-400);
  transition: transform 0.16s ease, color 0.12s linear;
}

.log__entry--expandable:hover .log__chevron {
  color: var(--ink-700);
}

.log__entry.is-open .log__chevron {
  transform: rotate(90deg);
  color: var(--ink-900);
}

/* Same 0fr/1fr grid-rows technique as `.record-detail__collapse`: the track
   animates to the content's own height without either side needing to know
   what that height is. */
.log__collapse {
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows 0.32s cubic-bezier(0.4, 0, 0.2, 1);
}

.log__collapse.is-open {
  grid-template-rows: 1fr;
}

.log__collapse-inner {
  min-height: 0;
  overflow: hidden;
}

.log__entry:first-child {
  padding-top: 0;
}

.log__entry:last-child {
  padding-bottom: 0;
  border-bottom: 0;
}

/* The gutter marker. Three weights for three severities - a hollow ring for
   informational, filled for a warning, filled and ringed for critical. */
.log__mark {
  width: 8px;
  height: 8px;
  margin-top: 5px;
  border: 1.5px solid var(--ink-300);
  border-radius: 50%;
  background: transparent;
}

.log__entry[data-severity='warn'] .log__mark {
  border-color: var(--ink-600);
  background: var(--ink-600);
}

.log__entry[data-severity='critical'] .log__mark {
  border-color: var(--ink-900);
  background: var(--ink-900);
  box-shadow: 0 0 0 3px rgba(23, 21, 18, 0.12);
}

.log__body {
  min-width: 0;
}

.log__head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 12px;
  flex-wrap: wrap;
}

.log__label {
  font-size: 13px;
  font-weight: 550;
}

.log__time {
  font-size: 11.5px;
  color: var(--ink-400);
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}

.log__detail {
  margin-top: 3px;
  font-size: 12px;
  line-height: 1.6;
  color: var(--ink-500);
}

/* The machine-readable action name and the address it came from. Monospace,
   because these are identifiers to be compared character by character rather
   than prose to be read. */
.log__code {
  font-family: ui-monospace, 'Cascadia Mono', 'SF Mono', Menlo, Consolas, monospace;
  font-size: 11px;
  padding: 1px 5px;
  background: var(--ink-100);
  border-radius: 3px;
  color: var(--ink-600);
}

/* --- Advisories ----------------------------------------------------------- */

.advisory {
  display: flex;
  align-items: flex-start;
  gap: 12px;
  padding: 13px 0;
  border-bottom: 1px solid var(--border);
}

.advisory:first-child {
  padding-top: 0;
}

.advisory:last-child {
  padding-bottom: 0;
  border-bottom: 0;
}

.advisory__mark {
  display: grid;
  place-items: center;
  flex-shrink: 0;
  width: 26px;
  height: 26px;
  border: 1px solid var(--border-strong);
  border-radius: 4.5px;
  color: var(--ink-700);
}

.advisory__body {
  flex: 1;
  min-width: 0;
}

.advisory__title {
  font-size: 13px;
  font-weight: 550;
}

.advisory__detail {
  margin-top: 3px;
  font-size: 12px;
  line-height: 1.6;
  color: var(--ink-500);
}

/* --- Top IPs ---------------------------------------------------------------
   A ranked bar list: the address is the thing being compared, so it sits on
   its own line above a full-width bar rather than squeezed beside one - an
   address is long and variable-width, and cramping it against a bar is what
   makes a ranked list of them hard to scan. */

.ip-rank {
  display: flex;
  flex-direction: column;
  gap: 11px;
}

.ip-rank__row {
  display: flex;
  align-items: center;
  gap: 10px;
}

.ip-rank__addr {
  flex: 0 0 128px;
  font-family: ui-monospace, 'Cascadia Mono', 'SF Mono', Menlo, Consolas, monospace;
  font-size: 11.5px;
  color: var(--ink-700);
  cursor: default;
}

.ip-rank__track {
  flex: 1 1 auto;
  height: 6px;
  background: var(--ink-100);
  border-radius: 999px;
  overflow: hidden;
}

.ip-rank__bar {
  height: 100%;
  background: var(--ink-400);
  border-radius: inherit;
}

/* An address responsible for a disproportionate share of the day's traffic
   earns the same solid-ink treatment as a critical severity marker elsewhere
   in this system, rather than a colour that would break the monochrome rule. */
.ip-rank__row--hot .ip-rank__bar {
  background: var(--ink-900);
}

.ip-rank__row--hot .ip-rank__addr {
  font-weight: 600;
  color: var(--ink-900);
}

.ip-rank__count {
  flex: 0 0 auto;
  min-width: 26px;
  text-align: right;
  font-size: 12px;
  font-variant-numeric: tabular-nums;
  color: var(--ink-500);
}

/* --- Hour chart --------------------------------------------------------------
   24 bars on a shared baseline, one per hour of the trailing day. Deliberately
   plainer than the time-series chart above it in the layout - this is a
   density read ("when"), not a trend a reader is meant to compare two series
   on, so it does not need that chart's axis lines or legend. */

.hour-chart {
  display: flex;
  flex-direction: column;
  gap: 6px;
}

.hour-chart__bars {
  display: grid;
  grid-template-columns: repeat(24, minmax(0, 1fr));
  align-items: end;
  gap: 3px;
  height: 96px;
}

.hour-chart__bar {
  min-height: 2px;
  background: var(--ink-300);
  border-radius: 2px 2px 0 0;
}

.hour-chart__bar--hot {
  background: var(--ink-900);
}

.hour-chart__axis {
  display: grid;
  grid-template-columns: repeat(24, minmax(0, 1fr));
}

.hour-chart__axis span {
  font-size: 10.5px;
  color: var(--ink-400);
  text-align: center;
  font-variant-numeric: tabular-nums;
}

/* --- Filter bar --------------------------------------------------------------
   The audit log's toolbar has more controls than `.toolbar` was built for -
   two selects, three text filters and a date range - so it gets its own
   wrapping flex row rather than stretching that component past the shape it
   was designed around. */

.filter-bar {
  display: flex;
  align-items: center;
  gap: 8px;
  flex-wrap: wrap;
  padding: 14px 20px;
  border-bottom: 1px solid var(--border);
}

.filter-bar__text {
  width: auto;
  flex: 1 1 150px;
  min-width: 0;
}

.filter-bar .field__input {
  padding: 7px 11px;
  font-size: 12.5px;
}

.filter-bar select.field__input {
  width: auto;
  min-width: 128px;
  padding-right: 30px;
}

.filter-bar input[type='datetime-local'] {
  width: auto;
  flex: 0 1 168px;
}

/* --- Log detail (expanded entry) ---------------------------------------------
   An expanded log entry reuses the grid-rows open/close animation from
   `.record-detail__collapse`, but its content is a flat key/value dump rather
   than the lifecycle-plus-fields layout that component was built for - so its
   inner content gets its own minimal treatment instead of `.record-detail__kv`. */

.detail-json {
  margin: 10px 0 0;
  padding: 14px 16px;
  font-family: ui-monospace, 'Cascadia Mono', 'SF Mono', Menlo, Consolas, monospace;
  font-size: 11.5px;
  line-height: 1.7;
  color: var(--ink-600);
  background: var(--ink-100);
  border-radius: var(--radius);
  white-space: pre-wrap;
  word-break: break-word;
}

/* --- Log pagination ----------------------------------------------------------
   Offset paging rather than "show more": the audit log is thousands of rows
   deep, and a button that only ever grows the page is the wrong control once
   there is no realistic point at which the reader has loaded all of it. */

.logs-pagination {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  padding: 14px 20px;
  border-top: 1px solid var(--border);
}

.pagination-info {
  font-size: 12.5px;
  color: var(--ink-500);
  font-variant-numeric: tabular-nums;
}

.pagination-btns {
  display: flex;
  align-items: center;
  gap: 8px;
}

.pagination-btns .ghost-button {
  padding: 6px 12px;
}

.pagination-btns .ghost-button:disabled {
  opacity: 0.45;
  cursor: not-allowed;
}

/* --- Role comparison ------------------------------------------------------
   Two dashboards side by side. Equal columns and a shared row structure, so
   the difference the reader is meant to see is the content of each pane rather
   than its shape - the moment the two sides have different layouts, the
   comparison stops being one. */

.grid--roles {
  grid-template-columns: repeat(2, minmax(0, 1fr));
  align-items: start;
  gap: 13px;
}

@media (max-width: 900px) {
  .grid--roles {
    grid-template-columns: minmax(0, 1fr);
  }
}

/* Each pane is a card acting as a miniature workspace, so it gets a header
   strip identifying whose view it is. */
.role-pane__head {
  display: flex;
  align-items: center;
  gap: 11px;
  padding: 15px 20px;
  border-bottom: 1px solid var(--border);
}

.role-pane__text {
  min-width: 0;
  margin-right: auto;
}

.role-pane__title {
  font-size: 14px;
  font-weight: 600;
  letter-spacing: -0.01em;
}

.role-pane__scope {
  margin-top: 2px;
  font-size: 11.5px;
  color: var(--ink-400);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* A compact KPI pair inside a pane. Two columns rather than the dashboard's
   five, because a pane is half the width and a five-across strip at this size
   would truncate every label. */
.role-stats {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 1px;
  background: var(--ink-150);
  border-bottom: 1px solid var(--ink-150);
}

.role-stat {
  display: flex;
  flex-direction: column;
  gap: 5px;
  padding: 13px 20px 15px;
  background: var(--surface);
}

.role-stat__label {
  font-size: 11.5px;
  color: var(--ink-500);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.role-stat__value {
  font-size: 20px;
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  letter-spacing: -0.024em;
  line-height: 1.1;
}

/* --- Queue list ----------------------------------------------------------- */

.queue {
  display: flex;
  flex-direction: column;
}

.queue__item {
  display: flex;
  align-items: flex-start;
  gap: 10px;
  padding: 10px 0;
  border-bottom: 1px solid var(--border);
}

.queue__item:first-child {
  padding-top: 0;
}

.queue__item:last-child {
  padding-bottom: 0;
  border-bottom: 0;
}

.queue__mark {
  width: 6px;
  height: 6px;
  margin-top: 6px;
  flex-shrink: 0;
  border: 1.5px solid var(--ink-300);
  border-radius: 50%;
}

.queue__item[data-tone='warn'] .queue__mark {
  border-color: var(--ink-600);
  background: var(--ink-600);
}

.queue__item[data-tone='critical'] .queue__mark {
  border-color: var(--ink-900);
  background: var(--ink-900);
  box-shadow: 0 0 0 3px rgba(23, 21, 18, 0.12);
}

.queue__text {
  min-width: 0;
}

.queue__name {
  font-size: 12.5px;
  font-weight: 500;
}

.queue__meta {
  margin-top: 1px;
  font-size: 11.5px;
  color: var(--ink-400);
}

/* --- Permission matrix ----------------------------------------------------
   Which role holds which grant. A tick and a dash rather than a tick and a
   blank: an empty cell is ambiguous between "not granted" and "not yet
   loaded", and the difference matters on a permissions screen. */

.matrix {
  width: 100%;
  border-collapse: collapse;
  font-size: 12.5px;
}

.matrix th,
.matrix td {
  padding: 9px 12px;
  border-bottom: 1px solid var(--border);
  text-align: center;
}

/* Matrix placeholders are sized per table, because the two matrices hold
   different numbers of rows and a shared figure would be wrong for both.

   Roles opens on the differences filter, which resolves to four rows. Switching
   to "all grants" afterwards is a deliberate act by the reader and is allowed
   to change the height; a load that silently resizes is not. The users matrix
   always lists all eleven grants. */
#roles-matrix-body[data-loading-slot] .brand-loader {
  min-height: 144px;
}

#users-matrix-body[data-loading-slot] .brand-loader {
  min-height: 396px;
}

.matrix th:first-child,
.matrix td:first-child {
  text-align: left;
  padding-left: 20px;
}

.matrix td:last-child,
.matrix th:last-child {
  padding-right: 20px;
}

.matrix thead th {
  color: var(--ink-400);
  font-weight: 500;
  font-size: 11.5px;
  text-transform: uppercase;
  letter-spacing: 0.04em;
}

.matrix tbody tr:last-child td {
  border-bottom: 0;
}

.matrix tbody tr:hover {
  background: var(--ink-100);
}

/* The column belonging to the role currently being compared against. Tinted so
   the eye can run down one role's grants without counting across.

   `--ink-150`, not `--ink-100`. The row hover directly above uses `--ink-100`,
   so at that value the focused column was the same tone as the row under the
   cursor and disappeared into it - which is exactly where the reader is
   looking. One step darker separates the two, and because the hover paints the
   row and this paints the column, their intersection reads as the darker of
   the two rather than as a third colour. */
.matrix .is-focus {
  background: var(--ink-150);
}

/* The column header for the focused role. The tint alone is easy to miss at
   the top of a table that is mostly whitespace, so the label it belongs to
   takes the full ink as well - this is the one column being asked about. */
.matrix thead .is-focus {
  color: var(--ink-900);
  font-weight: 600;
}

.matrix__grant {
  font-size: 12.5px;
  color: var(--ink-700);
}

.matrix__yes {
  color: var(--ink-900);
  font-weight: 600;
}

/* Partly granted. Between the two in weight as well as in glyph, so the three
   levels rank correctly by ink alone and survive a greyscale print - the dot is
   not carrying the distinction on its own. */
.matrix__part {
  color: var(--ink-600);
  font-size: 9px;
  vertical-align: middle;
}

.matrix__no {
  color: var(--ink-300);
}

/* --- Health rail ---------------------------------------------------------- */

.health {
  display: grid;
  grid-template-columns: repeat(4, minmax(0, 1fr));
  gap: 1px;
  background: var(--ink-150);
  border-top: 1px solid var(--ink-150);
  margin-top: 14px;
}

@media (max-width: 720px) {
  .health {
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }
}

.health__item {
  padding: 14px 20px 16px;
  background: var(--surface);
}

.health__name {
  font-size: 11.5px;
  color: var(--ink-500);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.health__value {
  margin-top: 5px;
  font-size: 18px;
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  letter-spacing: -0.02em;
}

.health__target {
  margin-top: 3px;
  font-size: 11px;
  color: var(--ink-400);
}

/* --- Table scroll container ----------------------------------------------
   Tables that outrun a narrow column scroll inside their own card rather than
   making the page scroll sideways. The card owns the radius, so the scroller
   inside it must not draw one of its own. */

.table-scroll {
  overflow-x: auto;
  overflow-y: visible;
}

/* `position: relative` is load-bearing, not decoration. Without it the rows
   this box clips still count toward the *page's* scroll height, so the roster
   hangs 240px of dead space below the footer note - and the gap tracks the row
   count, shrinking as filters cut the list down. Making the scroller a
   containing block keeps that overflow where it belongs. */
.table-scroll--tall {
  position: relative;
  max-height: var(--table-tall-h);
  overflow-y: auto;
}

/* The loading mark stands in for the rows, so it reserves the height the rows
   will fill. The roster runs well past the scroller's cap - two dozen members
   against a window that holds about twelve - so the filled table is always
   exactly this tall, and the card does not move when the rows arrive.

   Subtracting the header because the placeholder row sits underneath it, inside
   the same scrolling box. */
/* A floor, not the height itself, now that the placeholder is authored in the
   markup as ten 53px skeleton rows - those come to 530px and already exceed
   this, so it changes nothing for them. It stays because it costs nothing and
   still does its job for the plain pulsing mark, which is what this slot falls
   back to if the authored rows are ever removed. */
#users-body[data-loading-slot] .brand-loader {
  min-height: calc(var(--table-tall-h) - var(--record-head-h));
}

/* The skeleton wrapper must not inherit that floor as a fixed height - its
   rows define their own - but it must not collapse either. `min-height: 0` on
   `.brand-loader--skeleton` already handles the general case; this keeps the
   two rules from fighting inside the one slot that sets an explicit floor. */
#users-body[data-loading-slot] .brand-loader--skeleton {
  min-height: 0;
}

/* Cards on these pages hold tables that run to their own edges, so the card's
   own radius has to clip them. */
.card--flush {
  overflow: hidden;
}

.card--flush .card__head {
  padding-bottom: 15px;
  border-bottom: 1px solid var(--border);
}

/* A three-column split for the pages that pair a wide primary panel with a
   narrower rail of supporting cards. */
.grid--split {
  grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
  align-items: start;
}

@media (max-width: 1080px) {
  .grid--split {
    grid-template-columns: minmax(0, 1fr);
  }
}

/* Reduced motion is handled globally earlier in this file - the universal rule
   there covers every animation declared above. */

/* --- Draggable booking time grid ------------------------------------------
   A day/week time grid the reader manipulates directly: drag empty space to
   create a booking, drag a booking to move it, drag its edges to resize.

   Every vertical measurement derives from `--hour-px`, so one variable changes
   the density of the whole grid and the JS reads that same value back rather
   than keeping a second copy of it that can drift.

   Drag affordances are drawn with borders and lightness rather than colour, so
   they stay legible in the monochrome system: a ghost is a dashed outline, a
   live selection is filled ink, a past hour is dimmed. */

.timegrid {
  --hour-px: 52px;
  --gutter-w: 58px;

  /* Width of the body's scrollbar, which the hour rail has to cancel out - see
     `.timegrid__gutter`. `thin` is 11px in Blink and 12px in Gecko, and an
     overlay bar (macOS, and Windows 11's fluent bars) is 0. The exact figure is
     measured at runtime and written back over this by `syncScrollbarWidth` in
     calendar.js; this is the fallback for the frames before that has run, and
     the WebKit rule below pins Blink to the value stated here so the two agree
     rather than being separately guessed. */
  --timegrid-sb-w: 11px;

  height: var(--timegrid-h);
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  overflow: hidden;
  background: var(--surface);
  user-select: none;
}

/* --- Header row (weekday names) --- */

.timegrid__head {
  display: grid;
  grid-template-columns: var(--gutter-w) repeat(var(--cols, 7), minmax(0, 1fr));
  border-bottom: 1px solid var(--border);
  background: var(--surface);
}

.timegrid__head-gutter {
  border-right: 1px solid var(--border);
}

.timegrid__head-day {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 2px;
  padding: 8px 4px 9px;
  border-right: 1px solid var(--border);
  border-top: 0;
  border-bottom: 0;
  border-left: 0;
  background: none;
  font: inherit;
  cursor: pointer;
  transition: background 0.12s linear;
}

.timegrid__head-day:last-child {
  border-right: 0;
}

.timegrid__head-day:hover {
  background: var(--ink-100);
}

.timegrid__head-name {
  font-size: 10.5px;
  font-weight: 500;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  color: var(--ink-400);
}

.timegrid__head-num {
  display: flex;
  align-items: center;
  justify-content: center;
  min-width: 25px;
  height: 25px;
  border-radius: 50%;
  font-size: 13.5px;
  font-weight: 550;
  font-variant-numeric: tabular-nums;
  color: var(--ink-800);
}

/* Today takes the ink disc - the same "darkest thing is the current one" rule
   the month grid and the tab strip already use. */
.timegrid__head-day--today .timegrid__head-num {
  background: var(--ink-900);
  color: var(--surface);
}

.timegrid__head-day--today .timegrid__head-name {
  color: var(--ink-900);
}

.timegrid__head-day--weekend .timegrid__head-name {
  color: var(--ink-300);
}

/* --- Scrolling body --- */

/* The container the grid is drawn into, which holds the loading mark until the
   bookings land. Fixed to the height the grid itself will be so the swap costs
   nothing - the card is the same size empty and full.

   Only the time views are pinned. The month grid is a different height and sets
   its own, and it is not the view the page opens on. */
#cal-surface {
  min-height: var(--timegrid-h);
}

/* While it is waiting, the container is also given that height outright rather
   than only as a floor.

   `.brand-loader` centres itself with `place-items` over `height: 100%`, and a
   percentage height against a parent whose own height is `min-height` plus auto
   content resolves to zero - so the loader fell back to its 120px minimum and
   the mark sat centred in that 120px band at the top of a 678px box, which
   reads as the logo hanging off the ceiling of the card rather than sitting in
   the middle of the area it is holding.

   Applied only while loading, so the real grid still sizes itself. */
#cal-surface[data-loading-slot] {
  height: var(--timegrid-h);
}

/* The body is always taller than its cap - 24 hours at 52px is 1248px against a
   620px window - so this is a height, not a ceiling it might fall short of.
   That makes the whole grid a known quantity, which is what lets the loading
   placeholder above it reserve the identical box: both read `--timegrid-h`. */
.timegrid__body {
  /* Not a grid. The hour rail is taken out of flow below so that the scrollbar
     can run underneath it, and a grid track cannot be overlapped that way - the
     rail has to be positioned against this element instead. The day columns
     then take the full width and are inset past the rail by padding.

     Only the scrollbar is flipped, by the `direction: rtl` further down; every
     offset and inset in here is a physical property, so the flip cannot move
     them. Logical ones were tried first and are the wrong tool: RTL reverses
     what `inline-start` means, so the padding and the rail's offset both landed
     on the right-hand edge and the grid came apart. */
  position: relative;

  /* The rail's width, less the scrollbar's.

     The RTL flip below puts the bar on the left, and a classic bar is layout:
     it takes its width off that edge, so the content box already starts one
     bar-width in. Padding the full `--gutter-w` on top of that would inset the
     day columns by rail-plus-bar and push the last one out past the end of the
     card. Subtracting the bar is what makes the columns line up with the
     headings above them whether the platform draws a bar or overlays one. */
  padding-left: calc(var(--gutter-w) - var(--timegrid-sb-w));
  max-height: var(--timegrid-body-h);
  overflow-y: auto;

  /* Never horizontally, and not merely because nothing here is wider than the
     grid - the hour rail is deliberately pulled a scrollbar's width past this
     element's left content edge so the bar runs underneath it, and to an `auto`
     scroller that overhang is scrollable overflow like any other. It answered
     with a horizontal bar along the bottom of the calendar, scrolling the whole
     week eleven pixels sideways to reveal nothing.

     `hidden` states the intent directly: the grid is a vertical scroller, the
     columns are always sized to fit, and there is nothing off to either side to
     go and look at. */
  overflow-x: hidden;
  overscroll-behavior: contain;

  /* Thin, because a classic bar is layout rather than paint - it takes its
     width out of the content box, which the `padding-left` above allows for. */
  scrollbar-width: thin;

  /* And the flip that moves the bar to the left edge, under the rail.

     `direction: rtl` on the scroller, with the contents flipped straight back
     to LTR, so only the bar moves. This is the one mechanism that does the job
     without side effects here:

     - a `scaleX(-1)` mirror would move it too, but `getBoundingClientRect`
       reports transformed boxes, and the drag code reads exactly that to decide
       which day a pointer is over - so every drag across a day boundary would
       land on the mirrored column;
     - `scrollbar-gutter` reserves space, it does not move the bar.

     The two children below opt back into LTR immediately, so the flip reaches
     the scrollbar and nothing else. The rail is positioned with `left`, and the
     inset on the columns is `padding-left`, both of which are physical and so
     unaffected by the direction either way. */
  direction: rtl;
}

.timegrid__gutter,
.timegrid__cols {
  direction: ltr;
}

/* No width correction is needed on the columns themselves: the scroller's
   `padding-left` already accounts for the bar, so what is left over is exactly
   the width the seven headings span. */

/* The hour rail, floated over the scrollbar.

   Positioned rather than laid out, and pinned to the scroller's padding box, so
   the bar runs underneath it and the rail sits flush under the weekday header's
   own gutter. `absolute` rather than `fixed` so it scrolls with the hours it
   labels, and stretched to the full scroll height rather than the visible one -
   `bottom: 0` against a scrolling container resolves to the scrollable extent,
   which is the 1248px of a full day, not the 620px window onto it. */
.timegrid__gutter {
  position: absolute;
  z-index: 2;
  top: 0;

  /* The full scrollable height, not the visible one.

     `bottom: 0` resolves against the scroller's padding box - the 620px window,
     not the 1248px of hours behind it - so the rail would stop short and the
     later hours would scroll past with no labels beside them. The cells inside
     already total a day's worth of `--hour-px`, so the rail is left to be
     sized by them. */
  min-height: 100%;

  /* Back out over the scrollbar. Offsets are resolved against the padding box,
     which the bar has already pushed one bar-width in from the scroller's
     visible left edge, so `left: 0` alone would leave the rail sitting a bar's
     width to the right of the weekday gutter above it and leave the bar out in
     the open beside it. The negative offset puts the rail flush with the header
     and the bar underneath its near edge, which is the whole point. */
  left: calc(-1 * var(--timegrid-sb-w));
  width: var(--gutter-w);
  border-right: 1px solid var(--border);
  background: var(--surface);
}

/* The bar itself, kept quiet: it is running under the hour rail rather than
   along the edge of a panel, so it is drawn narrow and without the track that
   the page's other scrollers use - a filled track here would read as a second
   column beside the times. */
.timegrid__body::-webkit-scrollbar {
  width: var(--timegrid-sb-w);
}

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

.timegrid__body::-webkit-scrollbar-thumb {
  background: var(--ink-200);
  border: 3px solid transparent;
  background-clip: content-box;
  border-radius: 999px;
}

.timegrid__body::-webkit-scrollbar-thumb:hover {
  background: var(--ink-300);
  background-clip: content-box;
}

.timegrid__gutter-cell {
  position: relative;
  height: var(--hour-px);
}

/* The label straddles the hour rule it names rather than sitting inside the
   cell below it, so the number lines up with the line it is read against. The
   first is suppressed - there is no rule above midnight. */
.timegrid__gutter-label {
  position: absolute;
  top: -6px;
  right: 7px;
  font-size: 10.5px;
  font-variant-numeric: tabular-nums;
  color: var(--ink-400);
  white-space: nowrap;
}

.timegrid__gutter-cell:first-child .timegrid__gutter-label {
  display: none;
}

.timegrid__cols {
  display: grid;
  grid-template-columns: repeat(var(--cols, 7), minmax(0, 1fr));
}

.timegrid__day {
  position: relative;
  border-right: 1px solid var(--border);
  cursor: cell;
}

/* `pan-y` by default on mobile - vertical scroll is native and free right up
   to the moment a long-press actually settles, at which point `startDrag` in
   schedule.js adds `--touch-hold` and this drops to `none`. That switch only
   ever happens with the finger stationary: the browser has not yet had cause
   to commit to a pan gesture from a touch that has not moved, which is the
   one case a `touch-action` change lands cleanly instead of being read as a
   value moving under a gesture already in flight and cancelled for it - the
   failure an earlier, less careful version of this same idea hit reliably.
   Abandoning a hold (the finger moves before it settles) never touches this
   class at all: `pan-y` was already the truth of what the finger was doing,
   so there is nothing to correct on that path, and correcting it anyway -
   the mistake that version made - is what broke every touch drag in the
   first place. */
@media (max-width: 768px) {
  .timegrid__day {
    touch-action: pan-y;
  }

  .timegrid__day--touch-hold {
    touch-action: none;
  }
}

.timegrid__day:last-child {
  border-right: 0;
}

/* While a drag is live the column takes a resize cursor and stops the browser
   scrolling the page out from under the pointer on touch. */
.timegrid__day--dragging {
  cursor: ns-resize;
  touch-action: none;
}

.timegrid__hour {
  height: var(--hour-px);
  border-top: 1px solid var(--border);
  transition: background 0.1s linear;
}

.timegrid__hour:first-child {
  border-top: 0;
}

/* The half-hour rule, drawn lighter than the hour so the grid reads as a
   hierarchy rather than as forty-eight equal lines. */
.timegrid__hour::after {
  content: '';
  display: block;
  height: calc(var(--hour-px) / 2);
  border-bottom: 1px dashed rgba(23, 21, 18, 0.055);
}

.timegrid__day:not(.timegrid__day--dragging) .timegrid__hour:hover {
  background: var(--ink-100);
}

/* Hours already gone. Dimmed rather than hidden - a booking in the past is
   still a booking the reader may need to look at.
   Marked twice over so it reads at a glance rather than on close reading: a
   dashed edge where "now" cuts across the column, and a faint diagonal hatch
   through the shaded area. Both stay in the ink palette rather than reaching
   for red - the rest of the system carries no hue at all, and a red zone would
   read as an error state rather than as "already happened". */
.timegrid__past {
  position: absolute;
  inset: 0 0 auto 0;
  background:
    repeating-linear-gradient(
      -45deg,
      rgba(23, 21, 18, 0.028),
      rgba(23, 21, 18, 0.028) 5px,
      transparent 5px,
      transparent 11px
    ),
    rgba(23, 21, 18, 0.03);
  border-bottom: 1.5px dashed var(--border-strong);
  pointer-events: none;
  z-index: 1;
}

.timegrid__nowline {
  position: absolute;
  left: 0;
  right: 0;
  height: 0;
  border-top: 1.5px solid var(--ink-900);
  pointer-events: none;
  z-index: 5;
}

.timegrid__nowline::before {
  content: '';
  position: absolute;
  left: -3.5px;
  top: -4px;
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: var(--ink-900);
}

/* --- Events --- */

.timegrid__event {
  position: absolute;
  display: flex;
  flex-direction: column;
  gap: 1px;
  padding: 4px 6px;
  overflow: hidden;
  background: var(--ink-100);
  border: 1px solid var(--border);
  border-left: 2.5px solid var(--ink-700);
  border-radius: 3px;
  font: inherit;
  text-align: left;
  color: var(--ink-800);
  cursor: grab;
  transition: box-shadow 0.12s ease, background 0.12s linear;
}

/* Hover lifts a card clear of the shingle, so a booking buried at the back of
   a stack can still be read and grabbed. `!important` because the stacking
   order is set inline per card - see `eventHtml`. */
.timegrid__event:hover,
.timegrid__event:focus-visible {
  background: var(--ink-150);
  box-shadow: var(--shadow);
  z-index: 8 !important;
}

.timegrid__event:focus-visible {
  outline: 2px solid var(--ink-900);
  outline-offset: 1px;
}

/* The source element stays in place at low opacity as a reference for where
   the booking came from, while the ghost shows where it will land. */
.timegrid__event--dragging {
  opacity: 0.32;
  cursor: grabbing;
}

/* Pending and cancelled are marked by border treatment rather than by dropping
   the fill: overlapping bookings are shingled, so a transparent card lets the
   one behind it show straight through and neither is readable. */
.timegrid__event--pending {
  background: var(--surface);
  border-style: dashed;
  border-left-color: var(--ink-400);
}

.timegrid__event--cancelled {
  background: var(--surface);
  border-left-color: var(--ink-200);
  color: var(--ink-400);
}

.timegrid__event--cancelled .timegrid__event-title {
  text-decoration: line-through;
}

.timegrid__event-title {
  font-size: 11.5px;
  font-weight: 550;
  line-height: 1.3;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.timegrid__event-meta {
  font-size: 10.5px;
  line-height: 1.35;
  color: var(--ink-500);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.timegrid__event--cancelled .timegrid__event-meta {
  color: var(--ink-300);
}

/* Below roughly one grid hour there is only room for a single line, so
   everything under the title is dropped rather than clipped mid-word. */
.timegrid__event--compact {
  padding: 2px 5px;
}

.timegrid__event--compact .timegrid__event-title {
  font-size: 11px;
}

/* --- Resize handles --- */

.timegrid__resize {
  position: absolute;
  left: 0;
  right: 0;
  height: 7px;
  cursor: ns-resize;
  z-index: 2;
}

.timegrid__resize--top {
  top: -1px;
}

.timegrid__resize--bottom {
  bottom: -1px;
}

/* Drawn only on hover: a permanent grip on every booking would turn the grid
   into a field of handles. */
.timegrid__event:hover .timegrid__resize::after {
  content: '';
  position: absolute;
  left: 50%;
  transform: translateX(-50%);
  width: 22px;
  height: 2px;
  border-radius: 2px;
  background: var(--ink-400);
}

.timegrid__event:hover .timegrid__resize--top::after {
  top: 2px;
}

.timegrid__event:hover .timegrid__resize--bottom::after {
  bottom: 2px;
}

/* --- Drag overlays --- */

/* Where a dragged or resized booking will land. Dashed and barely filled, so
   the grid underneath stays readable through it. */
.timegrid__ghost {
  position: absolute;
  left: 1px;
  right: 1px;
  padding: 3px 6px;
  border: 1.5px dashed var(--ink-700);
  border-radius: 3px;
  background: rgba(23, 21, 18, 0.05);
  pointer-events: none;
  overflow: hidden;
  z-index: 9;
}

.timegrid__ghost-title {
  font-size: 11.5px;
  font-weight: 550;
  color: var(--ink-900);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.timegrid__ghost-time {
  font-size: 10.5px;
  font-variant-numeric: tabular-nums;
  color: var(--ink-600);
}

/* A new booking being dragged out of empty space. Filled ink, because unlike
   the move ghost it is not standing in for something that already exists - it
   is the thing being made. */
.timegrid__band {
  position: absolute;
  left: 1px;
  right: 1px;
  padding: 3px 6px;
  border-radius: 3px;
  background: var(--ink-900);
  color: var(--surface);
  pointer-events: none;
  overflow: hidden;
  z-index: 9;
}

.timegrid__band-time {
  font-size: 10.5px;
  font-weight: 550;
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}

/* --- Hint bar under the grid --- */

.timegrid-hints {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 6px 16px;
  margin-top: 11px;
  font-size: 11px;
  color: var(--ink-400);
}

.timegrid-hints__key {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 17px;
  padding: 1px 4px;
  margin-right: 3px;
  background: var(--surface);
  border: 1px solid var(--border-strong);
  border-bottom-width: 2px;
  border-radius: 3px;
  font-size: 10px;
  font-weight: 600;
  color: var(--ink-600);
}

/* --- Mini month picker (side rail) --- */

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

.minical__title {
  font-size: 12.5px;
  font-weight: 550;
}

.minical {
  /* The three numbers the month's height is built from, named once because the
     loading placeholder below has to reproduce that height exactly and would
     otherwise be a second copy free to drift from the real grid. */
  --minical-gap: 1px;
  --minical-weekday-line: 14.25px;
  --minical-weekday-pad: 4px;
}

.minical__grid {
  display: grid;
  grid-template-columns: repeat(7, minmax(0, 1fr));
  gap: var(--minical-gap);
}

/* The day cells are square, so the month's height follows the rail's width and
   cannot be written down as a number here - it has to be computed from it.

   An `aspect-ratio: 7 / 6` was used before and was wrong by about eighteen
   pixels, which is what made the card visibly shrink as the days landed. The
   ratio models six rows of seven squares and nothing else, but the rendered
   month is three stacked things: the weekday strip, six rows of squares, and
   the five one-pixel gaps between those rows. None of the last two are in a
   7:6 box.

   Spelled out, against a content box of width W:

     cell   = (W - 6 gaps) / 7        seven columns, six gaps between them
     rows   = 6 cells + 5 gaps        six rows, five gaps between them
     strip  = line-height + padding   fixed, and independent of width

   which is the calc below. Verified against the real grid at seven widths from
   1440 down to 560: the placeholder and the loaded month now agree to within a
   rounding hair at every one, so the card holds still through the swap.

   The width term has to be spent as `padding-top`, not as `height`. A
   percentage inside a `height` resolves against the parent's height, which is
   auto here and therefore indefinite, so the whole calc collapses to its fixed
   terms and the box comes out 32px tall. A percentage in `padding-top`
   resolves against the parent's *width*, which is the measurement actually
   wanted. The element is left at `height: 0` and the padding is the box.

   Applied only while the container is waiting - once the days are in they set
   the height themselves. */
/* The same box, reserved before any of it exists.

   The placeholder inside this container is written by JS, and on this page the
   JS is a module that dynamically imports two more modules before it gets to
   do so. The shell therefore paints - correctly, with all its cards and titles
   - while this container is still genuinely empty and zero pixels tall, and the
   placeholder only lands a couple of hundred milliseconds later, shoving the
   cards below it down the page as it arrives. That late shove is the blink on
   refresh, and no amount of sizing the placeholder fixes it, because during the
   window in question there is no placeholder to size.

   So the height is reserved here, on the empty container, in the stylesheet -
   which the browser has before it paints anything at all. `:empty` scopes it to
   exactly the pre-JS window: the moment either the loader or the real month is
   written in, the container has children and this stops applying. */
.minical:empty,
.minical[data-loading-slot] .brand-loader {
  position: relative;
  min-height: 0;
  height: 0;
  padding-top: calc(
    /* weekday strip */
    var(--minical-weekday-line) + var(--minical-weekday-pad) +
      /* six square rows */ 6 * (100% - 6 * var(--minical-gap)) / 7 +
      /* the gaps between them */ 5 * var(--minical-gap)
  );
}

/* The mark centres itself over the loader's `place-items`, and the rule above
   has just flattened that content box to nothing - so it is taken out of flow
   and centred against the padded box instead, which is the one standing in for
   the month. */
.minical[data-loading-slot] .brand-loader__mark {
  position: absolute;
  top: 50%;
  left: 50%;
  translate: -50% -50%;
}

.minical__weekday {
  padding-bottom: var(--minical-weekday-pad);
  text-align: center;
  font-size: 9.5px;
  /* Pinned rather than left to `normal`, so the placeholder's copy of this
     number above cannot fall out of step with the font's default leading. */
  line-height: var(--minical-weekday-line);
  font-weight: 500;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--ink-400);
}

.minical__day {
  position: relative;
  aspect-ratio: 1;
  display: flex;
  align-items: center;
  justify-content: center;
  border: 0;
  border-radius: 3px;
  background: transparent;
  font: inherit;
  font-size: 11.5px;
  font-variant-numeric: tabular-nums;
  color: var(--ink-700);
  cursor: pointer;
  transition: background 0.12s linear;
}

.minical__day:hover {
  background: var(--ink-150);
}

.minical__day--outside {
  color: var(--ink-300);
}

.minical__day--today {
  font-weight: 700;
  color: var(--ink-900);
}

/* The days inside the span the big grid is showing, so the mini calendar says
   where you are rather than only letting you jump. */
.minical__day--inrange {
  background: var(--ink-100);
}

.minical__day--selected {
  background: var(--ink-900);
  color: var(--surface);
}

/* A booking marker, held to a dot - the mini calendar answers "is there
   anything that day", and the grid answers everything else. */
.minical__day--has::after {
  content: '';
  position: absolute;
  bottom: 3px;
  left: 50%;
  transform: translateX(-50%);
  width: 3px;
  height: 3px;
  border-radius: 50%;
  background: var(--ink-400);
}

.minical__day--selected.minical__day--has::after {
  background: var(--surface);
}

/* Below 720px this is the live system's own "ultra-compact" mobile mode,
   ported directly rather than re-derived: the day is shown in full, not a
   window onto it, so the reader scrolls the page - which they are already
   doing to reach the card - instead of also having to notice and operate a
   second, inner scrollbar just to see the rest of the calendar. */
@media (max-width: 720px) {
  .timegrid {
    /* Close to the 52px desktop reads at, not the reference's own 30px - an
       hour this size still reads clearly on a phone, where 30px started
       compressing a booking's title and time range onto one cramped line. */
    --hour-px: 44px !important;
    --gutter-w: 44px;
  }

  /* The scrolling window becomes the full day's height outright - `--hour-px`
     times 24 hours - rather than a fixed guess capped below it. `--timegrid-h`
     already reads `--timegrid-body-h` plus the header strip, so raising this
     one variable is enough to carry the card, the loading placeholder and
     every other reader of `--timegrid-h` along with it. */
  :root {
    --timegrid-body-h: calc(44px * 24);
  }

  /* No inner scrollbar: the day is short enough at this density that the
     page's own scroll is what carries the reader to the rest of it, the same
     trade the reference system's own mobile mode makes. `overflow-x: hidden`
     is dropped too, not merely `overflow-y` - CSS forces the paired axis into
     `auto` scroll clipping the instant either one is non-`visible`, which is
     what would silently clip bookings back off if only the vertical axis
     were touched here. Nothing in this view is wider than the card at this
     width, so there is no horizontal content to lose by letting both run
     visible. */
  .timegrid__body {
    overflow: visible;
  }

  /* The hour rail is normally pulled left by the scrollbar's own width so it
     sits flush under the header - see `.timegrid__gutter`'s note in the
     unqualified rule. With no scrollbar left to cancel out on this
     breakpoint, that pull would just misalign the rail against the column
     headings above it. */
  .timegrid__gutter {
    left: 0;
  }

  .timegrid__body {
    padding-left: var(--gutter-w);
  }
}

/* ==========================================================================
   MYVTSG additions

   Components the reference workspace has no equivalent of - a lifecycle rail, an
   upload dropzone, a settings list, a definition list, an activity feed. Built
   from the same tokens, spacing steps and radii as everything above, so they
   read as part of the one system rather than as a second one bolted on.
   ========================================================================== */

/* --- Lifecycle rail -------------------------------------------------------
   Ordered stages, so they are drawn as discs on a track: a reached stage fills
   in, the latest carries a ring, and the connector behind it darkens. The whole
   path is shown on every row, because where a record stalled is the question
   this column exists to answer. */

.lifecycle {
  display: flex;
  align-items: center;
  gap: 0;
}

.lifecycle__step {
  width: 9px;
  height: 9px;
  flex: none;
  border: 1.5px solid var(--ink-300);
  border-radius: 50%;
  background: var(--surface);
}

.lifecycle__step.is-done {
  background: var(--ink-700);
  border-color: var(--ink-700);
}

.lifecycle__step.is-current {
  background: var(--ink-900);
  border-color: var(--ink-900);
  box-shadow: 0 0 0 3px var(--ink-150);
}

.lifecycle__bar {
  width: 14px;
  height: 1.5px;
  flex: none;
  background: var(--ink-200);
}

.lifecycle__bar.is-done {
  background: var(--ink-700);
}

/* --- Expandable record rows -------------------------------------------------
   A row expands in place to show one record's full progress rather than
   sending the reader to a separate view - the chevron column is a fixed
   narrow lead-in so it never competes with the data columns for width. */

.record-table--expandable .td--expand {
  width: 32px;
  padding-right: 0;
}

.record-table__row {
  cursor: pointer;
}

.row-chevron {
  color: var(--ink-400);
  transition: transform 0.16s ease, color 0.12s linear;
}

.record-table__row:hover .row-chevron {
  color: var(--ink-700);
}

.record-table__row.is-expanded .row-chevron {
  transform: rotate(90deg);
  color: var(--ink-900);
}

.record-table__row.is-expanded {
  background: var(--ink-100);
}

.record-table__detail-row td {
  padding: 0;
  border-bottom: 1px solid var(--border);
}

/* Grid-rows is the one CSS-only way to animate from/to an unknown content
   height: the track goes from `0fr` (nothing) to `1fr` (the content's own
   height), and the row's real height rides along for free. The grid item
   needs `overflow: hidden` and `min-height: 0` to compress past its content
   size while collapsed - and critically, it must carry no padding of its own,
   since padding is part of a box's floor and cannot be animated away by
   `overflow: hidden`. All spacing therefore lives one level deeper, on
   `.record-detail__inner`, so the collapsing box itself has nothing but its
   (animatable) content height. */
.record-detail__collapse {
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows 0.38s cubic-bezier(0.4, 0, 0.2, 1);
}

.record-detail__collapse.is-open {
  grid-template-rows: 1fr;
}

.record-detail {
  min-height: 0;
  overflow: hidden;
  background: var(--page);
}

.record-detail__inner {
  padding: 22px 20px 24px;
  opacity: 0;
  transform: translateY(-6px);
  transition: opacity 0.22s linear, transform 0.28s cubic-bezier(0.25, 1, 0.5, 1);
}

.record-detail__collapse.is-open .record-detail__inner {
  opacity: 1;
  transform: translateY(0);
  transition-delay: 0.08s;
}

.record-detail__kv {
  display: grid;
  /* Sized to however many fields the row actually has - a fitness member's
     panel carries three (Package, Price, Applied), an offer's carries four
     (Offer, Package, Price, Sent) - rather than a fixed four that leaves an
     empty cell trailing the shorter set. */
  grid-template-columns: repeat(var(--kv-cols, 4), minmax(0, 1fr));
  gap: 1px;
  margin-top: 20px;
  background: var(--ink-150);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  overflow: hidden;
}

.record-detail__field {
  padding: 12px 16px;
  background: var(--surface);
}

.record-detail__field dt {
  margin: 0;
  color: var(--ink-500);
  font-size: 11.5px;
  font-weight: 550;
  text-transform: uppercase;
  letter-spacing: 0.03em;
}

.record-detail__field dd {
  margin: 3px 0 0;
  color: var(--ink-900);
  font-size: 13px;
}

@media (max-width: 900px) {
  .record-detail__kv {
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }
}

/* --- Stage tracker -----------------------------------------------------------
   The expanded-row read of a record's lifecycle: the same idea as the compact
   dot rail, but with room for stage labels, for a single record read closely
   rather than many records scanned at once. Centred, since it is the one
   visual focus of the expanded row rather than a column among others. */

.stage-tracker {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 0;
  flex-wrap: wrap;
}

.stage-tracker__step {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  flex: none;
}

.stage-tracker__dot {
  display: grid;
  place-items: center;
  width: 20px;
  height: 20px;
  flex: none;
  border: 1.5px solid var(--ink-300);
  border-radius: 50%;
  background: var(--surface);
  color: var(--surface);
  font-size: 10.5px;
  font-weight: 650;
}

.stage-tracker__label {
  font-size: 12.5px;
  font-weight: 500;
  color: var(--ink-500);
  white-space: nowrap;
}

.stage-tracker__bar {
  width: 26px;
  height: 1.5px;
  flex: none;
  margin: 0 6px;
  background: var(--ink-200);
}

.stage-tracker__bar.is-done {
  background: var(--ink-700);
}

.stage-tracker__step.is-done .stage-tracker__dot {
  background: var(--ink-700);
  border-color: var(--ink-700);
}

.stage-tracker__step.is-current .stage-tracker__dot {
  background: var(--ink-900);
  border-color: var(--ink-900);
  box-shadow: 0 0 0 3px var(--ink-150);
}

.stage-tracker__step.is-done .stage-tracker__label,
.stage-tracker__step.is-current .stage-tracker__label {
  color: var(--ink-900);
  font-weight: 650;
}

/* --- Tags ------------------------------------------------------------------ */

.tag {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  padding: 2px 7px;
  border-radius: 999px;
  background: var(--ink-150);
  color: var(--ink-800);
  font-size: 11px;
  font-weight: 550;
  white-space: nowrap;
}

.tag button {
  display: grid;
  place-items: center;
  width: 14px;
  height: 14px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: transparent;
  color: var(--ink-500);
  font-size: 13px;
  line-height: 1;
  cursor: pointer;
}

.tag button:hover {
  background: var(--ink-200);
  color: var(--ink-900);
}

.muted {
  color: var(--ink-400);
}

/* --- Dropzone -------------------------------------------------------------- */

.dropzone {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 10px;
  padding: 40px 22px;
  border: 1.5px dashed var(--ink-300);
  border-radius: var(--radius);
  background: var(--page);
  text-align: center;
  cursor: pointer;
  transition: border-color 160ms ease, background 160ms ease;
}

.dropzone:hover,
.dropzone.is-over {
  border-color: var(--ink-900);
  background: var(--ink-100);
}

.dropzone:focus-visible {
  outline: 2px solid var(--ink-900);
  outline-offset: 3px;
}

.dropzone__icon {
  display: grid;
  place-items: center;
  width: 46px;
  height: 46px;
  border-radius: 50%;
  background: var(--ink-150);
  color: var(--ink-600);
}

.dropzone__title {
  font-size: 14px;
  font-weight: 600;
  color: var(--ink-900);
}

.dropzone__sub {
  font-size: 12.5px;
  color: var(--ink-500);
}

.formats {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 5px;
  margin-top: 2px;
}

.format {
  padding: 2px 7px;
  border: 1px solid var(--border);
  border-radius: 3px;
  background: var(--surface);
  color: var(--ink-500);
  font-size: 9.5px;
  font-weight: 650;
  letter-spacing: 0.04em;
}

/* --- Composer mode switch ---------------------------------------------------
   New enrollment vs. upgrading someone already enrolled - two different lists
   feeding the same composer below. It sits in the card header beside the
   title, the same slot the calendar's Day/Week/Month switch and the roles
   matrix's filter use for "the one control that changes what this whole card
   shows" - not squeezed above the toolbar as a second row, which read as an
   afterthought bolted onto the search bar rather than the primary choice a
   staff member makes before anything else on the page. Sized up from the
   default `.segmented` for the same reason: this one decides which of two
   different lists - and eventually which of two different agreements gets
   sent - not a display filter on rows already in view. */

.segmented--mode {
  flex: none;
}

.segmented--mode button {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  padding: 8px 15px;
}

@media (max-width: 640px) {
  /* `.card__head` already wraps the mode switch onto its own line below the
     title at this width; full-width and evenly split reads as two clear
     choices rather than a control that happens to be off to one side. */
  .segmented--mode {
    width: 100%;
  }

  .segmented--mode button {
    flex: 1;
    justify-content: center;
  }
}

/* --- Upgrade banner ---------------------------------------------------------
   The one thing that has to be impossible to miss when upgrading someone
   already paying for a package: what they are on today. Sits above the step
   rail so it is the first thing read, in the same tinted-surface language as
   `.insight` elsewhere, but with its own icon and a bold lead word so it never
   reads as just another line of body copy. */

.upgrade-banner {
  display: flex;
  align-items: flex-start;
  gap: 11px;
  margin: 0 18px 16px;
  padding: 12px 14px;
  background: var(--ink-100);
  border: 1px solid var(--border-strong);
  border-radius: var(--radius);
  color: var(--ink-700);
}

.upgrade-banner svg {
  flex: none;
  margin-top: 1px;
  color: var(--ink-900);
}

.upgrade-banner strong {
  display: block;
  font-size: 13px;
  color: var(--ink-900);
}

.upgrade-banner__detail {
  display: block;
  margin-top: 2px;
  font-size: 12.5px;
  line-height: 1.5;
  color: var(--ink-600);
}

/* --- Option grid ----------------------------------------------------------
   Selectable cards. The selected one is marked by a heavier border and a tinted
   ground together, never by fill alone, so it survives at low contrast. */

.option-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(178px, 1fr));
  gap: 9px;
}

.option {
  padding: 12px;
  border: 1px solid var(--border-strong);
  border-radius: var(--radius);
  background: var(--surface);
  font: inherit;
  text-align: left;
  cursor: pointer;
  transition: border-color 140ms ease, background 140ms ease, box-shadow 140ms ease;
}

.option:hover {
  border-color: var(--ink-400);
}

.option:focus-visible {
  outline: 2px solid var(--ink-900);
  outline-offset: 2px;
}

.option[aria-pressed='true'] {
  border-color: var(--ink-900);
  border-width: 1.5px;
  background: var(--ink-100);
  box-shadow: var(--shadow-sm);
}

/* Category and sharing options carry a lot of near-identical text ("Staff
   only" vs "Shared" vs "Private") that reads slowly at a glance - an icon
   ahead of the name gives the eye a shape to sort by before it reads a word. */
.option__head {
  display: flex;
  align-items: center;
  gap: 8px;
}

.option__icon {
  display: grid;
  place-items: center;
  flex-shrink: 0;
  width: 22px;
  height: 22px;
  border-radius: 4.5px;
  background: var(--ink-100);
  color: var(--ink-600);
  transition: background 140ms ease, color 140ms ease;
}

.option[aria-pressed='true'] .option__icon {
  background: var(--ink-900);
  color: var(--surface);
}

.option__name {
  font-size: 13px;
  font-weight: 600;
  color: var(--ink-900);
}

.option__price {
  margin-top: 3px;
  font-size: 17px;
  font-weight: 650;
  letter-spacing: -0.02em;
  font-variant-numeric: tabular-nums;
  color: var(--ink-900);
}

.option__meta {
  margin-top: 3px;
  font-size: 11.5px;
  color: var(--ink-500);
  line-height: 1.45;
}

/* --- Tag input ------------------------------------------------------------- */

.taginput {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 5px;
  padding: 5px 7px;
  border: 1px solid var(--border-strong);
  border-radius: var(--radius);
  background: var(--surface);
}

.taginput:focus-within {
  border-color: var(--ink-900);
  box-shadow: 0 0 0 3px rgba(23, 21, 18, 0.08);
}

.taginput__icon {
  flex-shrink: 0;
  margin-left: 3px;
  color: var(--ink-400);
}

.taginput input {
  flex: 1;
  min-width: 120px;
  padding: 2px;
  border: 0;
  outline: none;
  background: transparent;
  font: inherit;
  font-size: 13px;
  color: var(--ink-900);
}

/* --- Settings list --------------------------------------------------------- */

.setting {
  display: flex;
  align-items: center;
  gap: 16px;
  padding: 14px 18px;
  border-bottom: 1px solid var(--border);
}

.setting:last-child {
  border-bottom: 0;
}

.setting__text {
  flex: 1;
  min-width: 0;
}

.setting__label {
  font-size: 13px;
  font-weight: 600;
  color: var(--ink-900);
}

.setting__desc {
  margin-top: 2px;
  font-size: 12px;
  color: var(--ink-500);
  line-height: 1.5;
}

.setting__control {
  flex: none;
}

/* Destructive actions cannot be flagged with red in a monochrome system, so
   they are flagged structurally: a heavier rule down the leading edge. It is
   the only place that treatment is used. */
.setting--danger {
  box-shadow: inset 2.5px 0 0 var(--ink-900);
}

/* --- Switch ---------------------------------------------------------------- */

.switch {
  position: relative;
  display: inline-block;
  width: 38px;
  height: 21px;
  flex: none;
}

.switch input {
  position: absolute;
  width: 100%;
  height: 100%;
  margin: 0;
  opacity: 0;
  cursor: pointer;
}

.switch__track {
  position: absolute;
  inset: 0;
  border-radius: 999px;
  background: var(--ink-200);
  transition: background 180ms ease;
  pointer-events: none;
}

.switch__thumb {
  position: absolute;
  top: 2.5px;
  left: 2.5px;
  width: 16px;
  height: 16px;
  border-radius: 50%;
  background: var(--surface);
  box-shadow: var(--shadow-sm);
  transition: transform 180ms cubic-bezier(0.4, 0, 0.2, 1);
  pointer-events: none;
}

.switch input:checked ~ .switch__track {
  background: var(--ink-900);
}

.switch input:checked ~ .switch__thumb {
  transform: translateX(17px);
}

.switch input:focus-visible ~ .switch__track {
  outline: 2px solid var(--ink-900);
  outline-offset: 2px;
}

.switch input:disabled ~ .switch__track {
  opacity: 0.45;
}

@media (prefers-reduced-motion: reduce) {
  .switch__track,
  .switch__thumb {
    transition: none;
  }
}

/* --- Definition list ------------------------------------------------------- */

.kv {
  display: grid;
  grid-template-columns: 156px minmax(0, 1fr);
  margin: 0;
}

.kv dt,
.kv dd {
  padding: 11px 18px;
  margin: 0;
  border-bottom: 1px solid var(--border);
  font-size: 12.5px;
}

.kv dt {
  color: var(--ink-500);
  font-weight: 550;
}

.kv dd {
  color: var(--ink-900);
}

.kv > :nth-last-child(-n + 2) {
  border-bottom: 0;
}

/* --- Activity feed ---------------------------------------------------------
   Grouped by recency (see `groupByRecency` in notifications.js) rather than
   one flat list, with a type mark that is either a person's avatar - for the
   notifications a person actually triggered - or a tinted glyph chip for the
   ones the club itself sent. Unread is carried by a dot next to the title and
   a background tint strong enough to actually read at a glance, plus a
   heavier title weight - three signals that agree rather than one faint wash
   nobody could be sure they'd really seen. Rows are separated by air, not a
   ruled line under every single one - the line was doing table work in a
   list that isn't one. */

/* The feed holds a floor from first paint.

   This container is empty in the markup and gets its rows from two endpoints,
   so the card around it used to be about 8px tall - its head and nothing else -
   until something arrived. Then the skeleton put ~240px into it, and the real
   rows or the empty state changed it again. Three heights in the first second,
   which is the resizing this loading state was supposed to remove rather than
   cause.

   The floor is set to the skeleton's own height, and the skeleton is drawn at
   three rows because that is what the empty state comes to as well - so a page
   with no notifications at all settles at exactly the height it was already
   holding, and the card never starts flat.

   The floor belongs to those two states only. It used to sit on `.feed`
   itself, together with `align-content: center` to seat the empty state in the
   middle of it, and that centring caught the one case it was never meant to:
   a feed holding a single notification. One row is ~73px against a 239px
   floor, so the row floated in the middle of the card with ~83px of dead space
   above it and the same below - a list pretending to be a centred message.
   Two rows read the same way, just less of it.

   So the floor is scoped to the child that is actually standing in for absent
   content. A real list - one row or forty - sizes the card to itself and
   starts at the top edge. Both states are told apart by what the renderer puts
   in the feed: `.brand-loader` while loading, `.empty-state` when there is
   genuinely nothing, `.feed__group` for rows. Nothing about the loading
   sequence changes: the skeleton holds the same height it always did, and the
   empty state still settles at exactly that height. */
.feed {
  padding: 2px 6px 6px;
  /* So the floor is the box, not the box plus its 8px of padding. */
  box-sizing: border-box;
  display: grid;
  align-content: start;
}

/* `min-height` rather than a fixed height: the empty state is free to be
   taller than the floor if its copy ever wraps. */
.feed:has(> .brand-loader),
.feed:has(> .empty-state) {
  min-height: var(--feed-floor, 239px);
  align-content: center;
}

.feed__group + .feed__group {
  margin-top: 10px;
}

.feed__group-label {
  padding: 18px 12px 8px;
  font-size: 11px;
  font-weight: 650;
  letter-spacing: 0.07em;
  text-transform: uppercase;
  color: var(--ink-400);
}

.feed__group:first-child .feed__group-label {
  padding-top: 10px;
}

/* One notification, as its own object.
   The rows carry a radius and an unread tint but sat flush against each other,
   so a run of unread ones merged into a single tinted block and the reader had
   to find the boundaries from the text alone. A small gap is what says these
   are separate things - cheaper than a divider, and it lets the rounded corners
   actually show, which is what the radius was already for. */
.feed__row {
  display: flex;
  align-items: flex-start;
  gap: 14px;
  padding: 14px 16px;
  border-radius: 10px;
}

.feed__row + .feed__row {
  margin-top: 6px;
}

/* --- The notification feed's skeleton --------------------------------------
   The feed hosts are empty in the markup and stay empty until two endpoints
   have answered, so the page painted its chrome - topbar, tabs, card titles -
   and then a blank panel under them. That reads as a page that has finished
   loading and happens to have no notifications, which is the one thing it must
   not say while it is still asking.

   A skeleton rather than the pulsing mark, because the shape here is not a
   guess: every row is a 34px disc, two lines of text and a timestamp, at the
   padding `.feed__row` already sets. Only the words are unknown, which is
   exactly the case a block in the right shape is a truer preview than a mark.

   Drawn at the real row's geometry, gap included, so the rows that replace it
   land where the placeholder stood. */

.sk-feed__row {
  display: flex;
  align-items: flex-start;
  gap: 14px;
  padding: 14px 16px;
  border-radius: 10px;
}

.sk-feed__row + .sk-feed__row {
  margin-top: 6px;
}

/* Sized to the glyph that replaces it rather than to the box that holds it:
   the mark is a bare 22px icon now, so a 34px circle here would resolve into
   something visibly different. The margin keeps the 34px column the real mark
   occupies, so the text beside it does not shift when the rows land. */
.sk-feed__mark {
  width: 22px;
  height: 22px;
  flex: none;
  margin: 0 6px;
  border-radius: 4px;
}

/* The text block, built to the real row's metrics rather than to a guess.

   `.feed__text` is `padding-top: 2`, then a 13.5px/1.45 title, then a
   `.feed__meta` at `margin-top: 4` and 12.5px/1.55. That is 44.95px, and the
   bars below reproduce it exactly: each sits in a box the height of the line it
   stands for, with the bar itself drawn shorter inside. Sizing the bars alone
   made the skeleton row 62px against a real row's 73px, so the whole list grew
   by 11px per row the moment the notifications arrived - the snap a skeleton is
   supposed to prevent, delivered five times over. */
.sk-feed__text {
  flex: 1 1 auto;
  min-width: 0;
  padding-top: 2px;
}

/* The title line. Boxed to 13.5px x 1.45, the bar centred inside it. Widths
   vary per row from a custom property, so the block reads as a list of
   different notifications rather than a stack of identical bars. */
.sk-feed__text > span {
  display: flex;
  align-items: center;
}

.sk-feed__text > span:first-child {
  height: 19.575px;
}

.sk-feed__text > span:first-child .sk-block {
  height: 12px;
  width: var(--sk-title-w, 48%);
}

/* The message under it, at its own line height plus the 4px the real
   `.feed__meta` carries above it. */
.sk-feed__text > span:last-child {
  display: flex;
  align-items: center;
  height: 19.375px;
  margin-top: 4px;
}

.sk-feed__text > span:last-child .sk-block {
  height: 10px;
  width: var(--sk-meta-w, 72%);
}

.sk-feed__time {
  flex: none;
  width: 42px;
  height: 10px;
  margin-top: 4px;
  border-radius: 3px;
}

.feed__text {
  flex: 1;
  min-width: 0;
  padding-top: 2px;
}

.feed__title {
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: 13.5px;
  font-weight: 500;
  color: var(--ink-900);
  line-height: 1.45;
}

.feed__meta {
  margin-top: 4px;
  font-size: 12.5px;
  line-height: 1.55;
  color: var(--ink-500);
}

.feed__time {
  flex: none;
  padding-top: 3px;
  font-size: 11.5px;
  font-variant-numeric: tabular-nums;
  color: var(--ink-400);
  white-space: nowrap;
}

/* A notification row is clickable and carries its unread state three ways: a
   dot beside the title, a heavier title weight, and a tint on the row itself
   - see the note above on why one faint signal alone wasn't enough to read. */
.feed__row--action {
  position: relative;
  width: 100%;
  background: none;
  border: none;
  font: inherit;
  text-align: left;
  cursor: pointer;
  transition: background 140ms ease;
}

.feed__row--action:hover {
  background: var(--ink-100);
}

.feed__row--action:focus-visible {
  outline: 2px solid var(--ink-900);
  outline-offset: -2px;
}

.feed__row.is-unread {
  background: color-mix(in oklab, var(--ink-150) 78%, transparent);
}

.feed__row.is-unread:hover {
  background: color-mix(in oklab, var(--ink-150) 100%, var(--ink-100) 30%);
}

.feed__row.is-unread .feed__title {
  font-weight: 650;
}

.feed__row.is-unread .feed__meta {
  color: var(--ink-600);
}

.feed__dot {
  flex: none;
  width: 6px;
  height: 6px;
  margin-top: 6px;
  border-radius: 50%;
  background: var(--ink-900);
}

/* The avatar for a person-triggered notification - same shape and weight as
   `.identity__avatar` elsewhere in the app, so a person reads as the same
   thing wherever they show up. A hairline ring keeps it from floating loose
   against the card's own surface. */
.feed__avatar {
  display: grid;
  place-items: center;
  flex: none;
  width: 34px;
  height: 34px;
  border-radius: 50%;
  background: var(--ink-900);
  color: var(--surface);
  font-size: 11.5px;
  font-weight: 600;
  letter-spacing: 0.02em;
  box-shadow: 0 0 0 1px var(--surface), 0 0 0 2px var(--border);
}

/* The mark for a notification nobody in particular sent.

   The icon is drawn on the page rather than inside a tinted chip. The chip was
   doing the work of telling four event types apart, but at 16px inside a 34px
   circle the glyph - the part that actually carries the meaning - was the
   smaller half of the mark, and four low-contrast tints of the same ink scale
   read as four slightly different greys rather than as four categories. With
   the disc gone the line work is what is seen, at a size worth seeing, and the
   type is told by the shape of the icon.

   The box stays 34px so it still aligns with `.feed__avatar` beside it in the
   same list, and so a row with a person's photo and a row with a type icon put
   their text at the same x. Only the fill and the radius go. */
.feed__mark {
  display: grid;
  place-items: center;
  flex: none;
  width: 34px;
  height: 34px;

  /* Also the fallback for a mark with no type modifier, so an unrecognised
     notification still draws a visible icon rather than one in the page's own
     text colour.

     Declared here rather than in the block of type colours further down, where
     it used to sit. Every one of these selectors is a single class, so they all
     carry the same specificity and the winner is whichever comes last - and a
     bare `.feed__mark` written after `.feed__mark--system` and
     `--booking` silently overrode both. It was invisible while the default
     tint happened to be identical to the system one; the moment the types
     became distinct weights of ink it would have flattened two of them. */
  color: var(--ink-500);
}

.feed__mark svg {
  width: 22px;
  height: 22px;
}

/* The tints are gone with the chip, so each type is now a weight of ink rather
   than a wash behind one. The ordering is unchanged: system quietest, booking
   a step firmer, file and offer firmer still - see the block further down. */
.feed__mark--system {
  color: var(--ink-500);
}

.feed__mark--booking {
  color: var(--ink-700);
}

@media (max-width: 560px) {
  .feed {
    padding: 2px 3px 3px;
  }

  .feed__row,
  .sk-feed__row {
    gap: 11px;
    padding: 12px 10px;
  }

  /* Three rows at this breakpoint's own metrics. The row is bound by its text
     block rather than by its padding, so it comes down 4px rather than 8, and
     the feed's own padding is 5 here rather than 8:
     3 x 68.95 + 2 x 6 gap + 5 = 224. */
  .feed {
    --feed-floor: 224px;
  }

  .feed__time {
    display: none;
  }
}

/* --- Profile hero ---------------------------------------------------------- */

.hero-id {
  display: flex;
  align-items: center;
  gap: 18px;
}

/* The profile page's own large disc. Rests on the light plate for the same
   reason the header one does - see the note on `.profile__avatar`. This is the
   bigger of the two and sits on its own above the page, so the black flash
   between "no longer shimmering" and "photo decoded" was the more visible of
   the two. The ink plate comes with the initials, from
   `[data-avatar-initials]` further up. */
.hero-id__avatar {
  display: grid;
  place-items: center;
  flex: none;
  width: 68px;
  height: 68px;
  border-radius: 4.5px;
  background: var(--ink-100);
  color: var(--surface);
  font-size: 23px;
  font-weight: 600;
  letter-spacing: 0.02em;
}

.hero-id__text {
  min-width: 0;
}

.hero-id__name {
  margin: 0;
  font-size: 20px;
  font-weight: 650;
  letter-spacing: -0.02em;
  color: var(--ink-900);
}

.hero-id__mail {
  margin: 3px 0 0;
  font-size: 12.5px;
  color: var(--ink-500);
}

.hero-id__tags {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  margin-top: 9px;
}

/* --- Orders ---------------------------------------------------------------- */

.order {
  border-bottom: 1px solid var(--border);
}

.order:last-child {
  border-bottom: 0;
}

.order__head {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 13px 18px;
}

.order__id {
  font-size: 13px;
  font-weight: 650;
  font-variant-numeric: tabular-nums;
  color: var(--ink-900);
}

.order__date {
  margin-top: 1px;
  font-size: 11.5px;
  color: var(--ink-400);
}

.order__total {
  margin-left: auto;
  font-size: 15px;
  font-weight: 650;
  font-variant-numeric: tabular-nums;
  color: var(--ink-900);
}

.order__items {
  padding: 0 18px 13px 18px;
}

.order__item {
  display: flex;
  align-items: center;
  gap: 11px;
  padding: 6px 0;
  font-size: 12.5px;
  color: var(--ink-700);
}

.order__qty {
  flex: none;
  min-width: 28px;
  padding: 1px 6px;
  border: 1px solid var(--border);
  border-radius: 3px;
  background: var(--page);
  color: var(--ink-600);
  font-size: 10.5px;
  font-weight: 650;
  text-align: center;
  font-variant-numeric: tabular-nums;
}

.order__price {
  margin-left: auto;
  font-variant-numeric: tabular-nums;
  color: var(--ink-600);
}

/* --- Small utilities ------------------------------------------------------- */

.stack {
  display: flex;
  flex-direction: column;
  gap: 13px;
}

.bullets {
  margin: 0;
  padding-left: 18px;
  font-size: 12.5px;
  color: var(--ink-600);
  line-height: 1.75;
}

.bullets li + li {
  margin-top: 4px;
}

.toolbar__title {
  margin: 0;
  font-size: 14px;
  font-weight: 650;
  letter-spacing: -0.01em;
  color: var(--ink-900);
  white-space: nowrap;
}

.tb-arrows {
  display: inline-flex;
  gap: 3px;
}

.record-table tbody tr.is-selected {
  background: var(--ink-100);
}

@media (max-width: 620px) {
  .kv {
    grid-template-columns: minmax(0, 1fr);
  }

  .kv dt {
    padding-bottom: 0;
    border-bottom: 0;
  }

  .hero-id {
    flex-direction: column;
    align-items: flex-start;
  }
}

/* --- Tab count badge -------------------------------------------------------
   A live count beside a tab label. Deliberately an element of its own rather
   than part of the label text: the tab underline is measured with a Range over
   the tab's text nodes, and a number folded into that text would drag the
   marker out past the word it is supposed to be marking. */

.tabs__badge {
  display: inline-grid;
  place-items: center;
  min-width: 19px;
  height: 17px;
  padding: 0 5px;
  border-radius: 999px;
  background: var(--ink-150);
  color: var(--ink-600);
  font-size: 10.5px;
  font-weight: 650;
  font-variant-numeric: tabular-nums;
  transition: background 0.16s linear, color 0.16s linear;
}

.tabs__tab[aria-selected='true'] .tabs__badge {
  background: var(--ink-900);
  color: var(--surface);
}

/* A badge whose count is not known yet holds its space instead of taking it.

   The offers count arrives from `offerscheck.js` - from its cache at parse
   time where there is one, otherwise from a fetch a beat later. On a first
   load, a private window, or after storage is cleared, there is no cache, so
   the badge went from `hidden` to 19px wide and widened the tab under it by
   the badge plus the tab's gap. The tab strip is the widest thing on the page
   that is not the content, so that read as the whole header shifting.

   `data-badge-pending` is the honest middle state: space reserved, nothing
   drawn in it. The tab is its final width from the first painted frame, and
   the count either appears in a slot that already fits it or the slot is
   dropped once the answer is known to be zero. Marked in the markup and
   cleared by whoever paints the count. */
.tabs__badge[data-badge-pending] {
  visibility: hidden;
}

/* The reservation must survive `hidden`, which is what the badge ships as -
   otherwise there is no box to hold the space open. `[hidden]` wins by
   `!important` elsewhere in this file, so the pending state has to out-shout
   it, and only while the count is genuinely unknown. */
.tabs__badge[hidden][data-badge-pending] {
  display: inline-grid !important;
}

/* --- Stepper ---------------------------------------------------------------
   The offer composer's step rail. The whole path is shown at once rather than
   one step at a time, so the reader can see what the process involves before
   starting it, and a completed step can be returned to. Reached steps fill in
   and the connector behind them darkens - the same language as the lifecycle
   rail on the tracking pages, because it is the same idea. */

.stepper {
  display: flex;
  align-items: center;
  gap: 0;
  padding: 14px 18px;
  border-bottom: 1px solid var(--border);
  overflow-x: auto;
}

.stepper__step {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  flex: none;
  padding: 0;
  border: 0;
  background: transparent;
  font: inherit;
  white-space: nowrap;
  cursor: pointer;
}

.stepper__step:focus-visible {
  outline: 2px solid var(--ink-900);
  outline-offset: 3px;
  border-radius: 3px;
}

.stepper__num {
  display: grid;
  place-items: center;
  width: 22px;
  height: 22px;
  flex: none;
  border: 1.5px solid var(--ink-300);
  border-radius: 50%;
  background: var(--surface);
  color: var(--ink-500);
  font-size: 11px;
  font-weight: 650;
  transition: background 0.16s linear, border-color 0.16s linear, color 0.16s linear;
}

.stepper__label {
  font-size: 12.5px;
  font-weight: 500;
  color: var(--ink-500);
}

.stepper__step.is-done .stepper__num {
  background: var(--ink-700);
  border-color: var(--ink-700);
  color: var(--surface);
}

.stepper__step.is-current .stepper__num {
  background: var(--ink-900);
  border-color: var(--ink-900);
  color: var(--surface);
  box-shadow: 0 0 0 3px var(--ink-150);
}

.stepper__step.is-done .stepper__label,
.stepper__step.is-current .stepper__label {
  color: var(--ink-900);
  font-weight: 650;
}

.stepper__bar {
  flex: 1;
  min-width: 10px;
  height: 1.5px;
  margin: 0 7px;
  background: var(--ink-200);
}

/* Five steps in a side column do not fit at full size. Below the split-grid
   breakpoint the rail keeps every step but drops the labels of the ones not
   being worked on, so the path stays visible without the strip clipping - the
   numbers alone still carry position, and the current step keeps its word. */
.stepper__step:not(.is-current) .stepper__label {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0 0 0 0);
  white-space: nowrap;
  border: 0;
}

/* Below the split-grid breakpoint the composer is full width, so there is room
   for every label again. */
@media (max-width: 1080px) {
  .stepper__step .stepper__label {
    position: static;
    width: auto;
    height: auto;
    margin: 0;
    clip: auto;
  }
}

.stepper__bar.is-done {
  background: var(--ink-700);
}

/* --- Signature pad ---------------------------------------------------------
   A real draw surface: dashed while empty, because an empty solid-bordered
   box reads as a field that failed to load rather than one waiting for input.
   The hint sits absolutely over the canvas so it never intercepts pointer
   events meant for drawing. */

.sig-pad {
  position: relative;
  height: 160px;
  border: 1.5px dashed var(--ink-300);
  border-radius: var(--radius);
  background: var(--page);
  overflow: hidden;
}

.sig-pad:focus-within {
  border-color: var(--ink-500);
}

.sig-pad canvas {
  display: block;
  width: 100%;
  height: 100%;
  cursor: crosshair;
  touch-action: none;
}

.sig-pad__hint {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  color: var(--ink-400);
  font-size: 12.5px;
  pointer-events: none;
}

/* Right-aligns a control row, for the wizard's Back / Continue pair. */
.controls--end {
  justify-content: flex-end;
}

/* ==========================================================================
   Staff dashboard

   Below the two charts the page is lists, and this is the vocabulary they need:
   one row shape used by all five of them, plus the hint line under a KPI.
   Nothing here restyles a component that already exists - the cards, charts,
   pills and empty states are the ones the rest of the workspace uses.
   ========================================================================== */

/* A card whose body is a list of full-bleed rows. The body's own padding would
   inset every divider from the card edge, so it is dropped and each row carries
   its own gutter instead - which is what lets a hovered row tint edge to edge. */
.card__body--flush {
  padding: 0;
}

/* Two columns of list cards, each owning its natural height.

   `align-items: start` is the whole point. A default grid row stretches every
   card to match the tallest in it, and these lists are five, six and seven rows
   long - so the short ones ended up with an inch of blank surface under their
   last row, which reads as content that failed to load rather than as a list
   that is simply shorter than its neighbour. */
.grid--lists {
  grid-template-columns: repeat(2, minmax(0, 1fr));
  align-items: start;
}

@media (max-width: 1080px) {
  .grid--lists {
    grid-template-columns: minmax(0, 1fr);
  }
}

/* --- Mini week -------------------------------------------------------------
   Upcoming, drawn as a small read-only timetable instead of a row list - the
   same day-header and event-chip vocabulary the bookings page's drag grid
   uses (`.timegrid__head-day`, `.timegrid__event`), but with the hour axis,
   scrolling and drag handles removed. Nothing here is draggable or resizable;
   it exists to be scanned in the two seconds a landing page gets, and a grid
   with an hour rail earns its place only once someone is actually scheduling
   against it - which is what the "Timetable" link in the card head is for.

   One column per distinct day among the next few sessions, not one column per
   calendar day - three sessions across three of the next five days draw as
   three columns, not five with two left empty. The card is exactly as busy as
   the timetable actually is. */
/* Reserved rather than left to its content, on the same principle the row
   lists follow: this card has no separate loading phase, so its first paint is
   its real content, and the height that content settles at has to be the
   height it is given before any of it exists - the reservation and the render
   describe the same box from two different moments. 308px is three day columns
   at their tallest observed content (a header plus two stacked event chips). */
.mini-week {
  display: grid;
  grid-auto-flow: column;
  grid-auto-columns: minmax(0, 1fr);
  gap: 1px;
  min-height: 308px;
  background: var(--border);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  overflow: hidden;
}

.mini-week__day {
  display: flex;
  flex-direction: column;
  background: var(--surface);
  min-width: 0;
}

.mini-week__head {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 2px;
  padding: 9px 4px 8px;
  border-bottom: 1px solid var(--border);
  background: var(--page);
}

.mini-week__head-name {
  font-size: 10px;
  font-weight: 500;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  color: var(--ink-400);
}

.mini-week__head-num {
  display: grid;
  place-items: center;
  width: 22px;
  height: 22px;
  border-radius: 50%;
  font-size: 12px;
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  color: var(--ink-800);
}

/* Today takes the filled disc - the same "darkest thing is the current one"
   rule the bookings grid and the tab strip already use. */
.mini-week__day--today .mini-week__head-num {
  background: var(--ink-900);
  color: var(--surface);
}

.mini-week__day--today .mini-week__head-name {
  color: var(--ink-900);
}

.mini-week__slots {
  display: flex;
  flex-direction: column;
  gap: 5px;
  flex: 1;
  padding: 7px;
}

/* One session, as a compact card rather than a positioned block on an hour
   axis - there is no axis here, so a session's time is written on it instead
   of read off its vertical position. The left border is the same accent the
   full timegrid's event cards carry. */
.mini-week__event {
  padding: 6px 7px;
  border-radius: 3px;
  background: var(--ink-100);
  border-left: 2.5px solid var(--ink-700);
  transition: background 0.12s linear;
}

.mini-week__event:hover {
  background: var(--ink-150);
}

/* Full sessions get the loud border - the one state on this card that changes
   what the reader does about it - matching the fill gauge the row list used
   for the same figure. */
.mini-week__event--full {
  border-left-color: var(--ink-900);
}

.mini-week__event-time {
  font-size: 11px;
  font-weight: 650;
  font-variant-numeric: tabular-nums;
  color: var(--ink-900);
}

.mini-week__event-title {
  margin-top: 1px;
  font-size: 11.5px;
  font-weight: 600;
  color: var(--ink-800);
  line-height: 1.25;
  overflow: hidden;
  text-overflow: ellipsis;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
}

.mini-week__event-meta {
  margin-top: 3px;
  font-size: 10.5px;
  color: var(--ink-500);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.mini-week__event-fill {
  font-variant-numeric: tabular-nums;
}

.mini-week__event--full .mini-week__event-fill {
  color: var(--ink-800);
  font-weight: 650;
}

/* A day with nothing on it - genuinely empty, not a loading state, so it gets
   quiet static copy rather than a spinner. */
.mini-week__empty {
  flex: 1;
  display: grid;
  place-items: center;
  padding: 12px 6px;
  font-size: 10.5px;
  color: var(--ink-300);
  text-align: center;
}

@media (max-width: 480px) {
  .mini-week {
    grid-auto-flow: row;
  }

  .mini-week__day {
    border-bottom: 1px solid var(--border);
  }

  .mini-week__head {
    flex-direction: row;
    justify-content: space-between;
    padding: 7px 10px;
  }

  .mini-week__slots {
    flex-direction: row;
    flex-wrap: wrap;
  }

  .mini-week__event {
    flex: 1 1 140px;
  }
}

/* --- Reserved list height -------------------------------------------------
   Recent files has a known length, and a row has a known height, so the space
   it will occupy is knowable before its data arrives - and if it is knowable
   it must be reserved. Left to itself the placeholder collapsed to the
   loader's 120px floor and the rows then pushed the page down on arrival,
   which is the jolt this rule exists to remove.

   `--rows` is the count the endpoint returns and `--row-h` is the row height,
   measured in the browser rather than derived from the padding by hand. The
   `- 1px` is the last row dropping its bottom border. This is a floor, so a
   list that comes back shorter than expected keeps its reserved box and the
   page settles once either way. */
[data-rows] {
  min-height: calc(var(--rows) * var(--row-h, 67.3px) - 1px);
}

/* Title, two-line meta, trailing figure and pill. */
#recent-list {
  --row-h: 67.3px;
}

/* A summary strip renders from nothing on every page that has one - it has no
   loading placeholder, because its labels are not knowable before the data
   arrives. Its height is knowable, though: five tiles on one row, each a
   label, a value and a foot. Reserving it is what stops the strip appearing at
   1px tall and then pushing everything beneath it down the page on arrival. */
.kpi-row {
  min-height: 138px;
}

/* The strip drops to three columns at 1080px and two at 720px, so below that
   first breakpoint it is no longer one row and the reserved single-row height
   would be a floor holding open space the tiles do not need. The wrapped
   layouts grow downward from content instead. */
@media (max-width: 1080px) {
  .kpi-row {
    min-height: 0;
  }
}

/* --- The row --------------------------------------------------------------
   One shape for recent files, shared files, sessions, offers and activity.
   Three columns - a leading mark, the text, and one trailing status - because
   every one of those lists is the same sentence: what it is, what it says, and
   where it stands. Keeping them identical means a reader learns the shape once
   and then reads four more lists for free. */

.row {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 11px 20px;
  border-bottom: 1px solid var(--border);
  position: relative;
  transition: background 0.13s linear;
}

.row:last-child {
  border-bottom: 0;
}

/* These rows all stand for something openable, so they answer the pointer.
   The tint is the same one the record tables use for a hovered row - this is
   the same gesture over the same kind of object, so it gets the same feedback
   rather than a second vocabulary for it. */
.row:hover {
  background: var(--ink-100);
}

/* --- The trailing column --------------------------------------------------
   A status pill alone at the end of a 700px row left the reader crossing an
   inch of empty surface to connect a filename to its state, and the lists read
   as mostly-blank cards because of it. The trailing slot now carries a figure
   above its pill - downloads, a fill count, a size - so the right edge is a
   column of comparable numbers that the eye can run down, and the pill has
   something to sit against instead of floating. */
/* Sits directly after the capped text column rather than being pushed to the
   card edge by an auto margin - the slack belongs outside this pair, not
   between a row's name and its own status. A minimum width keeps the figures
   and pills of consecutive rows on a shared right edge, so the column still
   reads as a column even though it is no longer edge-anchored. */
.row__end {
  display: grid;
  justify-items: end;
  align-content: center;
  gap: 4px;
  flex: none;
  min-width: 108px;
  text-align: right;
}

/* Tabular, because the whole point of this column is that the figures in it
   line up and can be compared without reading each one. */
.row__figure {
  font-size: 12.5px;
  font-weight: 650;
  font-variant-numeric: tabular-nums;
  color: var(--ink-800);
  line-height: 1.2;
  white-space: nowrap;
}

.row__figure-unit {
  margin-left: 3px;
  font-size: 10.5px;
  font-weight: 500;
  color: var(--ink-400);
}

/* Capped, not merely flexible.

   `flex: 1` alone let the text column eat every spare pixel, which pushed the
   trailing figure and pill hard against the far edge and left an inch of
   nothing between a filename and its own status - the reader then has to track
   across the void to pair them. Bounding the text means the slack collects to
   the right of the trailing column instead of inside the row, so the figure
   stays within reading distance of the name it describes. */
.row__text {
  flex: 1 1 auto;
  min-width: 0;
  max-width: 34rem;
}

/* The name is the thing being scanned for, so it gets the weight and the whole
   remaining width. Truncated rather than wrapped: a two-line title would break
   the shared baseline that makes a column of these readable at a glance. */
.row__title {
  font-size: 13px;
  font-weight: 600;
  color: var(--ink-900);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.row__meta {
  margin-top: 2px;
  font-size: 11.5px;
  color: var(--ink-500);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.row__dot {
  margin: 0 5px;
  color: var(--ink-300);
}

/* --- Row leading marks ---------------------------------------------------- */

/* File type. A tinted tile rather than a bare glyph, so the column of them
   reads as a column at a glance instead of as scattered ink. */
.row-mark {
  display: grid;
  place-items: center;
  width: 34px;
  height: 34px;
  flex: none;
  border-radius: 7px;
  background: var(--ink-100);
  color: var(--ink-700);
}

.row-mark svg {
  width: 16px;
  height: 16px;
}

/* --- Reserved copy blocks -------------------------------------------------
   The last three empty boxes on the page. The legend and both insight lines
   render from nothing, and each is a known shape once the shape - not the
   words - is what you reserve: the legend is five key rows, and an insight is
   two lines of its own line-height. Without these the page still settled 20px
   late, which is small but is exactly the kind of drift that reads as the
   layout being unsure of itself. */

#storage-legend {
  min-height: 174px;
}

/* Measured rather than estimated from `2lh`, the same standard the row-height
   reservations hold elsewhere in this file - text at this size and width wraps
   to a real, measured height, not a generic two-line guess. The two differ
   (54px against 40px) because the storage sentence is longer and reliably
   wraps, while the metric one often does not. */
#storage-insight {
  min-height: 54px;
}

#metric-insight {
  min-height: 40px;
}

/* --- KPI hint -------------------------------------------------------------
   One clause under a count saying what it is counting. The strip is five bare
   numbers otherwise, and "3" over "Shared with you" leaves the reader guessing
   whether that is three files or three people. */

.kpi__hint {
  font-size: 11px;
  color: var(--ink-400);
  text-align: right;
  line-height: 1.35;
}

/* The hint and the delta share a row and both want to shrink first, so the
   foot wraps rather than crushing either one at narrow widths. */
.kpi__foot {
  flex-wrap: wrap;
}

/* At two tiles per row a tile is roughly 160px wide, and the hint almost
   always wraps under the delta rather than sitting beside it. Right-aligned
   text under a left-aligned delta reads as two unrelated lines at that width,
   so the hint switches to the same left edge once it is on its own line. */
@media (max-width: 720px) {
  .kpi__hint {
    flex-basis: 100%;
    text-align: left;
  }
}

@media (max-width: 620px) {
  .row {
    padding-left: 16px;
    padding-right: 16px;
  }
}

/* --- File cards -------------------------------------------------------------
   The library's browse view: media-first cards standing in for the ledger a
   record-table would draw. A file is something you recognise by what kind of
   thing it is before you read its filename, so a preview panel leads the
   card - full width, generous height - with the identity and metadata
   stacked underneath it, the way a media card reads anywhere else. */

.file-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
  gap: 15px;
  padding: 18px 8px 20px;
}

/* Without this the wrapper is `.file-grid`'s one and only item, sized to a
   single 230px cell rather than the grid's full width - the same problem
   `.mini-week` solves the same way. `display: contents` removes the wrapper
   from layout while `clearLoading` can still find it to tear down, so the
   `.sk-card`s become `.file-grid`'s direct children and pick up its columns
   themselves. */
#lib-grid > .brand-loader--skeleton {
  display: contents;
}

.file-card {
  display: flex;
  flex-direction: column;
  text-align: left;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  overflow: hidden;
  cursor: pointer;
  transition: border-color 0.16s cubic-bezier(0.22, 0.61, 0.36, 1),
    box-shadow 0.16s cubic-bezier(0.22, 0.61, 0.36, 1), transform 0.16s cubic-bezier(0.22, 0.61, 0.36, 1);
}

.file-card:hover,
.file-card:focus-visible {
  border-color: var(--ink-300);
  box-shadow: var(--shadow);
  transform: translateY(-2px);
}

.file-card:active {
  transform: translateY(0);
  transition-duration: 0.05s;
}

/* The preview panel. Five categories share one flat tint rather than five
   hues - this system carries no colour - so what tells a video from an
   archive at a glance is the icon and the corner tag, not the background.
   The icon grows very slightly on hover, the one piece of motion a static
   glyph can offer that reads as the card responding rather than just
   the border reacting on its own. */
.file-card__preview {
  position: relative;
  display: grid;
  place-items: center;
  height: 108px;
  /* A real thumbnail is absolutely positioned to fill this pane, and the card
     has rounded top corners - without this the image squares them off. */
  overflow: hidden;
  background: var(--ink-100);
  color: var(--ink-300);
  border-bottom: 1px solid var(--border);
}

.file-card__preview svg {
  transition: transform 0.16s cubic-bezier(0.22, 0.61, 0.36, 1);
}

.file-card:hover .file-card__preview svg,
.file-card:focus-visible .file-card__preview svg {
  transform: scale(1.08);
}

.file-card__type {
  position: absolute;
  top: 9px;
  right: 9px;
  padding: 3px 7px;
  border-radius: 999px;
  background: var(--surface);
  border: 1px solid var(--border);
  color: var(--ink-500);
  font-size: 10px;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.04em;
}

.file-card__body {
  display: flex;
  flex-direction: column;
  gap: 4px;
  padding: 12px 14px 0;
}

.file-card__name {
  font-size: 13.5px;
  font-weight: 600;
  color: var(--ink-900);
  line-height: 1.35;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

.file-card__desc {
  font-size: 12px;
  color: var(--ink-400);
  line-height: 1.5;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

.file-card__foot {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  margin-top: 11px;
  padding: 10px 14px;
  border-top: 1px solid var(--border);
  font-size: 11.5px;
  color: var(--ink-500);
}

.file-card__uploader {
  display: flex;
  align-items: center;
  gap: 7px;
  min-width: 0;
}

.file-card__avatar {
  display: grid;
  place-items: center;
  flex-shrink: 0;
  width: 18px;
  height: 18px;
  border-radius: 50%;
  background: var(--ink-900);
  color: var(--surface);
  font-size: 9px;
  font-weight: 600;
}

.file-card__uploader-name {
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.file-card__meta {
  flex-shrink: 0;
  font-variant-numeric: tabular-nums;
}

@media (prefers-reduced-motion: reduce) {
  .file-card,
  .file-card__preview svg {
    transition: none;
  }

  .file-card:hover,
  .file-card:focus-visible {
    transform: none;
  }
}

/* --- File preview modal -----------------------------------------------------
   The first modal in this system, so it borrows rather than invents: the
   profile menu's entrance curve and corner growth, the card's radius and
   shadow, the ghost/solid buttons for its actions. A dialog is a card that
   floats, not a different visual language. */

.modal-overlay {
  position: fixed;
  inset: 0;
  /* Above the header (30) and the profile menu (40) now that the overlay is a
     sibling of `main` in the markup, not a child of it - see the HTML
     comment above this element for why that nesting matters here. */
  z-index: 100;
  display: grid;
  place-items: center;
  padding: 24px;
  background: rgba(23, 21, 18, 0.42);
  animation: modal-overlay-in 0.16s linear;
}

.modal-overlay[hidden] {
  display: none;
}

@keyframes modal-overlay-in {
  from {
    opacity: 0;
  }
}

.modal {
  display: flex;
  flex-direction: column;
  width: 100%;
  max-width: 560px;

  /* `dvh`, not `100%`.

     The overlay is `display: grid; place-items: center`, and a centred grid
     item is sized by its own content - a percentage max-height has no definite
     containing height to resolve against, so `min(640px, 100%)` only ever
     enforced the 640px half. Any dialog taller than that grew past the bottom
     of the screen with no way to reach the rest of it, which is what happened
     to the twelve-field edit form.

     A viewport unit needs no containing block, so this actually binds. 48px is
     the overlay's own 24px padding, top and bottom. The `vh` line is the
     fallback for engines without `dvh`; `dvh` tracks the viewport as toolbars
     come and go on mobile, where `vh` reports the largest state and would tuck
     the footer under the address bar. */
  max-height: calc(100vh - 48px);
  max-height: calc(100dvh - 48px);

  background: var(--surface);
  border: 1px solid var(--border-strong);
  border-radius: var(--radius-lg);
  box-shadow: var(--shadow);
  animation: modal-in 0.18s cubic-bezier(0.22, 0.61, 0.36, 1);
}

@keyframes modal-in {
  from {
    opacity: 0;
    transform: scale(0.97) translateY(6px);
  }
}

.modal__head {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 14px;
  padding: 16px 18px;
  border-bottom: 1px solid var(--border);
}

.modal__title {
  font-size: 14.5px;
  font-weight: 600;
  letter-spacing: -0.01em;
  color: var(--ink-900);
}

.modal__sub {
  margin-top: 2px;
  font-size: 12.5px;
  color: var(--ink-400);
}

.modal__body {
  flex: 1;

  /* Without this the overflow rule below is dead.

     A flex item's default `min-height: auto` refuses to shrink it below its
     own content, so this box was never shorter than what it held and
     `overflow-y: auto` had nothing to scroll. Together with the height cap on
     `.modal` above, this is what makes every dialog's middle scroll while its
     head and foot - flex siblings that do not grow - stay pinned. */
  min-height: 0;
  overflow-y: auto;

  /* Stops a flick that reaches the end of this box from carrying on and
     scrolling the page behind the dialog. */
  overscroll-behavior: contain;

  padding: 18px;
}

/* --- Notification detail modal ----------------------------------------------
   Borrows the file preview modal's shell but leads with a mark rather than
   the plain title/close pairing everywhere else uses - the same avatar-or-
   glyph disc the feed row it was opened from carries, just larger, so the
   modal reads as that row grown into a full view rather than a different
   surface. */

.modal__head--notif {
  align-items: center;
  gap: 13px;
}

.modal__head--notif > div {
  flex: 1;
  min-width: 0;
}

.modal__notif-mark {
  display: grid;
  place-items: center;
  flex: none;
  width: 40px;
  height: 40px;
  border-radius: 50%;
  background: var(--ink-900);
  color: var(--surface);
  font-size: 13px;
  font-weight: 600;
  letter-spacing: 0.02em;
}

.modal__notif-mark--system {
  background: color-mix(in oklab, var(--ink-500) 16%, var(--surface));
  color: var(--ink-600);
}

.modal__notif-mark--booking {
  background: color-mix(in oklab, var(--ink-700) 14%, var(--surface));
  color: var(--ink-700);
}

.modal__description--top {
  margin-top: 0;
  padding-top: 0;
  border-top: 0;
  font-size: 13.5px;
  line-height: 1.7;
  color: var(--ink-700);
}

.modal__preview {
  display: grid;
  place-items: center;
  min-height: 160px;
  margin-bottom: 16px;
  padding: 20px;
  background: var(--ink-100);
  border-radius: var(--radius);
  color: var(--ink-400);
}

.modal__preview svg {
  color: var(--ink-300);
}

.modal__facts {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 12px 20px;
}

.modal__fact-label {
  font-size: 11px;
  color: var(--ink-400);
  text-transform: uppercase;
  letter-spacing: 0.04em;
}

.modal__fact-value {
  margin-top: 2px;
  font-size: 13px;
  color: var(--ink-900);
  font-variant-numeric: tabular-nums;
}

.modal__description {
  margin-top: 16px;
  padding-top: 14px;
  border-top: 1px solid var(--border);
  font-size: 12.5px;
  line-height: 1.6;
  color: var(--ink-600);
}

/* The one editable field a preview modal carries - a select rather than a
   paragraph, so it sits in its own row below the read-only facts instead of
   inside the two-column grid meant for label/value pairs. */
.modal__visibility {
  display: flex;
  flex-direction: column;
  gap: 6px;
  margin-top: 16px;
  padding-top: 14px;
  border-top: 1px solid var(--border);
}

.modal__foot {
  display: flex;
  justify-content: flex-end;
  gap: 8px;
  padding: 14px 18px;
  border-top: 1px solid var(--border);
}

@media (prefers-reduced-motion: reduce) {
  .modal-overlay,
  .modal {
    animation: none;
  }
}

/* --- Manage uploads: table becomes a card list on narrow screens ----------
   Eight columns (select, file, type, visibility, tags, size, downloads,
   added) reads fine on a wide desktop scan but has nothing to scan on a
   phone or a tablet-width window - it just clips at the viewport edge with
   no visible hint that the rest of the row exists off-screen. 980px rather
   than the usual 720px breakpoint because this table is unusually wide for
   its column count (two chip-bearing columns, tags and visibility), so it
   runs out of room earlier than a typical record table does. Below the
   breakpoint the table stops being a grid of columns and becomes a stack of
   cards, one per file: the row keeps being a `<tr>` for the checkbox and
   preview button to keep working unchanged, `display: block` on every level
   turns the layout into a column, and each remaining cell is labelled with
   the header text it lost (`data-label`, read by `::before`) so the value is
   never shown without knowing what it means. */
@media (max-width: 980px) {
  #mu-table,
  #mu-table thead,
  #mu-table tbody,
  #mu-table tr {
    display: block;
    width: 100%;
  }

  #mu-table thead {
    /* Column headers describe a grid this table no longer draws. */
    position: absolute;
    width: 1px;
    height: 1px;
    overflow: hidden;
    clip: rect(0 0 0 0);
    white-space: nowrap;
  }

  #mu-table tbody tr {
    display: grid;
    grid-template-columns: auto 1fr;
    column-gap: 12px;
    padding: 14px 16px;
    border-bottom: 1px solid var(--border);
  }

  #mu-table tbody tr:last-child {
    border-bottom: 0;
  }

  #mu-table tbody tr.is-selected {
    background: var(--ink-100);
  }

  #mu-table td {
    display: block;
    padding: 0;
    border-bottom: 0;
  }

  /* Checkbox and file identity sit side by side as the card's header row -
     the two things worth seeing before anything else, same as the desktop
     table's reading order. */
  #mu-table .mu-cell--select {
    grid-row: 1;
    grid-column: 1;
    align-self: start;
    padding-top: 2px;
  }

  #mu-table .mu-cell--file {
    grid-row: 1;
    grid-column: 2;
  }

  #mu-table .mu-cell--file .identity {
    align-items: flex-start;
  }

  /* Everything else spans under the header row as a labelled fact list,
     rather than trying to hold a second column that has nothing to sit
     beside it once the file name can run to two lines. */
  #mu-table td[data-label] {
    grid-column: 1 / -1;
    display: flex;
    align-items: baseline;
    gap: 10px;
    margin-top: 8px;
    padding-left: 42px;
  }

  #mu-table td[data-label]::before {
    content: attr(data-label);
    flex: 0 0 84px;
    font-size: 11px;
    font-weight: 500;
    color: var(--ink-400);
    text-transform: uppercase;
    letter-spacing: 0.04em;
  }

  #mu-table .td--num {
    text-align: left;
    font-variant-numeric: tabular-nums;
  }

  /* The scroller no longer needs a horizontal track to hide, and the tall
     variant's fixed max-height fights a card list's taller rows - let the
     card holding it define the page's scroll instead. */
  .table-scroll:has(#mu-table) {
    overflow-x: visible;
    max-height: none;
    overflow-y: visible;
  }
}

/* ==========================================================================
   Student workspace

   The student pages are the same shell, the same cards and the same monochrome
   rules as the staff side. What is added here is only what has no staff
   counterpart: the preview bar, the plan card the two subscription pages are
   built from, and the contract's terms and signature blocks.
   ========================================================================== */

/* --- The preview bar ------------------------------------------------------
   Sits above the topbar, spanning the shell, because it is a statement about
   everything beneath it rather than a control belonging to the header.

   Inverted rather than tinted. With hue removed there is no yellow to reach
   for, so the one channel left that reads as "this page is not in its normal
   state" is the full lightness flip - and it has to be unmistakable, because
   the entire failure mode of a preview mode is forgetting you are in one. */

.viewas {
  display: flex;
  align-items: center;
  gap: 12px;
  margin: 12px 0 0;
  padding: 9px 14px;
  background: var(--ink-900);
  color: var(--surface);
  border-radius: var(--radius);
  font-size: 12.5px;
  line-height: 1.45;
}

.viewas__mark {
  display: grid;
  place-items: center;
  flex: none;
  width: 22px;
  height: 22px;
  border-radius: 50%;
  background: rgba(253, 252, 250, 0.14);
}

.viewas__text {
  min-width: 0;
  color: var(--ink-200);
}

.viewas__text strong {
  color: var(--surface);
  font-weight: 600;
}

/* The way out. Outlined on the dark ground rather than filled, so it reads as
   the secondary action it is - the bar is telling you something first and
   offering an exit second. */
.viewas__exit {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  flex: none;
  margin-inline-start: auto;
  padding: 6px 11px;
  background: transparent;
  border: 1px solid rgba(253, 252, 250, 0.3);
  border-radius: var(--radius);
  font: inherit;
  font-size: 12.5px;
  font-weight: 500;
  color: var(--surface);
  cursor: pointer;
  white-space: nowrap;
  transition: background-color 0.12s linear, border-color 0.12s linear;
}

.viewas__exit:hover {
  background: rgba(253, 252, 250, 0.12);
  border-color: rgba(253, 252, 250, 0.5);
}

.viewas__exit:focus-visible {
  outline: 2px solid var(--surface);
  outline-offset: 2px;
}

/* Below the topbar's own wrap point the sentence is what gives way, not the
   control - a bar that keeps its explanation and loses its exit is the one
   arrangement that would make the mode hard to leave. */
@media (max-width: 720px) {
  .viewas {
    flex-wrap: wrap;
    gap: 8px 10px;
  }

  .viewas__text {
    order: 3;
    flex-basis: 100%;
  }
}

/* --- The plan card --------------------------------------------------------
   The subscription record, rendered as a masthead: what the plan is on the
   left, what it costs on the right. Both subscription pages open with it, so
   the reader arrives at the same object whether they came to read the contract
   or to cancel it. */

.plan {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 18px;
  flex-wrap: wrap;
  padding: 18px 20px;
  border-bottom: 1px solid var(--border);
}

.plan__id {
  display: flex;
  align-items: center;
  gap: 13px;
  min-width: 0;
}

.plan__mark {
  display: grid;
  place-items: center;
  flex: none;
  width: 40px;
  height: 40px;
  border: 1px solid var(--border-strong);
  border-radius: var(--radius);
  color: var(--ink-700);
}

.plan__name {
  font-size: 16px;
  font-weight: 600;
  letter-spacing: -0.01em;
}

.plan__meta {
  margin-top: 2px;
  font-size: 12.5px;
  color: var(--ink-500);
}

/* The price is the largest figure on the page and the only one set in the
   tabular face, because it is the number the reader came to check. */
.plan__price {
  text-align: right;
  flex: none;
}

.plan__amount {
  font-size: 26px;
  font-weight: 600;
  letter-spacing: -0.02em;
  font-variant-numeric: tabular-nums;
  line-height: 1.1;
}

.plan__cycle {
  margin-top: 2px;
  font-size: 12.5px;
  color: var(--ink-500);
}

/* --- Detail grid ----------------------------------------------------------
   Label over value, in columns. A dl would be the semantic fit but reads as a
   two-column table at this density; these are four independent facts, so each
   gets its own block and they wrap as units. */

.detail-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
  gap: 16px 20px;
  padding: 18px 20px;
}

.detail-grid--flush {
  padding: 0;
}

.detail__label {
  font-size: 11.5px;
  font-weight: 500;
  color: var(--ink-400);
  text-transform: uppercase;
  letter-spacing: 0.04em;
}

.detail__value {
  margin-top: 5px;
  font-size: 13.5px;
  color: var(--ink-900);
  font-variant-numeric: tabular-nums;
}

/* --- Contract terms -------------------------------------------------------
   Numbered clauses. The number is set in the margin rather than inline so the
   body text of every clause starts on one line, which is what makes a list of
   terms scannable rather than a wall. */

.terms {
  display: flex;
  flex-direction: column;
  gap: 14px;
}

.terms__item {
  display: grid;
  grid-template-columns: 22px minmax(0, 1fr);
  gap: 12px;
}

.terms__num {
  font-size: 12px;
  font-weight: 600;
  color: var(--ink-400);
  font-variant-numeric: tabular-nums;
  padding-top: 1px;
}

.terms__title {
  font-size: 13px;
  font-weight: 600;
  color: var(--ink-900);
}

.terms__body {
  margin-top: 3px;
  font-size: 13px;
  line-height: 1.55;
  color: var(--ink-600);
}

/* --- What is included -----------------------------------------------------
   A checklist. The tick is drawn rather than a bullet, because these are
   entitlements the reader is confirming they have, not items in a sequence. */

.includes {
  display: flex;
  flex-direction: column;
  gap: 9px;
  margin: 0;
  padding: 0;
  list-style: none;
}

.includes li {
  display: grid;
  grid-template-columns: 16px minmax(0, 1fr);
  gap: 10px;
  align-items: start;
  font-size: 13px;
  line-height: 1.5;
  color: var(--ink-700);
}

.includes svg {
  margin-top: 2px;
  color: var(--ink-900);
}

/* --- Signature ------------------------------------------------------------
   The captured signature, shown in a ruled box the way it appears on the
   printed contract. The script face is the one place in this system where type
   is doing representational work rather than setting text. */

.signature {
  border: 1px solid var(--border-strong);
  border-radius: var(--radius);
  padding: 14px 16px 11px;
  background: var(--surface);
}

.signature__mark {
  font-family: 'Segoe Script', 'Bradley Hand', 'Snell Roundhand', cursive;
  font-size: 25px;
  line-height: 1.3;
  color: var(--ink-900);
  padding-bottom: 9px;
  border-bottom: 1px solid var(--border);
}

.signature__meta {
  display: flex;
  justify-content: space-between;
  gap: 12px;
  flex-wrap: wrap;
  margin-top: 8px;
  font-size: 11.5px;
  color: var(--ink-500);
}

/* --- Notice ---------------------------------------------------------------
   A banner inside a page - the cancellation-pending state, the lifetime-access
   note. Left rule rather than a full tint, matching how an unread notification
   is marked in this system: the weight sits on one edge so the text keeps its
   normal ground and stays as readable as the rest of the page. */

.notice {
  display: flex;
  align-items: flex-start;
  gap: 12px;
  padding: 13px 15px;
  background: var(--ink-100);
  border-inline-start: 2px solid var(--ink-900);
  border-radius: var(--radius);
}

.notice svg {
  flex: none;
  margin-top: 1px;
  color: var(--ink-700);
}

.notice__title {
  font-size: 13px;
  font-weight: 600;
  color: var(--ink-900);
}

.notice__body {
  margin-top: 3px;
  font-size: 12.5px;
  line-height: 1.5;
  color: var(--ink-600);
}

/* The consequence-carrying variant: a pending cancellation, a failed payment.
   Solid rule and a heavier title, which is the same escalation `.pill--alert`
   makes over `.pill--wait`. */
.notice--alert {
  background: var(--surface);
  border: 1px solid var(--border-strong);
  border-inline-start: 3px solid var(--ink-900);
}

/* --- Payment ledger -------------------------------------------------------
   Rows rather than a table: three fields per payment is under the width where
   a table's column headers start paying for themselves, and the ledger sits
   inside a card that already has a heading. */

.ledger {
  display: flex;
  flex-direction: column;
}

.ledger__row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 14px;
  padding: 11px 0;
  border-bottom: 1px solid var(--border);
  font-size: 13px;
}

.ledger__row:last-child {
  border-bottom: 0;
}

.ledger__when {
  color: var(--ink-900);
  font-variant-numeric: tabular-nums;
}

.ledger__how {
  font-size: 12px;
  color: var(--ink-500);
  margin-top: 2px;
}

.ledger__amount {
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}

/* --- Cancel block ---------------------------------------------------------
   The rules of cancelling, then the button. Deliberately the last thing on the
   manage page and deliberately verbose: the consequences are read before the
   control, not after it. */

.consequences {
  display: flex;
  flex-direction: column;
  gap: 9px;
  margin: 0 0 16px;
  padding: 0;
  list-style: none;
}

.consequences li {
  display: grid;
  grid-template-columns: 16px minmax(0, 1fr);
  gap: 10px;
  align-items: start;
  font-size: 13px;
  line-height: 1.5;
  color: var(--ink-700);
}

.consequences svg {
  margin-top: 2px;
  color: var(--ink-600);
}

/* --- Booking marks --------------------------------------------------------
   On the student timetable a session carries one more fact than on the staff
   one: whether this student is in it. Filled disc for booked, hollow for open,
   and the label is always present - the mark is a shortcut for the reader who
   has learnt it, never the only way to know. */

.seat {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  font-size: 11.5px;
  font-weight: 500;
  color: var(--ink-600);
  white-space: nowrap;
}

.seat::before {
  content: '';
  width: 7px;
  height: 7px;
  border-radius: 50%;
  border: 1.5px solid var(--ink-500);
  flex: none;
}

.seat--mine {
  color: var(--ink-900);
  font-weight: 600;
}

.seat--mine::before {
  background: var(--ink-900);
  border-color: var(--ink-900);
}

.seat--full {
  color: var(--ink-400);
}

.seat--full::before {
  border-style: dashed;
}

/* The plan masthead inside a card that already has a head and padded body -
   the dashboard's summary, rather than the subscription page's own card where
   `.plan` supplies its own frame. */
.plan--flush {
  padding: 0 0 14px;
  border-bottom: 0;
}

/* Two cards whose natural heights differ a lot - the plan summary beside the
   timetable, "what is included" beside a six-row ledger - leave the shorter one
   ringed with slack under `align-items: start`. Stretching just these pairs
   lets the shorter card fill the row its neighbour sets, which is what the
   bento grid already does for the hero and the storage ring.

   Scoped to the student workspace: the staff lists are genuinely independent
   rows whose heights should not be coupled. */
[data-workspace='student'] .grid--lists {
  align-items: stretch;
}

[data-workspace='student'] .grid--lists > .card {
  height: 100%;
}

/* The admin workspace pairs the same way, for the same reason. "Waiting on you"
   is five queues and "Service health" is five components - both fixed-length
   lists that happen to measure differently, so under `align-items: start` the
   shorter one leaves a band of empty page beside a card that ends much lower.
   The pair reads as one row, so it should occupy one. */
[data-workspace='admin'] .grid--lists {
  align-items: stretch;
}

[data-workspace='admin'] .grid--lists > .card {
  height: 100%;
}

/* The security monitor's split holds two independent stacks of cards - a chart
   plus its IP/timeline/severity row on the left, three list panels on the
   right - each already sized to its own content. Unlike the pair above,
   stretching this one would force whichever stack is shorter to grow into
   empty space rather than end where its last card ends, so it keeps the
   grid's default `align-items: start`. */

/* --- A session this student is booked into --------------------------------
   On the student timetable the fact that decides how a session is read is
   whether they are in it. Carried the way this system carries every other
   state: the left rule goes to full ink and the title takes the heavier
   weight, so it separates from an open session by two channels rather than
   one. The aria-label says "you are booked in" as well - the fill is a
   shortcut for the reader who has learnt it, never the only way to know.

   Cancelled still wins, because a cancelled session you were booked into is
   first of all cancelled; that rule follows this one in the stylesheet. */
.timegrid__event--mine {
  background: var(--surface);
  border-left-color: var(--ink-900);
  border-left-width: 3px;
}

.timegrid__event--mine .timegrid__event-title {
  font-weight: 650;
  color: var(--ink-900);
}

/* Restated after `--mine` so a cancelled session that the student was booked
   into still reads as cancelled first - specificity is equal, so source order
   is what decides it. */
.timegrid__event--cancelled.timegrid__event--mine {
  background: var(--surface);
  border-left-color: var(--ink-200);
  color: var(--ink-400);
}

.timegrid__event--cancelled.timegrid__event--mine .timegrid__event-title {
  font-weight: 550;
  color: var(--ink-400);
  text-decoration: line-through;
}

/* A destructive control sized to its own words. `.ghost-button` is an
   inline-flex, but as the only child of a card body it inherits the body's
   full width through the block formatting around it - and a cancel button
   spanning the card reads as the primary action of the page, which is exactly
   what this one must not be. */
#mng-cancel,
#mng-reactivate {
  align-self: flex-start;
  width: auto;
}

/* ============================================================================
   Booking popover
   ============================================================================
   The floating card the time grid opens beside a click or a finished drag -
   either the create form standing over a just-drawn band, or an existing
   booking's details. `position: fixed` and placed in pixels by
   `positionPopover` in schedule.js, because it has to land beside whatever
   was clicked wherever that happens to be on screen, which a card grid or a
   dialog centred in the viewport cannot do. */

.bk-pop {
  position: fixed;
  z-index: 120;
  width: 320px;
  max-width: calc(100vw - 20px);
  max-height: calc(100vh - 20px);
  overflow-y: auto;
  padding: 16px 18px;
  background: var(--surface);
  border: 1px solid var(--border-strong);
  border-radius: var(--radius-lg);
  box-shadow: var(--shadow);
  animation: bk-pop-in 0.14s cubic-bezier(0.22, 0.61, 0.36, 1);
}

@keyframes bk-pop-in {
  from {
    opacity: 0;
    transform: scale(0.97) translateY(4px);
  }
}

.bk-pop__head {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 12px;
  padding-bottom: 12px;
  margin-bottom: 12px;
  border-bottom: 1px solid var(--border);
}

.bk-pop__title {
  font-size: 14px;
  font-weight: 600;
  letter-spacing: -0.01em;
  color: var(--ink-900);
}

.bk-pop__sub {
  margin-top: 3px;
  font-size: 12px;
  color: var(--ink-500);
  font-variant-numeric: tabular-nums;
}

.bk-pop__facts {
  display: flex;
  flex-direction: column;
  gap: 8px;
  margin-bottom: 16px;
}

.bk-pop__fact {
  font-size: 12.5px;
  color: var(--ink-800);
}

.bk-pop__fact-label {
  display: inline-block;
  min-width: 66px;
  margin-right: 4px;
  font-size: 11px;
  color: var(--ink-400);
  text-transform: uppercase;
  letter-spacing: 0.04em;
}

.bk-pop__actions {
  display: flex;
  align-items: center;
  gap: 8px;
}

.bk-pop__actions .solid-button,
.bk-pop__actions .ghost-button {
  flex: 1;
  justify-content: center;
}

/* --- Create form variant --- */

.bk-pop--form .field__input {
  font-size: 13px;
}

/* The title field reads as the card's own heading rather than as a field
   inside it - no border, no label, larger type - the same trick the live
   system's quick-create pop uses so naming the booking is the first and most
   prominent thing the form asks. */
.bk-pop--form #bk-title {
  padding: 2px 0 10px;
  border: none;
  border-bottom: 1px solid var(--border);
  border-radius: 0;
  font-size: 15px;
  font-weight: 600;
}

.bk-pop--form #bk-title:focus,
.bk-pop--form #bk-title:focus-visible {
  border-color: var(--ink-900);
  box-shadow: none;
}

.bk-pop__row {
  display: flex;
  align-items: center;
  gap: 8px;
  margin: 12px 0;
  font-size: 12.5px;
  color: var(--ink-700);
}

.bk-pop__row-label {
  font-weight: 500;
}

.bk-pop__time {
  width: auto;
  flex: 1;
  font-variant-numeric: tabular-nums;
}

/* --- Invite search --- */

.bk-invite {
  position: relative;
}

.bk-invite__results {
  position: absolute;
  left: 0;
  right: 0;
  top: calc(100% + 4px);
  z-index: 2;
  max-height: 168px;
  overflow-y: auto;
  padding: 4px;
  background: var(--surface);
  border: 1px solid var(--border-strong);
  border-radius: var(--radius-lg);
  box-shadow: var(--shadow);
}

.bk-invite__result {
  display: flex;
  align-items: center;
  gap: 9px;
  padding: 7px 8px;
  border-radius: 4px;
  font-size: 12.5px;
  color: var(--ink-800);
  cursor: pointer;
}

.bk-invite__result:hover {
  background: var(--ink-100);
}

.bk-invite__result-av {
  display: grid;
  place-items: center;
  flex: none;
  width: 22px;
  height: 22px;
  border-radius: 50%;
  background: var(--ink-800);
  color: var(--surface);
  font-size: 9.5px;
  font-weight: 600;
  letter-spacing: 0.01em;
}

.bk-invite__chips {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  margin-top: 8px;
}

.bk-invite__chips:empty {
  margin-top: 0;
}

.bk-chip {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  padding: 4px 6px 4px 10px;
  background: var(--ink-100);
  border-radius: 999px;
  font-size: 11.5px;
  color: var(--ink-800);
}

.bk-chip__remove {
  display: grid;
  place-items: center;
  width: 16px;
  height: 16px;
  padding: 0;
  border: none;
  border-radius: 50%;
  background: none;
  color: var(--ink-500);
  font-size: 13px;
  line-height: 1;
  cursor: pointer;
}

.bk-chip__remove:hover {
  background: var(--ink-200);
  color: var(--ink-900);
}

/* --- Capacity + colour row --- */

.bk-pop__capacity {
  width: 56px;
  flex: none;
  text-align: center;
}

.bk-swatch-row {
  display: flex;
  align-items: center;
  gap: 6px;
  margin-left: auto;
}

.bk-swatch {
  width: 18px;
  height: 18px;
  padding: 0;
  border: 2px solid transparent;
  border-radius: 50%;
  background: var(--swatch);
  cursor: pointer;
  transition: transform 0.1s ease, border-color 0.1s linear;
}

.bk-swatch:hover {
  transform: scale(1.12);
}

.bk-swatch--selected {
  border-color: var(--ink-900);
  box-shadow: 0 0 0 2px var(--surface) inset;
}

/* --- Visibility / recurrence toggles --- */

.bk-pop__toggle-group {
  display: flex;
  gap: 4px;
  padding: 3px;
  margin-top: 10px;
  background: var(--ink-100);
  border-radius: 999px;
}

.bk-pop__toggle {
  flex: 1;
  padding: 6px 6px;
  border: none;
  border-radius: 999px;
  background: none;
  font: inherit;
  font-size: 11.5px;
  font-weight: 500;
  color: var(--ink-600);
  white-space: nowrap;
  cursor: pointer;
  transition: background 0.12s linear, color 0.12s linear;
}

.bk-pop__toggle:hover {
  color: var(--ink-900);
}

.bk-pop__toggle--active {
  background: var(--surface);
  color: var(--ink-900);
  box-shadow: var(--shadow-sm);
}

/* --- Mobile: bottom sheet ---------------------------------------------------
   Below the same 768px width `isMobile()` in schedule.js switches on, the
   popover stops being a card placed beside whatever was tapped - on a phone
   there is rarely room beside anything - and becomes a sheet pinned to the
   bottom edge instead: full width, reachable with a thumb without hunting for
   it, and never able to land partly off the top or side of a screen this
   narrow the way a floating card could. `positionPopover` still runs and
   still sets `left`/`top` in schedule.js, but every property here carries
   `!important` because it has to win over those inline pixels, not merely
   over the desktop card rules - the two are deciding the same properties by
   two different mechanisms, and only one may actually apply. */
@media (max-width: 768px) {
  .bk-pop {
    position: fixed !important;
    left: 0 !important;
    right: 0 !important;
    bottom: 0 !important;
    top: auto !important;
    width: 100% !important;
    max-width: none;
    max-height: min(82dvh, calc(100vh - 20px));
    border-radius: var(--radius-lg) var(--radius-lg) 0 0;
    border-bottom: none;
    box-shadow: 0 -8px 28px rgba(23, 21, 18, 0.16);
    animation: bk-pop-sheet-in 0.18s cubic-bezier(0.22, 0.61, 0.36, 1);

    /* Everything above the actions row scrolls in its own box; the row itself
       is a fixed member of this flex column rather than content inside the
       scroller, which is what keeps Create/Cancel (or Join/Leave) reachable
       no matter how tall the fields above them get - a chip added to the
       invite list, a longer description, a phone with little vertical room
       to begin with. `overflow-y: auto` alone left the buttons wherever the
       form's natural height put them, which on a full field set was
       routinely below the fold with nothing to scroll them into view. */
    display: flex;
    flex-direction: column;
    padding: 14px 0 0;
    overflow: hidden;
  }

  /* A short drag-handle bar, the universal signal for "this sheet can be
     dismissed" even though dismissal here is a tap outside or Escape rather
     than an actual drag - it still tells the reader what kind of surface this
     is at a glance, before they have read a word of it. */
  .bk-pop::before {
    content: '';
    flex: none;
    display: block;
    width: 36px;
    height: 4px;
    margin: 0 auto 14px;
    border-radius: 999px;
    background: var(--ink-200);
  }

  /* Every direct child except the handle and the actions row is the
     scrollable body - wrapped at render time in schedule.js into
     `.bk-pop__scroll` so this rule has a single element to target instead of
     every field individually. */
  .bk-pop__scroll {
    flex: 1;
    min-height: 0;
    overflow-y: auto;
    padding: 0 18px;
  }

  .bk-pop__actions {
    flex: none;
    padding: 12px 18px calc(14px + env(safe-area-inset-bottom, 0px));
    background: var(--surface);
    border-top: 1px solid var(--border);
  }
}

@keyframes bk-pop-sheet-in {
  from {
    transform: translateY(100%);
  }
}

/* ==========================================================================
   MYVTSG integration layer

   Everything above this line is the design system as shipped. What follows is
   only what the live intranet needs on top of it, kept here rather than edited
   into the sections above so the two stay separable.
   ========================================================================== */

/* --- Notification badge ---------------------------------------------------
   `notificationcheck.js` is loaded on every authenticated page and appends its
   own <style> to <head> for the badge - a red disc pinned to the corner of a
   nav item, sized for the old sidebar. Two things about it are wrong here.

   It lands after this stylesheet in the cascade, so ordinary rules of the same
   specificity lose to it. And it is red, on a page whose whole premise is that
   with hue removed, emphasis is carried by weight, size, lightness and spacing
   - one saturated dot would be the only colour in the entire workspace, which
   makes it read as an error state rather than as a count.

   So the count is restyled as the ink disc the rest of the system would draw,
   and `!important` is used deliberately: it is the only way to outrank a style
   element injected into <head> at runtime, and the script's own `.badge-active`
   rule already uses it for the colours. Its show/hide logic is left alone -
   only the appearance is overridden, so a count of zero still hides.

   Scoped to the legacy sidebar markers only, and explicitly NOT to
   `.switcher__badge`. That class is the shell's own inline pill, and it is a
   flex item of `.switcher__link` - it takes its own space in the row rather
   than being pinned to a corner. This block sets `position: absolute` on every
   property with `!important`, so while it still matched the switcher pill it
   outranked `.switcher__badge` entirely: the same badge rendered as a small
   ringed disc on pages that load notificationcheck.js and as an inline pill on
   the notifications page, which does not. The `:not()` is what lets the pill
   style win, so every page in this shell draws the badge the same way. */
.switcher__link .notif-badge:not(.switcher__badge),
.switcher__link #notifBadge:not(.switcher__badge) {
  position: absolute !important;
  top: 1px !important;
  right: 2px !important;
  min-width: 15px !important;
  height: 15px !important;
  padding: 0 4px !important;
  border: 1.5px solid var(--ink-100) !important;
  border-radius: 999px !important;
  background: var(--ink-900) !important;
  color: var(--surface) !important;
  font-size: 9.5px !important;
  font-weight: 650 !important;
  font-variant-numeric: tabular-nums;
  line-height: 1 !important;
  letter-spacing: 0;
  box-shadow: none !important;
  /* The injected keyframes pulse a red glow that has nothing to pulse here. */
  animation: none !important;
}

/* The current page sits on the raised pill rather than the track, so the
   badge's cut-out ring has to match the surface behind it or it draws a grey
   notch into a white pill. */
.switcher__badge {
  display: inline-grid;
  place-items: center;
  min-width: 17px;
  height: 16px;
  padding: 0 5px;
  border-radius: 999px;
  background: var(--ink-900);
  color: var(--surface);
  font-size: 10px;
  font-weight: 650;
  font-variant-numeric: tabular-nums;
}

/* On the current tab the pill sits on an inverted ground, so it swaps to the
   surface colour to stay legible. */
.switcher__link[aria-current='page'] .switcher__badge {
  background: var(--surface);
  color: var(--ink-900);
}

/* notificationcheck.js is a shared classic script that fills every badge on the
   page. It also stamps `.badge-active` (a red ground, forced with !important),
   an inline `display: inline-flex`, and a pulse animation - the treatment the
   old dashboard badge wanted, and the opposite of the quiet pill this shell
   uses. Overriding it here keeps that script as the single source of the COUNT
   while this stylesheet stays the single source of the LOOK.

   `!important` is required to beat its own !important; the inline display is
   beaten by matching its specificity with the animation reset below. */
.switcher__badge.badge-active {
  background: var(--ink-900) !important;
  color: var(--surface) !important;
  animation: none !important;
  box-shadow: none !important;
  display: inline-grid !important;
  transform: none !important;
}

.switcher__link[aria-current='page'] .switcher__badge.badge-active {
  background: var(--surface) !important;
  color: var(--ink-900) !important;
}

/* At a zero count notificationcheck removes .badge-active and sets `hidden`,
   so the rule above stops applying - but .switcher__badge's own `inline-grid`
   would still show an empty pill. Hidden wins explicitly. */
.switcher__badge[hidden] {
  display: none !important;
}

/* No padding reservation for the badge, deliberately. `.switcher__link` is an
   inline-flex row with a gap, so the badge is a flex item that takes its own
   space and pushes the link wider - the same way it does on the notifications
   page, which carries no such rule either. The reservation this replaces was
   inherited from the old sidebar, where `.notif-badge` was pinned to the
   corner with `position: absolute` and genuinely did overlap the label. That
   class does not exist in this shell, so the rule matched nothing; kept as a
   rule it would only add a stray 20px of trailing space if it ever did. */

/* --- The metric switch on a narrow screen ---------------------------------
   `.segmented` is an inline-flex row that neither wraps nor scrolls, so it
   simply overhangs when its labels do not fit. On the reference's four short
   labels that never happened; "Conversion" is wide enough to push the row past
   a 390px viewport, and a control hanging off the side of the page takes the
   whole document's horizontal scroll with it.

   Scrolled rather than wrapped. Wrapping would reflow a four-button row into
   two ragged lines and move the card title with it, which changes the layout at
   the exact width where there is least room to spare. Scrolling keeps the
   control one row at every width - the same thing `.switcher__track` does, and
   for the same reason - and the pill inside it is positioned from `offsetLeft`,
   which is measured against the scrolling content rather than the viewport, so
   it stays under its button as the row moves.

   Scoped to the metric switch. The range filter (7d/30d/90d/12m) is four short
   labels that fit at any width this page is read at, and giving it a scroller
   it never uses would only put a scroll container around it for nothing. */
@media (max-width: 720px) {
  #metric.segmented {
    display: flex;
    max-width: 100%;
    overflow-x: auto;
    scrollbar-width: none;
    touch-action: pan-x;
    /* The buttons are the content being scrolled, so they must not shrink to
       fit - that is the wrapping behaviour this exists to avoid. */
    justify-content: flex-start;
  }

  #metric.segmented::-webkit-scrollbar {
    display: none;
  }

  #metric.segmented button {
    flex-shrink: 0;
  }
}

/* --- Staff library and documents ------------------------------------------ */

/* A confirmation dialog holds one sentence and two buttons. At the shared
   560px it is mostly empty space, which reads as a dialog that failed to load
   its contents rather than as a question. */
.modal--narrow {
  max-width: 420px;
}

/* The destructive commit, in the palette the rest of the page is drawn in -
   there is no hue anywhere in this design, and introducing one here would make
   this the single coloured element on the page. Weight carries it instead: the
   danger variant is the only solid button drawn as an outline that fills on
   hover, so it reads as deliberate rather than as the default action. */
.solid-button--danger {
  background: transparent;
  border-color: var(--ink-700);
  color: var(--ink-900);
}

.solid-button--danger:hover {
  background: var(--ink-900);
  border-color: var(--ink-900);
  color: var(--surface);
}

/* The upload queue's per-file progress. A row is a table cell wide, so the bar
   sits under the filename rather than in a column of its own - a column would
   be empty for every row that is not currently uploading. */
.queue-progress {
  position: relative;
  height: 3px;
  margin-top: 6px;
  overflow: hidden;
  background: var(--border);
  border-radius: 2px;
}

.queue-progress__fill {
  position: absolute;
  inset: 0 auto 0 0;
  width: 0;
  background: var(--ink-900);
  transition: width 0.18s linear;
}

.queue-progress__pct {
  margin-left: 8px;
  font-size: 11px;
  font-variant-numeric: tabular-nums;
  color: var(--ink-400);
}

/* A finished or failed row, marked on the row itself rather than with a status
   column - the state is temporary and the column would outlive it. */
.queue-row--done .identity__name {
  color: var(--ink-400);
}

.queue-row--error .identity__name {
  text-decoration: line-through;
  text-decoration-color: var(--ink-400);
}

/* Real media in the preview pane. The demo drew a glyph because it had no file
   behind it; here there is one, so an image or a video shows itself and only
   the formats that cannot be rendered inline fall back to the type glyph. */
.modal__preview img,
.modal__preview video {
  max-width: 100%;
  max-height: 320px;
  border-radius: var(--radius);
  object-fit: contain;
}

.modal__preview audio {
  width: 100%;
}

.modal__preview pre {
  width: 100%;
  max-height: 320px;
  margin: 0;
  overflow: auto;
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  font-size: 12px;
  line-height: 1.55;
  text-align: left;
  white-space: pre-wrap;
  word-break: break-word;
  color: var(--ink-600);
}

/* A card face that has a real thumbnail behind it. The image fills the pane the
   glyph used to sit in, so a grid of photographs reads as its contents rather
   than as a grid of identical image icons. */
.file-card__thumb {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* --- People picker (Documents, Sharing) ------------------------------------
   A search field with a results list that overlays the content below it. The
   list is positioned rather than in flow so that opening it does not push the
   rest of the card down and then pull it back up on every keystroke - a card
   that changes height as you type is the thing that makes a picker feel
   unstable. */

.combo {
  position: relative;
}

.combo__menu {
  position: absolute;
  z-index: 5;
  top: calc(100% + 4px);
  left: 0;
  right: 0;
  max-height: 232px;
  overflow-y: auto;
  padding: 4px;
  background: var(--surface);
  border: 1px solid var(--border-strong);
  border-radius: var(--radius);
  box-shadow: var(--shadow);
}

.combo__option {
  display: flex;
  align-items: center;
  gap: 9px;
  width: 100%;
  padding: 7px 8px;
  border: 0;
  border-radius: 4.5px;
  background: transparent;
  font: inherit;
  text-align: left;
  color: var(--ink-900);
  cursor: pointer;
}

.combo__option:hover,
.combo__option:focus-visible,
.combo__option[aria-selected='true'] {
  outline: none;
  background: var(--ink-100);
}

.combo__option-name {
  display: block;
  font-size: 13px;
  font-weight: 550;
}

.combo__option-meta {
  display: block;
  margin-top: 1px;
  font-size: 11px;
  color: var(--ink-400);
}

.combo__empty {
  padding: 9px 8px;
  font-size: 12px;
  color: var(--ink-400);
}

/* The people already chosen, under the field they were chosen in. Wraps rather
   than scrolls: an audience is read as a set, and a row that has to be scrolled
   sideways hides part of who a file is about to go to. */
.chip-row {
  display: flex;
  flex-wrap: wrap;
  gap: 5px;
  margin-top: 8px;
}

.chip-row:empty {
  display: none;
}

.chip {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 3px 5px 3px 9px;
  border-radius: 999px;
  background: var(--ink-150);
  color: var(--ink-800);
  font-size: 11px;
  font-weight: 550;
  white-space: nowrap;
}

.chip button {
  display: grid;
  place-items: center;
  width: 15px;
  height: 15px;
  padding: 0;
  border: 0;
  border-radius: 999px;
  background: transparent;
  color: var(--ink-500);
  font-size: 13px;
  line-height: 1;
  cursor: pointer;
}

.chip button:hover {
  background: var(--ink-300);
  color: var(--ink-900);
}

/* The two keys named in the tag hint. Drawn as a key rather than as code: this
   is an instruction to press something, not a snippet to read. */
kbd {
  padding: 1px 5px;
  border: 1px solid var(--border-strong);
  border-bottom-width: 2px;
  border-radius: 3.5px;
  background: var(--surface);
  font-family: inherit;
  font-size: 11px;
  font-weight: 600;
  color: var(--ink-700);
}

/* --- Password strength ----------------------------------------------------
   A bar and a word. The bar is the fast read - how far along the rule list the
   password has got - and the word is what makes it unambiguous, because a bar
   alone leaves the reader guessing where the thresholds are.

   Monochrome, like everything else here: strength is shown by how much of the
   track is filled and how dark the fill is, never by a red-amber-green ramp
   the palette does not contain and a colourblind reader could not separate. */

.strength {
  display: flex;
  align-items: center;
  gap: 9px;
  margin-top: 8px;
}

.strength__bar {
  flex: 1;
  height: 4px;
  border-radius: 999px;
  background: var(--ink-150);
  overflow: hidden;
}

.strength__fill {
  width: 0;
  height: 100%;
  border-radius: 999px;
  background: var(--ink-400);
  transition: width 0.22s cubic-bezier(0.22, 0.61, 0.36, 1), background-color 0.22s linear;
}

.strength[data-level='fair'] .strength__fill {
  background: var(--ink-600);
}

.strength[data-level='strong'] .strength__fill {
  background: var(--ink-900);
}

.strength__label {
  flex: none;
  min-width: 42px;
  font-size: 11.5px;
  font-weight: 600;
  color: var(--ink-500);
}

.strength[data-level='strong'] .strength__label {
  color: var(--ink-900);
}

/* --- Rule list ------------------------------------------------------------
   The policy the server actually enforces, shown while the reader types rather
   than reported after a rejected submit. Each rule ticks itself off as it is
   met, so the list doubles as the progress the strength bar summarises. */

.rules {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(155px, 1fr));
  gap: 5px 14px;
  margin: 14px 0 0;
  padding: 13px 0 0;
  border-top: 1px solid var(--border);
  list-style: none;
}

.rule {
  display: flex;
  align-items: center;
  gap: 7px;
  font-size: 11.5px;
  line-height: 1.5;
  color: var(--ink-500);
  transition: color 0.16s linear;
}

/* An empty ring until the rule is met, a filled disc with a tick after. The
   shape changes as well as the fill, so the state survives being read in
   greyscale or by someone who cannot separate the two tones. */
.rule__mark {
  display: grid;
  place-items: center;
  flex-shrink: 0;
  width: 13px;
  height: 13px;
  border: 1.5px solid var(--ink-300);
  border-radius: 999px;
  transition: background-color 0.16s linear, border-color 0.16s linear;
}

.rule__mark::before {
  content: '';
  width: 5px;
  height: 2.5px;
  border: solid var(--surface);
  border-width: 0 0 1.6px 1.6px;
  transform: rotate(-45deg) translate(0.5px, -0.5px);
  opacity: 0;
  transition: opacity 0.16s linear;
}

.rule.is-met {
  color: var(--ink-900);
  font-weight: 500;
}

.rule.is-met .rule__mark {
  background: var(--ink-900);
  border-color: var(--ink-900);
}

.rule.is-met .rule__mark::before {
  opacity: 1;
}

/* --- Callout --------------------------------------------------------------
   A block of consequence inside a dialog - the thing the reader has to know
   before the button below it is safe to press. Flagged structurally with a
   heavy leading rule, the same device `.setting--danger` uses, because the
   palette has no alarm colour to spend here. */

.callout {
  margin: 0 0 16px;
  padding: 11px 14px;
  border-radius: var(--radius);
  background: var(--ink-100);
  font-size: 12.5px;
  line-height: 1.55;
  color: var(--ink-700);
}

.callout--danger {
  box-shadow: inset 2.5px 0 0 var(--ink-900);
  color: var(--ink-900);
  font-weight: 500;
}

/* --- Profile hero avatar --------------------------------------------------
   The avatar is the photo control as well as the identity mark. Making the
   picture itself the button is what a reader tries first, and it saves the
   card from carrying a permanent "change photo" button next to something that
   is changed perhaps twice in an account's life.

   The base `.hero-id__avatar` rules above already set the size, radius and the
   ink ground the initials sit on; these add only what turning it into a button
   requires. */

button.hero-id__avatar {
  position: relative;
  padding: 0;
  border: 0;
  overflow: hidden;
  cursor: pointer;
  /* Only the family is inherited. `font: inherit` - which this used to say -
     is a shorthand, so it also reset the 23px size and 600 weight the base
     rule sets for the initials, dropping them to 14px body text. */
  font-family: inherit;
  /* The box is fixed, whatever is put inside it. A grid item sizes to its own
     intrinsic content by default, so a photo that arrives before its `width`
     applies - or any future content larger than the box - would otherwise
     stretch the avatar and shove the name beside it out of position. These
     three lines make 68px the answer in every direction. */
  flex: none;
  min-width: 0;
  box-sizing: border-box;
  /* The ink ground is what the initials are drawn against, and is also what
     shows through while a photo is still loading. */
  color: var(--surface);
  -webkit-appearance: none;
  appearance: none;
}

/* Absolutely positioned, so the image is taken out of the grid's sizing
   entirely: it fills the button's padding box and can never contribute an
   intrinsic width back to it, however large the file behind it is. */
.hero-id__photo {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  max-width: 100%;
  max-height: 100%;
  /* Cover, so a portrait or landscape upload fills the square by cropping
     rather than by distorting the face in it. */
  object-fit: cover;
  /* Centre the crop: a face is far more often in the middle of the frame than
     at its top-left, which is where `cover` would otherwise anchor. */
  object-position: center;
  display: block;
  border-radius: inherit;
}

/* The initials sit in the grid centre, and must never widen the box either -
   a long pair of glyphs in a wide font would otherwise push it out. */
.hero-id__initials {
  min-width: 0;
  font-size: 23px;
  font-weight: 600;
  letter-spacing: 0.02em;
  line-height: 1;
}

/* The camera badge. Off the card until the control is hovered or focused, so
   the resting state is a clean portrait and the affordance appears exactly
   when the reader is reaching for it. */
.hero-id__camera {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  background: rgba(23, 21, 18, 0.55);
  color: #fdfcfa;
  opacity: 0;
  transition: opacity 0.16s linear;
}

button.hero-id__avatar:hover .hero-id__camera,
button.hero-id__avatar:focus-visible .hero-id__camera {
  opacity: 1;
}

button.hero-id__avatar:focus-visible {
  outline: 2px solid var(--ink-900);
  outline-offset: 2px;
}

/* While an upload is in flight. The control is inert and says so, rather than
   accepting a second file that would race the first. */
button.hero-id__avatar[aria-busy='true'] {
  cursor: progress;
}

button.hero-id__avatar[aria-busy='true'] .hero-id__camera {
  opacity: 1;
  animation: avatar-busy 1.1s ease-in-out infinite;
}

@keyframes avatar-busy {
  50% {
    opacity: 0.62;
  }
}

@media (prefers-reduced-motion: reduce) {
  button.hero-id__avatar[aria-busy='true'] .hero-id__camera {
    animation: none;
  }
}

/* --- Avatar boxes hold their size ------------------------------------------
   Every avatar in the workspace is a fixed square with either two initials or
   a photo in it. Both the topbar control and the menu card are grid boxes, and
   a grid item sizes to its intrinsic content - so an `img` dropped into one
   (a Google account picture, or an uploaded photo on some future page) sizes
   the box to the file rather than the other way round, and the name beside it
   is pushed out of position.

   Declaring it once here means no avatar can be broken by what is put in it,
   rather than each call site having to remember to constrain its own image. */

.profile__avatar {
  flex: none;
  min-width: 0;
  overflow: hidden;
}

.profile__avatar > img,
.hero-id__avatar > img {
  width: 100%;
  height: 100%;
  max-width: 100%;
  max-height: 100%;
  object-fit: cover;
  object-position: center;
  display: block;
  border-radius: inherit;
}

/* --- Other people's portraits, in the lists that name them -----------------

   Every list in this workspace draws members - the member table, the prospect
   list, offer tracking, the dashboard feeds, the notification feed - and each
   one drew the same black disc with initials. Two "AL"s in a column tell a
   reader nothing; the portrait the account already has is the fastest way to
   tell one person from another.

   The picture is laid over the disc rather than swapped in for it, so the
   initials underneath are the fallback that is already on screen. A portrait
   that fails to load is removed by the shell's delegated `error` listener and
   what is left is exactly the disc that would have been drawn anyway - never
   an empty circle, never a broken-image glyph.

   Three avatar classes, one treatment: `.identity__avatar` in tables,
   `.row-mark--avatar` in the dashboard feeds, `.feed__avatar` in
   notifications. Each keeps its own size, radius and fallback colour; only
   what sits inside changes. That is why this rule sets no size of its own. */

/* Keyed on `[data-avatar-photo]`, so these rules only ever apply to a disc
   that is actually holding a portrait.

   That matters for one call site in particular: the store's product table puts
   `.row-mark--avatar` on an `<img>` directly rather than on a wrapper around
   one, because there the mark IS the product photo. Positioning and clipping
   every element carrying the class would reach that image too, for no reason.

   The attribute rather than `:has(> .avatar-photo)` - which would select the
   same elements - because it degrades in the right direction. It is plain
   attribute matching that every browser resolves, and it is already the hook
   the header avatar uses for the same job. A browser without `:has()` would
   drop the clipping and let a portrait overflow its disc; there is no such
   failure mode here. */
.identity__avatar[data-avatar-photo],
.row-mark--avatar[data-avatar-photo],
.student-modal__avatar[data-avatar-photo],
.feed__avatar[data-avatar-photo] {
  position: relative;
  overflow: hidden;

  /* `--ink-900` is the plate the initials are written on, and nothing else.
     Behind a portrait it shows as a dark ring wherever the picture does not
     quite fill the box, and straight through any image with an alpha channel.
     Swapped for a light tint while a picture is present, matching what the
     header avatar already does. */
  background: var(--ink-100);

  /* The initials are a bare text node under the image. Made transparent
     rather than removed, so they are still there to reveal the moment the
     picture fails and the attribute comes back off. */
  color: transparent;
}

.identity__avatar > .avatar-photo,
.row-mark--avatar > .avatar-photo,
.student-modal__avatar > .avatar-photo,
.feed__avatar > .avatar-photo {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: center;
  display: block;
  border-radius: inherit;
}

/* The quiet variant marks a member who is invited or suspended, and that is
   said by holding the identity at a lower weight. A full-colour portrait would
   undo it, so the picture is dimmed to keep the row reading as pending. */
.identity__avatar--quiet[data-avatar-photo] {
  background: var(--ink-200);
}

.identity__avatar--quiet > .avatar-photo {
  opacity: 0.55;
}

/* --- Notifications --------------------------------------------------------
   The notifications page used to carry its own stylesheet, a fork of this one
   that had drifted in 25 shared selectors and opted out of view transitions
   entirely - which is why that page, alone in the workspace, flashed white and
   resized on the way in while every other page held still.

   Consolidating it here is what fixes that permanently. Two sheets describing
   the same shell is a bug that regrows every time one of them is edited, and
   the drift was already visible: a different badge padding, a different group
   label padding, an `.error-banner` that had become a flex row on one page and
   not the other.

   What follows is the part of that sheet that was genuinely about
   notifications rather than about the shell. The rules are appended rather
   than merged into the blocks above so the provenance stays legible, and
   because a later rule of equal specificity is what makes the few deliberate
   overrides below land.
   ========================================================================== */

/* The spinning half-ring a control shows while its request is in flight. The
   button keeps its label and its box, so nothing around it moves - only the
   leading glyph changes into this. */
.spinner {
  display: inline-block;
  flex: none;
  width: 13px;
  height: 13px;
  border: 2px solid currentColor;
  border-right-color: transparent;
  border-radius: 50%;
  animation: spin 0.7s linear infinite;
}

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

/* Slowed rather than stopped: a spinner that does not spin reads as a
   control that has hung, which is the opposite of what it is there to say. */
@media (prefers-reduced-motion: reduce) {
  .spinner {
    animation-duration: 1.6s;
  }
}

/* The title's own words, kept to one line with an ellipsis so a long
   server-generated subject cannot push the unread dot and the timestamp out of
   alignment with the rows above and below it. */
.feed__title-text {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* Two lines of the message, then an ellipsis - the full text is one click
   away in the modal, and a feed row that grows to fit whatever the server
   sent stops being a scannable list. */
.feed__meta {
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
  overflow-wrap: anywhere;
}

/* A required action is the one thing on this page the reader cannot clear by
   reading it, and nothing else. The old design shouted this in red; here it is
   a ringed pill at the end of the title, which is the same emphasis every
   other non-negotiable in this system gets. */
.feed__required {
  flex: none;
  padding: 1px 7px;
  border: 1px solid var(--ink-700);
  border-radius: 999px;
  font-size: 10px;
  font-weight: 650;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--ink-800);
}

.feed__mark--file {
  color: var(--ink-700);
}

.feed__mark--offer {
  color: var(--ink-800);
}

/* The one type that is a demand rather than a report, and still the strongest
   mark on the page - now by being the darkest and heaviest-drawn icon rather
   than by sitting on a filled ink disc. The stroke is thickened as well as
   darkened, because with no disc behind it colour alone is a thinner signal
   than it was. */
.feed__mark--alert {
  color: var(--ink-900);
}

.feed__mark--alert svg {
  stroke-width: 2.4;
}

.modal__notif-mark--file {
  background: color-mix(in oklab, var(--ink-600) 15%, var(--surface));
  color: var(--ink-700);
}

.modal__notif-mark--offer {
  background: color-mix(in oklab, var(--ink-800) 13%, var(--surface));
  color: var(--ink-800);
}

.modal__notif-mark--alert {
  background: var(--ink-900);
  color: var(--surface);
}

/* Server-generated messages carry raw URLs (offer links, most notably), and
   a long unbroken one would otherwise widen the modal past the viewport. The
   pre-wrap keeps the line breaks the sender actually typed. */
.modal__description--top {
  white-space: pre-wrap;
  overflow-wrap: anywhere;
}

.modal__fact-value {
  overflow-wrap: anywhere;
}

/* The facts block is a section of the modal here rather than a bare grid, so
   it takes the rule that separates it from the description above it. */
.modal__facts {
  margin-top: 16px;
  padding-top: 14px;
  border-top: 1px solid var(--border);
}

@media (max-width: 560px) {
  .modal__facts {
    grid-template-columns: minmax(0, 1fr);
  }

  .modal__foot {
    flex-wrap: wrap;
  }
}

/* The error banner on this page carries a retry control beside its message,
   so it is a row with the two pushed apart rather than a plain block. */
.error-banner {
  display: flex;
  align-items: center;
  justify-content: space-between;
  flex-wrap: wrap;
  gap: 14px;
}

/* A ghost button that fades while its request is in flight. */
.ghost-button {
  transition: background 0.12s linear, border-color 0.12s linear, opacity 0.12s linear;
}

/* Carried over from the notifications sheet during consolidation: a long
   address in the account menu would otherwise widen the card rather than
   ellipsing, and a pressed segment showing a text cursor implies a control
   that is still waiting to be clicked. */
.profile__mail {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.profile__card {
  min-width: 0;
}

.segmented button[aria-pressed='true'] {
  cursor: default;
}

/* The workspace switch is no longer a control in the topbar.

   It was a two-segment Staff/Fitness pill beside the account menu, and it
   needed a block of CSS here to reserve its width while the server answered
   whether this account may use it at all - otherwise it appeared from nothing
   after first paint and shoved the top-right cluster sideways.

   It is a "View as ..." row inside the account menu now, which removes that
   problem rather than compensating for it: the menu is closed at first paint,
   so an item revealed inside it moves nothing on the page, and there is no
   space to hold open. The reservation rules that used to live here have gone
   with the control they described.

   `.segmented--role` itself is kept further up - the class is still the right
   one if a two-way pill is ever wanted again - but nothing carries it today. */

/* --- The avatar holds one box, from first paint ----------------------------
   The account disc is the one piece of chrome whose contents are not known at
   parse time: the name and role come from localStorage synchronously, but the
   photo is a file that has to be fetched. Left alone that produced the blink -
   an empty disc, then a dark disc, then a portrait, each a different thing in
   the same place.

   Three states, one box. `data-avatar-pending` is in the markup and shimmers;
   the shell drops it the moment identity resolves, leaving either initials on
   the ink disc or a photo. The box is the same 30px (38px in the menu card)
   throughout, so nothing beside it ever moves. */

.profile__avatar[data-avatar-pending],
.hero-id__avatar[data-avatar-pending] {
  /* The initials are in the markup as a fallback for a page whose script never
     runs; while the skeleton is up they would show through it. */
  color: transparent;
  background: linear-gradient(
      100deg,
      var(--ink-100) 32%,
      var(--ink-150) 50%,
      var(--ink-100) 68%
    )
    0 0 / 250% 100%;
  animation: sk-shimmer 1.9s ease-in-out infinite;
}

@media (prefers-reduced-motion: reduce) {
  .profile__avatar[data-avatar-pending],
  .hero-id__avatar[data-avatar-pending] {
    animation: none;
  }
}

/* --- A photo is not painted on a black plate ------------------------------
   `--ink-900` is the backdrop the initials are written on, and nothing else.
   Behind a photo it was doing one of two harmful things: showing as a black
   ring wherever a portrait did not quite fill its circle, and showing straight
   through any picture with an alpha channel - which is most uploads that have
   been through a background remover.

   Set only once the image has actually decoded, so the disc never flashes dark
   on its way to being a picture. */
.profile__avatar[data-avatar-photo],
.hero-id__avatar[data-avatar-photo] {
  background: var(--ink-100);
}

/* Only the topbar disc needs its text hidden: it holds the initials as a bare
   text node that the photo is appended beside, so the two would otherwise
   stack. The hero avatar keeps its initials in their own element and hides
   that with [hidden], and blanking colour here would take the camera badge's
   label with it. */
.profile__avatar[data-avatar-photo] {
  color: transparent;
}

/* --- The notification badge does not move the nav --------------------------
   `notificationcheck.js` polls after first paint, so the badge's count arrives
   a beat behind the page. As an in-flow flex item it was 17px of pill plus the
   row's 7px gap, so the moment it appeared the Notifications link grew from
   123px to 147px - which widened the centred track by 24px and slid every link
   in it 12px to the left. That is the shift: a page that looks settled, then
   jumps once the count lands.

   There is a cache in `notificationcheck.js` that paints the last known count
   at parse time, and it removes the jump on a warm load. It cannot remove it on
   a cold one, on the first ever load, or whenever the count changes - the cache
   has nothing to paint, or paints the wrong width.

   So the badge is taken out of flow entirely. Pinned to the link's top-right
   corner it contributes no width at any count, and the nav is the same size
   whether it shows nothing, 3 or 99+. `.switcher__link` is already
   `position: relative`, so this needs no other change.

   The link keeps a little right padding to sit under the badge - applied to
   whichever link actually carries one, and permanently, so it does not depend
   on whether the badge is currently showing. That is the one piece of space
   genuinely reserved, and it is reserved from first paint.

   Selected structurally rather than by href. An earlier version named
   `/staff-notifications` directly, which silently failed in the fitness
   workspace: that link is `/fitness-staff-notifications`, so it got the
   absolute positioning from the rule below but none of the padding here, and
   the badge sat on top of the word instead of beside it. Matching on "the link
   that contains a badge" is the thing actually being described, and it holds
   for both workspaces and any future one. */
.switcher__link:has(> .switcher__badge) {
  padding-right: 20px;
}

/* `:has()` is in every browser this shell targets (Chrome 105+, Safari 15.4+),
   but a browser without it would put the badge back over the label rather than
   merely losing a refinement - so the two known links are named as a fallback
   that costs nothing where `:has()` already matched. */
.switcher__link[href$="-notifications"] {
  padding-right: 20px;
}

.switcher__badge {
  position: absolute;
  /* Held inside the link box rather than hung off its corner: the track clips
     its own overflow (it scrolls horizontally), so a badge sitting proud of
     the top edge has its ring shaved off - visible at the widest count, where
     99+ is tall enough to reach it. */
  top: 3px;
  right: 3px;
  /* Above the raised pill on the current page, which is z-index 0 in the
     track's own stacking context. */
  z-index: 2;
  /* The count is a compact marker here rather than a full pill: out of flow it
     no longer has the row's gap holding it clear of the word, so it is sized to
     sit in the corner without crowding the label under it. */
  min-width: 15px;
  height: 14px;
  padding: 0 4px;
  font-size: 9.5px;
  /* A hairline of the surface behind it, so the pill reads as sitting on the
     nav rather than being cut out of the label it overlaps. */
  box-shadow: 0 0 0 1.5px var(--ink-100);
}

/* The badge stays dark on the current page too.

   Two earlier rules invert it to a white ground there, which was right when it
   sat inside the raised pill: a dark chip on a white pill needed to become a
   white chip to stay legible against the inverted ground. Pinned to the corner
   it no longer sits inside anything - it straddles the pill's top-right edge,
   half on white and half on the track - so an inverted badge reads as bare
   digits floating on the pill with no chip at all, which is what it looked
   like. Dark everywhere is the only treatment that works on both grounds, and
   it is also the same marker the reader already knows from every other page.

   Forced, because the rules it overrides carry their own to beat the inline
   styles notificationcheck.js stamps on. */
.switcher__link[aria-current='page'] .switcher__badge,
.switcher__link[aria-current='page'] .switcher__badge.badge-active {
  background: var(--ink-900) !important;
  color: var(--surface) !important;
  /* Ringed in the pill's own surface, so the chip is cleanly separated from
     the white pill it overlaps. */
  box-shadow: 0 0 0 1.5px var(--surface);
}

/* --- The account control is one fixed size ---------------------------------
   The name is the only thing in the topbar whose width is not knowable from the
   markup, and it arrives after first paint. The placeholder that stood in for
   it - the literal word "Loading" - measured 47px against a real name's 64px,
   so the control grew by 17px the moment identity landed and shoved the whole
   top-right cluster sideways. Every page did it, on every load.

   `identity-boot.js` now paints the stored name at parse time, which removes
   the swap on any load where the reader has signed in before. This is what
   covers the rest: a first-ever load with nothing stored, an unusually long
   name, and the frame between the two on a slow parse.

   The block is given a fixed width, so a name of any length lands in a box that
   was already exactly that size. Long names ellipse rather than push - which is
   also the right answer for a name that would otherwise crowd the role switch
   next to it. `.profile__id` is already a flex column with `min-width: 0`, so
   the children only need somewhere to clip to. */
.profile__id {
  width: 92px;
  flex: none;
}

.profile__name,
.profile__role {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* The menu card underneath has room to breathe, so a name is shown in full
   there - the trigger is the only place it has to be trimmed. The role beside
   it takes the same reset: it is the same pair of lines, and a long role label
   should no more be clipped in the card than a long name. */
.profile__card .profile__name,
.profile__card .profile__role {
  overflow: visible;
  text-overflow: clip;
  white-space: normal;
}

/* At 860px the block is already `display: none` (see the rule above this
   file's tail), so the reserved width is moot there - reset anyway, so that a
   future change re-showing the block on a narrow screen does not silently
   inherit a 92px reservation the topbar has no room for. */
@media (max-width: 860px) {
  .profile__id {
    width: auto;
  }
}

/* ==========================================================================
   Fitness cross-links
   ========================================================================== */

/* The strip tying the three fitness pages under the staff workspace together.
   The staff section bar carries no Fitness entry, so without this the schedule,
   applicants and tracking pages would be reachable from each other only by
   typing a URL.

   It borrows the tab row's look - a band of words on a rule, the current one
   underlined - because that is what the reader has already learned means "these
   belong together". It is deliberately not `.tabs`, though: these are links to
   other pages, not tabs over panels on this one, so they carry no tab
   semantics, and `createTabs` (which binds the first `.tabs__list` in the
   document) cannot mistake them for the real tab strip below. */
.subnav {
  position: relative;
  display: flex;
  gap: 2px;
  /* Scrolls rather than wrapping when three labels outrun a narrow column, the
     same way the section bar does. Horizontal only - `overflow-x: auto` would
     otherwise promote the other axis and let a trackpad nudge the row. */
  overflow-x: auto;
  overflow-y: hidden;
  scrollbar-width: none;
  margin-bottom: var(--tabs-gap);
  border-bottom: 1px solid var(--border-strong);
}

.subnav::-webkit-scrollbar {
  display: none;
}

.subnav__link {
  position: relative;
  display: inline-flex;
  align-items: center;
  gap: 8px;
  flex-shrink: 0;
  /* Matches `.tabs__tab`: the bottom padding gives back what the 3px marker
     takes, so both rows stand the same height. */
  padding: 11px 14px 10px;
  font-size: 13.5px;
  font-weight: 500;
  color: var(--ink-500);
  text-decoration: none;
  white-space: nowrap;
  transition: color 0.14s linear;
}

.subnav__link svg {
  flex-shrink: 0;
  opacity: 0.75;
  transition: opacity 0.14s linear;
}

.subnav__link:hover {
  color: var(--ink-900);
}

.subnav__link:hover svg {
  opacity: 1;
}

/* The page being read. Weight carries the state as well as the mark, so it
   survives being looked at quickly - and reads without relying on colour. */
.subnav__link[aria-current='page'] {
  color: var(--ink-900);
  font-weight: 620;
}

.subnav__link[aria-current='page'] svg {
  opacity: 1;
}

/* The mark underlines the label alone rather than the whole padded box, so it
   reads as underlining a word. The icon and its gap are both fixed widths, so
   starting past them lands the mark on the first glyph without CSS ever having
   to measure a text node. */
.subnav__link[aria-current='page']::after {
  content: '';
  position: absolute;
  left: calc(14px + 14px + 8px);
  right: 14px;
  bottom: 0;
  height: 3px;
  background: var(--ink-900);
  border-radius: 2px 2px 0 0;
}

.subnav__link:focus-visible {
  outline: 2px solid var(--ink-900);
  outline-offset: -3px;
  border-radius: 4px;
}

/* ==========================================================================
   Enrolment offers - the student-facing offer sheet

   The offer used to be its own emailed page. It is now a dialog over the
   offers list, so a reader who backs out of signing lands back on the list
   they came from rather than on whatever the browser remembers.

   Wider than the default modal because the contract step renders a real
   document: at 560px the clause text sets to about 40 characters a line,
   which is unreadable for something a reader is being asked to sign.
   ========================================================================== */

.modal--wide {
  max-width: 760px;
  max-height: min(88vh, 900px);
}

/* One offer in the list.
   A row rather than a card: the list is nearly always one item long, and a
   card around a single row reads as a container that failed to fill. */
.offer-row {
  display: flex;
  align-items: center;
  gap: 16px;
  padding: 16px 18px;
  border-bottom: 1px solid var(--border);
}

.offer-row:last-child {
  border-bottom: 0;
}

.offer-row__main {
  flex: 1;
  min-width: 0;
}

/* One line each, trimmed rather than wrapped.

   An offer title is composed by a staff member and has no length limit, and a
   title that wrapped made this row - and therefore the card around it - a
   different height for every offer and at every window width. The row holds
   its shape instead and a long title ends in an ellipsis; the full text is on
   the offer itself, one press away, which is where a reader who needs all of
   it is going anyway. `min-width: 0` on `.offer-row__main` above is what lets
   a flex child actually shrink far enough for this to take effect. */
.offer-row__title,
.offer-row__meta {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.offer-row__title {
  font-size: 14px;
  font-weight: 650;
  color: var(--ink-900);
  letter-spacing: -0.01em;
}

.offer-row__meta {
  margin-top: 3px;
  font-size: 12.5px;
  color: var(--ink-500);
}

/* The price is the figure the reader is looking for, so it is set at the
   weight of a heading and aligned right where the eye lands after the name. */
.offer-row__price {
  flex: none;
  font-size: 15px;
  font-weight: 680;
  color: var(--ink-900);
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}

.offer-row__price small {
  font-size: 11.5px;
  font-weight: 550;
  color: var(--ink-500);
}

.offer-row__actions {
  flex: none;
  display: flex;
  align-items: center;
  gap: 8px;
}

/* --- The offers list, waiting --------------------------------------------

   The offers list used to wait behind the pulsing brand mark under a 160px
   floor. Two things were wrong with that. The mark declines to guess a shape,
   which is right for a chart and wrong here - this list is always offer rows,
   and a row's shape is fixed by `.offer-row` above whatever the row turns out
   to say. And 160px is not the height of what arrives: one offer row is
   about 76px, so the card stood tall while it loaded and then collapsed by
   half the moment the row landed. That collapse is the resize the reader sees.

   `.sk-offer` is drawn to `.offer-row`'s own box - the same 16px/18px padding,
   the same hairline between rows, blocks at the sizes the title, the meta, the
   price, the pill and the button actually occupy - so the card is the height
   of its content before its content exists, and nothing moves when the real
   rows replace it. */

.sk-offer {
  display: flex;
  align-items: center;
  gap: 16px;
  padding: 16px 18px;
  border-bottom: 1px solid var(--border);
}

.sk-offer:last-child {
  border-bottom: 0;
}

/* Title and meta, stacked, taking the width the offer name leaves.

   The heights are the two real lines' own, measured rather than guessed:
   `.offer-row__title` occupies a 21px line box and `.offer-row__meta` an 18.8px
   one, with that row's 3px between them - 42.8px of stack. Blocks drawn at the
   height of the *ink* instead of the line box made the skeleton row 69px
   against the real row's 76px, so the card still stepped when the rows landed;
   smaller than the jump the brand mark caused, but the same fault.

   So the two bars are the line boxes, and `--sk-ink` insets the visible bar
   inside each one the way a glyph sits inside its line - the bar is padded, not
   shortened, and the stack still totals what the text will. */
.sk-offer__main {
  flex: 1 1 auto;
  min-width: 0;
  display: grid;
  gap: 3px;
}

.sk-offer__main .sk-block:first-child {
  height: 21px;
  width: var(--sk-title-w, 46%);
  --sk-ink: 3px;
}

.sk-offer__main .sk-block:last-child {
  height: 18.8px;
  width: var(--sk-meta-w, 62%);
  --sk-ink: 4px;
}

/* The bar inside its line box. `background-clip: content-box` keeps the
   shimmer inside the padding, so the block reserves a full line of height
   while painting only the few pixels of it a line of text would darken. */
.sk-offer__main .sk-block {
  padding: var(--sk-ink, 0) 0;
  background-clip: content-box;
}

/* The price, the status pill and the action button, in that order - the three
   fixed-width things on the right of a real row, at their real widths. */
.sk-offer__price {
  flex: none;
  height: 14px;
  width: 74px;
}

.sk-offer__pill {
  flex: none;
  height: 25.3px;
  width: 84px;
  border-radius: 999px;
}

.sk-offer__action {
  flex: none;
  height: 37.5px;
  width: 104px;
  border-radius: var(--radius-sm);
}

/* Narrow, at `.offer-row`'s own breakpoint and wrapping exactly the way it
   does: the name takes the first line, the price and pill sit on the second,
   and the button takes a full-width third. Matching the breakpoint matters as
   much as matching the rule - a skeleton that held one line while the real row
   took three would be the resize this whole block exists to remove, just moved
   to a window size. */
@media (max-width: 680px) {
  .sk-offer {
    flex-wrap: wrap;
  }

  .sk-offer__main {
    flex-basis: 100%;
  }

  .sk-offer__action {
    width: 100%;
  }
}

/* --- Inside the sheet ---------------------------------------------------- */

/* The step body. Padding matches `.modal__body`; the gap is what separates
   the stacked blocks each step is built from. */
.offer-step {
  display: flex;
  flex-direction: column;
  gap: 18px;
}

/* Summary of what is being offered, shown on the review step and again above
   the payment fields so the amount is never off-screen when card details are
   being typed. */
.offer-facts {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
  gap: 1px;
  background: var(--border);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  overflow: hidden;
}

.offer-fact {
  padding: 11px 13px;
  background: var(--surface);
}

.offer-fact__label {
  font-size: 10.5px;
  font-weight: 620;
  letter-spacing: 0.07em;
  text-transform: uppercase;
  color: var(--ink-500);
}

.offer-fact__value {
  margin-top: 3px;
  font-size: 13.5px;
  font-weight: 600;
  color: var(--ink-900);
  font-variant-numeric: tabular-nums;
}

/* ==========================================================================
   The review step

   The one screen where a reader decides. It is built around the two questions
   they actually have - what does it cost, and what do I get - rather than
   around the columns of the row behind it.

   It was a flat six-cell grid of label/value pairs with a bare browser list
   under it, which gave the monthly rate exactly as much weight as the internal
   reference number and left the list as the only unstyled element in the
   system. The price now leads at display size, the supporting facts step back
   into a quiet definition list, and the whole thing is two columns wide enough
   to use the room the page already has.
   ========================================================================== */

.offer-review {
  display: grid;
  grid-template-columns: minmax(0, 1.35fr) minmax(0, 1fr);
  gap: 22px;
  align-items: start;
}

.offer-review__lead,
.offer-review__side {
  display: grid;
  gap: 18px;
  align-content: start;
}

/* --- The headline figure --- */

/* The rate, given the room a decision deserves. Bordered rather than filled:
   this design carries no hue, so emphasis is made with weight, size and a
   ruled edge instead of a coloured panel. */
.offer-price {
  padding: 16px 18px 17px;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--surface);
}

.offer-price__label {
  font-size: 10.5px;
  font-weight: 620;
  letter-spacing: 0.07em;
  text-transform: uppercase;
  color: var(--ink-500);
}

/* `baseline` so the unit sits on the figure's own baseline rather than being
   centred against a number more than twice its size. */
.offer-price__amount {
  display: flex;
  align-items: baseline;
  gap: 6px;
  margin-top: 7px;
}

.offer-price__value {
  font-size: 29px;
  font-weight: 680;
  letter-spacing: -0.025em;
  color: var(--ink-900);
  font-variant-numeric: tabular-nums;
  line-height: 1.05;
}

.offer-price__unit {
  font-size: 13px;
  font-weight: 550;
  color: var(--ink-500);
}

/* A free offer says so in words. A currency symbol against a zero reads as a
   price that failed to load, which is the last thing this panel should say. */
.offer-price__free {
  font-size: 23px;
  font-weight: 660;
  letter-spacing: -0.02em;
  color: var(--ink-900);
  line-height: 1.15;
}

.offer-price__note {
  margin-top: 6px;
  font-size: 12.5px;
  color: var(--ink-500);
}

/* --- Blocks and their titles --- */

.offer-block {
  display: grid;
  gap: 9px;
}

/* An `h3` rather than a styled div: these are real headings for the sections
   under them, and a screen reader moving by heading should find them. Set to
   the same size as the label above the price so the two read as peers. */
.offer-block__title {
  margin: 0;
  font-size: 10.5px;
  font-weight: 620;
  letter-spacing: 0.07em;
  text-transform: uppercase;
  color: var(--ink-500);
}

/* --- What is included --- */

.offer-includes {
  margin: 0;
  padding: 0;
  list-style: none;
  display: grid;
  gap: 8px;
}

.offer-includes__item {
  display: grid;
  grid-template-columns: 13px 1fr;
  gap: 9px;
  align-items: start;
  font-size: 13px;
  line-height: 1.55;
  color: var(--ink-700);
}

/* Nudged down onto the first line's optical centre - a mark aligned to the box
   top sits visibly high against text this size. */
.offer-includes__mark {
  margin-top: 3px;
  color: var(--ink-900);
}

/* --- The supporting facts --- */

/* Label left, value right, ruled between - the shape of a receipt, which is
   what this list is. Quiet by construction: nothing here competes with the
   figure above it. */
.offer-detail {
  margin: 0;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  overflow: hidden;
}

.offer-detail__row {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 14px;
  padding: 9px 13px;
  border-bottom: 1px solid var(--border);
}

.offer-detail__row:last-child {
  border-bottom: 0;
}

.offer-detail__label {
  flex: none;
  font-size: 12px;
  color: var(--ink-500);
}

.offer-detail__value {
  margin: 0;
  min-width: 0;
  text-align: right;
  font-size: 12.5px;
  font-weight: 580;
  color: var(--ink-900);
  font-variant-numeric: tabular-nums;
}

/* --- The note from the school --- */

/* Set apart by a rule on its edge rather than a box, so it reads as something
   written by a person rather than another field of the record. */
.offer-note {
  padding: 2px 0 2px 13px;
  border-left: 2px solid var(--ink-200);
}

.offer-note__label {
  font-size: 10.5px;
  font-weight: 620;
  letter-spacing: 0.07em;
  text-transform: uppercase;
  color: var(--ink-500);
}

.offer-note__text {
  margin: 5px 0 0;
  font-size: 13px;
  line-height: 1.62;
  color: var(--ink-700);
}

/* --- The upgrade notice --- */

/* Neutral information, not a failure. It used to borrow `.offer-error`, which
   marked "your plan changes over cleanly" with the same treatment as a declined
   card. */
.offer-notice {
  display: grid;
  grid-template-columns: 15px 1fr;
  gap: 10px;
  align-items: start;
  padding: 12px 14px;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--surface);
}

.offer-notice__mark {
  margin-top: 2px;
  color: var(--ink-700);
}

.offer-notice__text {
  margin: 0;
  font-size: 12.5px;
  line-height: 1.6;
  color: var(--ink-700);
}

/* One column once two would leave the detail list too narrow to keep its
   label and value on one line. The lead column keeps its order, so the price
   is still the first thing under the heading on a phone. */
@media (max-width: 860px) {
  .offer-review {
    grid-template-columns: minmax(0, 1fr);
    gap: 18px;
  }
}

/* --- The review step, waiting ----------------------------------------------

   Every measurement below is the real element's, so the panel is the height of
   its content before its content exists and nothing moves when the offer
   lands. Each rule sits beside the one it stands in for, which is what keeps
   the two from drifting apart the next time either is touched. */

.sk-rv {
  display: grid;
  grid-template-columns: minmax(0, 1.35fr) minmax(0, 1fr);
  gap: 22px;
  align-items: start;
}

.sk-rv__col {
  display: grid;
  gap: 18px;
  align-content: start;
}

/* Matches `.offer-price`: the same border, radius and 16/18/17 padding. */
.sk-rv__price {
  display: grid;
  padding: 16px 18px 17px;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--surface);
}

/* Measured, not derived: `.offer-price__label` renders a 15.8px line box, the
   amount row 31.5px and the note 18.8px, with the 7px and 6px the real rules
   put between them. Together with the panel's 16/17 padding that is the 114px
   `.offer-price` actually occupies. */
.sk-rv__price-label {
  height: 15.8px;
  width: 82px;
  margin-bottom: 7px;
}

.sk-rv__price-value {
  height: 31.5px;
  width: 168px;
}

.sk-rv__price-note {
  height: 18.8px;
  width: 63%;
  margin-top: 6px;
}

/* `.offer-block` is a 9px-gap grid of a title and its content. */
.sk-rv__block {
  display: grid;
  gap: 9px;
}

/* `.offer-block__title` and `.offer-price__label` are the same 10.5px
   uppercase line, which lays out at 15.8px. */
.sk-rv__title {
  height: 15.8px;
  width: 96px;
}

/* `.offer-includes` is an 8px-gap list; each item is 13px type on 1.55, so a
   little over 20px per line. */
.sk-rv__incs {
  margin: 0;
  padding: 0;
  list-style: none;
  display: grid;
  gap: 8px;
}

.sk-rv__inc {
  display: grid;
  grid-template-columns: 13px 1fr;
  gap: 9px;
  align-items: start;
}

.sk-rv__inc-mark {
  height: 13px;
  width: 13px;
  margin-top: 3px;
  border-radius: 3px;
}

.sk-rv__inc .sk-block:last-child {
  height: 20.1px;
}

/* `.offer-detail` and its rows: same border and radius, and each row is 9px
   padding above and below a 12.5px line. */
.sk-rv__rows {
  border: 1px solid var(--border);
  border-radius: var(--radius);
  overflow: hidden;
}

.sk-rv__row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 14px;
  padding: 9px 13px;
  border-bottom: 1px solid var(--border);
}

.sk-rv__row:last-child {
  border-bottom: 0;
}

/* A real `.offer-detail__row` measures 37.8px: 9px of padding above and below
   a 12.5px value whose line box is 19.8px. The bars are drawn at the height of
   that line box so the row lands on the same number rather than on the height
   of the ink inside it. */
.sk-rv__row .sk-block {
  height: 18.8px;
}

.sk-rv__row-label {
  flex: none;
  width: 58px;
}

/* `.offer-note`: a 2px rule on the left, 13px of inset, the uppercase label
   and two lines of 13px text on a 1.62 leading with 5px above them - 66.8px in
   total for the two-line note this stands in for. */
.sk-rv__note {
  display: grid;
  gap: 5px;
  padding: 2px 0 2px 13px;
  border-left: 2px solid var(--ink-200);
}

.sk-rv__note-line {
  height: 21px;
}

/* The label above the note lines takes the 5px grid gap, but the real block
   puts nothing between its own two lines - they are one paragraph. */
.sk-rv__note .sk-rv__note-line + .sk-rv__note-line {
  margin-top: -5px;
}

/* The two buttons under the columns. `.offer-actions` is right-aligned with a
   9px gap and 4px of padding above it, and its buttons stand 37.5px tall - the
   same box `.solid-button` makes in the offers list. */
.sk-rv__actions {
  display: flex;
  justify-content: flex-end;
  gap: 9px;
  padding-top: 4px;
}

.sk-rv__btn {
  height: 37.5px;
  border-radius: var(--radius-sm);
}

@media (max-width: 860px) {
  .sk-rv {
    grid-template-columns: minmax(0, 1fr);
    gap: 18px;
  }
}

/* Pushes what follows to the far end of a flex row. Used by the placeholder
   action rows, which mirror `.offer-actions__spacer`. */
.sk-rv__spacer {
  flex: 1 1 auto;
}

/* The payment step's action row holds only Back, at the left. */
.sk-rv__actions--start {
  justify-content: flex-start;
}

/* --- The agreement, waiting ---
   `.offer-contract` is a fixed 320px scroller with 16/18 padding, so the box
   is exact and only the run of text inside it is stood in for. */

/* Mirrors `.doc` and `.doc__sheet`: the desk, with a page-shaped sheet on it.
   Drawn as one element rather than two, since the placeholder has no content
   to hold a measure for - the sheet's width is set directly instead. */
.sk-doc {
  height: 418px;
  padding: 34px 40px 38px;
  max-width: 74ch;
  margin: 22px auto;
  border: 1px solid var(--border);
  border-radius: 3px;
  background: var(--surface);
  box-shadow: 0 1px 2px rgb(23 21 18 / 0.06), 0 6px 16px rgb(23 21 18 / 0.05);
  overflow: hidden;
}

/* The desk the sheet rests on, matching `.doc`'s own frame. */
.sk-doc-desk {
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--ink-100);
}

@media (max-width: 720px) {
  .sk-doc {
    margin: 0;
    padding: 24px 22px 26px;
    border: 0;
    border-radius: 0;
    box-shadow: none;
  }

  .sk-doc-desk {
    background: var(--surface);
  }
}

/* 13px text on a 1.65 leading is a 21.5px line, which is what each bar holds
   so the block fills the same number of lines the contract will. */
.sk-doc__line {
  display: block;
  height: 21.5px;
  margin-bottom: 3px;
}

/* The space a paragraph break leaves. */
.sk-doc__gap {
  display: block;
  height: 12px;
}

/* `.offer-consent` is a checkbox beside a line of text. */
/* Matches `.offer-consent`, which is now a bordered panel rather than a bare
   row of text. */
.sk-consent {
  display: flex;
  align-items: center;
  gap: 11px;
  padding: 12px 14px;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--page);
}

.sk-consent__box {
  flex: none;
  width: 16px;
  height: 16px;
  border-radius: 3px;
}

.sk-consent__text {
  height: 17px;
  width: 58%;
}

/* --- The signature, waiting --- */

.sk-sig__intro {
  display: grid;
  gap: 3px;
}

.sk-sig {
  border: 1px solid var(--border);
  border-radius: var(--radius);
  overflow: hidden;
  background: var(--surface);
}

/* The toolbar's own 9/11 padding and its 26px controls. */
.sk-sig__bar {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 9px 11px;
  border-bottom: 1px solid var(--border);
  background: var(--page);
}

.sk-sig__chip {
  height: 28px;
  border-radius: var(--radius-sm);
}

/* The pad is a fixed 172px, so this is not a guess. Left as a plain tint
   rather than a shimmering block - it is a surface waiting to be drawn on, and
   a pulsing rectangle the size of the pad dominates the whole step. */
.sk-sig__pad {
  height: 240px;
  background: var(--ink-100);
}

/* The name under the line - 11.5px text, so a 17px line box, not the 21.5px
   the document lines use. */
.sk-sig__foot {
  padding: 0 11px 11px 34px;
}

.sk-sig__foot .sk-block {
  height: 17px;
}

/* --- Payment, waiting ---
   Two columns from the first frame, matching `.grid--checkout`. */

.sk-pay {
  display: grid;
  grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr);
  gap: 13px;
  align-items: start;
}

.sk-pay__card {
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--surface);
  overflow: hidden;
}

.sk-pay__head {
  display: grid;
  gap: 5px;
  padding: 13px 16px;
  border-bottom: 1px solid var(--border);
}

.sk-pay__body {
  display: grid;
  gap: 11px;
  padding: 14px 16px;
}

.sk-pay__wallets {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 9px;
}

.sk-pay__wallet {
  height: 46px;
  border-radius: var(--radius-sm);
}

/* `.wallet__note` under the wallet row. */
.sk-pay__note {
  height: 29px;
  width: 72%;
}

/* `.pay-divider` - the "or pay by card" rule between the wallets and the card
   fields. Drawn as its own rule rather than a block, because that is what it
   is: the fields below it are Stripe iframes and have no placeholder here. */
.sk-pay__divider {
  display: block;
  height: 1px;
  margin: 8px 0;
  background: var(--border);
}

.sk-pay__lines {
  display: grid;
  gap: 11px;
  height: 50px;
}

.sk-pay__totals {
  display: grid;
  gap: 8px;
  height: 59px;
  padding-top: 11px;
  border-top: 1px solid var(--border);
}

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

/* `.insight` - the line explaining what happens after the charge. */
.sk-pay__insight {
  height: 54px;
  border-radius: var(--radius-sm);
}

/* `.pay-commit` - the button under the total, full width. */
.sk-pay__commit {
  height: 42px;
  border-radius: var(--radius-sm);
}

@media (max-width: 900px) {
  .sk-pay {
    grid-template-columns: minmax(0, 1fr);
  }
}

/* --- Confirmation, waiting ---
   Centred, matching `.offer-done`. */

.sk-done {
  display: grid;
  justify-items: center;
  gap: 9px;
  padding: 34px 20px 30px;
}

.sk-done__mark {
  width: 46px;
  height: 46px;
  border-radius: 50%;
  margin-bottom: 4px;
}

.sk-done__title {
  height: 22px;
  width: 188px;
}

.sk-done__hint {
  height: 17px;
  max-width: 420px;
}

.sk-done__actions {
  display: flex;
  gap: 9px;
  margin-top: 10px;
}

/* ==========================================================================
   The signature step

   Rebuilt around the tools the old enrolment page had - ink colour, nib width,
   an eraser, a clear, and a ruled line to sign on. They are not ornament: a
   signature drawn with a mouse is a poor likeness at the best of times, and
   being able to take a heavier nib or rub out one bad stroke rather than
   starting over is the difference between signing once and signing five times.
   ========================================================================== */

.sig-intro {
  margin: 0;
  font-size: 13px;
  line-height: 1.62;
  color: var(--ink-600);
}

/* --- How wide a step gets ---------------------------------------------------

   These two steps take the full width of the card, like every other step. The
   earlier 760px cap left the panel half empty on a desktop, which read as a
   layout that had failed to fill rather than as a deliberate measure.

   The thing that actually needed capping was the *line length* of the
   agreement, not the panel around it - and `.doc__sheet` already does that on
   its own, centring a comfortable measure inside however much room it is
   given. So the frame goes full width and the text stays readable, which is
   what a printed page does.

   The signature pad has no measure to protect - it is a drawing surface - so
   it simply takes the room, and gets more height to draw in. */
#offer-body-signature .sig-pad {
  height: 240px;
}

/* --- The status pill, in the offers flow ---
   `.pill--wait` is ringed everywhere else in the system, which is right in a
   dense admin table where a row's state has to be findable at a glance. On an
   offer it is not: the state is already named in the step heading beside it,
   and an outlined capsule next to a card title reads as a control rather than
   as a label. Filled quietly instead - the same weight as `.pill--off`, no
   border - so it states the status without asking to be pressed.

   Scoped to this page, so the admin dashboards, the store and the
   subscriptions tables keep the ringed pill they were designed around. */
[data-workspace='guest'] .offer-row .pill--wait,
[data-offer-status].pill--wait {
  background: var(--ink-100);
  border-color: transparent;
  color: var(--ink-600);
}

/* Toolbar, pad and footing read as one instrument, so they share an outline
   rather than each carrying their own. */
.sig-zone {
  border: 1px solid var(--border);
  border-radius: var(--radius);
  overflow: hidden;
  background: var(--surface);
}

/* --- The toolbar --- */

.sig-bar {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 8px;
  padding: 9px 11px;
  border-bottom: 1px solid var(--border);
  background: var(--page);
}

.sig-bar__label {
  font-size: 10.5px;
  font-weight: 620;
  letter-spacing: 0.07em;
  text-transform: uppercase;
  color: var(--ink-500);
}

.sig-bar__sep {
  width: 1px;
  height: 17px;
  background: var(--border);
}

.sig-bar__spacer {
  flex: 1 1 auto;
}

/* --- Nib ---
   The old pad also offered navy and red ink. Deliberately not carried over:
   this system is monochrome by rule, and three saturated discs in the toolbar
   were the loudest thing on the page - on the one screen that should read most
   like a document. Width survives, because it changes how the signature reads
   rather than how the page looks.

   The three sit in one segmented control rather than as loose buttons, which
   is what the rest of this system uses for a small either/or choice. */

.sig-nibs {
  display: inline-flex;
  align-items: center;
  padding: 2px;
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  background: var(--surface);
}

/* The dot inside is the width being chosen, drawn at that width. */
.sig-nib {
  display: grid;
  place-items: center;
  width: 26px;
  height: 22px;
  padding: 0;
  border: 0;
  border-radius: 3px;
  background: transparent;
  cursor: pointer;
  transition: background 0.14s linear;
}

.sig-nib:hover .sig-nib__dot {
  background: var(--ink-900);
}

.sig-nib.is-on {
  background: var(--ink-900);
}

.sig-nib__dot {
  width: var(--d);
  height: var(--d);
  border-radius: 50%;
  background: var(--ink-500);
  transition: background 0.14s linear;
}

.sig-nib.is-on .sig-nib__dot {
  background: var(--surface);
}

.sig-nib:focus-visible,
.sig-tool:focus-visible {
  outline: 2px solid var(--ink-900);
  outline-offset: 2px;
}

/* --- Eraser and clear --- */

.sig-tool {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  height: 26px;
  padding: 0 9px;
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  background: var(--surface);
  font-size: 12px;
  font-weight: 520;
  color: var(--ink-700);
  cursor: pointer;
  transition: background 0.14s linear, border-color 0.14s linear, color 0.14s linear;
}

.sig-tool:hover {
  border-color: var(--border-strong);
  color: var(--ink-900);
}

/* The eraser is a mode, not an action, so it stays lit while it is on. */
.sig-tool.is-on {
  background: var(--ink-900);
  border-color: var(--ink-900);
  color: var(--surface);
}

/* --- The pad --- */

/* Scoped to this step. `.sig-pad` is shared - the staff offer composer, the
   fitness applicant sheet and the old enrolment page all use it and all want
   its dashed outline, which reads correctly as "draw here" when the pad stands
   on its own. Inside `.sig-zone` it does not: the zone already draws the box,
   and a second dashed edge within the first reads as a drop target rather than
   as paper. So the border is dropped here and left alone everywhere else. */
.sig-zone .sig-pad {
  position: relative;
  height: 172px;
  border: 0;
  border-radius: 0;
  background: var(--surface);
  overflow: hidden;
}

.sig-zone .sig-pad canvas {
  display: block;
  width: 100%;
  height: 100%;
  cursor: crosshair;
  touch-action: none;
}

.sig-zone .sig-pad canvas.is-erasing {
  cursor: cell;
}

/* The line to sign on, and the cross that marks where to start - the two marks
   every paper form uses, and the reason this reads as somewhere to sign rather
   than as an empty box. Both sit under the canvas in the stacking order and
   ignore the pointer, so a stroke can cross them freely. */
.sig-zone .sig-pad__rule,
.sig-zone .sig-pad__cross {
  position: absolute;
  pointer-events: none;
}

.sig-pad__rule {
  left: 34px;
  right: 34px;
  bottom: 44px;
  height: 1px;
  background: var(--ink-300);
}

/* The mark at the start of the line, the way a paper form prints one. Sized to
   read as a deliberate mark rather than a stray character - at 15px in
   `--ink-400` it vanished against the rule it sits on. */
.sig-pad__cross {
  left: 30px;
  bottom: 47px;
  font-size: 17px;
  font-weight: 600;
  line-height: 1;
  color: var(--ink-500);
}

/* Centred in the space above the line - the area actually being written in -
   rather than in the pad as a whole. The pad's lower 44px belong to the rule,
   the cross and the name, so centring over the full height pushed the hint
   down towards them; pinning it just above the line put it on top of them. The
   inset stops at the line and the hint centres in what is left. */
.sig-zone .sig-pad__hint {
  position: absolute;
  inset: 0 34px 58px;
  display: grid;
  place-items: center;
  color: var(--ink-400);
  font-size: 12.5px;
  pointer-events: none;
}

/* Whose signature this is, under the line - the same place a printed form puts
   it, which is what makes the ruled line read as a signature line. */
.sig-foot {
  margin: 0;
  padding: 0 11px 11px 34px;
  font-size: 11.5px;
  color: var(--ink-500);
}

/* --- The saved signature --- */

.sig-done {
  border: 1px solid var(--border);
  border-radius: var(--radius);
  overflow: hidden;
  background: var(--surface);
}

.sig-done__head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  padding: 9px 13px;
  border-bottom: 1px solid var(--border);
  background: var(--page);
}

.sig-done__ok {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  font-size: 12px;
  font-weight: 600;
  color: var(--ink-900);
}

.sig-done__when {
  font-size: 11.5px;
  color: var(--ink-500);
}

/* The signature, presented the way a signed document presents one: sitting on
   a ruled line with the signer's name printed beneath it.

   The block is sized to the signature rather than to the panel. The pad is as
   wide as the step, so a rule drawn across the full width put a short signature
   at one end of a very long line - the ink adrift in white space, which is what
   made this read as unfinished. `width: max-content` collapses the whole block
   to the width of the image, and the line is then exactly as long as what was
   signed on it. Capped so an unusually wide signature still fits. */
.sig-done__sheet {
  position: relative;
  display: inline-block;
  width: max-content;
  max-width: min(100%, 460px);
  padding: 22px 30px 24px;
}

.sig-done__img {
  display: block;
  height: 68px;
  width: auto;
  max-width: 100%;
  /* Drawn in ink on a transparent ground, so it needs nothing behind it. The
     image is cropped to the strokes before it is saved - see
     `trimmedSignature` - so this is the signature itself, not a wide field
     with a signature somewhere inside it. */
}

.sig-done__rule {
  display: block;
  height: 1px;
  margin-top: 7px;
  background: var(--ink-300);
}

.sig-done__name {
  display: block;
  padding: 6px 0 0;
  font-size: 11.5px;
  color: var(--ink-500);
}

.sig-done__foot {
  display: flex;
  justify-content: flex-end;
  padding: 11px 13px 13px;
}

/* A quieter ghost button, for a control that sits inside a panel rather than
   in the step's own action row. */
.ghost-button--sm {
  height: 30px;
  padding: 0 10px;
  font-size: 12px;
  gap: 5px;
}

/* --- The agreement, as a document ------------------------------------------

   The stored templates are plain text written for a fixed-width page: a title
   in capitals, a rule of hyphens under the letterhead, `Label:   value` blocks
   aligned by spaces, numbered clauses. Poured into a proportional font at panel
   width, all of that alignment collapsed and it read as console output in a
   grey box - which is what made this step look unfinished beside the others.

   `contractBlocks` in offers.js parses that structure back out, and these rules
   set each part as itself. The frame is a sheet of paper: white, ruled, and
   inset from the panel, so the thing being signed looks like a document rather
   than like a field. */

/* Scrolls inside the step rather than growing it, so the consent box and the
   controls under it stay on screen while the agreement is being read - the
   reader can see what they are agreeing to and the thing they agree with at
   the same time. */
/* The frame the document scrolls inside - read as the surface the page is
   resting on, so the room either side of a measure-capped page is a desk
   rather than dead margin. See `.doc__sheet` below. */
.doc {
  max-height: 460px;
  overflow-y: auto;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--ink-100);
  padding: 22px;
  /* No fade over the bottom edge. A gradient mask was tried and removed: it is
     painted whether or not there is anything left to scroll to, so at the end
     of the agreement it dims the final clause - the one place a contract must
     not look faint. The scrollbar already says there is more. */
}

.doc:focus-visible {
  outline: 2px solid var(--ink-900);
  outline-offset: 2px;
}

/* The measure is what makes a contract readable, so the text is capped near 80
   characters rather than run to the panel's full width. Centred in the sheet,
   which is what a printed page does - widening the window gives margin, not
   longer lines. The step itself is already capped at 760px, so the two work
   together rather than the cap being applied twice at different sizes. */
/* The page itself.

   Two columns of margin were the problem here. The panel runs the full width
   of the card, and a measure-capped block centred inside it left a wide empty
   band down each side of the text - a document floating in a frame rather than
   filling one.

   The measure still has to be capped, because a contract set to 180 characters
   a line is unreadable. So the *sheet* is capped instead of the text inside
   it: the paper is a page-shaped column, and the panel around it is dressed as
   a desk it is resting on. What was dead space is now clearly the surface
   under the document, which is what every reader already understands from
   every PDF viewer they have used. */
.doc__sheet {
  max-width: 74ch;
  margin: 0 auto;
  padding: 34px 40px 38px;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 3px;
  /* A page lifted off the surface behind it. Kept very shallow - this is a
     sheet of paper on a desk, not a card in a stack. */
  box-shadow: 0 1px 2px rgb(23 21 18 / 0.06), 0 6px 16px rgb(23 21 18 / 0.05);
  font-size: 13px;
  line-height: 1.68;
  color: var(--ink-700);
}

/* On a narrow column the desk is all margin and no benefit, so the paper takes
   the full width and only the frame remains. */
@media (max-width: 720px) {
  .doc {
    padding: 0;
    background: var(--surface);
  }

  .doc__sheet {
    padding: 24px 22px 26px;
    border: 0;
    border-radius: 0;
    box-shadow: none;
  }
}

.doc__title {
  margin: 0 0 2px;
  font-size: 14.5px;
  font-weight: 680;
  letter-spacing: 0.02em;
  color: var(--ink-900);
}

.doc__heading {
  margin: 22px 0 8px;
  font-size: 10.5px;
  font-weight: 650;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--ink-500);
}

/* The letterhead rule, drawn rather than typed as forty hyphens. */
.doc__rule {
  height: 0;
  margin: 13px 0 18px;
  border: 0;
  border-top: 1px solid var(--border);
}

.doc__para {
  margin: 0 0 11px;
}

.doc__para:last-child {
  margin-bottom: 0;
}

/* The particulars - name, package, rate, start date.

   Set the way a contract sets them: the label, then the value beside it on the
   same line, in a column aligned down the page. They were tried as a bordered
   table with the value pushed to the right edge, and that was wrong - a ruled
   receipt panel in the middle of a legal document reads as an interface
   element that has been dropped into the text, not as part of the agreement.
   On paper these lines are simply typed, aligned by spaces, and that is what
   this reproduces.

   `grid` with a fixed first column does the aligning that the monospace
   original did with padding, so every value starts at the same place however
   long its label is. */
.doc__facts {
  margin: 0 0 18px;
  display: grid;
  gap: 5px;
}

.doc__fact {
  display: grid;
  grid-template-columns: 148px minmax(0, 1fr);
  gap: 10px;
  align-items: baseline;
}

.doc__fact dt {
  font-size: 13px;
  color: var(--ink-500);
}

.doc__fact dd {
  margin: 0;
  min-width: 0;
  font-size: 13px;
  font-weight: 600;
  color: var(--ink-900);
}

/* On a narrow column the two stack, label above value, rather than squeezing
   the value into what is left of a fixed 148px gutter. */
@media (max-width: 520px) {
  .doc__fact {
    grid-template-columns: minmax(0, 1fr);
    gap: 1px;
  }
}

/* The clauses. Numbered in the margin so the text block stays flush - the
   shape a legal document has on paper. */
.doc__clauses {
  margin: 0 0 12px;
  padding: 0;
  list-style: none;
  counter-reset: clause;
}

.doc__clauses li {
  position: relative;
  counter-increment: clause;
  padding-left: 28px;
  margin-bottom: 9px;
}

.doc__clauses li:last-child {
  margin-bottom: 0;
}

.doc__clauses li::before {
  content: counter(clause) ".";
  position: absolute;
  left: 0;
  top: 0;
  width: 20px;
  text-align: right;
  font-weight: 620;
  font-variant-numeric: tabular-nums;
  color: var(--ink-500);
}

/* What the reader is agreeing to, sat directly under the document it refers
   to.

   Built on the shared `.checkbox` rather than a bare native input. This was the
   only unstyled form control left in the flow - a default browser tick box on
   the screen where consent is actually given, which is the last place it should
   appear. It is also given a surface of its own: this is a decision, not a
   caption, and it should not read as another line of the page. */
.offer-consent {
  align-items: flex-start;
  gap: 11px;
  padding: 12px 14px;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--page);
  transition: border-color 0.14s linear, background 0.14s linear;
}

.offer-consent:hover {
  border-color: var(--border-strong);
}

/* Once ticked, the row settles: the agreement has been given, so it stops
   inviting and starts confirming. */
.offer-consent:has(.checkbox__input:checked) {
  border-color: var(--ink-900);
  background: var(--surface);
}

.offer-consent .checkbox__box {
  margin-top: 1px;
}

.offer-consent__text {
  font-size: 13px;
  line-height: 1.55;
  color: var(--ink-700);
}

.offer-consent:has(.checkbox__input:checked) .offer-consent__text {
  color: var(--ink-900);
}

/* The step's controls. Always the last thing in the body, so the primary
   action sits in the same place on every step. */
.offer-actions {
  display: flex;
  align-items: center;
  justify-content: flex-end;
  gap: 9px;
  padding-top: 4px;
}

.offer-actions__spacer {
  flex: 1;
}

/* Stripe mounts its own iframe into these, so they are styled as fields
   without being fields - no `input` to inherit from. */
.offer-card-fields {
  display: grid;
  gap: 11px;
}

.offer-card-fields__split {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 11px;
}

.offer-card-field {
  padding: 10px 12px;
  border: 1px solid var(--border-strong);
  border-radius: var(--radius);
  background: var(--surface);
  transition: border-color 0.16s linear, box-shadow 0.16s linear;
}

.offer-card-field.is-focused {
  border-color: var(--ink-700);
  box-shadow: 0 0 0 3px var(--ink-150);
}

.offer-card-label {
  display: block;
  margin-bottom: 5px;
  font-size: 11.5px;
  font-weight: 620;
  color: var(--ink-600);
}

/* Payment and signing errors. Ringed rather than filled: this design carries
   no hue, so an error is marked by weight and a border, the same way
   `.pill--off` is. */
.offer-error {
  padding: 10px 12px;
  border: 1px solid var(--border-strong);
  border-left: 2.5px solid var(--ink-900);
  border-radius: var(--radius);
  background: var(--page);
  font-size: 12.5px;
  line-height: 1.55;
  color: var(--ink-800);
}

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

/* The finished state. One mark, one sentence, one way onward. */
.offer-done {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 12px;
  padding: 28px 20px 24px;
  text-align: center;
}

.offer-done__mark {
  display: grid;
  place-items: center;
  width: 46px;
  height: 46px;
  border-radius: 50%;
  background: var(--ink-900);
  color: var(--surface);
}

.offer-done__title {
  font-size: 16px;
  font-weight: 680;
  color: var(--ink-900);
  letter-spacing: -0.015em;
}

.offer-done__hint {
  max-width: 42ch;
  font-size: 13px;
  line-height: 1.6;
  color: var(--ink-500);
}

/* The signature, once taken, shown back on the review step. */
.offer-signature {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 12px 14px;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--page);
}

.offer-signature img {
  height: 46px;
  width: auto;
  max-width: 190px;
  /* The pad draws in ink on a transparent ground; on the warm page colour
     that is exactly right, so nothing is done to it here. */
}

.offer-signature__meta {
  font-size: 12px;
  color: var(--ink-500);
}

@media (max-width: 680px) {
  .offer-row {
    flex-wrap: wrap;
  }

  /* The name takes the whole first line, with the price and the pill under it.

     Wrapping the button alone left the name sharing a line with a price and a
     status pill that are both fixed-width, which squeezed it to about 130px -
     narrow enough that any real offer title ran to two or three lines. The row
     was then a different height for every offer, and a different height again
     at every window width, which is the resize the list was meant to stop
     having. Giving the name the line to itself means the row is two lines of
     known height whatever the offer is called. */
  .offer-row__main {
    flex-basis: 100%;
  }

  .offer-row__actions {
    width: 100%;
  }

  .offer-row__actions .solid-button,
  .offer-row__actions .ghost-button {
    flex: 1;
  }

  .offer-card-fields__split {
    grid-template-columns: 1fr;
  }
}

/* ==========================================================================
   Membership benefits, and the field furniture the apply form needs
   ========================================================================== */

/* Six short claims about what membership includes. A grid rather than a list
   because none of them ranks above the others - they are the same kind of
   thing, and a numbered column would imply an order that is not there. */
.benefit-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
  gap: 18px 24px;
}

.benefit {
  display: flex;
  align-items: flex-start;
  gap: 11px;
}

.benefit__mark {
  display: grid;
  place-items: center;
  width: 30px;
  height: 30px;
  flex: none;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--page);
  color: var(--ink-700);
}

.benefit__title {
  margin: 0;
  font-size: 13.5px;
  font-weight: 650;
  color: var(--ink-900);
  letter-spacing: -0.005em;
}

.benefit__body {
  margin: 3px 0 0;
  font-size: 12.5px;
  line-height: 1.55;
  color: var(--ink-500);
}

/* The line under a field. Currently only a character counter, which is why it
   is set right - it is feedback on what has been typed, not a label for what
   should be. */
.field__foot {
  display: flex;
  justify-content: flex-end;
  margin-top: 5px;
}

.field__count {
  font-size: 11.5px;
  color: var(--ink-400);
  font-variant-numeric: tabular-nums;
}

/* Over the limit. No hue in this design, so the warning is carried by weight
   and by the ink going to full strength. */
.field__count.is-full {
  color: var(--ink-900);
  font-weight: 650;
}

/* The payment step's own waiting line.
   Deliberately not the shared loading module: clearLoading() blanks every slot
   still holding a placeholder, and the offer sheet's body is one of them, so
   routing this through it wiped the step it was sitting in. */
.offer-pay-wait {
  padding: 26px 0;
  text-align: center;
  font-size: 12.5px;
  color: var(--ink-500);
}

/* The confirmation step's controls.
   The document download sits beside the way onward rather than under it: both
   are things to do from here, and stacking them implied an order that is not
   there - the agreement is worth keeping whether or not the dashboard is
   visited next. */
.offer-done__actions {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 10px;
  flex-wrap: wrap;
}

/* The checkout's two columns inside the offer sheet.
   The summary is the narrower of the two and holds the commit button, so it
   is pinned to the top rather than stretching to the payment column's height. */
.offer-step .grid--checkout {
  align-items: start;
}

/* Stripe mounts an iframe into these, so they are sized like a field without
   being one - there is no input to inherit a line height from. */
.offer-card-field {
  padding: 0 12px;
  display: flex;
  flex-direction: column;
  justify-content: center;
  min-height: 38px;
}

.offer-card-field.is-focused {
  border-color: var(--ink-700);
  box-shadow: 0 0 0 3px var(--ink-150);
}

/* ==========================================================================
   The guest landing page - choosing how to join

   A guest account is not a destination, it is the step before one. The page is
   therefore a choice between two paths rather than a dashboard, and it is
   built to look like a choice: two equal panels, each led by its own image,
   because the picture separates martial arts from wellness faster than any
   amount of copy does.

   Neither panel is the upsell. They share a width, a weight and a button
   style, so a reader who wants the wellness programme is not reading past a
   louder martial-arts panel to find it.
   ========================================================================== */

/* The page heading.
   Sized to `.page-head` rather than to itself. This block used to run its own
   scale - 27px at 680 against the rest of the system's 23px at 600 - which made
   the guest landing page the one page whose title was bigger and heavier than
   every other page's, for no reason a reader could name. It is the same kind of
   heading doing the same job, so it is the same size.

   `.page-head` is a flex row because staff pages hang controls off the right
   of it; there is nothing to hang here, so this keeps the simpler box and only
   borrows the type. */
.join-head {
  max-width: 56ch;
  margin: 0 0 20px;
}

.join-title {
  margin: 0;
  font-size: 23px;
  font-weight: 600;
  letter-spacing: -0.022em;
  line-height: 1.15;
  color: var(--ink-900);
}

.join-lede {
  margin: 3px 0 0;
  font-size: 13.5px;
  line-height: 1.6;
  color: var(--ink-500);
}

/* The two paths. `1fr 1fr` rather than auto columns so they stay equal even
   when one panel carries a line more copy than the other. */
.join-paths {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 20px;
  align-items: stretch;
}

/* A column, so the body can take the slack with `flex: 1` and both buttons
   sit on the same line however much text each panel carries. */
.join-path {
  display: flex;
  flex-direction: column;
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  background: var(--surface);
  box-shadow: var(--shadow-sm);
  overflow: hidden;
  transition: box-shadow 0.18s ease, border-color 0.18s ease;
}

.join-path:hover {
  border-color: var(--border-strong);
  box-shadow: var(--shadow);
}

/* The image well.
   A fixed ratio rather than a fixed height, so the supplied photograph sets
   its own crop and the panel never resizes when it loads.

   The box is held from first paint and never given up. An earlier version
   collapsed the well when its photograph was missing, which made the panel a
   different height depending on whether a file happened to be on disk, and
   made dropping one in later a layout change rather than a picture appearing.
   The product mark sits underneath instead, so an unfilled well reads as a
   frame waiting for its photograph rather than as an empty grey box. */
.join-path__media {
  position: relative;
  aspect-ratio: 16 / 9;
  background: var(--ink-100);
  border-bottom: 1px solid var(--border);
  overflow: hidden;
}

/* The mark under the photograph.
   Painted the way the loading mark is - a mask over a painted box - but
   resting rather than sweeping, because this is not a state that resolves. It
   is what the frame looks like with nothing in it. Quiet enough to read as a
   watermark: the panel's subject is its heading, not its placeholder. */
.join-path__media::after {
  content: '';
  position: absolute;
  inset: 0;
  margin: auto;
  width: 44px;
  height: 44px;
  background: color-mix(in srgb, var(--ink-400) 40%, transparent);
  -webkit-mask: url('/logo.svg') center / contain no-repeat;
  mask: url('/logo.svg') center / contain no-repeat;
}

/* While the page is still waiting on its data the mark sweeps, on the same
   1.9s beat every other placeholder in this system runs on - so the two wells
   and the skeleton bars below them read as one page loading rather than three
   separate things running on their own clocks. Dropped by the controller once
   the data lands, leaving the resting mark above. */
.join-path__media[data-media-pending]::after {
  background:
    linear-gradient(
        100deg,
        transparent 20%,
        var(--ink-400) 38%,
        var(--ink-400) 46%,
        transparent 64%
      )
      0 0 / 300% 100% no-repeat,
    linear-gradient(color-mix(in srgb, var(--ink-400) 32%, transparent) 0 0);
  animation: brand-sweep 1.9s cubic-bezier(0.5, 0, 0.5, 1) infinite;
}

@media (prefers-reduced-motion: reduce) {
  .join-path__media[data-media-pending]::after {
    animation: none;
  }
}

.join-path__media img {
  /* Positioned rather than in flow, so it lies over the mark beneath it and
     the well's height comes from its own aspect ratio either way - a photo
     that never arrives cannot change the box, because the box was never
     measured from the photo. */
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
  /* Hidden until it has actually decoded. A half-painted image scrolling in
     over the mark is the blink this whole treatment exists to remove, so the
     controller sets `data-media-ready` once and the picture fades up over the
     placeholder in one move. */
  opacity: 0;
  /* Very slightly desaturated so a colour photograph sits inside a monochrome
     design without fighting it. Eased back on hover, which is the panel
     coming forward as the reader considers it. */
  filter: saturate(0.72) contrast(1.02);
  transition: opacity 0.4s ease, filter 0.35s ease,
    transform 0.5s cubic-bezier(0.22, 0.61, 0.36, 1);
}

.join-path__media[data-media-ready] img {
  opacity: 1;
}

.join-path:hover .join-path__media img {
  filter: saturate(0.95) contrast(1.02);
  transform: scale(1.02);
}

/* Set by the controller when the image is missing or fails to load. The well
   keeps its box - collapsing it moved everything below and made two panels
   different heights whenever only one photograph was on disk - and the img is
   simply taken out of the accessibility tree, leaving the resting mark. */
.join-path__media[data-empty='true'] img {
  display: none;
}

/* The label over the image. Solid ink rather than a translucent wash: over an
   unknown photograph a frosted chip can land on anything, and legibility is
   not worth gambling on the crop. */
.join-path__badge {
  position: absolute;
  left: 14px;
  bottom: 14px;
  padding: 5px 10px;
  border-radius: var(--radius);
  background: var(--ink-900);
  color: var(--surface);
  font-size: 10.5px;
  font-weight: 640;
  letter-spacing: 0.09em;
  text-transform: uppercase;
}

.join-path__body {
  flex: 1;
  padding: 20px 20px 4px;
}

.join-path__title {
  margin: 0;
  font-size: 17px;
  font-weight: 670;
  letter-spacing: -0.02em;
  color: var(--ink-900);
}

.join-path__desc {
  margin: 8px 0 0;
  font-size: 13.5px;
  line-height: 1.6;
  color: var(--ink-600);
}

/* What is included. Ticked rather than bulleted, because each line is
   something the reader gets rather than a point being made. */
.join-path__list {
  margin: 16px 0 0;
  padding: 0;
  list-style: none;
  display: grid;
  gap: 7px;
}

.join-path__list li {
  position: relative;
  padding-left: 21px;
  font-size: 13px;
  line-height: 1.5;
  color: var(--ink-700);
}

.join-path__list li::before {
  content: '';
  position: absolute;
  left: 2px;
  top: 6px;
  width: 9px;
  height: 5px;
  border-left: 1.6px solid var(--ink-700);
  border-bottom: 1.6px solid var(--ink-700);
  transform: rotate(-45deg);
}

/* What happens after applying, stated before the control that applies.
   A rail rather than a list: the steps are sequential, and the connecting line
   is what says so. */
.join-steps {
  margin: 18px 0 0;
  padding: 16px 0 0;
  border-top: 1px solid var(--border);
  list-style: none;
  display: grid;
  gap: 0;
}

.join-step {
  position: relative;
  display: flex;
  align-items: center;
  gap: 10px;
  padding-bottom: 13px;
}

.join-step:last-child {
  padding-bottom: 0;
}

/* The rail between the numbers. Drawn from every step except the last, so it
   stops at the final disc rather than running past it. */
.join-step:not(:last-child)::before {
  content: '';
  position: absolute;
  left: 10px;
  top: 21px;
  bottom: 3px;
  width: 1px;
  background: var(--ink-200);
}

.join-step__num {
  position: relative;
  z-index: 1;
  display: grid;
  place-items: center;
  width: 21px;
  height: 21px;
  flex: none;
  border: 1px solid var(--border-strong);
  border-radius: 50%;
  background: var(--surface);
  color: var(--ink-600);
  font-size: 11px;
  font-weight: 640;
}

.join-step__text {
  font-size: 12.5px;
  line-height: 1.45;
  color: var(--ink-600);
}

.join-path__foot {
  padding: 18px 20px 20px;
}

.join-path__note {
  margin: 9px 0 0;
  font-size: 11.5px;
  line-height: 1.5;
  color: var(--ink-400);
  text-align: center;
}

/* A path already taken. The panel stays - the reader should still see what
   they applied for - but it stops competing for attention with the one still
   open to them. */
.join-path.is-done .join-path__media img {
  filter: saturate(0.35) contrast(1.02);
}

.join-path.is-done:hover .join-path__media img {
  filter: saturate(0.35) contrast(1.02);
  transform: none;
}

/* The control once its path has been taken.

   This was a `.pill` - a small ringed capsule, centred in a foot sized for a
   full-width button. Against the other panel, which still carried its button,
   the two feet did not line up and the taken path read as the panel that had
   lost its control rather than the one that had used it.

   So it takes the button's geometry exactly: same width, padding, radius,
   font-size and weight, laid out the same way. Only the fill changes - ringed
   rather than solid ink, which is the same "awaiting" treatment `.pill--wait`
   carries, and the reason it is not simply a disabled button. A disabled
   button reads as a control the reader failed to use; this reads as a state
   they have already reached.

   Not a `<button>` either: there is nothing to press. It is a `<span>`, so it
   is out of the tab order and announces as text.

   It no longer borrows the button's geometry, though, which is what the first
   version did - same width, padding, radius and weight, differing only by being
   outlined rather than filled. Against a filled button on the other panel that
   distinction was legible; on its own it was not, and a guest who had already
   registered saw what looked like a button and reported that clicking it did
   nothing. It was behaving correctly and simply looked wrong.

   So: a tinted ground, a filled tick, smaller and lighter than a button, and
   left-aligned rather than centred. Nothing about it suggests pressing. */
.join-done {
  display: flex;
  align-items: center;
  gap: 8px;
  width: 100%;
  box-sizing: border-box;
  padding: 9px 12px;
  background: var(--ink-100);
  border: 1px solid var(--border);
  border-radius: 4.5px;
  font-size: 12.5px;
  font-weight: 500;
  line-height: 1.45;
  color: var(--ink-600);
  text-align: left;
}

/* The tick, in a filled disc.
 *
 * A bare two-line check at text colour read as decoration next to the label. A
 * filled disc is the conventional "done" mark, and being the darkest thing in
 * a muted foot it is what the eye lands on - which is the whole job of this
 * state. Ink rather than green on purpose: this system carries no colour
 * tokens, only the ink scale, and a lone green would be the only saturated
 * pixel in the workspace. Drawn rather than shipped as an icon, the same way
 * the "what is included" list draws its own: one less request. */
.join-done::before {
  content: '';
  width: 15px;
  height: 15px;
  flex: none;
  border-radius: 50%;
  background: var(--ink-800) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23fff' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'/%3E%3C/svg%3E") center / 9px 9px no-repeat;
}

/* --- The join panel's skeleton --------------------------------------------
   The two panels are the whole page, and both are static markup that is
   correct from the first frame - the copy, the lists and the steps never
   change. What is not correct until the data lands is the control at the foot
   of each one: whether it offers a button or reports an application already
   made. So this is the only part that waits, and it waits in exactly the shape
   of what replaces it.

   A skeleton is a guess everywhere the shape is unknown, and this system says
   so at length. Here there is no guess: the foot is a full-width control and
   one centred line of note under it, at those exact dimensions, whichever of
   the two states it resolves to. */

.join-path__foot .brand-loader--skeleton {
  display: block;
}

/* `gap` rather than a margin, matching the real note's own 9px top margin, so
   the two states stack to the same total height. */
.sk-join {
  display: grid;
  gap: 9px;
}

/* The control. Sized to `.solid-button--block` exactly - 13.5px at the body's
   1.5 line-height, plus 10px of padding either side and a 1px border - so the
   panel does not change height at the moment the real button arrives. That is
   the one thing a skeleton has to get right, and the whole reason for drawing
   one instead of leaving the foot empty.

   One height covers both outcomes, because .join-done takes the same box as
   the button it replaces. While the done state was a small centred pill this
   was only ever right for one of the two. */
.sk-join__control {
  height: 42.25px;
  border-radius: 4.5px;
}

/* The note beneath it, centred the way the real note is. Two-thirds width:
   a full-width bar reads as a second button rather than as a line of text.
   Boxed to the note's own line height (11.5px at 1.5) with the bar itself
   drawn shorter inside it, so the panel is the same height before and after. */
.sk-join__note {
  height: 17.25px;
  display: flex;
  align-items: center;
  justify-content: center;
}

.sk-join__note .sk-block {
  height: 9px;
  width: 66%;
  border-radius: 3px;
}

.join-aside {
  margin: 22px 0 0;
  font-size: 12.5px;
  color: var(--ink-500);
  text-align: center;
}

.join-aside a {
  color: var(--ink-900);
  text-decoration: underline;
  text-underline-offset: 2px;
}

/* The lists below the choice, separated from it. */
#guest-lists {
  margin-top: 30px;
}

/* One column below the split. Two panels this tall side by side stop being
   comparable on a narrow viewport - the second lands entirely below the fold,
   which is the same as not offering it. */
@media (max-width: 900px) {
  .join-paths {
    grid-template-columns: 1fr;
  }

  /* Shorter wells once stacked, so the second path is still reachable without
     a long scroll past the first. */
  .join-path__media {
    aspect-ratio: 21 / 9;
  }
}

@media (prefers-reduced-motion: reduce) {
  .join-path,
  .join-path__media img {
    transition: none;
  }

  .join-path:hover .join-path__media img {
    transform: none;
  }
}

/* An anchor styled as the primary button. `a.ghost-button` has always had this
   reset; the solid variant never needed one until the guest landing page used
   a link for the wellness path, which is a real navigation rather than a
   submit - and inherited the underline every other link on the page carries. */
a.solid-button {
  text-decoration: none;
  color: var(--surface);
}

/* The two lists under the guest chooser hold at most four rows and are usually
   empty, so the reserved row height leaves a tall band of nothing under a
   one-line empty state. They size to their content instead - the chooser above
   is what the page is for, and these should not out-measure it. */
#guest-lists [data-rows] {
  min-height: 0;
}

/* ==========================================================================
   The student dashboard - a bento

   Five tiles on a four-column grid rather than a row of counts above a stack
   of equal cards. The sizes carry the ranking: the plan is what this page is
   about and takes half the top row, storage takes the tall cell beside it
   because a ring needs to be square to read, and the three lists share the
   rest evenly since none of them outranks the others.

   The figure strip that used to sit above this was four counts of things that
   are mostly zero on a new account - "My files 0", "Shared with me 0" - which
   is a row telling a reader four times that they have not started yet. The
   cards below already say it once, in their own empty states, and say it
   better because they also say what to do about it.
   ========================================================================== */

.grid--bento-student {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: 16px;
  align-items: start;
}

/* Top row: the plan across half, storage in the tall cell beside it. */
.card--plan {
  grid-column: span 2;
}

.card--storage {
  grid-column: span 2;
  grid-row: span 2;
}

/* Second row, under the plan: what is coming next. */
.card--upcoming {
  grid-column: span 2;
}

/* Third row: the two file lists, side by side under everything. */
.card--recent {
  grid-column: span 2;
}

.card--shared {
  grid-column: span 2;
}

/* The ring needs vertical room to be worth drawing. Given here rather than on
   `.chart` so it applies only where a donut actually lands - the same class is
   used for flat charts elsewhere, which do not want a floor this tall. */
.card--storage .chart--center {
  min-height: 240px;
}

/* The legend under it. Rows rather than a wrapped strip, because each line is
   a file type and a size - a pair, not a label. */
.card--storage .key--rows {
  margin-top: 4px;
}

/* Below the bento breakpoint the four-column grid stops being a bento and
   becomes a stack of full-width cards. Two columns first: the lists still
   pair, and the ring keeps its square. */
@media (max-width: 1080px) {
  .grid--bento-student {
    grid-template-columns: repeat(2, 1fr);
  }

  .card--plan,
  .card--storage,
  .card--upcoming,
  .card--recent,
  .card--shared {
    grid-column: span 2;
  }

  /* The ring no longer sits beside the plan, so it has no neighbour to match
     the height of and does not need to span two rows. */
  .card--storage {
    grid-row: auto;
  }
}

@media (max-width: 720px) {
  .grid--bento-student {
    grid-template-columns: 1fr;
  }

  .card--plan,
  .card--storage,
  .card--upcoming,
  .card--recent,
  .card--shared {
    grid-column: span 1;
  }

  .card--storage .chart--center {
    min-height: 210px;
  }
}

/* One path rather than two.
   A single panel stretched across the full grid reads as a banner, and this is
   something to be read before it is acted on - so it keeps a column width the
   eye can take in, centred, with the image well shallower to match. */
.join-paths--single {
  grid-template-columns: minmax(0, 560px);
  justify-content: center;
}

/* With one panel the header has to agree with it. Left-aligned copy above a
   centred card reads as a layout that lost its second column - which is
   exactly what happened, and exactly what should not show. */
.join-head--single {
  max-width: 560px;
  margin-left: auto;
  margin-right: auto;
  text-align: center;
}

.join-paths--single .join-path__media {
  aspect-ratio: 21 / 9;
}

/* The plan card's facts.
   Four of them, pinned to two columns rather than left to auto-fit. Auto-fit
   at this card width lands three across and one alone, and the empty cell
   beside it takes the surface colour - a filled box with nothing in it, which
   reads as a value that failed to load rather than as a tidy grid. */
.card--plan .offer-facts {
  grid-template-columns: repeat(2, 1fr);
}

@media (max-width: 560px) {
  .card--plan .offer-facts {
    grid-template-columns: 1fr;
  }
}

/* --- The offer flow as a tab strip ---------------------------------------- */

/* A step the reader has not reached yet.
   It is shown rather than hidden, because the value of the strip here is that
   it states the whole process up front - someone deciding whether to sign wants
   to know a payment step is coming. It must not be a way in, though: the
   payment step in particular is reached by signing, never by pressing a tab.
   Dimmed and un-hoverable says "later", where hiding it would say "never". */
.tabs__tab[disabled] {
  color: var(--ink-300);
  cursor: default;
  pointer-events: none;
}

.tabs__tab[disabled] .tabs__mark {
  opacity: 0.45;
}

/* A step already passed. The mark carries it rather than the label, so a
   completed step still reads as a peer of the one in progress instead of
   competing with it for the eye. */
.tabs__tab.is-done:not([aria-selected='true']) .tabs__mark {
  color: var(--ink-900);
  opacity: 1;
}

/* A step this offer does not have at all - the payment tab on a free or
   offline offer - is removed from the strip rather than dimmed. It is not a
   later step, it is not part of this offer's process. */
.tabs__tab[hidden] {
  display: none !important;
}

/* The review, agreement and signature steps sit inside a card and take its
   padding. The payment and confirmation steps draw their own cards instead, so
   their panels are bare containers - the step's own .offer-step supplies the
   rhythm between the cards it lays out. */
#panel-payment .offer-step,
#panel-done .offer-step {
  gap: 13px;
}

/* --- Withdrawing an offer, inside the tracker's expanded row -------------- */

/* Separated from the detail above it by a rule rather than by space alone:
   this is the one part of the panel that changes something, and it should not
   read as another field in the list of facts. */
.record-detail__actions {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 14px;
  flex-wrap: wrap;
  margin-top: 18px;
  padding-top: 16px;
  border-top: 1px solid var(--border);
}

/* Why an offer cannot be withdrawn, and what to do instead. Also carries the
   consequence line once the confirm is showing. */
.record-detail__note {
  margin: 0;
  font-size: 12.5px;
  line-height: 1.55;
  color: var(--ink-600);
  max-width: 62ch;
}

/* Mid-confirm the note leads and the buttons follow it, rather than sitting
   opposite: the sentence is what the reader has to take in before choosing, so
   it should not be something they read to the side of the button. */
.record-detail__actions--confirm {
  flex-direction: column;
  align-items: flex-start;
  gap: 12px;
}

.record-detail__confirm {
  display: flex;
  align-items: center;
  gap: 8px;
  flex-wrap: wrap;
}

/* The withdraw column in the offer trackers.
   Shrink-to-fit and right-aligned, so the control sits at the end of the row
   rather than floating in a column sized for a heading that is not drawn. */
.record-table .td--actions {
  width: 1%;
  white-space: nowrap;
  text-align: right;
}

/* The armed state of a row-level destructive control. It has to say what it
   will do, so it carries text where the resting state carried an icon - kept
   small enough that arming a row does not re-flow the column around it. */
.ghost-button--tiny {
  padding: 4px 9px;
  font-size: 11.5px;
  border-radius: 5px;
}

/* The withdraw icon only resolves on hover in a row of many, so it is quiet
   until the row is under the pointer - and never quieter than the text beside
   it once it is. */
.record-table__row .icon-button[data-arm-delete],
.record-table tr .icon-button[data-arm-delete] {
  opacity: 0.55;
  transition: opacity 0.12s linear, background 0.12s linear, color 0.12s linear;
}

.record-table tr:hover .icon-button[data-arm-delete],
.record-table .icon-button[data-arm-delete]:focus-visible {
  opacity: 1;
}

/* ── Confirmation step: the sign-out notice ─────────────────────────────────
   Completing an enrolment grants the student role and revokes every session
   the account has, so the token this page still holds is dead and says
   "guest". The reader has to sign back in to pick up their new access, and
   this block is where that is explained and counted down.

   Separated from the actions above by a rule rather than only by space: it is
   an explanation of what happens next, not another thing to press, and the
   line stops it reading as a caption to the buttons. */
.offer-done__signout {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 8px;
  margin-top: 18px;
  padding-top: 18px;
  border-top: 1px solid var(--line-200, rgba(0, 0, 0, 0.08));
  width: 100%;
}

/* The countdown is the one live thing on a finished screen, so it is set
   apart from the prose around it - tabular figures stop the line reflowing
   as the seconds tick down, which is the difference between a timer and a
   flicker. */
#offer-signout-countdown {
  font-variant-numeric: tabular-nums;
  font-weight: 600;
  color: var(--ink-700, var(--ink-900));
}

/* Nothing counts down for a reader who has asked for less motion; the text
   still updates, it simply is not emphasised into the foreground. */
@media (prefers-reduced-motion: reduce) {
  #offer-signout-countdown {
    font-weight: 500;
  }
}

/* ── Compose step: the agreement picker ─────────────────────────────────────
   Radios rather than a select, because the choice carries a description each -
   which agreement suits which package is exactly the thing a staff member
   composing their first offer does not know, and a collapsed dropdown hides
   precisely that. Three or four options is small enough to show at once. */
.agreement-picker {
  border: 0;
  margin: 0 0 16px;
  padding: 0;
}

.agreement-picker__list {
  display: grid;
  gap: 8px;
  margin-top: 8px;
}

.agreement-option {
  display: flex;
  align-items: flex-start;
  gap: 10px;
  padding: 11px 13px;
  border: 1px solid var(--line-200, rgba(0, 0, 0, 0.1));
  border-radius: 9px;
  cursor: pointer;
  background: var(--surface, #fff);
  transition: border-color 0.14s ease, background 0.14s ease;
}

.agreement-option:hover {
  border-color: var(--line-400, rgba(0, 0, 0, 0.2));
}

/* The selected row is marked by its border and ground, not by colouring the
   text: the label has to stay just as readable when it is the chosen one. */
.agreement-option.is-selected {
  border-color: var(--ink-900, #171512);
  background: var(--surface-2, rgba(0, 0, 0, 0.02));
}

.agreement-option input {
  margin: 2px 0 0;
  flex: none;
  accent-color: var(--ink-900, #171512);
}

.agreement-option__text {
  display: flex;
  flex-direction: column;
  gap: 2px;
  min-width: 0;
}

.agreement-option__name {
  font-size: 13px;
  font-weight: 620;
  color: var(--ink-900);
}

.agreement-option__hint {
  font-size: 12px;
  line-height: 1.5;
  color: var(--ink-500);
}

/* The agreement is read as a document, so it is set in a monospaced face at a
   comfortable measure - the terms are numbered clauses and ruled headings, and
   a proportional face collapses that structure into prose. */
#offer-contract {
  font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
  font-size: 12.5px;
  line-height: 1.62;
  white-space: pre-wrap;
  tab-size: 4;
}

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

/* ── The drawn card on the payment step ─────────────────────────────────────
   It mirrors the Stripe fields below it. Those fields are cross-origin
   iframes, so this page never sees a digit - the face shows the brand, a mask
   that fills when the number completes, and dots for the expiry and CVC.
   Showing invented digits would be worse than showing none.

   Proportioned to ISO/IEC 7810 ID-1 and sized against its own width with
   container queries, so every mark on it sheds size at the same rate the face
   does. Held at fixed pixels, the chip and type would close up the padding on
   a narrow column and stop reading as a card. */
.paycard {
  width: 100%;
  max-width: 340px;
  aspect-ratio: 1.586;
  /* Sits BELOW the fields, so the margin is on top. The card is feedback on
     what is being typed; putting it above pushed the actual inputs down the
     panel and made the reader scroll past a picture to reach them. */
  margin: 18px auto 4px;
  perspective: 1400px;
  container-type: inline-size;
  container-name: paycard;
  /* Carries the small lift while the number is being typed. The rotation lives
     on `.paycard__flip` - see the note there - so the two never contend for
     one `transform`. */
  transition: transform 0.22s cubic-bezier(0.22, 0.61, 0.36, 1);
}

/* The turning element. Both faces are children of this, back-face hidden, so
   one 3D rotation shows the other side rather than two elements cross-fading. */
.paycard__flip {
  position: relative;
  width: 100%;
  height: 100%;
  transform-style: preserve-3d;
  transition: transform 0.62s cubic-bezier(0.4, 0.14, 0.2, 1);
}

.paycard.is-flipped .paycard__flip {
  transform: rotateY(180deg);
}

.paycard__face {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  padding: clamp(12px, 5.3cqw, 18px) clamp(14px, 5.9cqw, 20px);
  border-radius: 12px;
  color: #fdfcfa;
  overflow: hidden;
  isolation: isolate;
  backface-visibility: hidden;
  -webkit-backface-visibility: hidden;
  /* The ground is set further down, from the --card-* variables, so both faces
     take the detected network's colour together. A fixed gradient here would
     apply to whichever face the cascade reached last and leave the other one
     black - which is exactly what made a Visa card turn black on flip. */
  /* Contact shadow plus a cast one - the pair separates the card from the page
     instead of just darkening under it. Two inset hairlines light the top edge
     and shade the bottom. */
  box-shadow:
    0 1px 2px rgb(23 21 18 / 0.16),
    0 10px 24px -12px rgb(23 21 18 / 0.42),
    inset 0 1px 0 rgb(255 255 255 / 0.13),
    inset 0 -1px 0 rgb(0 0 0 / 0.35);
}

.paycard__face--back {
  transform: rotateY(180deg);
  justify-content: flex-start;
}

/* The sweep of light. Its own layer so it sits above the background but under
   the content, and clips to the card's radius. */
.paycard__sheen {
  position: absolute;
  inset: 0;
  z-index: -1;
  pointer-events: none;
  background: linear-gradient(
    104deg,
    transparent 32%,
    rgb(255 255 255 / 0.07) 46%,
    rgb(255 255 255 / 0.11) 50%,
    rgb(255 255 255 / 0.04) 55%,
    transparent 68%
  );
}

.paycard__top {
  display: flex;
  align-items: center;
  gap: clamp(8px, 3.2cqw, 11px);
}

/* The chip is styled further down, where it is drawn as a real contact plate
   rather than an SVG. The `height: auto` that used to sit here fought the
   aspect-ratio that shapes it, so this rule is deliberately gone. */

.paycard__wave {
  flex-shrink: 0;
  width: clamp(14px, 5.3cqw, 18px);
  height: auto;
  color: rgb(255 255 255 / 0.62);
}

/* The number takes the slack, which keeps it optically centred whatever the
   face's rendered height works out to. */
.paycard__number {
  display: flex;
  align-items: center;
  gap: clamp(8px, 3.6cqw, 13px);
  flex: 1;
}

.paycard__group {
  display: flex;
  align-items: center;
  gap: clamp(3px, 1.3cqw, 4.5px);
}

/* Dots rather than bullet glyphs: a bullet is a narrow character on a text
   baseline, so it renders small and sitting low. Fixed dots centre properly
   and track the face's size. */
/* The dots keep only their shape here. How they are lit - recessed when empty,
   raised and bright when filled - is set further down, where the second pass
   replaced the old opacity fade. Leaving `opacity: 0.34` in this rule would
   have dimmed the lit state too, since opacity multiplies rather than being
   overridden by a later background. */
.paycard__group i {
  border-radius: 50%;
}

.paycard__foot {
  display: flex;
  align-items: flex-end;
  gap: clamp(10px, 6.5cqw, 22px);
}

.paycard__pair {
  display: flex;
  flex-direction: column;
  gap: 3px;
  min-width: 0;
}

/* The expiry is a fixed five characters, so it holds its size and the
   cardholder beside it absorbs whatever the footer is short of. */
.paycard__pair:nth-of-type(2) {
  flex-shrink: 0;
}

.paycard__key {
  font-size: clamp(7px, 2.5cqw, 8.5px);
  font-weight: 500;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  white-space: nowrap;
  color: rgb(255 255 255 / 0.48);
}

.paycard__val {
  font-size: clamp(10.5px, 3.7cqw, 12.5px);
  font-weight: 500;
  letter-spacing: 0.02em;
  font-variant-numeric: tabular-nums;
  text-shadow: 0 1px 1px rgb(0 0 0 / 0.32);
  /* The cardholder is the only value here whose length is not fixed, so it is
     the one that gives - a long name ellipses rather than wrapping to a second
     line or shouldering the brand off the trailing edge. */
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
}

/* Set in the italic weight the network marks share - close enough to read as a
   brand, drawn in the card's own ink rather than reproducing a logo. Its box is
   reserved whether or not a brand has been detected, so the footer does not
   shift when Stripe names the card. */
/* The brand slot is laid out further down, where it holds a drawn network mark
   rather than a word. The italic type that used to be set here was standing in
   for a logo, and it is exactly what gave the card away as a drawing - so it is
   gone rather than left to apply to the few networks still shown as text. */

/* --- The back --- */

/* The magnetic stripe runs the full bleed of the card, so it is pulled out
   past the face's padding rather than being inset by it. */
.paycard__stripe {
  margin: clamp(6px, 3cqw, 12px) calc(clamp(14px, 5.9cqw, 20px) * -1) clamp(12px, 5cqw, 18px);
  height: clamp(30px, 12cqw, 42px);
  background: linear-gradient(180deg, #14120f 0%, #26221c 52%, #14120f 100%);
}

/* The signature panel: a light strip with the code at its trailing end, which
   is where it sits on the object. */
.paycard__cvcrow {
  display: flex;
  align-items: center;
  justify-content: flex-end;
  gap: clamp(6px, 2.6cqw, 10px);
  /* Fixed height, not padding around whatever is inside.
     The panel used to be sized by its contents, so it was one height while the
     code box was empty and grew the moment three characters appeared - the
     signature strip visibly resizing as you type, which is the opposite of what
     a printed panel does. Its height is now the card's, and the code sits
     inside it. */
  height: clamp(26px, 10cqw, 34px);
  padding: 0 clamp(7px, 3.2cqw, 11px);
  border-radius: 3px;
  background: repeating-linear-gradient(
    -45deg,
    #efece5 0 6px,
    #e2ded4 6px 12px
  );
}

.paycard__cvcrow .paycard__key {
  color: rgb(23 21 18 / 0.52);
}

/* A value that is still being typed reads a shade back from one that is
   finished, so the card distinguishes "in progress" from "done" without
   needing a tick or a colour. The expiry starts as the literal `MM / YY`
   placeholder and fills character by character; `is-set` is added the moment
   Stripe says the field is valid. */
#paycard-exp,
#paycard-cvc {
  transition: opacity 0.2s linear;
  opacity: 0.72;
}

#paycard-exp.is-set,
#paycard-cvc.is-set {
  opacity: 1;
}

/* Reserves three characters whether or not a code has been entered, so the
   panel does not resize as it is typed. */
.paycard__cvc {
  min-width: 3ch;
  text-align: right;
  font-size: clamp(11px, 4cqw, 14px);
  font-weight: 600;
  letter-spacing: 0.14em;
  font-variant-numeric: tabular-nums;
  color: #171512;
}

.paycard__smallprint {
  margin-top: auto;
  font-size: clamp(6.5px, 2.4cqw, 8px);
  line-height: 1.5;
  color: rgb(255 255 255 / 0.4);
}

/* The turn is the one piece of motion here that a reader could find
   disorienting, so it is the first thing to go. The back is still reachable -
   it simply arrives without rotating. */
@media (prefers-reduced-motion: reduce) {
  .paycard__flip,
  .paycard__group i {
    transition: none;
  }
}

/* ── No-blink payment column ────────────────────────────────────────────────
   Every box on this column is laid out at its final height before Stripe has
   answered anything, so the arrival of the card fields, the wallet row or the
   brand loader replaces content in place rather than resizing the page.

   This is the whole reason the reserved heights below are in the stylesheet
   rather than being set from JS: a height applied after the first paint is a
   layout shift no matter how quickly it follows. */

/* The skeleton inside this slot draws itself at the exact size of the form it
   stands in for - four labelled fields plus the card - so the slot must NOT
   impose a height of its own. An earlier fixed 440px here fought the skeleton
   and left a gap under it whenever the two disagreed.

   `.brand-loader--skeleton` already zeroes the 120px floor that the plain
   brand-loader applies, so nothing else is needed. */
#offer-pay-loading:not(:empty) {
  display: block;
}

/* Stripe mounts an iframe into each of these. They are sized like a field
   without being one - there is no input inside to give them a line height, so
   an explicit height is what stops them collapsing before Stripe paints. */
/* Stripe mounts an iframe into each of these.
 *
 * `align-items: center` was here and it broke card entry outright: Stripe's
 * iframe is a block child with no intrinsic height, and centring a flex item
 * shrink-wraps it to nothing - so the box looked like a field, and had no
 * clickable target inside it at all. Nothing could be typed.
 *
 * `stretch` (the flex default) is what the iframe needs: it fills the row's
 * height, so the whole field is live. The column direction and centred
 * justification match the earlier rule in this file, which is the one that was
 * working before this block was added. */
.offer-card-field {
  min-height: 42px;
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: stretch;
}

/* The iframe itself. Stripe sizes it, but it must be allowed to fill the field
   horizontally or the clickable area stops short of the border. */
.offer-card-field iframe {
  width: 100%;
}

/* Both wallets are always present; only their enabled state changes. Reserving
   the row's height means the layout is identical before and after
   canMakePayment() replies, and identical on a device with no wallet at all. */
.wallets {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 10px;
  min-height: 46px;
}

/* An unavailable wallet is shown, not removed: Apple Pay is a real way to pay,
   it simply is not set up here. Dimmed and not-allowed rather than hidden, so
   the column looks the same on every device. */
.wallet.is-unavailable {
  opacity: 0.42;
  cursor: not-allowed;
  filter: grayscale(1);
}

.wallet.is-unavailable .wallet__mark {
  opacity: 0.9;
}

/* Two lines' worth, so the note swapping from "checking" to its answer cannot
   move the divider under it. */
.wallet__note {
  min-height: 2.6em;
}

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

/* ── Templates on the Package step ──────────────────────────────────────────
   There are no built-in presets any more. Every card on this grid is a
   template somebody here saved from an offer they actually composed, so each
   one needs a way to be unsaved - which is what the wrap exists for. */
.option-wrap {
  position: relative;
}

.option-wrap .option {
  width: 100%;
}

/* The delete control sits over the card's trailing top corner. It is faint
   until the card is hovered or something inside it has focus: it is a
   destructive action on a screen whose main job is choosing, so it should not
   compete with the choice, but it must never be reachable only by hover -
   keyboard focus brings it up too. */
.option__remove {
  position: absolute;
  top: 7px;
  inset-inline-end: 7px;
  display: grid;
  place-items: center;
  width: 22px;
  height: 22px;
  padding: 0;
  border: 0;
  border-radius: 6px;
  background: transparent;
  color: var(--ink-500);
  cursor: pointer;
  opacity: 0;
  transition: opacity 0.14s ease, background 0.14s ease, color 0.14s ease;
}

.option-wrap:hover .option__remove,
.option-wrap:focus-within .option__remove {
  opacity: 1;
}

.option__remove:hover {
  background: var(--surface-2, rgba(0, 0, 0, 0.05));
  color: var(--ink-900);
}

/* Always visible when focused, whatever the hover state - otherwise the
   control is invisible to a keyboard exactly when it is being used. */
.option__remove:focus-visible {
  opacity: 1;
  outline: 2px solid var(--ink-900);
  outline-offset: 1px;
}

/* "Start from scratch" is a peer of the saved templates, not a lesser option -
   it is where the composer opens - so it keeps the same footprint and is
   distinguished by a dashed edge rather than by being smaller or quieter. */
.option--blank {
  border-style: dashed;
}

.option--blank .option__price {
  display: grid;
  place-items: center;
  color: var(--ink-500);
}

/* ── Save as template, on the Review step ───────────────────────────────────
   Offered at the end because this is the first point the offer is finished. A
   template captured from a half-filled composer would carry the gaps with it. */
.save-template {
  margin-top: 18px;
  padding: 14px 16px;
  border: 1px solid var(--line-200, rgba(0, 0, 0, 0.09));
  border-radius: 10px;
  background: var(--surface-2, rgba(0, 0, 0, 0.015));
}

.save-template__head {
  display: flex;
  flex-direction: column;
  gap: 3px;
  margin-bottom: 10px;
}

.save-template__title {
  font-size: 13px;
  font-weight: 620;
  color: var(--ink-900);
}

.save-template__hint {
  font-size: 12px;
  line-height: 1.55;
  color: var(--ink-500);
}

/* The name field takes the width and the button holds its own, so the pair
   reads as one control rather than two stacked ones. */
.save-template__row {
  display: flex;
  gap: 8px;
  align-items: center;
}

.save-template__row .field__input {
  flex: 1;
  min-width: 0;
}

.save-template__row .ghost-button {
  flex-shrink: 0;
}

/* The outcome sits under the control that caused it. Reserved space would be
   wrong here - this is genuinely absent until something has been attempted,
   and it is the last block in the card, so revealing it moves nothing above. */
.save-template__result {
  margin-top: 9px;
  font-size: 12px;
  line-height: 1.55;
  color: var(--ink-600, var(--ink-500));
}

.save-template__result.is-error {
  color: var(--danger-600, #b4342a);
}

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

/* ── Payment column refinements ─────────────────────────────────────────────
   The drawn card sits under the fields, and this note sits under the card. It
   is a statement about the whole column - where card data goes - not a hint on
   any single input, so it is centred under the card rather than left-aligned
   against the last field above it. */
/* Full width of the column, not the card's.
   It was capped at 340px to match the card above it, which squeezed a two-line
   sentence into four cramped lines with the panel's own padding still outside
   it - the "too much padding" that was really a too-narrow measure. The note is
   about the whole payment column, so it uses the whole column. */
.pay-note--under {
  margin: 16px 0 2px;
  text-align: center;
  font-size: 11.5px;
  line-height: 1.6;
  color: var(--ink-400);
}

/* The card reacts while the number is being entered. Stripe's field is a
   cross-origin iframe and never reports the digits, so the face cannot show
   them - what it can do is stop looking inert: the lit dots advance as soon as
   the field stops being empty, and the whole card lifts very slightly while
   entry is in progress.

   The lift is on `.paycard`, NOT on `.paycard__flip`. It used to sit on the
   flipper, where it was a plain `transform` at the same specificity as the
   `rotateY(180deg)` that turns the card over - and being later in the file it
   won. So whenever the number field was mid-entry the card physically could
   not flip to show the CVC side, which is the "only sometimes flips" fault.
   One element, one transform: the flipper rotates, the card lifts. */
.paycard.is-typing {
  transform: translateY(-1px);
}

@media (prefers-reduced-motion: reduce) {
  .paycard,
  .paycard.is-typing {
    transform: none;
    transition: none;
  }
}

/* ── The order summary, as a bill ───────────────────────────────────────────
   Rules between the parts rather than around them. A boxed panel reads as a
   card of information; a bill reads as a column of figures with the total
   ruled off beneath - which is what this is, and what the reader is used to
   seeing on anything they are about to pay. */

/* The hairlines between line items are lighter than the rule under the total:
   they separate peers, while the total is the line the eye should stop at. */
.card--summary .line {
  border-bottom-color: var(--border);
}

.card--summary .line:last-of-type {
  border-bottom: 0;
}

/* The double rule above the totals is the bill convention - one hairline, a
   small gap, then the figures. It is drawn with a border plus a box-shadow
   rather than two elements, so nothing is added to the markup for it. */
.card--summary .totals {
  padding-top: 13px;
  margin-top: 2px;
  border-top: 1px solid var(--border-strong, var(--border));
}

/* The grand total is the one line that is not a hairline apart from its
   neighbours: it gets air above it and sits in full-strength ink, so the
   amount being committed to is unmistakable. */
.card--summary .totals__row--grand {
  margin-top: 4px;
  padding-top: 11px;
  border-top: 1px solid var(--border);
  font-size: 14.5px;
  font-weight: 660;
  color: var(--ink-900);
  letter-spacing: -0.01em;
}

.card--summary .totals__row--grand span:last-child {
  font-variant-numeric: tabular-nums;
}

/* The commit button is the end of the bill, so it is separated from the
   figures by space rather than by another rule - three rules in a row would
   turn a summary into a ledger. */
.card--summary .pay-commit {
  margin-top: 16px;
}

/* Figures line up on the decimal wherever they appear in the summary. */
.card--summary .line__amount,
.card--summary .totals__row span:last-child {
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}

/* ── The payment form's skeleton ────────────────────────────────────────────
   Shaped like the form it stands in for - four labelled fields and the card
   beneath them - rather than a spinner in an empty box. Every block is drawn
   at the size the real control occupies, so Stripe's fields replace them in
   place and the column never resizes as they mount.

   The brand mark rides on the card block. That is the part genuinely being
   waited for, and the logo there reads as "the payment form is coming" rather
   than as a generic busy state. */
.pay-skeleton {
  display: flex;
  flex-direction: column;
}

/* Matches .field__label: same size and the same gap beneath it. */
.pay-skeleton__label {
  width: 84px;
  height: 9px;
  margin-bottom: 7px;
  border-radius: 3px;
}

/* Matches .offer-card-field's 42px min-height and the gap between fields. */
.pay-skeleton__field {
  width: 100%;
  height: 42px;
  margin-bottom: 14px;
  border-radius: var(--radius);
}

/* The expiry / security-code pair, on the same grid as the real .field-row. */
.pay-skeleton__split {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 12px;
}

.pay-skeleton__split > span {
  display: block;
  min-width: 0;
}

/* Stands in for the drawn card: same max-width, same aspect, same margins, so
   the real card lands exactly where this block was. */
.pay-skeleton__card {
  display: grid;
  place-items: center;
  width: 100%;
  max-width: 340px;
  aspect-ratio: 1.586;
  margin: 18px auto 4px;
  border-radius: 12px;
  background: var(--surface-2, rgba(0, 0, 0, 0.04));
}

/* ── The drawn card, second pass ────────────────────────────────────────────
   What this card can show is fixed by Stripe: the fields are cross-origin
   iframes, so the digits never reach this page and never can. The number is a
   mask, and no amount of work changes that.

   So the card has to earn its place some other way - by being a convincing
   object rather than a box with dots in it. Three things do that: the face
   takes the network's own colour once the brand is known, the marks are drawn
   in their real geometry instead of set as page text, and the chip is drawn
   with its actual contact layout rather than as a gold rectangle. */

/* Each network's ground. The card starts near-black and becomes the brand's
   colour the moment Stripe recognises the number - which is the one genuinely
   informative thing the face can do, so it is given the whole surface to do it
   with. Transitioned, because an instant repaint mid-typing reads as a glitch. */

/* The brand's ink, as three stops on the card itself rather than a background
   on one face.
 *
 * These used to be written straight onto `.paycard__face--front`, which meant
 * the back kept the default near-black - so a Visa card turned blue, and then
 * turned black again the moment it flipped to the security code. Both faces
 * are the same object and must be the same colour.
 *
 * Declared as variables on the card and consumed by a single rule below, so a
 * new network is three values rather than two duplicated gradients. */
.paycard {
  --card-a: #2f2b25;
  --card-b: #1c1a16;
  --card-c: #100f0d;
  --card-lift: 0.16;
}

.paycard[data-brand='visa'] {
  --card-a: #1a3a72;
  --card-b: #14284f;
  --card-c: #0d1b35;
  --card-lift: 0.18;
}

.paycard[data-brand='mastercard'] {
  --card-a: #3d2418;
  --card-b: #2a1810;
  --card-c: #180d08;
}

.paycard[data-brand='amex'] {
  --card-a: #1c5f8a;
  --card-b: #14455f;
  --card-c: #0d2c3d;
  --card-lift: 0.2;
}

.paycard[data-brand='discover'] {
  --card-a: #4a3320;
  --card-b: #332215;
  --card-c: #1d130b;
}

/* One rule, both faces. The back is the same card seen from behind, so it
   carries the same ground - only its contents differ. */
.paycard__face--front,
.paycard__face--back {
  background:
    radial-gradient(120% 140% at 12% 8%, rgb(255 255 255 / var(--card-lift)), transparent 52%),
    linear-gradient(148deg, var(--card-a) 0%, var(--card-b) 46%, var(--card-c) 100%);
  transition: background 0.5s cubic-bezier(0.4, 0.14, 0.2, 1);
}

/* The chip. Drawn at the proportions of a real EMV plate, with the contact
   traces as part of the artwork rather than a flat gold block - it is the
   detail the eye uses to read the object as a card. */
.paycard__chip {
  flex-shrink: 0;
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  grid-template-rows: repeat(2, 1fr);
  width: clamp(28px, 10.5cqw, 36px);
  /* 1.31 is the plate's real proportion - wider than tall. */
  aspect-ratio: 1.31;
  border-radius: 4px;
  overflow: hidden;
  /* Brushed gold: two highlights running against the gradient, which is what
     stops it reading as a flat swatch. */
  background: linear-gradient(135deg, #e8d9a8 0%, #c9b177 38%, #e3d3a2 62%, #b89f66 100%);
  box-shadow:
    0 1px 2px rgb(0 0 0 / 0.4),
    inset 0 1px 0 rgb(255 255 255 / 0.5);
}

/* The six contact pads. Hairlines between them, none on the outer edge, so the
   plate reads as divided rather than as a grid drawn on top of it. */
.paycard__chip i {
  border-right: 0.5px solid rgb(0 0 0 / 0.28);
  border-bottom: 0.5px solid rgb(0 0 0 / 0.28);
}

.paycard__chip i:nth-child(3n) {
  border-right: 0;
}

.paycard__chip i:nth-child(n + 4) {
  border-bottom: 0;
}

/* The number row. The dots are the whole mask, so they are given the size and
   spacing real embossed digits would have rather than being small and timid. */
.paycard__group i {
  width: clamp(5px, 2cqw, 7px);
  height: clamp(5px, 2cqw, 7px);
  /* Unlit dots are a recess in the surface; lit ones are raised. The shift
     between the two is what makes entry feel like it is registering. */
  background: rgb(255 255 255 / 0.001);
  box-shadow:
    inset 0 1px 1px rgb(0 0 0 / 0.45),
    0 1px 0 rgb(255 255 255 / 0.06);
  transition: background 0.22s ease, box-shadow 0.22s ease, transform 0.22s ease;
}

/* Lit, once the number is actually valid.
   Applied to the whole row rather than to individual dots - see the note on
   `.paycard__number` below for why the per-dot count was removed. */
.paycard__number.is-complete .paycard__group i {
  background: #fdfcfa;
  box-shadow:
    0 1px 2px rgb(0 0 0 / 0.5),
    inset 0 -1px 0 rgb(0 0 0 / 0.18);
  transform: translateY(-0.5px);
}

/* The dots settle in a short run left to right rather than all at once, so a
   completed number lands as a sweep across the row instead of a flash. The
   delay is per group, which is close enough to per digit to read as one motion
   and costs nothing to compute. */
.paycard__number.is-complete .paycard__group:nth-child(1) i {
  transition-delay: 0ms;
}

.paycard__number.is-complete .paycard__group:nth-child(2) i {
  transition-delay: 45ms;
}

.paycard__number.is-complete .paycard__group:nth-child(3) i {
  transition-delay: 90ms;
}

.paycard__number.is-complete .paycard__group:nth-child(4) i {
  transition-delay: 135ms;
}

/* The brand mark. Sized to the face and given a fixed box so the footer does
   not shift when a mark replaces the empty resting state. */
.paycard__brand {
  margin-left: auto;
  flex-shrink: 0;
  display: flex;
  align-items: center;
  justify-content: flex-end;
  min-width: clamp(34px, 13cqw, 46px);
  height: clamp(14px, 5.5cqw, 20px);
  /* Only applies to the fallback text for the undrawn networks. */
  font-size: clamp(8px, 3cqw, 10px);
  font-weight: 650;
  letter-spacing: 0.02em;
  color: rgb(255 255 255 / 0.9);
  text-shadow: 0 1px 2px rgb(0 0 0 / 0.4);
}

.paycard__brand svg {
  height: 100%;
  width: auto;
  max-width: 100%;
  /* The marks carry their own colours, so they are lifted off the ground with
     a shadow rather than being tinted to match it. */
  filter: drop-shadow(0 1px 2px rgb(0 0 0 / 0.45));
}

@media (prefers-reduced-motion: reduce) {
  .paycard__face--front,
  .paycard__group i {
    transition: none;
  }
}

/* ── The number's three states ──────────────────────────────────────────────

   The row used to light dots one at a time to imply a digit count. It could
   not know one. Stripe's card number field is a cross-origin iframe and its
   change event reports `empty`, `complete`, `error` and `brand` - never the
   digits, never how many. The count was inferred by adding one per change
   event, so a backspace advanced it, a paste advanced it by one, and fixing a
   digit in the middle advanced it again. It re-synced only at empty or
   complete, which left the mask confidently wrong for the whole of entry.

   A travelling shimmer was tried in its place and removed: a loading effect on
   an object that is meant to read as a physical card is exactly the wrong
   metaphor - cards do not scan themselves, and an animation running on the
   face made the whole thing look like a web widget waiting on a server.

   What is left is the card behaving like a card. The number is embossed, and
   embossing is a property of the surface: unlit dots are pressed into it, lit
   ones stand out of it and catch the light. Entry raises them just enough to
   read as ink beginning to appear; completion raises them fully. No motion of
   its own - only the material changing state, which is what the rest of this
   card already does. */

/* Being typed. The digits are forming: the recess fills and a faint highlight
   appears along the top of each dot, but they are not yet standing proud. */
.paycard__number.is-entering .paycard__group i {
  background: rgb(255 255 255 / 0.22);
  box-shadow:
    inset 0 1px 1px rgb(0 0 0 / 0.28),
    0 1px 0 rgb(255 255 255 / 0.07);
}

/* Rejected. The number goes back to being an empty recess rather than sitting
   half-formed, so a refused card does not look like one still being filled. */
.paycard.is-invalid .paycard__number .paycard__group i {
  background: rgb(255 255 255 / 0.001);
  box-shadow:
    inset 0 1px 1px rgb(0 0 0 / 0.45),
    0 1px 0 rgb(255 255 255 / 0.06);
}

@media (prefers-reduced-motion: reduce) {
  .paycard__number.is-complete .paycard__group i {
    transition-delay: 0ms;
  }
}

/* ── The payment card's skeleton, matched to the real form ──────────────────
   The two columns are laid out by `.grid--checkout` from the first frame, and
   the order summary paints immediately from data already in hand - so only
   this column ever waits. Its skeleton is scoped inside the payment card's
   body, which is what stops the page reading as one big load: the summary is
   already there and readable while the card fields are still arriving. */

/* The blocks themselves are sized further up, where the skeleton is defined.
   The wallet row and its note are part of the real column above this slot, so
   the skeleton deliberately does not repeat them - it stands in only for the
   part that is actually missing, which is why the column does not jump when
   Stripe's fields arrive. */

/* ===========================================================================
   STUDENT PROFILE DETAIL AND STAFF STUDENT MANAGEMENT
   Added for the staff management page and the contact / emergency / medical
   cards on the profile page it reads from.
   =========================================================================== */

/* --- Definition-list refinements ------------------------------------------
   `.kv` is a two-column grid over bare dt/dd. These add the two states a
   detail row needs that a plain value does not: a value that is absent, and a
   value that carries its own line breaks. */

/* A field nobody has filled in yet. Lighter than a real value and italic, so
   the eye skips the gaps when reading down a filled record and finds them
   immediately when looking for what is missing. */
.kv__empty {
  color: var(--ink-500);
  font-style: italic;
}

/* An address is several lines and is read as several lines. `<br />` supplies
   the breaks; this only relaxes the line height so they do not crowd. */
.kv__multiline {
  line-height: 1.65;
}

/* --- A block of somebody else's own words ----------------------------------
   Medical notes, a deletion reason, a staff note. Set narrower than the card
   and with a looser line height than the dense table type around it, because
   this is prose to be read rather than a field to be scanned. */
.prose-note {
  margin: 0;
  padding: 14px 18px;
  max-width: 68ch;
  font-size: 12.5px;
  line-height: 1.7;
  color: var(--ink-900);
  white-space: pre-wrap;
  overflow-wrap: anywhere;
}

/* --- A callout that carries an action --------------------------------------
   The base `.callout` is a paragraph of text. When it holds a button as well,
   the two need to sit apart rather than run together, and the button must not
   stretch to the full width on a narrow screen. */
.callout > p {
  margin: 0;
}

.callout > .solid-button,
.callout > .ghost-button {
  margin-top: 11px;
  align-self: flex-start;
}

.callout:has(> .solid-button),
.callout:has(> .ghost-button) {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
}

/* --- Student roster: the completeness meter --------------------------------
   How much of one student's record is filled in, as a short bar beside the
   fraction. A bar rather than a percentage alone because the table is read by
   scanning down a column, and a row of bars sorts itself visually in a way a
   column of numbers does not.

   `--fill` is set inline per row - it is per-datum, so it cannot live in the
   stylesheet. */
.gauge {
  display: flex;
  align-items: center;
  gap: 9px;
  min-width: 96px;
}

.gauge__track {
  position: relative;
  flex: 1;
  height: 5px;
  min-width: 46px;
  border-radius: 999px;
  background: var(--ink-100);
  overflow: hidden;
}

.gauge__fill {
  position: absolute;
  inset: 0 auto 0 0;
  width: var(--fill, 0%);
  border-radius: inherit;
  background: var(--ink-900);
  transition: width 260ms ease;
}

/* This system is monochrome - there is no accent colour to reach for, and
   inventing one here would be the only saturated pixel on the page. An
   incomplete record is distinguished by a lighter, hatched-looking fill
   instead, which still separates the two at a glance in greyscale. */
.gauge--partial .gauge__fill {
  background: var(--ink-300);
}

.gauge__value {
  font-size: 11.5px;
  font-variant-numeric: tabular-nums;
  color: var(--ink-500);
  white-space: nowrap;
}

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

/* --- The student detail sheet ----------------------------------------------
   A wide dialog, because it holds a portrait, a record and a notes column
   side by side. It is the one dialog in the system that is a page in its own
   right rather than a single decision, so it is allowed the width. */
.modal--sheet {
  width: min(1080px, calc(100vw - 40px));
  max-width: none;

  /* The height cap and the scrolling body both live on `.modal` and
     `.modal__body` now - this dialog was where those two bugs were found, but
     they were never specific to it. See the notes there. */
}

/* --- The student sheet: identity banner ------------------------------------
   The dialog used to open with a `modal__head` reading "Student record" and
   the person's name repeated in a block below it - a title bar naming the
   category while the subject sat underneath. The banner replaces both: the
   identity IS the header, and it carries the close control.

   Drawn as a page surface, not a dark plate. An earlier pass gave this an ink
   ground to separate identity from record; it did separate them, but nothing
   else in this workspace is a large filled field - every card, table and
   dialog is `--surface` behind a hairline - so it read as a component borrowed
   from a different product. The separation is carried by the tint step from
   `--page` to `--surface` and the rule beneath, which is the same device the
   card headers already use. */
.sheet-banner {
  display: grid;
  grid-template-columns: 88px minmax(0, 1fr) auto;
  gap: 18px;
  align-items: center;
  padding: 18px 20px;
  /* One step back from the dialog's own `--surface`, the same relationship a
     table header has to the card it sits in. */
  background: var(--page);
  border-bottom: 1px solid var(--border);
  border-radius: var(--radius-lg) var(--radius-lg) 0 0;
  /* The dialog clips its corners; without this the banner's square top corners
     would sit proud of the rounded ones behind them. */
  margin: -1px -1px 0;
}

.sheet-head__portrait {
  position: relative;
  width: 88px;
  height: 88px;
  border-radius: var(--radius);
  overflow: hidden;
  /* The initials plate stays ink - it is a small filled shape, which is what
     every other avatar in the workspace is, rather than a large field. */
  background: var(--ink-900);
  display: grid;
  place-items: center;
  flex: none;
}

/* The picture sits ON TOP of the initials rather than replacing them, which
   is what makes the fallback free: shell.js removes a failed image and the
   plate underneath is already the right one. Absolute, so the initials keep
   the box at full size while an image is loading. */
.sheet-head__portrait > .avatar-photo {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}

/* The initials stand-in. Large, because this is the one place a face is shown
   at size and a two-letter fallback should read as deliberate. */
.sheet-head__initials {
  font-size: 28px;
  font-weight: 600;
  letter-spacing: 0.02em;
  color: var(--surface);
}

.sheet-banner__text {
  min-width: 0;
}

/* The largest type in the dialog. The step down to the 12.5px email below is
   well past the 1.25 ratio the design laws ask for, so the hierarchy holds on
   weight and scale without reaching for a second colour. */
.sheet-head__name {
  margin: 0 0 3px;
  font-size: 20px;
  font-weight: 600;
  letter-spacing: -0.015em;
  line-height: 1.15;
  color: var(--ink-900);
  overflow-wrap: anywhere;
}

.sheet-head__mail {
  margin: 0 0 10px;
  font-size: 12.5px;
  color: var(--ink-500);
  overflow-wrap: anywhere;
}

.sheet-head__tags {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
}

/* No pill overrides here any more. On a light ground the shared `.pill--on`,
   `.pill--wait` and `.pill--off` already read correctly, and restating them
   was only ever compensation for the dark plate this used to be. */

.sheet-banner__close {
  align-self: start;
}


/* --- The sheet body --------------------------------------------------------
   Two columns: the record on the left, this reader's own notes on the right.
   They are read together - who is this, and what do I owe them - which is the
   whole reason they share a dialog. */
.sheet-grid {
  display: grid;
  grid-template-columns: minmax(0, 1.25fr) minmax(0, 0.85fr);
  gap: 34px;
  align-items: start;
}

.sheet-col {
  min-width: 0;
}

/* The notes column is an aside, and `min-width: 0` above lets a grid track
   collapse to nothing when the record beside it is wide. A floor keeps the
   composer usable rather than letting it squeeze to a sliver. */
.sheet-col--notes {
  min-width: 260px;
}

/* A ruled section of one column, not a card.

   Six equally-weighted bordered panels stacked down a column read as an
   identical card grid, which PRODUCT.md names as an anti-reference. A hairline
   and a label separate these instead, so the eye runs down one surface and the
   frame is kept for the two blocks that actually hold a person's own words. */
.sheet-block {
  min-width: 0;
}

.sheet-block + .sheet-block {
  margin-top: 26px;
  padding-top: 22px;
  border-top: 1px solid var(--border);
}

.sheet-block__title {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 10px;
  margin: 0 0 12px;
  font-size: 11px;
  font-weight: 600;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--ink-500);
}

.sheet-block__hint {
  font-size: 11px;
  font-weight: 500;
  letter-spacing: 0;
  text-transform: none;
  color: var(--ink-400);
}

/* The two-column detail list, unframed. The rules between rows are the
   structure; a border around them as well is a box inside a box. */
.kv--bare {
  grid-template-columns: 140px minmax(0, 1fr);
}

.kv--bare dt,
.kv--bare dd {
  padding: 8px 0;
}

.kv--bare dt {
  padding-right: 14px;
}

/* The frame, kept for the blocks that hold prose rather than fields: the
   medical note and the notes column. */
.sheet-panel {
  border: 1px solid var(--border);
  border-radius: var(--radius);
  overflow: hidden;
  background: var(--surface);
}

.sheet-panel .kv {
  border: 0;
}

/* The notes column stays put while the record scrolls past it - it is the one
   part of this dialog that is written to rather than read, and hunting for the
   composer after scrolling to the bottom of a long record is the friction this
   removes. Falls back to a normal block where the viewport is too short for
   sticky to help. */
@media (min-width: 901px) {
  .sheet-block--sticky {
    position: sticky;
    top: 0;
  }
}

.sheet-panel--notes {
  display: flex;
  flex-direction: column;
  /* Long enough that the list and the composer are both visible without the
     panel growing past the fold on a laptop. */
  max-height: min(460px, 60vh);
}

.sheet-panel--notes .note-list {
  overflow-y: auto;
  overscroll-behavior: contain;
}

/* The composer is pinned to the bottom of the panel while the list above it
   scrolls, so the field is always where it was last time. */
.sheet-panel--notes .note-compose {
  flex: none;
  background: var(--surface);
}

@media (max-width: 900px) {
  .sheet-grid {
    grid-template-columns: minmax(0, 1fr);
    gap: 26px;
  }

  /* The portrait goes above the name rather than shrinking - a 40px face is
     worse than none - and the close control keeps its own corner. */
  .sheet-banner {
    grid-template-columns: minmax(0, 1fr) auto;
    gap: 16px;
    padding: 18px;
  }

  .sheet-head__portrait {
    grid-column: 1 / -1;
    width: 76px;
    height: 76px;
  }

  .sheet-head__initials {
    font-size: 25px;
  }

  .sheet-head__name {
    font-size: 19px;
  }

  .sheet-panel--notes {
    max-height: none;
  }

  /* The floor is for the two-column layout only. Left on, it would force the
     single column wider than a narrow phone and overflow the dialog
     horizontally. */
  .sheet-col--notes {
    min-width: 0;
  }
}

/* --- Notes and todos -------------------------------------------------------
   A staff member's private list against one student. Compact, because a note
   is a line and the list is read as a list. */
.note-list {
  display: flex;
  flex-direction: column;
}

.note-item {
  display: flex;
  align-items: flex-start;
  gap: 10px;
  padding: 11px 14px;
  border-bottom: 1px solid var(--border);
}

.note-item:last-child {
  border-bottom: 0;
}

.note-item__body {
  flex: 1;
  min-width: 0;
  font-size: 12.5px;
  line-height: 1.6;
  color: var(--ink-900);
  white-space: pre-wrap;
  overflow-wrap: anywhere;
}

/* A done todo stays on the list rather than vanishing - I already dealt with
   that is information - but recedes so the open ones read first. */
.note-item--done .note-item__body {
  color: var(--ink-500);
  text-decoration: line-through;
}

.note-item__meta {
  margin-top: 3px;
  font-size: 11px;
  color: var(--ink-500);
}

.note-item__actions {
  display: flex;
  gap: 4px;
  flex: none;
}

/* The tick for a todo. A plain button rather than a checkbox because it posts
   to the server - a checkbox implies a form that is submitted later. */
.note-tick {
  flex: none;
  width: 17px;
  height: 17px;
  margin-top: 1px;
  padding: 0;
  display: grid;
  place-items: center;
  border: 1.5px solid var(--border-strong);
  border-radius: 5px;
  background: transparent;
  color: transparent;
  cursor: pointer;
  transition: background 140ms ease, border-color 140ms ease, color 140ms ease;
}

.note-tick:hover {
  border-color: var(--ink-900);
}

.note-tick[aria-pressed='true'] {
  background: var(--ink-900);
  border-color: var(--ink-900);
  color: #fff;
}

.note-tick:focus-visible {
  outline: 2px solid var(--ink-900);
  outline-offset: 2px;
}

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

/* The composer under the list: a text field and its two kind buttons. */
.note-compose {
  display: flex;
  flex-direction: column;
  gap: 9px;
  padding: 12px 14px;
  border-top: 1px solid var(--border);
}

.note-compose__row {
  display: flex;
  align-items: center;
  gap: 8px;
}

.note-compose__row .toolbar__spacer {
  flex: 1;
}

/* --- Request-info checklist ------------------------------------------------
   The dialog staff use to ask a student for what is missing. Each row is a
   checkbox and a label; the ones already on file are disabled and marked, so
   staff can see at a glance that there is nothing to ask for. */
.ask-list {
  display: flex;
  flex-direction: column;
  gap: 2px;
  margin: 0 0 4px;
}

.ask-row {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 9px 2px;
  border-bottom: 1px solid var(--border);
  font-size: 12.5px;
  color: var(--ink-900);
}

.ask-row:last-child {
  border-bottom: 0;
}

.ask-row__label {
  flex: 1;
  min-width: 0;
  cursor: pointer;
}

/* Already supplied, so there is nothing to ask for. Kept on the list rather
   than filtered out, because we already have this is the answer to the
   question staff came here with. */
.ask-row--have {
  color: var(--ink-500);
}

.ask-row--have .ask-row__label {
  cursor: default;
}

/* --- Row states ------------------------------------------------------------
   Two utilities the record tables reach for. Neither existed while both were
   already being written into markup - `is-clickable` by the admin management
   page's storage table and `is-muted` by the staff roster - so a clickable row
   gave no pointer and a pending-deletion row read exactly like every other. */

/* A whole row that opens something. The pointer is the affordance; the ground
   shift on hover is what says which row the press will land on. */
.record-table tbody tr.is-clickable {
  cursor: pointer;
}

.record-table tbody tr.is-clickable:hover {
  background: var(--ink-100);
}

/* A row whose account is on its way out. Recedes rather than disappearing:
   the record is still there, still openable, and still working - it is the
   decision about it that is pending. */
.record-table tbody tr.is-muted td {
  opacity: 0.62;
}

.record-table tbody tr.is-muted:hover td {
  opacity: 1;
}

/* A box that cannot be ticked because there is nothing to ask for. Kept
   visible so the row still reads as a checklist item that is already
   satisfied, rather than as a control that failed to render. */
.checkbox__input:disabled + .checkbox__box {
  opacity: 0.4;
  cursor: default;
}

/* --- Offer rows ------------------------------------------------------------
   An enrolment offer, said in a sentence.

   The earlier version of this block printed each offer as a row of codes -
   "AED 1080.00 - One-off - sent - Aug 5, 2026" - which is four facts and no
   meaning. It does not say whether the offer was accepted, whether money
   changed hands, or whether anyone needs to act. Reading it required knowing
   what the status enum meant.

   So the row leads with a plain sentence and a state pill, and demotes the
   figures to a footer line. The sentence is the thing being read; the numbers
   are there for whoever wants to check them. */

.plan-history {
  border-top: 1px solid var(--border);
}

.plan-history__title {
  padding: 12px 18px 2px;
  font-size: 11px;
  font-weight: 600;
  letter-spacing: 0.07em;
  text-transform: uppercase;
  color: var(--ink-500);
}

.plan-row {
  padding: 13px 18px 15px;
  border-bottom: 1px solid var(--border);
}

.plan-row:last-child {
  border-bottom: 0;
}

/* The live plan sits above the ruled history and needs no rule of its own -
   the kv list directly below it already opens with one. */
.plan-row--current {
  border-bottom: 0;
  padding-bottom: 4px;
}

.plan-row__head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  margin-bottom: 5px;
}

.plan-row__name {
  font-size: 13px;
  font-weight: 600;
  color: var(--ink-900);
  min-width: 0;
  overflow-wrap: anywhere;
}

/* The sentence. Slightly looser than the dense table type around it because
   it is prose, and capped in width so it stays readable in the wide sheet
   rather than running the full column. */
.plan-row__say {
  margin: 0;
  max-width: 62ch;
  font-size: 12.5px;
  line-height: 1.6;
  color: var(--ink-700);
}

/* The part staff act on carries the weight - "needs chasing", "never signed",
   "no renewal and nothing to expire". Ink rather than a colour, because this
   system is monochrome and the emphasis has to survive greyscale. */
.plan-row__say strong {
  font-weight: 600;
  color: var(--ink-900);
}

.plan-row__foot {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  margin-top: 9px;
}

.plan-row__figures {
  font-size: 11.5px;
  font-variant-numeric: tabular-nums;
  color: var(--ink-500);
  min-width: 0;
  overflow-wrap: anywhere;
}

/* An offer with no agreement behind it has no button, so the figures would
   otherwise sit alone against a justify-content that expects two children. */
.plan-row__foot:only-child,
.plan-row__foot > .plan-row__figures:only-child {
  justify-content: flex-start;
}

/* The alert slot sits at the very top of the scrolling body, above the record.
   Empty on most records, so it collapses rather than reserving space. */
#sheet-alert:empty {
  display: none;
}

#sheet-alert {
  margin-bottom: 20px;
}

/* The medical note and the activity list are the two blocks whose contents are
   rendered as prose or lines rather than a kv grid, so they set their own
   inner padding. `.prose-note` already carries 14px 18px; this keeps an empty
   state aligned to the same edge instead of hugging the frame. */
.sheet-block .empty-state__hint {
  color: var(--ink-500);
}

/* --- Record-table skeleton rows --------------------------------------------
   A stand-in for a `.record-table` row, drawn at exactly the height the real
   row occupies so the table neither grows nor shrinks when the data lands.

   The measurement, so it can be checked rather than trusted: `.record-table td`
   is `padding: 11px 20px` around a 30px `.identity__avatar`, plus a 1px bottom
   border - 11 + 30 + 11 + 1 = 53px. Every value below is derived from that
   rather than guessed, which is the whole reason this is worth authoring in the
   page instead of letting the pulsing mark stand in for the rows.

   These live inside a single full-width `<td>`, because a `<tbody>` cannot hold
   a `<div>` - the parser hoists one out of the table. The host row carries no
   padding or rule of its own, so the bars sit exactly where real rows would. */

.sk-row-host > td {
  /* A container for the placeholder, not a cell being read. Its own padding
     would offset every bar by 11px and its border would draw a line the rows
     inside already draw for themselves. */
  padding: 0 !important;
  border-bottom: 0 !important;
}

/* The wrapper must not impose the 120px floor or the centring the plain mark
   uses - these rows define their own height. */
.sk-row-host .brand-loader--skeleton {
  display: block;
  min-height: 0;
  padding: 0;
}

/* One row: the avatar plate, then four columns. The first column is the
   identity - a name over an email, so it holds two stacked bars where the
   others hold one. */
.sk-rec {
  display: grid;
  grid-template-columns: 30px minmax(0, 1.6fr) minmax(0, 1fr) minmax(0, 1fr) minmax(0, 0.8fr);
  align-items: center;
  gap: 0 12px;
  height: 53px;
  padding: 0 20px;
  border-bottom: 1px solid var(--border);
  /* The real rows all carry a rule including the last, so keeping it here
     means no line appears or disappears when the data lands. */
}

/* Every bar and the avatar plate share one fill and one clock - the same 1.9s
   beat `.sk-block` and the brand mark use, so a page with both reads as one
   system breathing rather than two clocks running past each other. */
.sk-rec__plate,
.sk-rec span {
  background: linear-gradient(
      100deg,
      var(--ink-100) 32%,
      var(--ink-150) 50%,
      var(--ink-100) 68%
    )
    0 0 / 250% 100%;
  animation: sk-shimmer 1.9s ease-in-out infinite;
}

/* The avatar plate. Square, at `.identity__avatar`'s own 30px and 4.5px
   radius, so the shape that appears is the shape that was reserved. */
.sk-rec__plate {
  width: 30px;
  height: 30px;
  border-radius: 4.5px;
}

/* The identity column. Two bars at the real `.identity__name` and
   `.identity__meta` line heights, stacked with the 2px between them the real
   pair sits at. */
.sk-rec__id {
  display: flex;
  flex-direction: column;
  gap: 4px;
  min-width: 0;
}

.sk-rec__id span:first-child {
  height: 10px;
  width: var(--sk-a, 40%);
  border-radius: 4px;
}

.sk-rec__id span:last-child {
  height: 8px;
  width: var(--sk-b, 55%);
  border-radius: 4px;
}

/* The three trailing columns: one bar each, at the height of a single line of
   table type. */
.sk-rec > span {
  height: 9px;
  border-radius: 4px;
}

.sk-rec > span:nth-of-type(1) { width: var(--sk-c, 46%); }
.sk-rec > span:nth-of-type(2) { width: 58%; }
.sk-rec > span:nth-of-type(3) { width: 40%; }

/* The owners table has one fewer column and a shorter identity, so its tracks
   are split differently at the same row height. */
.sk-rec--owner {
  grid-template-columns: 30px minmax(0, 1.7fr) minmax(0, 0.7fr) minmax(0, 0.7fr) minmax(0, 1fr);
}

@media (prefers-reduced-motion: reduce) {
  .sk-rec__plate,
  .sk-rec span {
    animation: none;
  }
}

/* ==========================================================================
   The roster - who the studio has, and who owes money
   ==========================================================================

   Two cards under the revenue row. Money is a shape over time and gets a
   chart; people are a population and a queue, and neither is a chart worth
   drawing - four counts do not need an axis, and six names in date order are
   a list, not a plot.

   The left card leads with two figures in the page's largest type - how many
   are actually paying, and what that is worth a month - and then breaks the
   roster into four ranked rows, each carrying its own proportion bar. The
   headline is read once at a glance; the rows are the breakdown you only look
   at once it has told you whether to care.

   The right card is the queue, and it is now a table rather than a list: who,
   what is owed and why, how much, and by when. Both cards' rows open a dialog
   - the roster's open everybody in that state, the queue's open one person -
   because the question a figure raises is almost always "which of them", and
   answering it used to mean a page load and a filter.
   ========================================================================== */

/* --- The two roster cards -------------------------------------------------
   The two cards now carry a comparable amount, so the row is split much closer
   to even than the 2:1 it used to be. "Payments due" stopped being a queue of
   names with a date on the end and became a table - who, what is owed and why,
   how much, and by when - and four columns inside a third of the row is four
   columns none of which can be read.

   The left card keeps the larger share because it opens with two figures set in
   the page's largest type, and those need room to breathe rather than room to
   fit. */
.grid--bento-roster {
  grid-template-columns: minmax(0, 1fr) minmax(0, 1.42fr);
}

/* One column well before the desktop breakpoint the rest of the page uses. The
   due table's four columns need roughly 520px between them before the state
   text starts wrapping mid-sentence, and at 1180px in a two-column row it has
   less than that. */
@media (max-width: 1180px) {
  .grid--bento-roster {
    grid-template-columns: minmax(0, 1fr);
  }
}

/* The taller card sets the height of this row, and the other is stretched to
   match it. Left alone that pools the difference under whichever card has less
   to say, which is the same dead surface the bento above was arranged to avoid.

   The states block absorbs it instead: it grows, and its rows distribute the
   slack between them, so the extra height lands as air between four rows rather
   than as a void beneath them. */
.card--roster .card__body {
  display: flex;
  flex-direction: column;
}

/* The states block takes the slack and spends it BETWEEN its four rows, never
   inside them.
 *
 * `flex: 1` on the rows themselves was the first attempt and it was wrong: a
 * row is a grid of label, figure, bar and foot, and growing the row stretched
 * the gap between the label and the foot line that explains it - pulling apart
 * the one grouping the row exists to hold together. `space-between` on the
 * container moves the same space into the dividers instead, where a gap reads
 * as a separation rather than as a row that has come loose. */
.card--roster .roster-states {
  flex: 1;
  justify-content: space-between;
}

/* The queue is a row list, so its rows run edge to edge and its own body adds
   no padding - `card__body--flush` in the markup does that. What it does need
   is to not stretch its rows when the card beside it is the taller one: a
   five-row list in a card sized for six leaves the last row with a border and
   nothing under it, which reads as a missing sixth entry. */
.card--renewals .card__body {
  justify-content: flex-start;
}

/* --- The headline pair ----------------------------------------------------
   Two figures at the top of the card, in the largest type on the page. The
   roster total is deliberately not one of them - it is already in the card's
   title count, and spending the biggest type on the least actionable number is
   how a dashboard ends up decorative.

   Side by side rather than stacked, and divided by a rule: they are one
   reading ("31 students bringing in AED 6,240 a month"), not two facts that
   happen to be adjacent. */
.roster-hero {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 1.15fr);
  gap: 18px;
  padding-bottom: 18px;
  border-bottom: 1px solid var(--border);
}

.roster-hero__figure {
  display: flex;
  flex-direction: column;
  gap: 5px;
  min-width: 0;
}

/* The money half gets the rule between them. Drawn on the left edge of the
   second figure rather than as a gap, so the two stay visually paired at every
   width the card takes. */
.roster-hero__figure--money {
  padding-left: 18px;
  border-left: 1px solid var(--border);
}

/* Large, tight and tabular. Tabular matters here because the two figures sit on
   a shared baseline and one of them is currency - proportional digits make the
   pair look misaligned even when they are not. */
.roster-hero__value {
  font-size: 34px;
  font-weight: 620;
  line-height: 1;
  letter-spacing: -0.033em;
  color: var(--ink-900);
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* The currency figure runs several characters longer than the count beside it
   ("AED 6,240" against "31"), so it is set a step down - matching them would
   either shrink the count to nothing or overflow the column. */
.roster-hero__figure--money .roster-hero__value {
  font-size: 27px;
  letter-spacing: -0.025em;
}

.roster-hero__label {
  display: flex;
  flex-direction: column;
  gap: 2px;
  font-size: 12.5px;
  font-weight: 500;
  color: var(--ink-600);
}

/* The qualifier under each label - a share for the count, a caveat for the
   money. Set back from the label above it, because it is the line that stops
   the figure being over-read and not the line the figure is named by. */
.roster-hero__share {
  font-size: 11px;
  font-weight: 400;
  color: var(--ink-400);
}

@media (max-width: 560px) {
  .roster-hero {
    grid-template-columns: minmax(0, 1fr);
    gap: 14px;
  }

  /* Stacked, the vertical rule is between nothing - it becomes a horizontal
     one above the second figure. */
  .roster-hero__figure--money {
    padding: 14px 0 0;
    border-left: 0;
    border-top: 1px solid var(--border);
  }
}

/* --- The four states, ranked ----------------------------------------------
   These were four equal tiles in a grid. That gave every state the same width
   and the same weight, which is exactly wrong for a roster: three of the four
   are usually near zero and one holds almost everybody, and four equal boxes
   hide precisely that.

   A row per state instead, each carrying its own proportion bar. The bar
   answers "is that a lot", the figure answers "how many", and the share answers
   both - so the shape of the roster is readable without the reader converting
   anything. The old funnel bar under the tiles said the same thing once for all
   four and is gone: it needed a key, and a key is a second lookup for a
   reading the rows now give inline. */
.roster-states {
  display: flex;
  flex-direction: column;
  margin-top: 4px;
}

/* Four columns and two rows: the label and figure lead, the bar and its share
   sit to the right, and the foot line runs under the label across the whole
   row. `auto` on the figure rather than a fixed width so a three-digit count
   does not clip, and `min-content` on the share so the percentage never wraps
   off its own bar. */
.roster-state {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto minmax(72px, 0.9fr) 34px;
  grid-template-areas:
    'label value bar share'
    'foot  foot  bar share';
  align-items: center;
  column-gap: 12px;
  row-gap: 2px;
  padding: 12px 10px;
  margin: 0 -10px;
  border-radius: var(--radius);
  border-bottom: 1px solid var(--border);
  color: inherit;
  text-decoration: none;
  text-align: left;

  /* These are `<button>` elements when there is somebody to show. A button
     brings a user-agent background, border, font and padding with it, none of
     which belongs on a row - so they are reset here rather than in four places
     down the file. */
  width: calc(100% + 20px);
  background: none;
  border-left: 0;
  border-right: 0;
  border-top: 0;
  font: inherit;
  cursor: pointer;

  transition: background 0.13s linear;
}

.roster-state:last-child {
  border-bottom: 0;
}

.roster-state:hover {
  background: var(--ink-100);
}

/* An empty state is a link to the page where that state is worked, not a
   button that opens nobody. It still answers the pointer - it still goes
   somewhere - but without the affordance that says "this opens here". */
a.roster-state {
  cursor: pointer;
}

.roster-state__label {
  grid-area: label;
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: 13px;
  font-weight: 560;
  color: var(--ink-800);
  min-width: 0;
}

/* A small disc in the same ink as this row's proportion bar. It is what ties
   the label to its bar when the eye jumps between columns, and what carried the
   old tiles to their slices of the funnel. */
.roster-state__dot {
  width: 8px;
  height: 8px;
  flex: none;
  border-radius: 50%;
  background: var(--seg-ink, var(--ink-400));
}

/* Big enough to read across a desk, tabular so the four line up down the
   column whatever their digit counts are. */
.roster-state__value {
  grid-area: value;
  font-size: 21px;
  font-weight: 620;
  line-height: 1;
  letter-spacing: -0.02em;
  color: var(--ink-900);
  font-variant-numeric: tabular-nums;
}

.roster-state__foot {
  grid-area: foot;
  font-size: 11px;
  line-height: 1.45;
  color: var(--ink-400);
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* The proportion bar. Spans both rows of the grid so it is centred against the
   figure and its foot together rather than against either one. */
.roster-state__bar {
  grid-area: bar;
  height: 7px;
  border-radius: 999px;
  background: var(--ink-150);
  overflow: hidden;
}

.roster-state__fill {
  display: block;
  height: 100%;
  width: var(--share, 0%);
  border-radius: 999px;
  background: var(--seg-ink, var(--ink-400));
  transform-origin: left center;

  /* A floor, so a state holding one person out of four hundred still draws
     something. A bar that rounds to zero width says "nobody", which is a
     different and wrong answer to the question the row is asking. */
  min-width: 3px;
}

.roster-state__share {
  grid-area: share;
  font-size: 11.5px;
  font-weight: 600;
  color: var(--ink-500);
  font-variant-numeric: tabular-nums;
  text-align: right;
}

/* The chevron on an openable row. Absolutely placed rather than given a fifth
   column: it is an affordance, not data, and a column for it would push the
   share off its bar. Only visible on hover and on focus, so four permanent
   arrows do not compete with four figures. */
.roster-state__go {
  position: absolute;
  right: 10px;
  display: grid;
  place-items: center;
  color: var(--ink-400);
  opacity: 0;
  transform: translateX(-3px);
  transition: opacity 0.13s linear, transform 0.13s linear;
}

.roster-state {
  position: relative;
}

.roster-state:hover .roster-state__go,
.roster-state:focus-visible .roster-state__go {
  opacity: 1;
  transform: none;
}

/* The one row the reader may need to act on today. A marker in the gutter -
   the same way the audit log raises one row out of a set without introducing a
   colour the palette does not have. */
.roster-state--urgent::before {
  content: '';
  position: absolute;
  inset: 8px auto 8px 0;
  width: 2px;
  border-radius: 999px;
  background: var(--ink-900);
}

/* A count of zero is good news in three of these four rows, so it is drawn back
   rather than shouted - the eye should land on the rows with something in
   them. */
.roster-state--empty .roster-state__value,
.roster-state--empty .roster-state__share {
  color: var(--ink-300);
}

.roster-state--empty .roster-state__label {
  color: var(--ink-500);
}

.roster-state--empty .roster-state__bar {
  background: var(--ink-100);
}

/* Entrance: each bar grows from the left once, on the render that first brings
   the card in. Staggered so the four arrive in funnel order rather than as one
   block. */
.roster-states--enter .roster-state__fill {
  animation: roster-fill-grow 0.5s cubic-bezier(0.22, 0.61, 0.36, 1) backwards;
}

.roster-states--enter .roster-state:nth-child(1) .roster-state__fill { animation-delay: 0.02s; }
.roster-states--enter .roster-state:nth-child(2) .roster-state__fill { animation-delay: 0.08s; }
.roster-states--enter .roster-state:nth-child(3) .roster-state__fill { animation-delay: 0.14s; }
.roster-states--enter .roster-state:nth-child(4) .roster-state__fill { animation-delay: 0.2s; }

@keyframes roster-fill-grow {
  from { transform: scaleX(0); }
  to { transform: scaleX(1); }
}

/* Narrow: the bar and its share drop under the label rather than competing with
   it for a width neither can use. */
@media (max-width: 620px) {
  .roster-state {
    grid-template-columns: minmax(0, 1fr) auto 38px;
    grid-template-areas:
      'label value value'
      'foot  foot  foot'
      'bar   bar   share';
    row-gap: 6px;
  }

  .roster-state__bar {
    margin-top: 2px;
  }
}

/* --- Payments due: the money strip ----------------------------------------
   Above the list, what the list adds up to. The rows say who and by when, and
   none of them says how much the card comes to - a reader who wants that is
   otherwise adding six figures by eye.

   Two totals rather than one, because they are different kinds of number: what
   is already late is money the studio was supposed to have, and what is coming
   is money it has not earned yet. One combined figure would say neither. */
.due-summary {
  display: flex;
  flex-wrap: wrap;
  gap: 10px 26px;
  padding: 14px 20px;
  border-bottom: 1px solid var(--border);

  /* A shade off the card, so the strip reads as a header over the list rather
     than as a first row of it. Mixed rather than a token, because the palette
     has no step between `--surface` and `--ink-100` and `--ink-100` is the
     hover tint - a static band in it would make the rows below look
     permanently hovered. */
  background: color-mix(in oklab, var(--ink-100) 45%, var(--surface));
}

.due-summary__item {
  display: flex;
  flex-direction: column;
  gap: 1px;
  min-width: 0;
}

.due-summary__value {
  font-size: 17px;
  font-weight: 640;
  line-height: 1.15;
  letter-spacing: -0.02em;
  color: var(--ink-700);
  font-variant-numeric: tabular-nums;
}

/* Already late is the figure this card exists for, so it takes the full ink the
   palette reserves for the one thing on a card that needs acting on. */
.due-summary__item--late .due-summary__value {
  color: var(--ink-900);
}

/* The count of rows nobody will be charged for automatically. A count, not
   money, so it is set a step down - it is a qualifier on the two totals rather
   than a third total. */
.due-summary__item--note .due-summary__value {
  font-size: 15px;
  color: var(--ink-600);
}

.due-summary__label {
  font-size: 11px;
  color: var(--ink-400);
  white-space: nowrap;
}

/* --- Payments due: the column header --------------------------------------
   The list has real columns now, so it gets a header strip. `aria-hidden`
   because each cell below is already readable on its own - the strip is a
   visual alignment aid, and a screen reader announcing four extra words before
   every row would make the list slower to get through, not faster. */
.due-head {
  display: grid;
  grid-template-columns:
    34px minmax(0, 1.15fr) minmax(0, 1.35fr) minmax(88px, auto) minmax(104px, auto) 18px;
  gap: 12px;
  padding: 9px 20px;
  border-bottom: 1px solid var(--border);
  font-size: 10.5px;
  font-weight: 600;
  letter-spacing: 0.045em;
  text-transform: uppercase;
  color: var(--ink-400);
}

/* The first track is the avatar column, which has no heading - the name beside
   it is the heading for both. */
.due-head__cell--who   { grid-column: 2; }
.due-head__cell--what  { grid-column: 3; }
.due-head__cell--amount { grid-column: 4; text-align: right; }
.due-head__cell--when  { grid-column: 5; text-align: right; }

/* --- Payments due: the rows ------------------------------------------------
   A `.row` still, so the leading avatar, the hover tint and the divider are the
   ones the rest of the page uses - but laid out on the same grid as the header
   above rather than as three flex children. The card is wide enough now to give
   the state, the amount and the deadline each a column, which is what turns the
   list from "names with a date on the end" into something a reader can compare
   down. */
.due-row {
  display: grid;
  grid-template-columns:
    34px minmax(0, 1.15fr) minmax(0, 1.35fr) minmax(88px, auto) minmax(104px, auto) 18px;
  align-items: center;
  gap: 12px;
  width: 100%;
  text-align: left;

  /* A `<button>`, so the user-agent styling has to come off. Every row here
     opens a person, and a button is what gets that from the keyboard and from a
     screen reader for free. */
  background: none;
  border-left: 0;
  border-right: 0;
  border-top: 0;
  font: inherit;
  color: inherit;
  cursor: pointer;
}

.due-row__who {
  display: flex;
  flex-direction: column;
  gap: 2px;
  min-width: 0;
}

/* The state column: the pill, and under it the sentence that says what the
   pill costs the reader. That second line is the whole reason the state gets a
   column - "Card declined" alone does not tell a new member of staff that
   access is about to be cut off. */
.due-row__what {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 4px;
  min-width: 0;
}

.due-row__because {
  font-size: 11px;
  line-height: 1.4;
  color: var(--ink-500);

  /* Two lines, then clipped. A three-line consequence in one row of a
     five-row list would set the height of all five. */
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

.due-row__amount {
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  gap: 1px;
  text-align: right;
}

.due-row__price {
  font-size: 13.5px;
  font-weight: 640;
  color: var(--ink-900);
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}

/* "No charge" is words in a column of figures, so it drops to the weight and
   size of the cycle line under the real amounts rather than being set as though
   it were one of them. */
.due-row__price--none {
  font-size: 11.5px;
  font-weight: 500;
  color: var(--ink-400);
}

.due-row__cycle {
  font-size: 10.5px;
  color: var(--ink-400);
  white-space: nowrap;
}

.due-row__when {
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  gap: 0;
}

/* The chevron. Same treatment as the roster rows' - an affordance that appears
   on hover, so a column of five permanent arrows does not compete with the
   figures beside them. */
.due-row__go {
  display: grid;
  place-items: center;
  color: var(--ink-400);
  opacity: 0;
  transform: translateX(-3px);
  transition: opacity 0.13s linear, transform 0.13s linear;
}

.due-row:hover .due-row__go,
.due-row:focus-visible .due-row__go {
  opacity: 1;
  transform: none;
}

/* Narrow: the four columns stack into two rows of two. The state's consequence
   line is dropped rather than wrapped - at this width it would be most of the
   row, and the pill above it already says which of five situations this is. */
@media (max-width: 720px) {
  .due-head {
    display: none;
  }

  .due-row {
    grid-template-columns: 34px minmax(0, 1fr) auto;
    grid-template-areas:
      'mark who    amount'
      'mark what   when';
    row-gap: 6px;
  }

  .due-row > .row-mark { grid-area: mark; }
  .due-row__who    { grid-area: who; }
  .due-row__what   { grid-area: what; }
  .due-row__amount { grid-area: amount; }
  .due-row__when   { grid-area: when; }
  .due-row__go     { display: none; }

  .due-row__because {
    display: none;
  }
}

/* The list's own row height. Two lines of text against a two-line trailing
   column, plus the consequence line under the pill - taller than the activity
   feed beside it, which is why it is set here rather than shared. */
#renewals-list {
  --row-h: 78px;
}

/* --- The countdown --------------------------------------------------------
   Tabular and heavier than the meta beneath it, because this is the figure the
   list is sorted by and the one compared down the column.

   The value carries its own preposition ("Due in 12") rather than being a bare
   number, so a row says which direction it counts without the reader having to
   infer it from the pill beside it. That makes it a short phrase instead of one
   or two digits, which will not sit on a baseline beside its unit inside the
   column - so the unit drops to its own line underneath, right-aligned with
   it. */
.due {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  justify-content: flex-end;
  gap: 0 4px;
  font-variant-numeric: tabular-nums;
  line-height: 1.25;
  text-align: right;
}

.due__value {
  font-size: 13px;
  font-weight: 650;
  color: var(--ink-700);
  white-space: nowrap;
}

.due__unit {
  font-size: 10.5px;
  font-weight: 500;
  color: var(--ink-400);
}

/* Already late. The only state in the list where the number is a debt rather
   than a runway, so it takes the full ink the palette reserves for the one
   thing on a card that needs acting on. */
.due--late .due__value {
  color: var(--ink-900);
}

.due--late .due__unit {
  color: var(--ink-600);
}

/* Due today or tomorrow - not late yet, but past the point where it can be
   left until next week. */
.due--soon .due__value {
  color: var(--ink-900);
}

/* --- The grace meter ------------------------------------------------------
   Under a late row only: how much of the grace window is spent before access
   is suspended. A track the width of the trailing column, so a nearly-full bar
   in a column of them is legible as "this one runs out first" without reading
   a figure.

   `--spent` is set inline per row - it is per-datum. */
.due-meter {
  width: 100%;
  max-width: 68px;
  height: 3px;
  margin-top: 6px;
  border-radius: 999px;
  background: var(--ink-150);
  overflow: hidden;
}

.due-meter__bar {
  height: 100%;
  width: var(--spent, 0%);
  border-radius: 999px;

  /* Mid ink, not `--ink-700`. At 68px wide against an 11px caption this bar is
     a large enough area that the darker value read as the heaviest thing in
     the row - louder than the countdown it is qualifying, which is the figure
     the row is actually sorted by. */
  background: var(--ink-500);
}

/* Most of the window gone. Same threshold and same treatment as the storage
   meters, so "nearly out" looks the same wherever the page says it. */
.due-meter__bar--high {
  background: var(--ink-900);
}

/* --- The upgrade line -----------------------------------------------------
   One sentence under the roster states naming the students who have been here
   long enough to be worth a bigger package. It sits inside the insight so it
   inherits the rule above it, and the names are set apart from the prose so
   they can be picked out without reading the sentence.

   Deliberately prose rather than a fifth row: "who can upgrade" is a prompt to
   go and do something on another page, not a figure to track. */
.insight__names {
  font-weight: 600;
  color: var(--ink-800);
}

@media (prefers-reduced-motion: reduce) {
  .roster-states--enter .roster-state__fill {
    animation: none;
  }

  .roster-state__go,
  .due-row__go {
    transition: none;
  }
}

/* --- Roster skeletons -----------------------------------------------------
   The headline pair, four state rows, and the money strip above the due list.
   All three shapes are fixed before their data arrives - there are always two
   figures and always four states - so this is the case the block placeholder is
   for rather than the pulsing mark. */
.sk-hero {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 1.15fr);
  gap: 18px;
  padding-bottom: 18px;
  border-bottom: 1px solid var(--border);
}

.sk-hero__figure {
  display: flex;
  flex-direction: column;
  gap: 8px;
}

/* Drawn at the line heights of the figure and label they stand in for, so the
   card is exactly as tall before its data as after it. */
.sk-hero__value {
  height: 34px;
  width: 58%;
  border-radius: 6px;
}

.sk-hero__label {
  height: 12px;
  width: 76%;
}

.sk-states {
  display: flex;
  flex-direction: column;
  margin-top: 4px;
}

.sk-states__row {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto minmax(72px, 0.9fr) 34px;
  grid-template-areas:
    'label value bar share'
    'foot  foot  bar share';
  align-items: center;
  column-gap: 12px;
  row-gap: 4px;
  padding: 12px 0;
  border-bottom: 1px solid var(--border);
}

.sk-states__row:last-child {
  border-bottom: 0;
}

.sk-states__label { grid-area: label; height: 12px; width: 54%; }
.sk-states__value { grid-area: value; height: 21px; width: 30px; border-radius: 5px; }
.sk-states__bar   { grid-area: bar;   height: 7px;  border-radius: 999px; }
.sk-states__foot  { grid-area: foot;  height: 10px; width: 72%; }

.sk-due-summary {
  display: flex;
  gap: 26px;
  padding: 14px 20px;
  border-bottom: 1px solid var(--border);
}

.sk-due-summary__item {
  display: flex;
  flex-direction: column;
  gap: 5px;
}

.sk-due-summary__value { height: 17px; width: 74px; border-radius: 5px; }
.sk-due-summary__label { height: 11px; width: 92px; }

/* ==========================================================================
   The roster dialogs - a state's members, and one person's standing
   ==========================================================================

   Two dialogs that open from the two cards above and stack on each other: a
   press on "Past due 4" opens the four, and a press on a name in that list
   opens that person on top of it. Both borrow the file preview modal's shell
   rather than inventing a second dialog language - what they add is the shape
   of what goes inside.
   ========================================================================== */

/* Wider than the 560px default. A list of people with a package, a price and a
   countdown on each row is a table, and a table in a 560px box wraps into
   something that has to be read rather than scanned. */
.modal--state {
  max-width: 660px;
}

/* The list runs edge to edge, so the body it sits in adds no padding of its
   own - the same arrangement `card__body--flush` makes on the cards. */
.modal__body--flush {
  padding: 0;
}

.state-modal__list {
  display: flex;
  flex-direction: column;
}

/* A row inside the state list. Lighter than a row in "Payments due" on purpose:
   the reader has already chosen a state, so every row here is in it, and
   repeating that state on each one would be a column of identical pills. */
.member-row {
  display: grid;
  grid-template-columns: 34px minmax(0, 1fr) auto;
  align-items: center;
  gap: 12px;
  padding: 11px 18px;
  border-bottom: 1px solid var(--border);
  width: 100%;
  text-align: left;

  background: none;
  border-left: 0;
  border-right: 0;
  border-top: 0;
  font: inherit;
  color: inherit;
  cursor: pointer;
  transition: background 0.13s linear;
}

.member-row:last-child {
  border-bottom: 0;
}

.member-row:hover {
  background: var(--ink-100);
}

.member-row__text {
  display: flex;
  flex-direction: column;
  gap: 2px;
  min-width: 0;
}

.member-row__name {
  font-size: 13px;
  font-weight: 600;
  color: var(--ink-900);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.member-row__meta {
  font-size: 11.5px;
  color: var(--ink-500);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* The trailing column: what they pay above when it is next due, so the two
   numbers a reader compares down a list of people in one state sit in one
   place. */
.member-row__end {
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  gap: 3px;
  flex: none;
}

.member-row__price {
  font-size: 12.5px;
  font-weight: 640;
  color: var(--ink-800);
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}

.member-row__asked {
  font-size: 10.5px;
  color: var(--ink-400);
  white-space: nowrap;
}

/* The truncation note in the dialog's footer. Pushed to the left so the two
   buttons keep the right edge they have in every other dialog on the page. */
.state-modal__note {
  margin-right: auto;
  font-size: 11.5px;
  color: var(--ink-400);
}

/* --- One person's standing -------------------------------------------------
   Everything the dashboard knows about one student, opened from either list.
   It leads with a summary paragraph rather than with the facts, because the
   facts are what is true and the paragraph is what it means - and the second
   half of that paragraph, the "unless somebody acts" clause, is the part that
   is nowhere else on the page. */
.modal--student {
  max-width: 600px;
}

.modal__head--student {
  align-items: center;
  gap: 13px;
}

.modal__head--student > div {
  flex: 1;
  min-width: 0;
}

/* The same avatar disc the row carried, one size up - so the dialog reads as
   that row grown into a full view rather than as a different surface.

   The picture half of this is not written here: `.student-modal__avatar` is
   listed alongside `.row-mark--avatar` in the shared `[data-avatar-photo]`
   rules further up, which is what clips a portrait to the disc and hides the
   initials behind it. A second copy of those rules would be a second place for
   the failed-load fallback to drift out of agreement. */
.student-modal__portrait {
  display: block;
  flex: none;
}

.student-modal__avatar {
  display: grid;
  place-items: center;
  width: 42px;
  height: 42px;
  border-radius: 50%;
  background: var(--ink-900);
  color: var(--surface);
  font-size: 14px;
  font-weight: 600;
  letter-spacing: 0.02em;
}

/* The summary paragraph. A tinted block with a rule down its left edge, which
   is the treatment this system already uses for the one thing on a surface
   that is being asserted rather than listed. */
.student-modal__line {
  margin: 0;
  padding: 12px 14px;
  border-left: 2px solid var(--ink-300);
  border-radius: 0 var(--radius) var(--radius) 0;
  background: var(--ink-100);
  font-size: 13px;
  line-height: 1.65;
  color: var(--ink-700);
}

/* The rule carries the urgency, in ink rather than in colour - the workspace is
   monochrome, and a red bar here would be the one hue in it. */
.student-modal__line--alert {
  border-left-color: var(--ink-900);
  color: var(--ink-800);
}

.student-modal__line--wait {
  border-left-color: var(--ink-600);
}

.student-modal__line--off,
.student-modal__line--ok {
  border-left-color: var(--ink-300);
}

.student-modal__tags {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px 10px;
  margin-top: 14px;
}

/* The grace meter, with its figure said in words beside it. In the row the bar
   alone is enough - it is compared against the bars above and below it - but
   here there is nothing to compare it to, so it needs the number. */
/* The bar and its caption are one object, so they sit tight against each other
   and the pair is pushed to the far end of the tag row - away from the pills,
   which are a different kind of statement. Left inline among them the bar read
   as a third pill that had lost its label. */
.student-modal__grace {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  margin-left: auto;
}

.student-modal__grace .due-meter {
  width: 52px;
  max-width: none;
  margin-top: 0;
}

.student-modal__grace-text {
  font-size: 11px;
  color: var(--ink-500);
  white-space: nowrap;
}

/* The fact grid, above a rule that separates what is asserted from what is
   merely recorded. */
.student-modal__facts {
  margin-top: 16px;
  padding-top: 15px;
  border-top: 1px solid var(--border);
  gap: 13px 20px;
}

.student-modal__facts .modal__fact-value {
  /* A package name is the one value here that runs longer than its column
     ("Ving Tsun Experience - Level 1 (Interpersonal Journey)"), and wrapping it
     is better than clipping the name of the thing being paid for. */
  overflow-wrap: anywhere;
}

/* Three ways out rather than two, so the close sits apart from the two that go
   somewhere. */
.student-modal__foot {
  flex-wrap: wrap;
}

.student-modal__foot #student-modal-cancel {
  margin-right: auto;
}

@media (max-width: 560px) {
  .modal__facts {
    grid-template-columns: minmax(0, 1fr);
  }

  .student-modal__foot #student-modal-cancel {
    margin-right: 0;
  }
}

/* --- The role control ------------------------------------------------------
   Two options and a commit button, in their own section below the record.

   Everything above this on the sheet is a fact about a person; this is the one
   control that changes what they can reach, and using it signs them out. So it
   is separated rather than sitting inline among the contact fields, where it
   could be changed while correcting a phone number.

   Radios rather than a select: there are exactly two choices, and both should
   be readable without opening anything. A select hides the option not
   currently taken, which is the one the reader is deciding about. */

.role-set {
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.role-opt {
  display: flex;
  align-items: flex-start;
  gap: 11px;
  padding: 12px 14px;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  cursor: pointer;
  transition: border-color 120ms ease, background 120ms ease;
}

.role-opt:hover {
  border-color: var(--border-strong);
}

/* The role the account holds now. Marked by weight and a full-ink border
   rather than a tint, so it reads as the settled state rather than as a
   selection being offered. */
.role-opt.is-current {
  border-color: var(--ink-900);
}

/* The native input, kept in the accessibility tree and operable by keyboard,
   with the dot below drawn against it. `appearance: none` rather than hiding
   it, so focus still lands somewhere real. */
.role-opt__input {
  appearance: none;
  flex: none;
  width: 16px;
  height: 16px;
  margin: 1px 0 0;
  border: 1.5px solid var(--border-strong);
  border-radius: 50%;
  background: transparent;
  cursor: pointer;
  transition: border-color 120ms ease, box-shadow 120ms ease;
}

.role-opt__input:checked {
  border-color: var(--ink-900);
  /* The dot, drawn as an inset ring so it needs no pseudo-element and cannot
     be knocked out of alignment by the label beside it. */
  box-shadow: inset 0 0 0 3.5px var(--surface), inset 0 0 0 8px var(--ink-900);
}

.role-opt__input:focus-visible {
  outline: 2px solid var(--ink-900);
  outline-offset: 2px;
}

.role-opt__text {
  display: flex;
  flex-direction: column;
  gap: 2px;
  min-width: 0;
}

.role-opt__name {
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: 13px;
  font-weight: 600;
  color: var(--ink-900);
}

/* "Current" beside the name it belongs to, at label scale so it reads as an
   annotation rather than as part of the role's name. */
.role-opt__now {
  font-size: 10.5px;
  font-weight: 600;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--ink-500);
}

.role-opt__hint {
  font-size: 12px;
  line-height: 1.5;
  color: var(--ink-500);
}

/* What using this does, said before the press rather than only in the
   confirmation that follows it. */
.role-set__note {
  margin: 2px 0 0;
  font-size: 12px;
  line-height: 1.55;
  color: var(--ink-500);
}

.role-set__note strong {
  color: var(--ink-900);
  font-weight: 600;
}

.role-set__foot {
  display: flex;
  justify-content: flex-end;
  margin-top: 2px;
}

/* Disabled until the selection differs from what the account holds. A commit
   button that would change nothing is a button that lies about what it does,
   and pressing it would sign the member out for no reason. */
.role-set__foot .solid-button:disabled {
  opacity: 0.45;
  cursor: not-allowed;
}

@media (prefers-reduced-motion: reduce) {
  .role-opt,
  .role-opt__input {
    transition: none;
  }
}

/* --- Restore preview ------------------------------------------------------
   The dry run inside the "restore an account" dialog. It has to make one
   distinction legible at a glance: what the file will write, versus what the
   server is refusing to write from it. The refused group is the interesting
   one - it is where an admin sees that a role or a payment status in the file
   is being ignored on purpose - so it gets the marked-off treatment rather
   than reading as an error. */
.restore-preview {
  margin: 0 0 16px;
  padding: 12px 14px;
  border-radius: var(--radius);
  background: var(--ink-100);
  font-size: 12.5px;
  line-height: 1.55;
  color: var(--ink-700);
  /* An account with a full profile still fits, and a pathological one
     scrolls inside the panel rather than pushing the buttons off screen. */
  max-height: 320px;
  overflow-y: auto;
}

.restore-preview__head {
  margin: 0 0 10px;
  color: var(--ink-900);
}

.restore-preview__label {
  margin: 12px 0 5px;
  font-size: 11px;
  font-weight: 600;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--ink-500);
}

.restore-preview__label:first-child {
  margin-top: 0;
}

.restore-preview__list {
  margin: 0;
  padding: 0;
  list-style: none;
}

.restore-preview__list li {
  padding: 3px 0 3px 10px;
  box-shadow: inset 2px 0 0 var(--ink-200);
}

.restore-preview__list code {
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: 11.5px;
  color: var(--ink-900);
}

.restore-preview__note {
  margin: 12px 0 0;
  padding-top: 10px;
  border-top: 1px solid var(--ink-200);
  color: var(--ink-500);
}

/* ==========================================================================
   Staff overview: plain-language layer
   --------------------------------------------------------------------------
   The dashboard as read by someone who runs a school, not a spreadsheet.
   Three additions and three rewrites, all in the page's existing monochrome
   ink: no hue is introduced anywhere, so emphasis comes from inversion, weight
   and tint exactly as it does in the rest of the workspace.
   ========================================================================== */

/* --- Today ---------------------------------------------------------------
   The one inverted surface on the page. Everything else is ink on paper; this
   is paper on ink, which is what makes it the first thing the eye lands on
   without borrowing a colour to shout with. */

.brief {
  margin-bottom: 16px;
  padding: 18px 22px 8px;
  background: var(--ink-900);
  color: var(--ink-150);
  border-radius: var(--radius-lg);
  box-shadow: var(--shadow);
}

.brief__head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 6px 16px;
  flex-wrap: wrap;
}

.brief__title {
  margin: 0;
  font-size: 15.5px;
  font-weight: 600;
  letter-spacing: -0.01em;
  color: var(--page);
}

.brief__sub {
  margin: 0;
  font-size: 12.5px;
  color: var(--ink-300);
}

.brief__list {
  margin: 10px 0 0;
  padding: 0;
  list-style: none;
}

.brief__item {
  display: grid;
  grid-template-columns: 10px minmax(0, 1fr) auto;
  align-items: center;
  gap: 14px;
  padding: 12px 0;
  border-top: 1px solid rgba(244, 243, 240, 0.1);
}

.brief__item:first-child {
  border-top: 0;
}

/* The mark says how much an item costs to ignore, by fill rather than hue:
   solid for money at risk now, ringed for money waiting, dimmer for growth and
   for plain information. */
.brief__mark {
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: var(--ink-600);
}

.brief__item--alert .brief__mark {
  background: var(--page);
  box-shadow: 0 0 0 3px rgba(244, 243, 240, 0.18);
}

.brief__item--due .brief__mark {
  background: transparent;
  box-shadow: inset 0 0 0 1.5px var(--ink-200);
}

.brief__item--grow .brief__mark {
  background: var(--ink-400);
}

.brief__item--clear {
  grid-template-columns: 18px minmax(0, 1fr);
}

.brief__item--clear .brief__mark {
  width: 18px;
  height: 18px;
  background: none;
  color: var(--page);
}

.brief__text {
  font-size: 13.5px;
  line-height: 1.55;
  color: var(--ink-200);
}

.brief__text strong {
  font-weight: 600;
  color: var(--page);
}

.brief__act {
  display: flex;
  justify-content: flex-end;
}

/* A ghost button turned inside out for the dark surface. It fills with paper on
   hover, the same gesture the ghost button makes with ink on the light cards. */
.brief__button {
  display: inline-flex;
  align-items: center;
  padding: 6px 13px;
  background: transparent;
  border: 1px solid rgba(244, 243, 240, 0.3);
  border-radius: 4.5px;
  font: inherit;
  font-size: 12.5px;
  font-weight: 500;
  color: var(--page);
  text-decoration: none;
  white-space: nowrap;
  cursor: pointer;
  transition: background 0.15s ease-out, color 0.15s ease-out, border-color 0.15s ease-out;
}

.brief__button:hover {
  background: var(--page);
  border-color: var(--page);
  color: var(--ink-900);
}

.brief__button:focus-visible {
  outline: 2px solid var(--page);
  outline-offset: 2px;
}

.brief__button.is-busy {
  cursor: progress;
  opacity: 0.7;
}

.brief__button.is-done,
.brief__button.is-done:hover {
  background: transparent;
  border-color: rgba(244, 243, 240, 0.14);
  color: var(--ink-300);
  cursor: default;
}

/* Loading lines, redrawn for the dark surface - the default skeleton sweep is
   paper-coloured and would read as three white bars on the ink. */
.sk-brief {
  display: grid;
  grid-template-columns: 10px minmax(0, 1fr) 96px;
  align-items: center;
  gap: 14px;
  padding: 13px 0;
}

.brief .sk-block {
  background: rgba(244, 243, 240, 0.1);
  animation: none;
}

.sk-brief__mark {
  height: 8px;
  border-radius: 50%;
}

.sk-brief__text {
  height: 12px;
  width: 70%;
}

.sk-brief__act {
  height: 26px;
}

@media (max-width: 640px) {
  .brief {
    padding: 16px 16px 6px;
  }

  .brief__item {
    grid-template-columns: 10px minmax(0, 1fr);
    align-items: start;
  }

  .brief__mark {
    margin-top: 6px;
  }

  .brief__act {
    grid-column: 2;
    justify-content: flex-start;
  }
}

/* --- In short -------------------------------------------------------------
   The chart said in words, on a tint so it is found without hunting for it
   under the plot. Quieter than the today panel on purpose: this explains, the
   panel above instructs. */

.plain-summary {
  display: grid;
  gap: 4px;
  margin-top: 14px;
  padding: 13px 15px;
  background: var(--ink-100);
  border: 1px solid var(--border);
  border-radius: var(--radius);
}

.plain-summary__label,
.section-label {
  margin: 0;
  font-size: 10.5px;
  font-weight: 600;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--ink-500);
}

.plain-summary__text {
  margin: 0;
  max-width: 72ch;
  font-size: 13.5px;
  line-height: 1.6;
  color: var(--ink-700);
}

.plain-summary__text strong {
  font-weight: 600;
  color: var(--ink-900);
  font-variant-numeric: tabular-nums;
}

/* --- Students: one dot per person ----------------------------------------- */

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

.people__value {
  font-size: 40px;
  font-weight: 600;
  line-height: 0.9;
  letter-spacing: -0.03em;
  font-variant-numeric: tabular-nums;
  color: var(--ink-900);
}

.people__label {
  display: flex;
  flex-direction: column;
  gap: 2px;
  font-size: 13px;
  font-weight: 500;
  color: var(--ink-700);
}

.people__aside {
  font-size: 12px;
  font-weight: 400;
  color: var(--ink-500);
}

.people__dots {
  display: flex;
  flex-wrap: wrap;
  gap: 5px;
  margin-top: 16px;
}

/* Fill carries the state, in the same order of weight the pills use: solid
   for settled, a heavy ring for money that failed, a light ring for money not
   yet paid, a faint disc for someone who has only asked. */
.people__dot {
  flex: none;
  width: 12px;
  height: 12px;
  border-radius: 50%;
  background: var(--ink-900);
}

.people__dot--pastDue {
  background: var(--surface);
  box-shadow: inset 0 0 0 2.5px var(--ink-900);
}

.people__dot--awaiting {
  background: transparent;
  box-shadow: inset 0 0 0 1.5px var(--ink-500);
}

.people__dot--interested {
  background: var(--ink-200);
}

.people--enter .people__dots .people__dot {
  animation: people-dot-in 360ms cubic-bezier(0.22, 1, 0.36, 1) both;
}

@keyframes people-dot-in {
  from {
    opacity: 0;
    transform: scale(0.4);
  }
}

@media (prefers-reduced-motion: reduce) {
  .people--enter .people__dots .people__dot {
    animation: none;
  }
}

.people__scale {
  margin: 8px 0 0;
  font-size: 11.5px;
  color: var(--ink-500);
}

/* The key names each kind of dot and opens the people in it. */
.people-key {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 6px;
  margin-top: 16px;
}

.people-key__item {
  display: grid;
  grid-template-columns: 12px minmax(0, 1fr) auto;
  align-items: start;
  gap: 10px;
  padding: 9px 11px;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  font: inherit;
  color: inherit;
  text-align: left;
  text-decoration: none;
  cursor: pointer;
  transition: background 0.12s linear, border-color 0.12s linear;
}

.people-key__item:hover {
  background: var(--ink-100);
  border-color: var(--border-strong);
}

.people-key__item .people__dot {
  margin-top: 3px;
}

.people-key__item--urgent {
  border-color: var(--ink-900);
}

.people-key__item--empty .people-key__name,
.people-key__item--empty .people-key__value {
  color: var(--ink-400);
}

.people-key__text {
  display: flex;
  flex-direction: column;
  gap: 2px;
  min-width: 0;
}

.people-key__name {
  font-size: 13px;
  font-weight: 600;
  color: var(--ink-900);
}

.people-key__foot {
  font-size: 11.5px;
  line-height: 1.4;
  color: var(--ink-500);
}

.people-key__value {
  font-size: 15px;
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  color: var(--ink-900);
}

@media (max-width: 520px) {
  .people-key {
    grid-template-columns: minmax(0, 1fr);
  }
}

/* --- Plans ---------------------------------------------------------------- */

.plans,
.ready {
  margin-top: 20px;
  padding-top: 16px;
  border-top: 1px solid var(--border);
}

.plans:empty {
  display: none;
}

.plans__head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 6px 12px;
  flex-wrap: wrap;
  margin-bottom: 12px;
}

.plans__key {
  display: inline-flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 4px 14px;
  font-size: 11.5px;
  color: var(--ink-500);
}

/* Swatch and label travel together, so a wrap never strands a swatch at the
   end of one line with its meaning on the next. */
.plans__key-item {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  white-space: nowrap;
}

.plans__swatch {
  display: inline-block;
  flex: none;
  width: 10px;
  height: 10px;
  border-radius: 2px;
}

/* Card is solid ink; bank or cash is hatched, because it is the half of the
   bar that is not finished until somebody goes and collects it. */
.plans__swatch--card,
.tier__seg--card {
  background: var(--ink-900);
}

.plans__swatch--hand,
.tier__seg--hand {
  background: repeating-linear-gradient(
    135deg,
    var(--ink-500) 0 2px,
    var(--ink-150) 2px 5px
  );
}

.plans__list {
  display: grid;
  /* minmax(0, ...) rather than the implicit auto track: a grid item will not
     shrink below its content by default, and a long plan name on one line
     pushed the row past the card on a phone. This lets the name truncate. */
  grid-template-columns: minmax(0, 1fr);
  gap: 14px;
  margin: 0;
  padding: 0;
  list-style: none;
}

.tier {
  min-width: 0;
}

.tier__top {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 12px;
}

.tier__name {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-size: 13px;
  font-weight: 500;
  color: var(--ink-900);
}

.tier__price {
  flex: none;
  font-size: 13px;
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  color: var(--ink-900);
}

.tier__cadence {
  font-size: 11.5px;
  font-weight: 400;
  color: var(--ink-500);
}

.tier__track {
  margin-top: 6px;
  height: 8px;
  background: var(--ink-100);
  border-radius: 999px;
  overflow: hidden;
}

.tier__bar {
  display: flex;
  gap: 2px;
  width: var(--share);
  height: 100%;
  border-radius: 999px;
  overflow: hidden;
}

.tier__foot {
  margin-top: 5px;
  font-size: 11.5px;
  color: var(--ink-500);
}

/* --- Ready for a bigger plan ---------------------------------------------- */

.ready__list {
  margin: 0;
  padding: 0;
  list-style: none;
}

.ready-row {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr) auto;
  align-items: center;
  gap: 12px;
  padding: 10px 0;
  border-bottom: 1px solid var(--border);
}

.ready-row:last-child {
  border-bottom: 0;
  padding-bottom: 0;
}

.ready-row__text {
  display: flex;
  flex-direction: column;
  gap: 2px;
  min-width: 0;
}

.ready-row__name {
  font-size: 13.5px;
  font-weight: 600;
  color: var(--ink-900);
}

.ready-row__meta {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-size: 12px;
  color: var(--ink-500);
}

/* Time with the school against a year, so "four months" is a length. */
.ready-row__meter {
  display: block;
  width: min(180px, 100%);
  height: 4px;
  margin-top: 5px;
  background: var(--ink-100);
  border-radius: 999px;
  overflow: hidden;
}

.ready-row__fill {
  display: block;
  width: var(--year);
  height: 100%;
  background: var(--ink-700);
  border-radius: 999px;
}

.ready__none {
  margin: 0;
  font-size: 12.5px;
  line-height: 1.5;
  color: var(--ink-500);
}

/* --- Payments due --------------------------------------------------------- */

.due-line {
  padding: 12px 20px;
  background: var(--ink-100);
  border-top: 1px solid var(--border);
  border-bottom: 1px solid var(--border);
}

.due-line:empty {
  display: none;
}

.due-line__text {
  margin: 0;
  font-size: 13px;
  line-height: 1.55;
  color: var(--ink-700);
}

.due-line__text strong {
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  color: var(--ink-900);
}

.due-item {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto auto;
  align-items: center;
  gap: 12px;
  padding: 11px 20px;
  border-bottom: 1px solid var(--border);
}

.due-item:last-child {
  border-bottom: 0;
}

.due-item__who {
  display: flex;
  align-items: center;
  gap: 11px;
  min-width: 0;
  padding: 0;
  background: none;
  border: 0;
  font: inherit;
  color: inherit;
  text-align: left;
  cursor: pointer;
}

.due-item__who:hover .due-item__name {
  text-decoration: underline;
  text-underline-offset: 3px;
}

.due-item__text {
  display: flex;
  flex-direction: column;
  gap: 1px;
  min-width: 0;
}

.due-item__name {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-size: 13.5px;
  font-weight: 600;
  color: var(--ink-900);
}

.due-item__line {
  font-size: 12.5px;
  color: var(--ink-600);
}

.due-item__line strong {
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  color: var(--ink-900);
}

.due-item--late .due-item__line {
  color: var(--ink-900);
}

.due-item__none {
  font-size: 12px;
  color: var(--ink-400);
  white-space: nowrap;
}

.due-item__act {
  display: flex;
  justify-content: flex-end;
  min-width: 118px;
}

.due-tag {
  display: inline-flex;
  align-items: center;
  padding: 3px 9px;
  border: 1px solid transparent;
  border-radius: 999px;
  font-size: 11.5px;
  font-weight: 500;
  white-space: nowrap;
}

.due-tag--auto,
.due-tag--quiet {
  background: var(--ink-100);
  color: var(--ink-500);
}

.due-tag--hand {
  background: var(--surface);
  border-color: var(--border-strong);
  color: var(--ink-800);
}

.due-tag--alert {
  background: var(--surface);
  border-color: var(--ink-900);
  color: var(--ink-900);
  font-weight: 600;
}

@media (max-width: 560px) {
  .due-item {
    grid-template-columns: minmax(0, 1fr) auto;
    row-gap: 8px;
  }

  .due-item .due-tag {
    grid-column: 1;
    justify-self: start;
    margin-left: 43px;
  }

  .due-item__act {
    min-width: 0;
  }
}

/* --- Latest updates ------------------------------------------------------- */

.update {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr) auto;
  align-items: start;
  gap: 12px;
  padding: 12px 20px;
  border-bottom: 1px solid var(--border);
}

.update:last-child {
  border-bottom: 0;
}

.update__title {
  font-size: 13.5px;
  line-height: 1.45;
  color: var(--ink-700);
}

.update__title strong {
  font-weight: 600;
  color: var(--ink-900);
}

.update__meta {
  margin-top: 2px;
  font-size: 12px;
  color: var(--ink-500);
}

/* The instruction, one step darker than the facts above it so it reads as the
   point of the row rather than as more metadata. */
.update__next {
  margin-top: 6px;
  font-size: 12.5px;
  font-weight: 500;
  color: var(--ink-800);
}

.update__act {
  align-self: center;
}

@media (max-width: 560px) {
  .update {
    grid-template-columns: auto minmax(0, 1fr);
  }

  .update__act {
    grid-column: 2;
    justify-self: start;
  }
}

/* The Students card now carries the dots, the plans and the upgrade list, so it
   is much the taller of the pair. Stretching the payments card to match left a
   screen of empty card under five rows, which reads as missing data. Each card
   keeps its own height instead. */
.grid--bento-roster {
  align-items: start;
}

/* --- Reminder buttons on the light cards ---------------------------------- */

.ghost-button.is-busy {
  cursor: progress;
  opacity: 0.7;
}

.ghost-button.is-done,
.ghost-button.is-done:hover {
  background: var(--surface);
  border-color: var(--border);
  color: var(--ink-500);
  cursor: default;
}

/* ==========================================================================
   Staff overview: bento
   --------------------------------------------------------------------------
   The landing page as two stacked columns. Rows of paired cards stretched
   the shorter card of each pair to the taller one, which is what left pools
   of empty surface under half the page. A column stack lets every card be
   exactly as tall as what it holds: the wide column is money and what to do
   about it, the narrow one is standing state.

   Still strictly monochrome. Emphasis comes from fill, weight and tint, the
   same as everywhere else in the workspace.
   ========================================================================== */

.overview {
  display: grid;
  grid-template-columns: minmax(0, 1.85fr) minmax(0, 1fr);
  gap: 14px;
  align-items: start;
  margin-bottom: 16px;
}

.overview__col {
  display: flex;
  flex-direction: column;
  gap: 14px;
  min-width: 0;
}

/* One column on a narrow screen, in order of urgency rather than in the two
   columns' reading order - the columns dissolve and the cards are ranked. */
@media (max-width: 1080px) {
  .overview {
    display: flex;
    flex-direction: column;
  }

  .overview__col {
    display: contents;
  }

  .overview .card--hero { order: 1; }
  .overview .card--today { order: 2; }
  .overview .card--renewals { order: 3; }
  .overview .card--roster { order: 4; }
  .overview .card--activity { order: 5; }
  .overview .card--agenda { order: 6; }
  .overview .card--side { order: 7; }
}

/* --- Revenue --------------------------------------------------------------
   A fixed, modest plot. Nothing beside it sets its height any more, and a
   510px trend for one line was most of a laptop screen spent on a shape that
   reads in a glance. The figures under it carry the detail. */

.overview #metric-chart {
  flex: 0 0 auto;
  height: 248px;
  min-height: 248px;
  max-height: none;
}

@media (max-width: 760px) {
  .overview #metric-chart {
    height: 210px;
    min-height: 210px;
  }
}

.metric-stats {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  margin: 16px 0 0;
  padding-top: 14px;
  border-top: 1px solid var(--border);
}

.metric-stats__cell {
  min-width: 0;
  padding: 0 16px;
  border-left: 1px solid var(--border);
}

.metric-stats__cell:first-child {
  padding-left: 0;
  border-left: 0;
}

.metric-stats dd {
  margin-left: 0;
}

.metric-stats__label {
  font-size: 11.5px;
  font-weight: 500;
  color: var(--ink-500);
}

.metric-stats__value {
  margin-top: 4px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-size: 20px;
  font-weight: 600;
  letter-spacing: -0.02em;
  font-variant-numeric: tabular-nums;
  color: var(--ink-900);
}

.metric-stats__foot {
  margin-top: 2px;
  font-size: 11.5px;
  color: var(--ink-400);
}

.metric-stats__note {
  margin: 10px 0 0;
  font-size: 12px;
  color: var(--ink-500);
}

@media (max-width: 560px) {
  .metric-stats {
    grid-template-columns: minmax(0, 1fr);
    gap: 12px;
  }

  .metric-stats__cell {
    padding: 0;
    border-left: 0;
  }
}

/* --- Needs you today ------------------------------------------------------
   A light card like its neighbours, not an inverted slab: it is the first
   list on the page because of where it sits, and the marks down its left
   edge say how much each line costs to ignore - filled ink for money at risk
   now, a ring for money waiting, a tint for growth and plain information. */

.today {
  margin: 12px 0 0;
  padding: 0;
  list-style: none;
}

.today__item {
  position: relative;
  display: grid;
  grid-template-columns: 34px minmax(0, 1fr) auto;
  align-items: center;
  gap: 14px;
  padding: 12px 20px;
  border-top: 1px solid var(--border);
  transition: background 0.12s linear;
}

.today__item:hover {
  background: var(--page);
}

/* Money at risk carries a hairline of ink down its edge as well as the filled
   mark, so a list of six still sorts itself at a glance. */
.today__item--alert::before {
  content: '';
  position: absolute;
  top: 10px;
  bottom: 10px;
  left: 0;
  width: 2px;
  border-radius: 0 2px 2px 0;
  background: var(--ink-900);
}

.today__icon {
  display: grid;
  place-items: center;
  width: 34px;
  height: 34px;
  border-radius: 7px;
  background: var(--ink-100);
  color: var(--ink-600);
}

.today__item--alert .today__icon {
  background: var(--ink-900);
  color: var(--surface);
}

.today__item--due .today__icon {
  background: var(--surface);
  box-shadow: inset 0 0 0 1.5px var(--ink-300);
  color: var(--ink-800);
}

.today__item--grow .today__icon {
  color: var(--ink-800);
}

.today__text {
  font-size: 13.5px;
  line-height: 1.5;
  color: var(--ink-600);
}

.today__text strong {
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  color: var(--ink-900);
}

.today__act {
  display: flex;
  align-items: center;
  justify-content: flex-end;
  gap: 6px;
}

/* Nothing to do is a finished state, said calmly and centred, rather than an
   empty list with a border round it. */
.today__clear {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 4px;
  padding: 26px 20px 30px;
  border-top: 1px solid var(--border);
  text-align: center;
}

.today__clear-mark {
  display: grid;
  place-items: center;
  width: 40px;
  height: 40px;
  margin-bottom: 8px;
  border-radius: 50%;
  background: var(--ink-100);
  color: var(--ink-800);
}

.today__clear-title {
  font-size: 14px;
  font-weight: 600;
  color: var(--ink-900);
}

.today__clear-hint {
  max-width: 44ch;
  font-size: 12.5px;
  line-height: 1.5;
  color: var(--ink-500);
}

/* The loading lines, redrawn for the light card - the old ones were tinted
   for the inverted panel this replaces. */
.today .sk-brief {
  grid-template-columns: 34px minmax(0, 1fr) 96px;
  padding: 12px 20px;
  border-top: 1px solid var(--border);
}

.today .sk-brief__mark {
  width: 34px;
  height: 34px;
  border-radius: 7px;
}

@media (max-width: 640px) {
  .today__item {
    grid-template-columns: 34px minmax(0, 1fr);
    align-items: start;
  }

  .today__act {
    grid-column: 2;
    justify-content: flex-start;
  }
}

/* --- Small buttons --------------------------------------------------------
   The filled button at the same 30px as `.ghost-button--sm`, for the one
   commit a row offers - "Collected" - set beside its quieter "Remind". */

.solid-button--sm {
  height: 30px;
  padding: 0 11px;
  font-size: 12px;
  gap: 5px;
  white-space: nowrap;
}

.solid-button.is-busy {
  cursor: progress;
  opacity: 0.7;
}

.solid-button.is-done,
.solid-button.is-done:hover {
  background: var(--ink-100);
  border-color: var(--ink-100);
  color: var(--ink-600);
  cursor: default;
}

.solid-button:disabled {
  cursor: default;
}

.ghost-button--sm,
.solid-button--sm {
  text-decoration: none;
}

.due-item__act {
  gap: 6px;
}

.due-tag {
  gap: 5px;
}

.due-tag svg {
  flex: none;
  opacity: 0.85;
}

/* --- How a student pays ---------------------------------------------------
   A round mark, the same everywhere it appears: a tinted disc with a card for
   a subscription the card pays on its own, an ink ring with a note for money
   somebody has to collect - the ring is the one that asks for a person, so it
   is the heavier of the two. A one-time package sits back further still. */

.pay-mark {
  display: inline-grid;
  place-items: center;
  flex: none;
  width: 26px;
  height: 26px;
  border-radius: 50%;
}

.pay-mark--auto {
  background: var(--ink-100);
  color: var(--ink-600);
}

.pay-mark--hand {
  background: var(--surface);
  box-shadow: inset 0 0 0 1.5px var(--ink-900);
  color: var(--ink-900);
}

.pay-mark--once {
  background: var(--ink-100);
  color: var(--ink-400);
}

/* --- Students -------------------------------------------------------------- */

.roster-head {
  padding: 2px 20px 16px;
  border-bottom: 1px solid var(--border);
}

.roster-stats {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  background: var(--page);
  border: 1px solid var(--border);
  border-radius: var(--radius);
}

.roster-stat {
  display: flex;
  flex-direction: column;
  gap: 3px;
  min-width: 0;
  padding: 11px 12px;
  border-left: 1px solid var(--border);
}

.roster-stat:first-child {
  border-left: 0;
}

.roster-stat__value {
  display: flex;
  align-items: center;
  gap: 7px;
  font-size: 20px;
  font-weight: 600;
  line-height: 1.1;
  letter-spacing: -0.02em;
  font-variant-numeric: tabular-nums;
  color: var(--ink-900);
}

.roster-stat .pay-mark {
  width: 22px;
  height: 22px;
}

.roster-stat__label {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-size: 11.5px;
  color: var(--ink-500);
}

.roster-rate {
  margin: 10px 0 0;
  font-size: 12.5px;
  color: var(--ink-500);
}

.roster-rate strong {
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  color: var(--ink-900);
}

.roster-chips {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  margin-top: 12px;
}

.roster-chip {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  height: 28px;
  padding: 0 11px 0 4px;
  background: var(--surface);
  border: 1px solid var(--border-strong);
  border-radius: 999px;
  font: inherit;
  font-size: 12px;
  color: var(--ink-700);
  text-decoration: none;
  white-space: nowrap;
  cursor: pointer;
  transition: background 0.12s linear, border-color 0.12s linear;
}

.roster-chip:hover {
  background: var(--ink-100);
  border-color: var(--ink-400);
}

.roster-chip__n {
  display: inline-grid;
  place-items: center;
  min-width: 20px;
  height: 20px;
  padding: 0 6px;
  border-radius: 999px;
  background: var(--ink-100);
  font-size: 11px;
  font-weight: 650;
  font-variant-numeric: tabular-nums;
  color: var(--ink-900);
}

.roster-chip--alert {
  border-color: var(--ink-900);
  color: var(--ink-900);
}

.roster-chip--alert .roster-chip__n {
  background: var(--ink-900);
  color: var(--surface);
}

.roster-list {
  display: flex;
  flex-direction: column;
}

.roster-row {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr) auto;
  align-items: center;
  gap: 11px;
  width: 100%;
  padding: 10px 20px;
  background: none;
  border: 0;
  border-bottom: 1px solid var(--border);
  font: inherit;
  color: inherit;
  text-align: left;
  cursor: pointer;
  transition: background 0.12s linear;
}

.roster-row:last-child {
  border-bottom: 0;
}

.roster-row:hover {
  background: var(--page);
}

.roster-row:focus-visible {
  outline: 2px solid var(--ink-900);
  outline-offset: -2px;
}

.roster-row__text {
  display: flex;
  flex-direction: column;
  gap: 1px;
  min-width: 0;
}

.roster-row__name,
.roster-row__meta {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.roster-row__name {
  font-size: 13.5px;
  font-weight: 600;
  color: var(--ink-900);
}

.roster-row__meta {
  font-size: 12px;
  color: var(--ink-500);
}

.roster-row__end {
  display: inline-flex;
  align-items: center;
  gap: 8px;
}

.roster-foot {
  border-top: 1px solid var(--border);
}

.roster-foot:empty {
  display: none;
}

.roster-more {
  display: flex;
  justify-content: center;
  padding: 11px 20px;
  font-size: 12.5px;
  font-weight: 500;
  color: var(--ink-700);
  text-decoration: none;
  transition: background 0.12s linear, color 0.12s linear;
}

.roster-more:hover {
  background: var(--page);
  color: var(--ink-900);
}

/* --- Storage, in the narrow column ----------------------------------------- */

.overview #storage-chart {
  height: 220px;
  min-height: 220px;
}

/* --- Upcoming, as an agenda -------------------------------------------------
   The narrow column has no room for a week of side-by-side columns, so each
   day becomes a row: the date in a fixed gutter, its sessions stacked beside
   it. Same cells and chips as the mini week, rearranged. */

.mini-week--agenda {
  grid-auto-flow: row;
  grid-auto-columns: auto;
  min-height: 0;
}

.mini-week--agenda .mini-week__day {
  display: grid;
  grid-template-columns: 56px minmax(0, 1fr);
  border-bottom: 0;
}

.mini-week--agenda .mini-week__head {
  flex-direction: column;
  justify-content: center;
  padding: 10px 4px;
  border-bottom: 0;
  border-right: 1px solid var(--border);
}

.mini-week--agenda .mini-week__slots {
  flex-direction: column;
  flex-wrap: nowrap;
  gap: 6px;
  padding: 8px;
}

.mini-week--agenda .mini-week__event {
  flex: none;
}

/* --- Collected dialog ------------------------------------------------------ */

.modal--collect {
  max-width: 440px;
}

.modal--collect > form {
  display: flex;
  flex: 1;
  flex-direction: column;
  min-height: 0;
}

.collect-amount {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 12px;
  margin-bottom: 16px;
  padding: 14px 16px;
  background: var(--page);
  border: 1px solid var(--border);
  border-radius: var(--radius);
}

.collect-amount__label {
  font-size: 12.5px;
  color: var(--ink-500);
}

.collect-amount__value {
  font-size: 22px;
  font-weight: 600;
  letter-spacing: -0.02em;
  font-variant-numeric: tabular-nums;
  color: var(--ink-900);
}

.field__optional {
  margin-left: 4px;
  font-size: 11.5px;
  font-weight: 400;
  color: var(--ink-400);
}

.collect-note {
  margin: 4px 0 0;
  font-size: 12.5px;
  line-height: 1.55;
  color: var(--ink-500);
}

/* --- Figure skeletons -------------------------------------------------------- */

.sk-figures {
  display: grid;
  grid-template-columns: repeat(var(--n, 3), minmax(0, 1fr));
  gap: 16px;
  margin-top: 16px;
  padding-top: 14px;
  border-top: 1px solid var(--border);
}

.roster-head .sk-figures {
  margin-top: 0;
  padding: 12px;
  border: 1px solid var(--border);
  border-radius: var(--radius);
}

.sk-figures__cell {
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.sk-figures__label {
  width: 60%;
  height: 10px;
}

.sk-figures__value {
  width: 75%;
  height: 20px;
}

/* ==========================================================================
   Seamless chrome
   --------------------------------------------------------------------------
   The nav's raised pill and each segmented control's pill are placed by JS,
   which on this app runs ~800ms after first paint. Until then the selected
   item had no ground at all, so every load showed the nav flat and then
   popped the pill in under the current page. The selected item now carries
   the same surface from the first frame, and hands it to the pill the moment
   the pill is ready - the two are the same size and colour, so the handover
   is invisible.
   ========================================================================== */

.switcher__track:not(:has(> .switcher__pill.is-ready)) .switcher__link[aria-current='page'] {
  background: var(--surface);
  box-shadow: var(--shadow-sm);
}

.segmented:not(:has(> .segmented__pill.is-ready)) button[aria-pressed='true'] {
  background: var(--surface);
  box-shadow: var(--shadow-sm);
}

/* The list cards reserve five rows' height so the skeleton and the rows it
   becomes are the same size. Once real rows have landed the reservation has
   done its job, and holding it under a shorter list only left a band of empty
   card - so on the overview it is released after load. */
.overview [data-rows]:not(:has(.sk-row, .brand-loader)) {
  min-height: 0;
}

/* A by-hand row carries two buttons. Beside the name on a phone they squeezed
   both the name and the tag down to an ellipsis, so on a narrow screen the row
   stacks: who, then the tag, then the actions, all on the name's left edge. */
@media (max-width: 560px) {
  .overview .due-item {
    grid-template-columns: minmax(0, 1fr);
  }

  .overview .due-item .due-tag,
  .overview .due-item__act {
    grid-column: 1;
    justify-self: start;
    justify-content: flex-start;
    margin-left: 45px;
  }
}

/* --- Pay marks, larger and borderless --------------------------------------
   At 26px with a 14px glyph and a hairline ring the marks were too small to
   tell apart at a glance. They are now 34px - the same size as the avatar
   beside them - with an 18px glyph and no border. The difference is carried
   by fill alone: a solid ink disc for money somebody has to collect (the one
   that asks for a person), a soft tint for a card that pays itself, and a
   paler tint for a one-time package. */
.pay-mark {
  width: 34px;
  height: 34px;
  box-shadow: none;
}

.pay-mark--auto {
  background: var(--ink-150);
  color: var(--ink-800);
}

.pay-mark--hand {
  background: var(--ink-900);
  box-shadow: none;
  color: var(--surface);
}

.pay-mark--once {
  background: var(--ink-100);
  color: var(--ink-500);
}

.roster-stat .pay-mark {
  width: 28px;
  height: 28px;
}

.pay-mark svg {
  stroke-width: 1.8;
}

/* Storage's sentence is now a warning only, and empty otherwise - its rule
   and padding go with it. */
.overview .insight:empty {
  display: none;
}

/* The storage legend reserves five rows (174px) so the skeleton and the legend
   match. An account with three file types draws four, which left a band of
   blank card under it - released once the real rows have landed. */
.overview #storage-legend:not(:has(.brand-loader, .sk-legend)) {
  min-height: 0;
}

/* ==========================================================================
   Student sheet: payments
   --------------------------------------------------------------------------
   The ledger for one student, with in-place correction of the payments a
   person recorded. One ruled list like the plan history above it; a row
   being edited opens into a small form where it stands, so the reader never
   loses which payment they were fixing.
   ========================================================================== */

.pay-list {
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--surface);
}

.pay-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  padding: 11px 14px;
  border-top: 1px solid var(--border);
}

.pay-row:first-child {
  border-top: 0;
}

.pay-row--confirm {
  background: var(--page);
}

.pay-row--editing {
  display: block;
  background: var(--page);
}

.pay-row__main {
  display: flex;
  flex-direction: column;
  gap: 2px;
  min-width: 0;
}

.pay-row__amount {
  font-size: 14.5px;
  font-weight: 600;
  letter-spacing: -0.01em;
  font-variant-numeric: tabular-nums;
  color: var(--ink-900);
}

.pay-row__amount--refund {
  color: var(--ink-500);
}

.pay-row__meta {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-size: 12px;
  color: var(--ink-500);
}

.pay-row__end {
  display: flex;
  align-items: center;
  flex: none;
  gap: 6px;
}

.pay-row__ask {
  margin-right: 4px;
  font-size: 12.5px;
  font-weight: 500;
  color: var(--ink-900);
}

.pay-form {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-end;
  gap: 10px;
}

.pay-form__field {
  display: flex;
  flex-direction: column;
  gap: 5px;
  flex: 1 1 140px;
  min-width: 0;
}

.pay-form__label {
  font-size: 11.5px;
  font-weight: 500;
  color: var(--ink-600);
}

.pay-form .field__input {
  height: 34px;
  padding-top: 6px;
  padding-bottom: 6px;
}

.pay-form__actions {
  display: flex;
  gap: 6px;
  margin-left: auto;
}

.pay-note {
  margin: 8px 0 0;
  font-size: 12px;
  line-height: 1.5;
  color: var(--ink-500);
}

@media (max-width: 560px) {
  .pay-row {
    flex-direction: column;
    align-items: flex-start;
  }

  .pay-form__actions {
    width: 100%;
    justify-content: flex-end;
  }
}

/* ==========================================================================
   Dashboard dialogs: record a payment, send a reminder
   --------------------------------------------------------------------------
   Both actions touch a student - one writes to their payment record, one
   emails them - so each opens a dialog that says exactly what will happen
   before anything does. Same modal shell as the rest of the page.
   ========================================================================== */

.modal--collect {
  max-width: 520px;
}

.collect-grid {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
  gap: 12px;
}

.collect-grid .field {
  margin-bottom: 0;
}

.collect-money {
  display: flex;
  align-items: stretch;
}

.collect-money__code {
  display: grid;
  place-items: center;
  padding: 0 10px;
  background: var(--ink-100);
  border: 1px solid var(--border-strong);
  border-right: 0;
  border-radius: 4.5px 0 0 4.5px;
  font-size: 12px;
  font-weight: 600;
  color: var(--ink-600);
}

.collect-money .field__input {
  border-radius: 0 4.5px 4.5px 0;
  font-variant-numeric: tabular-nums;
}

.collect-hint {
  margin: 6px 0 0;
  font-size: 12px;
  line-height: 1.45;
  color: var(--ink-600);
}

.collect-plan {
  margin: 18px 0 0;
  padding: 0;
  border: 0;
}

.collect-plan legend {
  margin-bottom: 8px;
  padding: 0;
}

.collect-option {
  display: flex;
  align-items: flex-start;
  gap: 10px;
  padding: 10px 12px;
  border: 1px solid var(--border);
  border-radius: 4.5px;
  cursor: pointer;
  transition: border-color 0.12s linear, background 0.12s linear;
}

.collect-option + .collect-option,
.collect-change + .collect-option {
  margin-top: 6px;
}

.collect-option:hover {
  background: var(--page);
}

.collect-option:has(input:checked) {
  border-color: var(--ink-900);
  background: var(--page);
}

.collect-option input {
  margin: 3px 0 0;
  accent-color: var(--ink-900);
}

.collect-option__text {
  display: flex;
  flex-direction: column;
  gap: 1px;
}

.collect-option__name {
  font-size: 13.5px;
  font-weight: 600;
  color: var(--ink-900);
}

.collect-option__hint {
  font-size: 12px;
  color: var(--ink-500);
}

.collect-change {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
  gap: 12px;
  margin-top: 6px;
  padding: 12px;
  border: 1px solid var(--border);
  border-top: 0;
  border-radius: 0 0 4.5px 4.5px;
}

.collect-change[hidden] {
  display: none;
}

.collect-change .field {
  margin-bottom: 0;
}

.collect-summary {
  margin-top: 18px;
  padding-top: 14px;
  border-top: 1px solid var(--border);
}

.collect-summary__title,
.remind-part__label {
  margin: 0 0 8px;
  font-size: 10.5px;
  font-weight: 600;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--ink-500);
}

.collect-summary__list {
  margin: 0;
  padding: 0 0 0 18px;
  font-size: 13.5px;
  line-height: 1.55;
  color: var(--ink-700);
}

.collect-summary__list li + li {
  margin-top: 4px;
}

.collect-summary__list strong {
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  color: var(--ink-900);
}

@media (max-width: 520px) {
  .collect-grid,
  .collect-change {
    grid-template-columns: minmax(0, 1fr);
  }
}

/* --- Reminder preview ------------------------------------------------------ */

.modal--remind {
  max-width: 560px;
}

.remind-lead {
  margin: 0 0 14px;
  font-size: 13.5px;
  line-height: 1.55;
  color: var(--ink-700);
}

.remind-lead strong {
  font-weight: 600;
  color: var(--ink-900);
}

.remind-part {
  padding: 12px 0;
  border-top: 1px solid var(--border);
}

.remind-part__title {
  margin: 0 0 4px;
  font-size: 13.5px;
  font-weight: 600;
  color: var(--ink-900);
}

.remind-part__text {
  margin: 0;
  font-size: 13px;
  line-height: 1.55;
  color: var(--ink-600);
}

/* The email as plain text, in its own line breaks - a faithful preview of
   what arrives, not a second rendering of it. */
.remind-part__text--email {
  max-height: 220px;
  overflow-y: auto;
  padding: 10px 12px;
  background: var(--page);
  border: 1px solid var(--border);
  border-radius: 4.5px;
  white-space: pre-wrap;
}

.remind-note,
.remind-loading {
  margin: 12px 0 0;
  font-size: 13px;
  line-height: 1.5;
  color: var(--ink-600);
}

.remind-loading {
  margin: 0;
}

/* ==========================================================================
   Overview, calmer
   --------------------------------------------------------------------------
   The three lists on the landing page - Needs you today, Payments due and
   Students - named the same few people three times, each time with its own
   set of black buttons and black discs. Read together they were a wall of
   alarms on a page where most of the news is good.

   So each card now answers one question, in one voice:

     Needs you today  what to do, one quiet button per line
     Payments due     when money arrives - a schedule, no buttons
     Students         who is on a plan - a headcount, a split, a list

   Ink is spent only where something is wrong. Every row opens the person,
   and the person's dialog is where the actions live.
   ========================================================================== */

/* --- Shared: status words ------------------------------------------------
   A small dot and a word under a name: what the row's situation is. The dot
   carries the weight - full ink for money lost or at risk, a ring for money
   someone has to go and get, a pale dot for a notice. */

.status {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-size: 12px;
  color: var(--ink-500);
}

.status::before {
  content: '';
  flex: none;
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: var(--ink-300);
}

.status--alert {
  font-weight: 550;
  color: var(--ink-900);
}

.status--alert::before {
  background: var(--ink-900);
}

.status--wait {
  color: var(--ink-700);
}

.status--wait::before {
  background: transparent;
  box-shadow: inset 0 0 0 1.5px var(--ink-700);
}

/* "Show more" under a list: a full-width quiet row, the same as the
   "See all" foot the Students card already ends on. */
.list-more {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 6px;
  width: 100%;
  padding: 11px 20px;
  background: none;
  border: 0;
  border-top: 1px solid var(--border);
  font: inherit;
  font-size: 12.5px;
  font-weight: 500;
  color: var(--ink-600);
  cursor: pointer;
  transition: background 0.12s linear, color 0.12s linear;
}

.list-more:hover {
  background: var(--page);
  color: var(--ink-900);
}

.list-more[hidden] {
  display: none;
}

.list-more svg {
  transform: rotate(90deg);
  transition: transform 0.18s ease;
}

.list-more[aria-expanded='true'] svg {
  transform: rotate(-90deg);
}

.list-more:focus-visible {
  outline: 2px solid var(--ink-900);
  outline-offset: -2px;
}

/* --- Needs you today ------------------------------------------------------ */

.todo {
  display: grid;
  grid-template-columns: 30px minmax(0, 1fr) auto;
  align-items: center;
  gap: 14px;
  padding: 12px 20px;
  border-top: 1px solid var(--border);
  transition: background 0.12s linear;
}

.todo:hover {
  background: var(--page);
}

.today:not([data-expanded]) .todo--extra {
  display: none;
}

.todo__mark {
  display: grid;
  place-items: center;
  width: 30px;
  height: 30px;
  border-radius: 50%;
  background: var(--ink-100);
  color: var(--ink-500);
}

.todo--alert .todo__mark {
  background: var(--ink-900);
  color: var(--surface);
}

.todo--due .todo__mark {
  background: var(--surface);
  box-shadow: inset 0 0 0 1.5px var(--ink-300);
  color: var(--ink-800);
}

.todo__main {
  display: flex;
  flex-direction: column;
  gap: 2px;
  min-width: 0;
  padding: 0;
  background: none;
  border: 0;
  font: inherit;
  color: inherit;
  text-align: left;
}

button.todo__main {
  cursor: pointer;
}

button.todo__main:hover .todo__title {
  text-decoration: underline;
  text-decoration-color: var(--ink-300);
  text-underline-offset: 3px;
}

button.todo__main:focus-visible {
  outline: 2px solid var(--ink-900);
  outline-offset: 3px;
  border-radius: 2px;
}

.todo__title {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-size: 13.5px;
  font-weight: 600;
  color: var(--ink-900);
}

.todo__detail {
  font-size: 12.5px;
  line-height: 1.45;
  color: var(--ink-500);
}

.todo__detail strong {
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  color: var(--ink-800);
}

.todo__act {
  display: flex;
  justify-content: flex-end;
}

/* --- Payments due ---------------------------------------------------------- */

.due-sum {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  margin: 0 20px 4px;
  padding: 16px 0;
  border-bottom: 1px solid var(--border);
}

.due-sum:empty {
  display: none;
}

.due-sum .sk-figures {
  grid-column: 1 / -1;
  margin-top: 0;
  padding-top: 0;
  border-top: 0;
}

.due-sum__cell {
  display: flex;
  flex-direction: column;
  gap: 3px;
  min-width: 0;
  padding: 0 16px;
  border-left: 1px solid var(--border);
}

.due-sum__cell:first-child {
  padding-left: 0;
  border-left: 0;
}

.due-sum__label {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  font-size: 11.5px;
  font-weight: 500;
  color: var(--ink-500);
}

.due-sum__cell--alert .due-sum__label::before {
  content: '';
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: var(--ink-900);
}

.due-sum__value {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-size: 20px;
  font-weight: 600;
  line-height: 1.2;
  letter-spacing: -0.02em;
  font-variant-numeric: tabular-nums;
  color: var(--ink-900);
}

.due-sum__foot {
  font-size: 11.5px;
  color: var(--ink-400);
}

.due-group + .due-group {
  border-top: 1px solid var(--border);
}

.due-group__label {
  padding: 12px 20px 4px;
  font-size: 10.5px;
  font-weight: 650;
  letter-spacing: 0.07em;
  text-transform: uppercase;
  color: var(--ink-400);
}

.sched-row {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr) auto;
  align-items: center;
  gap: 12px;
  width: 100%;
  padding: 9px 20px;
  background: none;
  border: 0;
  font: inherit;
  color: inherit;
  text-align: left;
  cursor: pointer;
  transition: background 0.12s linear;
}

.sched-row:last-child {
  padding-bottom: 12px;
}

.sched-row:hover {
  background: var(--page);
}

.sched-row:focus-visible {
  outline: 2px solid var(--ink-900);
  outline-offset: -2px;
}

.sched-row__text,
.sched-row__end {
  display: flex;
  flex-direction: column;
  gap: 2px;
  min-width: 0;
}

.sched-row__end {
  align-items: flex-end;
  text-align: right;
}

.sched-row__name {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-size: 13.5px;
  font-weight: 600;
  color: var(--ink-900);
}

.sched-row__amount {
  font-size: 13.5px;
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
  color: var(--ink-900);
}

/* A plan that is leaving brings nothing in - its figure is for reference. */
.sched-row__amount--off {
  font-weight: 500;
  color: var(--ink-400);
  text-decoration: line-through;
  text-decoration-color: var(--ink-300);
}

.sched-row__when {
  font-size: 12px;
  white-space: nowrap;
  color: var(--ink-500);
}

/* --- Students -------------------------------------------------------------- */

.card--roster .roster-head {
  padding: 4px 20px 16px;
}

.roster-sum {
  display: flex;
  align-items: flex-end;
  justify-content: space-between;
  gap: 16px;
}

.roster-sum__figure {
  display: flex;
  flex-direction: column;
  gap: 2px;
  min-width: 0;
}

.roster-sum__figure--end {
  align-items: flex-end;
  text-align: right;
}

.roster-sum__value {
  font-size: 30px;
  font-weight: 600;
  line-height: 1.05;
  letter-spacing: -0.03em;
  font-variant-numeric: tabular-nums;
  color: var(--ink-900);
}

.roster-sum__value--money {
  font-size: 16px;
  letter-spacing: -0.01em;
}

.roster-sum__label {
  font-size: 12px;
  color: var(--ink-500);
}

.split {
  margin-top: 14px;
}

.split__bar {
  display: flex;
  gap: 3px;
  height: 6px;
}

.split__seg {
  min-width: 6px;
  border-radius: 999px;
}

.split__seg--card,
.split__dot--card {
  background: var(--ink-200);
}

.split__seg--hand,
.split__dot--hand {
  background: var(--ink-800);
}

.split__key {
  display: flex;
  flex-wrap: wrap;
  justify-content: space-between;
  gap: 4px 14px;
  margin-top: 9px;
}

.split__item {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  font-size: 12px;
  color: var(--ink-500);
}

.split__item strong {
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  color: var(--ink-900);
}

.split__dot {
  width: 8px;
  height: 8px;
  border-radius: 50%;
}

.roster-lead {
  display: flex;
  align-items: center;
  gap: 10px;
  width: 100%;
  margin-top: 14px;
  padding: 9px 10px 9px 9px;
  background: var(--page);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  font: inherit;
  font-size: 12.5px;
  color: var(--ink-600);
  text-align: left;
  text-decoration: none;
  cursor: pointer;
  transition: border-color 0.12s linear, background 0.12s linear;
}

.roster-lead:hover {
  background: var(--ink-100);
  border-color: var(--border-strong);
}

.roster-lead:focus-visible {
  outline: 2px solid var(--ink-900);
  outline-offset: 2px;
}

.roster-lead__mark {
  display: grid;
  place-items: center;
  flex: none;
  width: 26px;
  height: 26px;
  border-radius: 50%;
  background: var(--surface);
  color: var(--ink-700);
}

.roster-lead__text {
  flex: 1;
  min-width: 0;
}

.roster-lead__text strong {
  font-weight: 600;
  color: var(--ink-900);
}

.roster-lead__go {
  display: grid;
  color: var(--ink-400);
}

.pay-word {
  font-size: 12px;
  white-space: nowrap;
  color: var(--ink-400);
}

.pay-word--hand {
  color: var(--ink-700);
}

/* --- Student dialog: links out, then what to do ------------------------------ */

.student-modal__foot {
  align-items: center;
}

.student-modal__links {
  display: flex;
  flex-wrap: wrap;
  gap: 4px 16px;
  margin-right: auto;
}

.text-link {
  font-size: 12.5px;
  font-weight: 500;
  color: var(--ink-600);
  text-decoration: underline;
  text-decoration-color: var(--ink-200);
  text-underline-offset: 3px;
  transition: color 0.12s linear, text-decoration-color 0.12s linear;
}

.text-link:hover {
  color: var(--ink-900);
  text-decoration-color: var(--ink-500);
}

.student-modal__actions {
  display: flex;
  gap: 8px;
}

.student-modal__actions:empty {
  display: none;
}

.student-modal__foot #student-modal-cancel {
  margin-right: 0;
}

.student-modal__foot #student-modal-cancel[hidden] {
  display: none;
}

/* --- Narrow screens --------------------------------------------------------- */

@media (max-width: 640px) {
  .todo {
    grid-template-columns: 30px minmax(0, 1fr);
    align-items: start;
    row-gap: 10px;
  }

  .todo__act {
    grid-column: 2;
    justify-content: flex-start;
  }

  .due-sum__cell {
    padding: 0 10px;
  }

  .due-sum__value {
    font-size: 16px;
  }

  .student-modal__links {
    width: 100%;
    margin-bottom: 4px;
  }

  .student-modal__actions {
    flex: 1;
  }

  .student-modal__actions > * {
    flex: 1;
    justify-content: center;
  }
}
