/* ============================================================
   Shared component classes — CAU Meets design system.

   For every agent styling Web, Telegram, or the admin panel. Read this
   comment before adding a class here or reaching for a new magic number.

   WHAT THIS IS FOR
   A university's internal tool, used on a laptop for hours at a time and on a
   phone in a corridor. Dense, quiet, hairline-ruled; the same components serve
   a 1440px admin table and a 375px Mini App list, because a booking queue does
   not become a different thing on a smaller screen. Nothing here fills a
   surface with gold, and nothing here uses a gradient.

   LOAD ORDER
   This file defines no custom properties and no reset of its own — every
   class below consumes tokens from app.css (--space-*, --radius-*, --fs-*,
   --lh-*, --weight-*, --size-*, and the colour palette on :root and in the
   dark blocks), plus its global `*{box-sizing:border-box}`. Load app.css (or
   whatever stylesheet defines that same token set) before this file, e.g.
       <link rel="stylesheet" href="css/app.css">
       <link rel="stylesheet" href="css/components.css">

   NAMESPACE AND STATE
   Every class here is prefixed `ds-` (design system) and every sub-part uses
   a BEM-style `__part` / `--modifier` suffix — ".ds-card__title", not
   ".card-title". app.css already owns plain names like .card, .chip, .st,
   .sheet, .badge for the Mini App's own markup; the prefix guarantees this
   file can never collide with those, or with whatever web.css / admin.css /
   telegram.css define next to it. Do not add an unprefixed class here, and
   do not rename an existing app.css class to reuse it — extend the ds-*
   family instead.

   Two state classes, and only two: `.ds-open` for anything that appears over
   the page (scrim, dialog, modal, drawer, sheet, toast) and `.ds-active` for
   the chosen one of a set (tab, segment). `.ds-invalid` marks a field the
   server or the form rejected. A component's own markup carries no other
   state class, so a consumer never has to guess between .on / .selected /
   .is-active the way the three shells currently each guess differently.

   SCALE
   All sizing comes from the tokens app.css defines: --radius-*, --space-*,
   --fs-*, --lh-*, --weight-*, --size-* (see the long comment above them in
   app.css for why they are named by value — --space-12 is 12px — instead of
   by rank). Reach for an existing token before writing a new number; if a
   genuinely new size is needed, add it to that scale in app.css so every
   stylesheet shares it, rather than inventing a local one here. No literal
   font size appears below: type comes from --fs-* only, and the smallest
   sanctioned size for new text is --fs-11.

   COLOUR
   No hex or rgb()/rgba() literal appears below outside this sentence. Every
   colour is var(--...) from app.css's palette, or `currentColor` where a mark
   must follow the text it sits next to. If a component ever needs a colour
   the palette does not define yet, add the token to app.css's :root AND to
   both dark blocks first — one theme silently inheriting the other theme's
   colour is exactly the bug tests/test_design_system.py exists to catch.

   MOTION
   Every `transition` below names its properties — never bare `transition:
   .2s` — because that is precisely what left elements stuck on the previous
   theme's colour after a switch (see tests/test_web_theme.py). Loading
   animations animate `opacity` only, never a colour. app.css cuts all motion
   under prefers-reduced-motion; the two animations here also opt out
   explicitly so they stop rather than merely speed up.

   COMPONENTS
   - Button (.ds-btn)          primary / secondary / danger / quiet, plus the
     --sm, --block and --icon shapes. One 40px touch floor for all of them;
     --sm drops to 34px for a dense desktop table and is put back to 40px
     under 430px, where the same button is hit with a thumb.
   - StatusBadge (.ds-badge)   the seven display statuses from
     app/domain/display_status.py, one modifier each, named exactly as the API
     spells them: ds-badge--new / --approved / --in-progress / --solved /
     --rejected / --cancelled / --no-show, so a consumer can write
     'ds-badge ds-badge--' + display_status.replace('_','-') and never invent
     a second mapping. Each carries its translated word from i18n.js AND a
     different dot *shape* (disc, ring, broken ring, dash, square, hollow
     square, diamond), so the seven are still seven in greyscale, to a
     colour-blind reader, and on a projector that eats saturation.
     The room-state trio (--free / --busy / --wait) and --neutral stay for
     "is this room free now", which is a different question from "where is
     this request in the queue".
   - UrgentBadge (.ds-badge--urgent) the one badge that fills SOLID and gently
     pulses. It exists for one real field (booking.urgent), which is what
     earns it being the loud exception.
   - RoleBadge (.ds-role-badge) soft fill + hairline border: a role tag reads
     as calmer and more structural than a status pill.
   - UnreadBadge (.ds-unread)  a count, or a bare dot with --dot. Accent, not
     red: an unread message is not an error.
   - UserAvatar (.ds-avatar)   initials or a photo, four sizes.
   - Card (.ds-card) + RoomCard / BookingCard / RequestCard  three semantic
     entry points onto one foundation, composed from shared parts (__head,
     __icon, __row, __time, __body, __meta, __actions). RequestCard adds a
     left priority stripe because an admin scanning a queue needs to tell
     "waiting" from "waiting AND urgent" without reading every row.
   - ListRow (.ds-row) inside .ds-rows  the dense alternative to a card: one
     hairline between rows, no elevation, three slots (lead / main / trail).
     A list of forty bookings is a list, not forty cards.
   - Table (.ds-table)         hairline rows, uppercase micro-label head,
     optional sticky head, right-aligned numerics with tabular figures. Wrap
     it in .ds-table-wrap so a wide table scrolls inside itself instead of
     panning the page.
   - Field (.ds-field)         label / control / hint / error, with .ds-input,
     .ds-select, .ds-textarea, .ds-check, .ds-radio and .ds-switch. The error
     text is bound to the control by the consumer's aria-describedby; the red
     border is never the only signal that something is wrong.
   - Tabs (.ds-tabs)           underline tabs, the desktop shape; .ds-segmented
     is the pill-track shape for two or three short choices on a phone.
   - ConfirmationDialog (.ds-dialog) one irreversible decision, centred, small.
   - Modal (.ds-modal)         a longer piece of content with its own head,
     scrolling body and footer; becomes a bottom sheet under 430px.
   - Drawer (.ds-drawer)       a side panel for detail next to a list — the
     admin shape. Full width under 430px.
   - BottomSheet (.ds-sheet)   a longer flow on a phone; slides from the
     bottom. All four share one .ds-scrim backdrop.
   - Toast (.ds-toast in .ds-toasts) transient feedback, stacked bottom-centre.
   - LoadingSkeleton (.ds-skeleton) opacity-pulse placeholder shapes.
   - EmptyState / ErrorState   same skeleton, ErrorState only recolours the
     icon; kept as one ruleset so they cannot visually drift apart.
   - PageHeader (.ds-page-header) back button, title/subtitle, actions slot.
   - Utilities  .ds-touch, .ds-scroll-x, .ds-stack, .ds-cluster, .ds-divider,
     .ds-truncate, .ds-num, .ds-sr-only.

   Cards are bordered with a hairline and one restrained shadow (--shadow);
   overlays get --shadow-2 and nothing else does. Depth in this interface comes
   from rules and spacing, not from shade — which is also why there is no third
   elevation token to reach for.
   ============================================================ */

/* ---------------- buttons ---------------- */
/* Four intents. Primary is the one filled shape on a screen; secondary is the
   bordered default and should be the one you reach for most; danger is filled
   because a destructive confirm must not look like a cancel; quiet has no box
   at all until you point at it, for the third and fourth action in a row. */
.ds-btn{
  display:inline-flex;align-items:center;justify-content:center;gap:var(--space-7);
  border:1px solid transparent;cursor:pointer;text-decoration:none;
  font:var(--weight-640) var(--fs-13-5)/var(--lh-1) var(--font);
  padding:var(--space-10) var(--space-16);border-radius:var(--radius-8);
  /* Touch floor: every interactive ds-* element presents at least a 40px
     target (see .ds-touch below) — padding alone left this at ~38px, which
     is exactly the kind of miss a thumb notices and a screenshot doesn't. */
  min-height:var(--size-40);
  letter-spacing:.1px;white-space:nowrap;
  transition:background-color .16s, border-color .16s, color .16s, opacity .16s, transform .12s;
}
.ds-btn:active{transform:scale(.97)}
.ds-btn:disabled,.ds-btn[aria-disabled="true"]{opacity:.45;cursor:not-allowed;pointer-events:none}
.ds-btn svg{width:var(--size-16);height:var(--size-16);flex:none}

.ds-btn--primary{background:var(--accent-grad);color:var(--on-accent)}
.ds-btn--secondary{background:var(--surface);border-color:var(--border-strong);color:var(--text)}
.ds-btn--danger{background:var(--danger);color:var(--on-accent)}
.ds-btn--quiet{background:transparent;color:var(--text-dim)}
/* The older name for the bordered button, kept because it shipped in this
   file's first pass. Same look as --secondary; new markup should use that. */
.ds-btn--ghost{background:var(--surface);border-color:var(--border-strong);color:var(--text)}

.ds-btn--sm{min-height:var(--size-34);padding:var(--space-6) var(--space-11);font-size:var(--fs-12-5)}
.ds-btn--block{display:flex;width:100%}
.ds-btn--icon{padding:var(--space-8);width:var(--size-40);gap:0}
.ds-btn--icon.ds-btn--sm{width:var(--size-34)}

/* ---------------- badges ---------------- */
.ds-badge{
  display:inline-flex;align-items:center;gap:var(--space-5);
  padding:var(--space-3) var(--space-8);border-radius:var(--radius-pill);
  font:var(--weight-700) var(--fs-10)/var(--lh-1) var(--font);
  letter-spacing:.3px;text-transform:uppercase;white-space:nowrap;
}
/* currentColor, so a modifier sets one colour and the mark follows it. */
.ds-badge__dot{width:var(--size-6);height:var(--size-6);border-radius:50%;background:currentColor;flex:none}

/* Room state: is this room free right now. */
.ds-badge--free{background:var(--free-soft);color:var(--free)}
.ds-badge--busy{background:var(--busy-soft);color:var(--busy)}
.ds-badge--wait{background:var(--wait-soft);color:var(--wait)}
.ds-badge--neutral{background:var(--surface-2);color:var(--text-faint)}

/* The seven display statuses. Colour first... */
.ds-badge--new{background:var(--status-new-soft);color:var(--status-new)}
.ds-badge--approved{background:var(--status-approved-soft);color:var(--status-approved)}
.ds-badge--in-progress{background:var(--status-progress-soft);color:var(--status-progress)}
.ds-badge--solved{background:var(--status-solved-soft);color:var(--status-solved)}
.ds-badge--rejected{background:var(--status-rejected-soft);color:var(--status-rejected)}
.ds-badge--cancelled{background:var(--status-cancelled-soft);color:var(--status-cancelled)}
.ds-badge--no-show{background:var(--status-noshow-soft);color:var(--status-noshow)}

/* ...then shape, because colour alone is not a signal. Seven marks a reader
   can still tell apart in greyscale: ring (waiting to be looked at), disc
   (decided), broken ring (turning), dash (closed), square (stopped), hollow
   square (withdrawn), diamond (nobody came). */
.ds-badge--new .ds-badge__dot{
  width:var(--size-7);height:var(--size-7);background:transparent;border:2px solid currentColor;
}
.ds-badge--in-progress .ds-badge__dot{
  width:var(--size-7);height:var(--size-7);background:transparent;
  border:2px solid currentColor;border-right-color:transparent;
}
.ds-badge--solved .ds-badge__dot{width:var(--size-9);height:var(--size-2);border-radius:var(--radius-2)}
/* The three angular marks are 8px, not the disc's 6: a 6px square with a 2px
   radius is a disc with pretensions, and this row of seven is read at a
   glance across a queue. */
.ds-badge--rejected .ds-badge__dot{
  width:var(--size-8);height:var(--size-8);border-radius:var(--radius-2);
}
.ds-badge--cancelled .ds-badge__dot{
  width:var(--size-8);height:var(--size-8);border-radius:var(--radius-2);
  background:transparent;border:1px solid currentColor;
}
.ds-badge--no-show .ds-badge__dot{
  width:var(--size-8);height:var(--size-8);border-radius:var(--radius-2);transform:rotate(45deg);
}

/* The one badge that fills solid instead of soft, and the one thing here
   that moves on its own — reserved for booking.urgent, not for decoration. */
.ds-badge--urgent{
  background:var(--danger);color:var(--on-accent);
  animation:ds-pulse-urgent 1.6s ease-in-out infinite;
}
@keyframes ds-pulse-urgent{0%,100%{opacity:1}50%{opacity:.78}}
@media (prefers-reduced-motion: reduce){
  .ds-badge--urgent{animation:none}
}

.ds-role-badge{
  display:inline-flex;align-items:center;gap:var(--space-5);
  padding:var(--space-2) var(--space-8);border-radius:var(--radius-pill);
  border:1px solid transparent;
  font:var(--weight-700) var(--fs-10)/var(--lh-1) var(--font);
  letter-spacing:.3px;text-transform:uppercase;white-space:nowrap;
}
.ds-role-badge--admin{background:var(--gold-soft);color:var(--gold);border-color:var(--gold-line)}
.ds-role-badge--user{background:var(--surface-2);color:var(--text-dim);border-color:var(--border)}

/* Unread count. Accent rather than red: an unread message is not an error,
   and this sits next to a conversation name all day. */
.ds-unread{
  display:inline-flex;align-items:center;justify-content:center;
  min-width:var(--size-18);height:var(--size-18);padding:0 var(--space-5);
  border-radius:var(--radius-pill);background:var(--accent-grad);color:var(--on-accent);
  font:var(--weight-700) var(--fs-10)/var(--lh-1) var(--font);
  font-variant-numeric:tabular-nums;flex:none;
}
.ds-unread--dot{min-width:var(--size-8);width:var(--size-8);height:var(--size-8);padding:0}

/* ---------------- avatar ---------------- */
/* Fill is --accent-grad + --on-accent — the same pairing as the primary
   button — never the flat --accent colour, which on-accent text
   under-contrasts against in one of the two themes. */
.ds-avatar{
  border-radius:50%;flex:none;overflow:hidden;display:grid;place-items:center;
  background:var(--accent-grad);color:var(--on-accent);
  font:var(--weight-700) var(--fs-13)/var(--lh-1) var(--font);
  letter-spacing:.2px;text-transform:uppercase;
}
.ds-avatar img{width:100%;height:100%;object-fit:cover;display:block}
.ds-avatar--xs{width:var(--size-26);height:var(--size-26);font-size:var(--fs-10-5)}
.ds-avatar--sm{width:var(--size-34);height:var(--size-34);font-size:var(--fs-11-5)}
.ds-avatar--md{width:var(--size-40);height:var(--size-40)}
.ds-avatar--lg{width:var(--size-56);height:var(--size-56);font-size:var(--fs-16-5)}

/* ---------------- cards ---------------- */
.ds-card,.ds-room-card,.ds-booking-card,.ds-request-card{
  position:relative;background:var(--surface);border:1px solid var(--border);
  border-radius:var(--r-card);padding:var(--space-14);box-shadow:var(--shadow);
}
.ds-card--interactive{cursor:pointer;min-height:var(--size-40);transition:border-color .16s, background-color .16s, transform .16s}
.ds-card--interactive:active{transform:scale(.985);border-color:var(--border-strong)}
.ds-card--muted{opacity:.5;cursor:not-allowed}
/* No shadow at all: for a card inside a panel that already has one. */
.ds-card--flat{box-shadow:none}

.ds-card__head,.ds-card__row{display:flex;gap:var(--space-12);align-items:flex-start}
.ds-card__row{align-items:center}
.ds-card__icon{
  width:var(--size-44);height:var(--size-44);border-radius:var(--radius-11);
  background:var(--surface-2);border:1px solid var(--border);flex:none;
  display:grid;place-items:center;color:var(--text-dim);
}
.ds-card__icon svg{width:var(--size-26);height:var(--size-26)}
.ds-card__time{
  flex:none;text-align:center;padding:var(--space-8) var(--space-10);
  border-radius:var(--radius-11);background:var(--accent-soft);border:1px solid var(--border);
  min-width:var(--size-66);font-variant-numeric:tabular-nums;
}
.ds-card__time b{display:block;font-size:var(--fs-15);font-weight:var(--weight-660);color:var(--text);letter-spacing:-.2px}
.ds-card__time span{display:block;font-size:var(--fs-10-5);color:var(--text-dim);margin-top:var(--space-2)}
.ds-card__body{flex:1;min-width:0}
.ds-card__title{margin:0;font-size:var(--fs-15-5);font-weight:var(--weight-640);color:var(--text);letter-spacing:-.2px}
.ds-card__subtitle{margin:var(--space-3) 0 0;font-size:var(--fs-12-5);color:var(--text-dim)}
.ds-card__meta{display:flex;gap:var(--space-10);align-items:center;margin-top:var(--space-7);font-size:var(--fs-11-5);color:var(--text-faint);flex-wrap:wrap}
.ds-card__meta span{display:inline-flex;align-items:center;gap:var(--space-4);white-space:nowrap}
.ds-card__actions{display:flex;gap:var(--space-8);margin-top:var(--space-12)}
.ds-card__actions .ds-btn{flex:1}

/* RequestCard: a priority stripe, because an admin scanning a queue needs to
   tell "waiting" from "waiting AND urgent" without reading every row. The
   left padding drops by exactly the border's growth (1px -> 3px) so the
   icon/text inside lines up with every other card regardless of the stripe. */
.ds-request-card{border-left:3px solid var(--wait);padding-left:var(--space-12)}
.ds-request-card--urgent{border-left-color:var(--danger)}

/* ---------------- list rows ---------------- */
/* Forty bookings are a list, not forty cards. One hairline between rows, no
   elevation, and the whole group sits in one bordered container so it reads
   as a single object at a glance. */
.ds-rows{
  background:var(--surface);border:1px solid var(--border);
  border-radius:var(--r-card);overflow:hidden;
}
.ds-row{
  display:flex;align-items:center;gap:var(--space-12);width:100%;text-align:left;
  padding:var(--space-10) var(--space-14);min-height:var(--size-48);
  background:transparent;border:0;color:var(--text);
  font:var(--weight-500) var(--fs-13)/var(--lh-1-35) var(--font);
}
.ds-row + .ds-row{border-top:1px solid var(--border)}
.ds-row--interactive{cursor:pointer;transition:background-color .14s}
.ds-row--interactive:active{background-color:var(--hover)}
.ds-row__lead{flex:none;display:flex;align-items:center;gap:var(--space-8)}
.ds-row__main{flex:1;min-width:0}
/* `display:block` is load-bearing, not tidiness. A .ds-row lives inside a
   <button> in three places (js/admin.js's journal, js/messaging.js's thread
   list, js/release.js's request list), and a button may hold only phrasing
   content — so those write the two lines as <span>/<b>. On an inline box
   `overflow:hidden` and `text-overflow:ellipsis` do nothing at all, so a long
   name did not clip: it painted straight over the badge on its right.
   js/release.js:287 asks for exactly this rule in so many words. */
.ds-row__title{
  display:block;
  margin:0;font-size:var(--fs-13-5);font-weight:var(--weight-620);color:var(--text);
  white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
}
.ds-row__sub{
  display:block;
  margin:var(--space-2) 0 0;font-size:var(--fs-11-5);color:var(--text-faint);
  white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
}
.ds-row__trail{flex:none;display:flex;align-items:center;gap:var(--space-8);color:var(--text-dim)}

/* ---------------- tables ---------------- */
/* The admin panel's real workhorse. Hairlines, no zebra: stripes are a crutch
   for rows that are too tall, and these are not. */
.ds-table-wrap{
  max-width:100%;overflow-x:auto;overscroll-behavior-x:contain;
  background:var(--surface);border:1px solid var(--border);border-radius:var(--r-card);
}
.ds-table{width:100%;border-collapse:collapse;font-size:var(--fs-13);color:var(--text)}
.ds-table th{
  text-align:left;padding:var(--space-9) var(--space-12);
  background:var(--surface-2);color:var(--text-faint);
  font:var(--weight-700) var(--fs-10-5)/var(--lh-1-2) var(--font);
  letter-spacing:.6px;text-transform:uppercase;white-space:nowrap;
  border-bottom:1px solid var(--border);
}
.ds-table td{
  padding:var(--space-11) var(--space-12);border-top:1px solid var(--border);
  vertical-align:middle;
}
.ds-table tbody tr:first-child td{border-top:0}
/* Opt-in: only a table inside a container with its own height can stick. The
   head goes fully opaque when it does — the quiet fill is translucent in the
   dark theme, and rows sliding under a 92%-transparent band are legible
   through it. */
.ds-table--sticky th{position:sticky;top:0;z-index:1;background:var(--surface-solid)}
.ds-table--compact th{padding:var(--space-6) var(--space-10)}
.ds-table--compact td{padding:var(--space-7) var(--space-10)}
/* Numbers line up under each other or they are not worth tabulating. */
.ds-table__num{text-align:right;font-variant-numeric:tabular-nums;white-space:nowrap}
.ds-table__actions{text-align:right;white-space:nowrap}
.ds-table caption{
  caption-side:top;text-align:left;padding:var(--space-11) var(--space-12);
  font:var(--weight-700) var(--fs-11)/var(--lh-1-2) var(--font);
  letter-spacing:.6px;text-transform:uppercase;color:var(--text-faint);
}

/* ---------------- form fields ---------------- */
.ds-field{display:block;margin-bottom:var(--space-14)}
.ds-field__label{
  display:block;margin-bottom:var(--space-6);color:var(--text-faint);
  font:var(--weight-700) var(--fs-11)/var(--lh-1-2) var(--font);
  letter-spacing:.5px;text-transform:uppercase;
}
.ds-field__label--required::after{content:" *";color:var(--danger)}
.ds-field__hint{margin:var(--space-6) 0 0;font-size:var(--fs-11-5);line-height:var(--lh-1-45);color:var(--text-faint)}
/* The red border is never the only signal: this text is, and the consumer
   points the control's aria-describedby at it. */
.ds-field__error{
  margin:var(--space-6) 0 0;font-size:var(--fs-11-5);line-height:var(--lh-1-45);
  color:var(--danger);font-weight:var(--weight-600);
}

.ds-input,.ds-select,.ds-textarea{
  width:100%;min-height:var(--size-40);
  padding:var(--space-10) var(--space-12);
  border:1px solid var(--border-strong);border-radius:var(--radius-8);
  background:var(--surface);color:var(--text);
  font:var(--weight-500) var(--fs-13-5)/var(--lh-1-35) var(--font);
  transition:background-color .16s, border-color .16s, box-shadow .16s;
}
.ds-textarea{min-height:var(--size-72);resize:vertical;padding-top:var(--space-9)}
.ds-select{
  /* No custom arrow: the native one is the one the operating system, the
     screen reader and the keyboard all already agree about. */
  appearance:auto;padding-right:var(--space-8);cursor:pointer;

  /* Opaque, and declared — which is the whole of the dark-theme fix.
     ------------------------------------------------------------------
     An open dropdown is composited against nothing. `--surface`, which the
     shared input rule above sets, is rgba(255,255,255,.045) in the dark
     theme: four per cent white, a colour that only means anything over the
     page it is sitting on. `--surface-solid` is the opaque member of the
     pair — #ffffff in the light theme, the same value `--surface` has there,
     so none of this changes how anything looks in light mode.

     The rows below matter more. Before this, the product declared nothing at
     all for them: `option` had no rule in any stylesheet, so every dropdown
     in CAU Meets was painted by whatever the engine inferred from
     `color-scheme`. Chromium on a desktop infers a dark popup, which is why
     this never showed up here; a WebView that does not falls back to its
     default light surface, and that is the pale panel in the report.

     `color-scheme` is still declared on :root in css/app.css and is still
     worth having — it governs the arrow, the scrollbar and the date picker.
     It is not what this relies on any more. */
  background:var(--surface-solid);
}

/* The rows. Both properties, because a dark row with inherited dark text is
   as unreadable as a light one — and because saying it is the point: the bug
   was an absence, not a wrong value. */
.ds-select option,
.ds-select optgroup{
  background:var(--surface-solid);
  color:var(--text);
}
.ds-select option:disabled{color:var(--text-faint)}
/* Which row is the current one, in the in-page listbox a `size` or `multiple`
   select draws — where the chosen row would otherwise be indistinguishable
   once the control loses focus.

   Scoped to those two deliberately. `--accent-soft` is translucent in the dark
   theme, which is exactly the thing that made this bug: it is a fine colour
   over a page and an unpredictable one over a popup that has no page behind
   it. A listbox drawn in the document has the page behind it; a popup does
   not, and there the engine paints its own selection highlight anyway. */
.ds-select[size] option:checked,
.ds-select[multiple] option:checked{background:var(--accent-soft);color:var(--text)}

/* A long list — Department is the one that grows — drawn in the page rather
   than in a popup. A popup's height is the browser's business and it scrolls
   it; this is the case the browser does not handle, where the control would
   otherwise be as tall as the catalogue. */
.ds-select[size],
.ds-select[multiple]{max-height:var(--size-180);overflow-y:auto;padding-right:var(--space-4)}

.ds-input:focus,.ds-select:focus,.ds-textarea:focus{border-color:var(--accent)}
.ds-input::placeholder,.ds-textarea::placeholder{color:var(--text-faint)}
.ds-input:disabled,.ds-select:disabled,.ds-textarea:disabled{
  background:var(--surface-2);color:var(--text-faint);cursor:not-allowed;
}
.ds-field--invalid .ds-input,
.ds-field--invalid .ds-select,
.ds-field--invalid .ds-textarea,
.ds-invalid{border-color:var(--danger)}
.ds-input--num{font-variant-numeric:tabular-nums}

/* A control and its label are one target: the whole row is clickable and the
   row itself holds the 40px floor, since an 18px box never will. */
.ds-check,.ds-radio{
  display:flex;align-items:flex-start;gap:var(--space-9);
  min-height:var(--size-40);padding:var(--space-8) 0;cursor:pointer;
  font-size:var(--fs-13);line-height:var(--lh-1-35);color:var(--text);
}
.ds-check input,.ds-radio input{
  width:var(--size-18);height:var(--size-18);flex:none;margin:var(--space-1) 0 0;
  accent-color:var(--accent);cursor:pointer;
}
.ds-check input:disabled,.ds-radio input:disabled{cursor:not-allowed}
.ds-check__text,.ds-radio__text{min-width:0}
.ds-check__sub,.ds-radio__sub{display:block;margin-top:var(--space-2);font-size:var(--fs-11-5);color:var(--text-faint)}

/* A switch is a checkbox that says "this takes effect now" rather than "this
   will be saved". Built on a real <input type=checkbox> so it stays keyboard
   and screen-reader native; only the box is redrawn. */
.ds-switch{display:inline-flex;align-items:center;gap:var(--space-9);cursor:pointer;min-height:var(--size-40)}
.ds-switch input{
  appearance:none;-webkit-appearance:none;margin:0;flex:none;cursor:pointer;
  width:var(--size-36);height:var(--size-20);border-radius:var(--radius-pill);
  background:var(--surface-2);border:1px solid var(--border-strong);position:relative;
  transition:background-color .16s, border-color .16s;
}
.ds-switch input::after{
  content:"";position:absolute;top:var(--space-2);left:var(--space-2);
  width:var(--size-14);height:var(--size-14);border-radius:50%;
  background:var(--text-faint);transition:transform .16s, background-color .16s;
}
.ds-switch input:checked{background:var(--accent-grad);border-color:transparent}
.ds-switch input:checked::after{transform:translateX(var(--size-16));background:var(--on-accent)}
.ds-switch input:disabled{opacity:.45;cursor:not-allowed}

/* ---------------- tabs and segmented control ---------------- */
/* Underline tabs: the desktop shape, for four or more sections that each own
   a whole view. They scroll sideways rather than wrap, so the row never
   becomes two rows and shifts everything below it. */
.ds-tabs{
  display:flex;gap:var(--space-2);border-bottom:1px solid var(--border);
  overflow-x:auto;overscroll-behavior-x:contain;scrollbar-width:none;
}
.ds-tabs::-webkit-scrollbar{display:none}
.ds-tab{
  flex:none;border:0;background:transparent;cursor:pointer;color:var(--text-dim);
  font:var(--weight-620) var(--fs-13)/var(--lh-1) var(--font);
  padding:var(--space-11) var(--space-12);min-height:var(--size-40);
  border-bottom:2px solid transparent;margin-bottom:-1px;white-space:nowrap;
  transition:color .16s, border-color .16s;
}
.ds-tab.ds-active{color:var(--text);border-bottom-color:var(--accent)}
.ds-tab__count{margin-left:var(--space-6);color:var(--text-faint);font-variant-numeric:tabular-nums}

/* Segmented: two or three short choices, phone-shaped. Recessed track, raised
   selection — the same direction as app.css's .tabs and .lang. */
.ds-segmented{
  display:inline-flex;gap:var(--space-2);padding:var(--space-3);
  background:var(--surface-2);border:1px solid var(--border);border-radius:var(--radius-pill);
}
.ds-segmented__item{
  border:0;background:transparent;cursor:pointer;color:var(--text-dim);
  font:var(--weight-620) var(--fs-12-5)/var(--lh-1) var(--font);
  padding:var(--space-8) var(--space-14);border-radius:var(--radius-pill);
  min-height:var(--size-34);white-space:nowrap;
  transition:background-color .16s, color .16s;
}
.ds-segmented__item.ds-active{background:var(--surface);color:var(--text);box-shadow:var(--shadow)}

/* ---------------- empty / error states ---------------- */
.ds-empty-state,.ds-error-state{
  display:flex;flex-direction:column;align-items:center;text-align:center;
  padding:var(--space-48) var(--space-16);color:var(--text-faint);
}
.ds-empty-state--compact,.ds-error-state--compact{padding:var(--space-24) var(--space-16)}
.ds-empty-state__icon,.ds-error-state__icon{
  width:var(--size-56);height:var(--size-56);border-radius:var(--radius-16);
  background:var(--surface-2);border:1px solid var(--border);
  display:grid;place-items:center;margin-bottom:var(--space-12);
}
.ds-empty-state__icon svg{width:var(--size-26);height:var(--size-26);color:var(--text-dim)}
.ds-error-state__icon{border-color:var(--danger-soft)}
.ds-error-state__icon svg{width:var(--size-26);height:var(--size-26);color:var(--danger)}
.ds-empty-state__title,.ds-error-state__title{
  margin:0 0 var(--space-4);font-size:var(--fs-15-5);font-weight:var(--weight-640);
  color:var(--text);letter-spacing:-.2px;
}
.ds-empty-state__text,.ds-error-state__text{margin:0;font-size:var(--fs-13-5);line-height:var(--lh-1-5);max-width:280px}
.ds-empty-state__action,.ds-error-state__action{margin-top:var(--space-16)}

/* ---------------- loading skeleton ---------------- */
/* Opacity only, never a colour — a colour *transition* is what leaves an
   element stuck on the wrong theme (tests/test_web_theme.py); this is an
   `animation`, which always names its own keyframe properties, but it is
   kept to opacity anyway so a skeleton never implies a colour that isn't
   there yet. */
.ds-skeleton{
  background:var(--surface-2);border-radius:var(--radius-6);
  animation:ds-pulse 1.3s ease-in-out infinite;
}
.ds-skeleton + .ds-skeleton{margin-top:var(--space-9)}
.ds-skeleton--text{height:var(--size-12);width:100%}
.ds-skeleton--title{height:var(--size-17);width:60%;border-radius:var(--radius-6)}
.ds-skeleton--avatar{width:var(--size-40);height:var(--size-40);border-radius:50%}
.ds-skeleton--block{height:var(--size-56);width:100%;border-radius:var(--radius-11)}
/* One placeholder list row, the height of a real .ds-row. */
.ds-skeleton--row{height:var(--size-48);width:100%;border-radius:var(--radius-8)}
@keyframes ds-pulse{0%,100%{opacity:.45}50%{opacity:.9}}
@media (prefers-reduced-motion: reduce){
  .ds-skeleton{animation:none;opacity:.7}
}

/* ---------------- overlays: scrim, dialog, modal, drawer, sheet ---------------- */
.ds-scrim{
  position:fixed;inset:0;z-index:80;background:var(--scrim);backdrop-filter:blur(3px);
  opacity:0;visibility:hidden;
  transition:opacity .18s, visibility .18s;
}
.ds-scrim.ds-open{opacity:1;visibility:visible}

/* ConfirmationDialog: a single irreversible decision, centred, small. */
.ds-dialog{
  position:fixed;left:50%;top:50%;z-index:81;
  width:min(340px,calc(100vw - 32px));
  background:var(--surface-solid);border:1px solid var(--border);
  border-radius:var(--radius-16);padding:var(--space-20);box-shadow:var(--shadow-2);
  opacity:0;visibility:hidden;transform:translate(-50%,-50%) scale(.96);
  transition:opacity .18s, transform .18s, visibility .18s;
}
.ds-dialog.ds-open{opacity:1;visibility:visible;transform:translate(-50%,-50%) scale(1)}
.ds-dialog__icon{
  width:var(--size-44);height:var(--size-44);border-radius:50%;
  display:grid;place-items:center;margin:0 auto var(--space-12);
  background:var(--surface-2);color:var(--text-dim);
}
.ds-dialog__icon svg{width:var(--size-26);height:var(--size-26)}
.ds-dialog__icon--warn{background:var(--warning-soft);color:var(--warning)}
.ds-dialog__icon--danger{background:var(--danger-soft);color:var(--danger)}
.ds-dialog__title{
  margin:0 0 var(--space-6);font-size:var(--fs-16-5);font-weight:var(--weight-660);
  color:var(--text);letter-spacing:-.2px;text-align:center;
}
.ds-dialog__text{margin:0 0 var(--space-16);font-size:var(--fs-13-5);line-height:var(--lh-1-5);color:var(--text-dim);text-align:center}
.ds-dialog__actions{display:flex;gap:var(--space-10)}
.ds-dialog__actions .ds-btn{flex:1}

/* Modal: more than one decision — a form, a detail, a preview. Its body
   scrolls on its own so the head and the footer stay put; under 430px it
   becomes a bottom sheet, because a centred box on a phone is a phone
   pretending to be a desktop. */
.ds-modal{
  position:fixed;left:50%;top:50%;z-index:81;
  width:min(560px,calc(100vw - 32px));max-height:calc(100vh - 64px);
  display:flex;flex-direction:column;
  background:var(--surface-solid);border:1px solid var(--border);
  border-radius:var(--radius-16);box-shadow:var(--shadow-2);overflow:hidden;
  opacity:0;visibility:hidden;transform:translate(-50%,-50%) scale(.98);
  transition:opacity .18s, transform .18s, visibility .18s;
}
.ds-modal.ds-open{opacity:1;visibility:visible;transform:translate(-50%,-50%) scale(1)}
.ds-modal--wide{width:min(880px,calc(100vw - 32px))}
.ds-modal__head{
  display:flex;align-items:center;gap:var(--space-12);flex:none;
  padding:var(--space-14) var(--space-16);border-bottom:1px solid var(--border);
}
.ds-modal__title{margin:0;flex:1;min-width:0;font-size:var(--fs-16);font-weight:var(--weight-660);color:var(--text);letter-spacing:-.2px}
.ds-modal__body{flex:1;min-height:0;overflow-y:auto;padding:var(--space-16)}
.ds-modal__foot{
  display:flex;justify-content:flex-end;gap:var(--space-10);flex:none;
  padding:var(--space-12) var(--space-16);border-top:1px solid var(--border);
  background:var(--surface-2);
}

/* Drawer: detail beside a list, the shape an admin panel actually wants —
   the list stays on screen and keeps its scroll position. */
.ds-drawer{
  position:fixed;top:0;bottom:0;right:0;z-index:81;
  width:min(420px,100vw);display:flex;flex-direction:column;
  background:var(--surface-solid);border-left:1px solid var(--border);
  box-shadow:var(--shadow-2);
  transform:translateX(101%);visibility:hidden;
  transition:transform .28s cubic-bezier(.32,.72,0,1), visibility .28s;
}
.ds-drawer.ds-open{transform:none;visibility:visible}
.ds-drawer--left{right:auto;left:0;border-left:0;border-right:1px solid var(--border);transform:translateX(-101%)}
.ds-drawer--wide{width:min(640px,100vw)}
.ds-drawer__head{
  display:flex;align-items:center;gap:var(--space-12);flex:none;
  padding:var(--space-14) var(--space-16);border-bottom:1px solid var(--border);
}
.ds-drawer__title{margin:0;flex:1;min-width:0;font-size:var(--fs-16);font-weight:var(--weight-660);color:var(--text);letter-spacing:-.2px}
.ds-drawer__body{flex:1;min-height:0;overflow-y:auto;padding:var(--space-16)}
.ds-drawer__foot{
  display:flex;justify-content:flex-end;gap:var(--space-10);flex:none;
  padding:var(--space-12) var(--space-16);border-top:1px solid var(--border);
  background:var(--surface-2);
}

/* BottomSheet: a longer flow on a phone — and not tied to any one instance's
   id the way app.css's own #sheet-book/#sheet-find are. */
.ds-sheet{
  position:fixed;left:0;right:0;bottom:0;z-index:81;max-width:480px;margin:0 auto;
  background:var(--surface-solid);border:1px solid var(--border);border-bottom:0;
  border-radius:var(--radius-18) var(--radius-18) 0 0;
  padding:var(--space-8) var(--space-18) calc(var(--space-20) + env(safe-area-inset-bottom));
  transform:translateY(101%);visibility:hidden;
  transition:transform .32s cubic-bezier(.32,.72,0,1), visibility .32s;
  box-shadow:var(--shadow-2);
}
.ds-sheet.ds-open{transform:none;visibility:visible}
.ds-sheet__handle{width:var(--size-38);height:var(--size-4);border-radius:var(--radius-pill);background:var(--border-strong);margin:0 auto var(--space-16)}
.ds-sheet__title{margin:0 0 var(--space-4);font-size:var(--fs-18);font-weight:var(--weight-660);color:var(--text);letter-spacing:-.3px}
.ds-sheet__lead{margin:0 0 var(--space-16);font-size:var(--fs-13);color:var(--text-dim)}
.ds-sheet__body{margin-bottom:var(--space-14)}
.ds-sheet__actions{display:flex;gap:var(--space-10)}
.ds-sheet__actions .ds-btn{flex:1}

/* ---------------- toast ---------------- */
/* One stack, bottom centre, newest last. The container is inert so a toast
   never eats a click meant for the page underneath; each toast takes its own
   pointer events back because it may carry an Undo. */
.ds-toasts{
  position:fixed;left:50%;bottom:calc(var(--space-24) + env(safe-area-inset-bottom));
  transform:translateX(-50%);z-index:82;
  display:flex;flex-direction:column;gap:var(--space-8);
  width:min(420px,calc(100vw - 32px));pointer-events:none;
}
.ds-toast{
  display:flex;align-items:flex-start;gap:var(--space-9);pointer-events:auto;
  padding:var(--space-11) var(--space-14);
  background:var(--surface-solid);border:1px solid var(--border);
  border-left:3px solid var(--text-faint);border-radius:var(--radius-11);
  box-shadow:var(--shadow-2);color:var(--text);
  font:var(--weight-600) var(--fs-13)/var(--lh-1-35) var(--font);
  opacity:0;visibility:hidden;transform:translateY(var(--space-8));
  transition:opacity .18s, transform .18s, visibility .18s;
}
.ds-toast.ds-open{opacity:1;visibility:visible;transform:none}
.ds-toast--success{border-left-color:var(--success)}
.ds-toast--danger{border-left-color:var(--danger)}
.ds-toast--warning{border-left-color:var(--warning)}
.ds-toast--info{border-left-color:var(--info)}
.ds-toast__body{flex:1;min-width:0}
.ds-toast__title{display:block;font-weight:var(--weight-700)}
.ds-toast__text{display:block;margin-top:var(--space-2);font-weight:var(--weight-500);color:var(--text-dim);font-size:var(--fs-12-5)}
.ds-toast__action{flex:none}

/* ---------------- page header ---------------- */
.ds-page-header{
  display:flex;align-items:center;gap:var(--space-12);
  padding:var(--space-14) var(--space-16);
  border-bottom:1px solid var(--border);
  background:color-mix(in srgb,var(--bg) 88%,transparent);
  backdrop-filter:saturate(140%) blur(14px);-webkit-backdrop-filter:saturate(140%) blur(14px);
}
.ds-page-header--sticky{position:sticky;top:0;z-index:20}
.ds-page-header__back{
  /* --size-40, not the --size-34 app.css's own .back uses: this component is
     the shared generalisation, and shared components hold the 40px touch
     floor. app.css's .back sits inside a header whose padding makes up the
     difference; a ds- consumer gets no such guarantee. */
  width:var(--size-40);height:var(--size-40);min-height:var(--size-40);
  border-radius:var(--radius-8);
  border:1px solid var(--border);background:var(--surface);color:var(--text);
  display:grid;place-items:center;cursor:pointer;flex:none;
}
.ds-page-header__back svg{width:var(--size-17);height:var(--size-17)}
.ds-page-header__text{flex:1;min-width:0}
.ds-page-header__title{
  margin:0;font-size:var(--fs-15-5);font-weight:var(--weight-650);color:var(--text);
  letter-spacing:-.2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
}
.ds-page-header__subtitle{
  margin:var(--space-2) 0 0;font-size:var(--fs-12);color:var(--text-dim);
  white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
}
.ds-page-header__actions{display:flex;align-items:center;gap:var(--space-8);flex:none}

/* ---------------- utilities ---------------- */
/* Touch floor for someone else's control. The ds-* components above carry
   their own 40px minimum; a button that lives in web.css / admin.css /
   telegram.css markup can opt into the same floor with this class instead
   of retyping the numbers. 40px, not Apple's 44px: the compact density was a
   deliberate decision (see app.css) and 40px is the floor the whole page was
   tuned against — tests/test_responsive_qa.py holds it. */
.ds-touch{min-height:var(--size-40);min-width:var(--size-40)}

/* The one sanctioned way to be wider than the viewport. app.css forbids the
   page itself from panning sideways (body{overflow-x:clip}); content that is
   honestly wider than a phone — the schedule table, a long day strip — goes
   inside this wrapper and scrolls alone. overscroll-behavior keeps the swipe
   from leaking into the page (or into Telegram's own back gesture) when the
   strip hits its end. */
.ds-scroll-x{
  max-width:100%;overflow-x:auto;overflow-y:hidden;
  -webkit-overflow-scrolling:touch;overscroll-behavior-x:contain;
  scrollbar-width:thin;
}

/* Vertical rhythm without a wrapper div per gap. */
.ds-stack{display:flex;flex-direction:column;gap:var(--space-12)}
.ds-stack--tight{gap:var(--space-8)}
.ds-stack--loose{gap:var(--space-20)}
/* A row of things that wraps instead of overflowing — filter chips, actions. */
.ds-cluster{display:flex;flex-wrap:wrap;align-items:center;gap:var(--space-8)}
.ds-cluster--end{justify-content:flex-end}
.ds-divider{height:1px;background:var(--border);border:0;margin:var(--space-12) 0}
.ds-truncate{min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.ds-num{font-variant-numeric:tabular-nums}
/* For the label an icon-only button still owes a screen reader. */
.ds-sr-only{
  position:absolute;width:1px;height:1px;padding:0;margin:-1px;
  overflow:hidden;clip-path:inset(50%);white-space:nowrap;border:0;
}

/* ---------------- pointer refinements ---------------- */
/* Hover is a mouse idea. On a touch screen these rules would stick to the
   last thing tapped, which is why they live behind the capability query
   rather than next to the components. */
@media (hover: hover){
  .ds-btn--primary:hover{background:var(--accent)}
  .ds-btn--secondary:hover,.ds-btn--ghost:hover{border-color:var(--accent);color:var(--accent)}
  .ds-btn--quiet:hover{background:var(--hover);color:var(--text)}
  .ds-btn--danger:hover{opacity:.9}
  .ds-card--interactive:hover{border-color:var(--border-strong)}
  .ds-row--interactive:hover{background-color:var(--hover)}
  .ds-table tbody tr:hover td{background-color:var(--hover)}
  .ds-tab:hover{color:var(--text)}
  .ds-segmented__item:hover{color:var(--text)}
  .ds-page-header__back:hover{border-color:var(--border-strong)}
}

/* ---------------- narrow screens ---------------- */
/* 430px is the canon's widest phone and the Mini App's own width. Below it,
   a centred box is the wrong shape and a 34px button is the wrong size. */
@media (max-width: 430px){
  .ds-btn--sm{min-height:var(--size-40);padding:var(--space-8) var(--space-12)}
  .ds-segmented__item{min-height:var(--size-40)}
  .ds-modal{
    left:0;right:0;top:auto;bottom:0;width:100%;max-height:88vh;
    transform:translateY(101%);border-radius:var(--radius-18) var(--radius-18) 0 0;
    border-bottom:0;
  }
  .ds-modal.ds-open{transform:none}
  .ds-drawer,.ds-drawer--wide{width:100%}
  .ds-table th{padding:var(--space-8) var(--space-10)}
  .ds-table td{padding:var(--space-9) var(--space-10)}
  .ds-card,.ds-room-card,.ds-booking-card,.ds-request-card{padding:var(--space-12)}
  .ds-toasts{width:calc(100vw - 24px)}
}

/* ---------------- laptop and up ---------------- */
/* More room does not mean more air: it means more rows on screen. The type
   grows one step where a heading has to hold a wide column together, and the
   table tightens rather than loosening. */
@media (min-width: 1024px){
  .ds-card,.ds-room-card,.ds-booking-card,.ds-request-card{padding:var(--space-16)}
  .ds-modal__body,.ds-drawer__body{padding:var(--space-20)}
  .ds-page-header{padding:var(--space-16) var(--space-24)}
  .ds-page-header__title{font-size:var(--fs-17)}
  .ds-row{padding-left:var(--space-16);padding-right:var(--space-16)}
}

/* A photograph inside the initials circle.
   The element keeps its size and its round clip; the image fills it and is
   cropped rather than squashed, because a face squashed into a circle is worse
   than a face cropped to one. */
.ds-avatar--photo { padding: 0; }
.ds-avatar__img {
  width: 100%;
  height: 100%;
  border-radius: 50%;
  object-fit: cover;
  display: block;
}

/* The top level. Filled rather than tinted: there are at most three of these on
   a campus, and «Владелец» has to read differently from «Администратор» at a
   glance in a table of forty rows. */
.ds-role-badge--owner{background:var(--accent);color:var(--on-accent);border-color:transparent}
