/*
 * ui.css, component vocabulary for the UI rebuild (issue #518).
 * Requires ui-tokens.css to be loaded first.
 *
 * COEXISTENCE RULE
 * Every selector in this file is scoped to a .ui- class. There is not a
 * single bare element selector, so loading this next to app.css and
 * pages.css cannot restyle an old page. The base typography and the box
 * model reset hang off .ui-root, which the new shell puts on <body>; an
 * old page that never sets .ui-root is untouched.
 *
 * NAMING
 *   .ui-block            component root
 *   .ui-block__part      child part
 *   .ui-block--variant   variant
 *   .is-*                runtime state written by JS or the server
 * State that a browser already models (open, invalid, busy, current,
 * selected, disabled) is read off ARIA and native attributes instead of
 * a class, so the accessible name and the paint can never disagree.
 *
 * BREAKPOINTS
 *   < 768px phone, >= 768px rail, >= 1200px wide content.
 * The literals below mirror --ui-bp-md and --ui-bp-lg in ui-tokens.css.
 *
 * WHAT LATER ISSUES OWN
 * This file ships appearance and layout. Behavior (open/close, focus
 * trap, request wiring) belongs to the issue that needs it: #519 shell,
 * #520 palette, #521 drawer + typeahead + toast, #522 dictation.
 * The JS contracts those issues code against are written as comments on
 * the component they belong to.
 */

/* ============================================================
   Base
   ============================================================ */

.ui-root {
  font-family: var(--ui-font-ui);
  font-size: var(--ui-fs-sm);
  line-height: var(--ui-lh-normal);
  color: var(--ui-text);
  background-color: var(--ui-canvas);
  min-height: 100dvh;
  margin: 0;
  -webkit-text-size-adjust: 100%;
  -webkit-tap-highlight-color: transparent;
  text-rendering: optimizeLegibility;
}

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

.ui-root :focus-visible {
  outline: none;
  box-shadow: var(--ui-focus-ring);
  border-radius: var(--ui-radius-sm);
}

/* The hidden attribute wins over a component's own display.
 *
 * Every overlay here sets a display value on its root: .ui-drawer and
 * .ui-palette are flex, .ui-menu and .ui-typeahead__menu are block.
 * An author rule beats the user agent's [hidden] {display:none} at
 * equal specificity, so <div class="ui-palette" hidden> paints anyway:
 * a full-screen scrim, invisible at opacity 0, swallowing every click
 * on the page behind it. #520 hit that with the palette and #521 with
 * the shell's more sheet, both of which toggle the attribute rather
 * than mounting and unmounting.
 *
 * One rule in the reset covers every component that will ever be
 * toggled that way, including the ones nobody has written yet.
 * !important is deliberate: hidden means hidden, and a component that
 * wants to animate on the way out drops the attribute and animates,
 * which is what ui-drawer.js does. */
.ui-root [hidden] {
  display: none !important;
}

/* Scroll lock while a drawer, sheet or palette owns the screen. JS adds
   this to .ui-root and restores scrollTop itself. */
.ui-root.is-scroll-locked {
  overflow: hidden;
}

/* Paints the current theme's canvas on any container. Pair with a
   .theme-light or .theme-dark class to nest a themed island inside a
   page running the other theme. */
.ui-themed {
  background: var(--ui-canvas);
  color: var(--ui-text);
}

.ui-link {
  color: var(--ui-accent-text);
  text-decoration: none;
  border-bottom: 1px solid transparent;
}

.ui-link:hover {
  border-bottom-color: currentColor;
}

/* ============================================================
   Utilities
   ============================================================ */

.ui-sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

.ui-skip-link {
  position: absolute;
  left: var(--ui-space-2);
  top: calc(-1 * var(--ui-space-16));
  z-index: var(--ui-z-tooltip);
  padding: var(--ui-space-2) var(--ui-space-4);
  background: var(--ui-accent-fill);
  color: var(--ui-accent-on);
  border-radius: var(--ui-radius-sm);
  font-weight: var(--ui-fw-semibold);
}

.ui-skip-link:focus {
  top: var(--ui-space-2);
}

/* Monospace family. Issue #518: IDs, durations and timestamps are
   monospace everywhere so a column of them scans as a column. */
.ui-mono,
.ui-id,
.ui-ts,
.ui-dur,
.ui-num {
  font-family: var(--ui-font-mono);
  font-variant-numeric: tabular-nums;
  font-feature-settings: "tnum" 1;
}

.ui-id {
  font-size: 0.9em;
  letter-spacing: var(--ui-tracking-id);
  color: var(--ui-text-2);
}

.ui-ts,
.ui-dur {
  font-size: 0.92em;
  color: var(--ui-text-2);
  white-space: nowrap;
}

/* A paragraph is prose, not a value (#555).
   .ui-ts means "timestamp", and nowrap is right for one: "2 hours ago"
   should not break across lines. 69 of these render hint sentences
   instead, and nowrap on a sentence pushes the whole document sideways.
   Every page that scrolled horizontally traced to one: the settings
   page ran 1097px past a 375px viewport on a single line of help text.
   The 167 span uses, which are real values, keep the nowrap.

   The class also lands on containers, and an element selector does not
   reach through one. /tpia closes with a <footer class="ui-ts"> holding
   two paragraphs of small print; nowrap inherited into both, the second
   laid out 1746px wide, and that was the whole of the page's document
   scroll at all three widths. A .ui-ts holding a paragraph is prose no
   matter what tag carries the class, so the descendant selector wraps
   it too. A span, which is where the real values live, cannot hold
   one.

   A div is the same case, found a third time on /records/calendar,
   where "Subscribe in your calendar app. Read-only, and the token is
   the credential." leaked 270px past a 375px viewport. Rather than
   name a fourth element later, the rule is now the distinction the
   class already relied on: a block-level .ui-ts is a prose container
   and wraps, an inline one is a value and does not. Of the 48 div
   uses, the ones holding a real value hold a single token, and a
   single token has nothing to wrap, so this costs them nothing. */
p.ui-ts,
div.ui-ts,
.ui-ts p {
  white-space: normal;
}

/* A .ui-ts span that holds a sentence rather than a value (#571).
   The rule above reaches prose that is marked up as a paragraph, and
   #571 is the three sentences that cannot be. One is the descriptive
   half of a scope checkbox, and <label> takes phrasing content only.
   One is the single item of a flex card footer, where a paragraph is
   the wrong box. So the span stays and declares what it holds instead.
   /admin/tampering ran 230px past a 375px viewport on that footer
   sentence alone, and /admin/api-keys ran 29px past it on the scope
   descriptions.

   Ask for this only when the content is a sentence. A value keeps the
   nowrap, which is what the class is for. */
.ui-ts--wrap {
  white-space: normal;
}

.ui-num {
  text-align: right;
}

/* A stored document printed as it is: the details blob on an audit event
   (#531). It keeps its own newlines and scrolls sideways rather than
   wrapping, because a re-wrapped JSON document is a document whose
   indentation stops meaning anything. */
.ui-pre {
  margin: 0;
  padding: var(--ui-space-3);
  border: 1px solid var(--ui-line);
  border-radius: var(--ui-radius-md);
  background: var(--ui-surface-raised);
  font-family: var(--ui-font-mono);
  font-size: var(--ui-fs-sm);
  line-height: var(--ui-lh-tight);
  white-space: pre;
  tab-size: 2;
  overflow-x: auto;
  max-width: 100%;
}

.ui-truncate {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  min-width: 0;
}

.ui-clamp-2 {
  display: -webkit-box;
  -webkit-line-clamp: 2;
  line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

/* Layout primitives. Sections compose with these instead of adding a
   page stylesheet (issue #518 acceptance). */
.ui-stack {
  display: flex;
  flex-direction: column;
  gap: var(--ui-gap, var(--ui-space-4));
  min-width: 0;
}

.ui-row {
  display: flex;
  align-items: center;
  gap: var(--ui-gap, var(--ui-space-2));
  min-width: 0;
}

.ui-row--wrap {
  flex-wrap: wrap;
}

.ui-row--between {
  justify-content: space-between;
}

.ui-row--end {
  justify-content: flex-end;
}

.ui-spacer {
  flex: 1 1 auto;
  min-width: 0;
}

/* Auto-fitting card grid. --ui-col-min sets the smallest a card may get
   before the grid drops a column; the default collapses to one column a
   little under 375px. */
.ui-grid {
  display: grid;
  gap: var(--ui-gap, var(--ui-space-4));
  grid-template-columns: repeat(
    auto-fill,
    minmax(min(100%, var(--ui-col-min, 260px)), 1fr)
  );
}

.ui-scroll-x {
  overflow-x: auto;
  overscroll-behavior-x: contain;
  -webkit-overflow-scrolling: touch;
}

.ui-only-phone {
  display: none;
}

@media (max-width: 767px) {
  .ui-only-phone {
    display: revert;
  }

  .ui-hide-phone {
    display: none !important;
  }
}

@media (min-width: 768px) {
  .ui-hide-desk {
    display: none !important;
  }
}

/* ============================================================
   App shell (#519)
   ============================================================
   Markup contract:
     <body class="ui-root">
       <a class="ui-skip-link" href="#ui-main">…</a>
       <div class="ui-app">
         <nav class="ui-rail">…8 .ui-rail__item…</nav>
         <main class="ui-app__main" id="ui-main">…</main>
         <nav class="ui-tabbar">…5 .ui-tabbar__item + more…</nav>
       </div>
       <div class="ui-toast-region" id="ui-toasts"></div>
       <div id="ui-drawer-mount"></div>
     </body>
   The boosted content region is .ui-app__main; hx-boost swaps its
   children and leaves the rail and tab bar in the DOM. */

.ui-app {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  min-height: 100dvh;
}

.ui-app__main {
  min-width: 0;
  padding-bottom: calc(var(--ui-tabbar-h) + env(safe-area-inset-bottom, 0px));
}

/* --- Slim rail, desktop only --- */
.ui-rail {
  display: none;
  flex-direction: column;
  align-items: stretch;
  gap: var(--ui-space-1);
  position: sticky;
  top: 0;
  height: 100dvh;
  padding: var(--ui-space-3) var(--ui-space-2);
  background: var(--ui-surface);
  border-right: 1px solid var(--ui-line);
  z-index: var(--ui-z-rail);
}

.ui-rail__brand {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--ui-space-05);
  padding-bottom: var(--ui-space-3);
  margin-bottom: var(--ui-space-2);
  border-bottom: 1px solid var(--ui-line);
  color: var(--ui-text);
  text-decoration: none;
}

.ui-rail__wordmark {
  font-family: var(--ui-font-display);
  font-size: var(--ui-fs-lg);
  font-weight: var(--ui-fw-bold);
  letter-spacing: var(--ui-tracking-tight);
  line-height: 1;
}

.ui-rail__tagline {
  font-size: var(--ui-fs-3xs);
  letter-spacing: var(--ui-tracking-caps);
  text-transform: uppercase;
  color: var(--ui-text-3);
}

.ui-rail__item {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: var(--ui-space-1);
  min-height: var(--ui-tap-min);
  padding: var(--ui-space-2) var(--ui-space-1);
  border-radius: var(--ui-radius-md);
  color: var(--ui-text-3);
  text-decoration: none;
  transition: background var(--ui-dur-1) var(--ui-ease-out),
    color var(--ui-dur-1) var(--ui-ease-out);
}

.ui-rail__item:hover {
  background: var(--ui-hover);
  color: var(--ui-text);
}

.ui-rail__item[aria-current] {
  background: var(--ui-accent-tint);
  color: var(--ui-accent-text);
}

.ui-rail__icon {
  width: 20px;
  height: 20px;
  flex: none;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.75;
}

.ui-rail__label {
  font-size: var(--ui-fs-3xs);
  line-height: 1;
  text-align: center;
}

.ui-rail__foot {
  margin-top: auto;
  padding-top: var(--ui-space-2);
  border-top: 1px solid var(--ui-line);
}

/* --- Bottom tab bar, phone only --- */
.ui-tabbar {
  position: fixed;
  inset: auto 0 0 0;
  z-index: var(--ui-z-rail);
  display: grid;
  grid-auto-flow: column;
  grid-auto-columns: 1fr;
  align-items: stretch;
  height: calc(var(--ui-tabbar-h) + env(safe-area-inset-bottom, 0px));
  padding-bottom: env(safe-area-inset-bottom, 0px);
  background: var(--ui-surface);
  border-top: 1px solid var(--ui-line);
}

/* An item is an <a> for the five destinations and a <button> for the
   More trigger, which needs aria-haspopup="dialog" and aria-expanded.
   The button reset lives here so both spell the same class and neither
   carries inline style (#519 shipped four inline declarations on the
   More trigger; #521 moved them here). */
.ui-tabbar__item {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 3px;
  appearance: none;
  border: none;
  margin: 0;
  padding: 0;
  background: none;
  color: var(--ui-text-3);
  text-decoration: none;
  font: inherit;
  font-size: var(--ui-fs-3xs);
  cursor: pointer;
}

.ui-tabbar__item[aria-current] {
  color: var(--ui-accent-text);
}

.ui-tabbar__icon {
  width: 22px;
  height: 22px;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.75;
}

@media (min-width: 768px) {
  .ui-app {
    grid-template-columns: var(--ui-rail-w) minmax(0, 1fr);
  }

  .ui-app__main {
    padding-bottom: 0;
  }

  .ui-rail {
    display: flex;
  }

  .ui-tabbar {
    display: none;
  }
}

/* --- Account corner, the foot of the rail (#531) ---
   /profile, /settings and /help belong to no section, so they get no
   rail item and no tab from shell_nav.go. Without something here they
   are URLs with nothing pointing at them, and POST /logout has no
   button anywhere in the app once base.html goes. One entry, in the
   foot beside Admin, opening the account pages; the row of three and
   the sign-out live on the pages themselves.

   The class is deliberately not .ui-rail__item: that class names one of
   the eight destinations in shellSections, and this is not one of
   them. The two share every declaration below. */
.ui-rail__account {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: var(--ui-space-1);
  min-height: var(--ui-tap-min);
  padding: var(--ui-space-2) var(--ui-space-1);
  border-radius: var(--ui-radius-md);
  color: var(--ui-text-3);
  text-decoration: none;
  transition: background var(--ui-dur-1) var(--ui-ease-out),
    color var(--ui-dur-1) var(--ui-ease-out);
}

.ui-rail__account:hover {
  background: var(--ui-hover);
  color: var(--ui-text);
}

.ui-rail__account[aria-current] {
  background: var(--ui-accent-tint);
  color: var(--ui-accent-text);
}

/* ============================================================
   Signed-out layout (#531)
   ============================================================
   /login, /set-password and /forgot-password render through
   web/templates/auth.html, which has no rail and no tab bar. One
   centred column, capped narrow enough that a password field never
   grows to the width of a desktop. */

.ui-auth {
  min-height: 100dvh;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: var(--ui-space-4);
}

.ui-auth__panel {
  width: 100%;
  max-width: 26rem;
}

.ui-auth__brand {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--ui-space-05);
  margin-bottom: var(--ui-space-4);
  color: var(--ui-text);
  text-decoration: none;
}

.ui-auth__wordmark {
  font-family: var(--ui-font-display);
  font-size: var(--ui-fs-2xl);
  font-weight: var(--ui-fw-bold);
  letter-spacing: var(--ui-tracking-tight);
  line-height: 1;
}

.ui-auth__tagline {
  font-size: var(--ui-fs-3xs);
  letter-spacing: var(--ui-tracking-caps);
  text-transform: uppercase;
  color: var(--ui-text-3);
}

.ui-auth__foot {
  margin-top: var(--ui-space-3);
  text-align: center;
  font-size: var(--ui-fs-xs);
  color: var(--ui-text-3);
}

/* The six-digit field on the MFA step. Wide tracking is the whole
   reason it is not a plain .ui-input. */
.ui-auth__code {
  text-align: center;
  font-family: var(--ui-font-mono);
  font-size: var(--ui-fs-xl);
  letter-spacing: 0.4em;
}

/* ============================================================
   Page frame and tabs
   ============================================================ */

.ui-page {
  padding: var(--ui-space-4);
  max-width: var(--ui-content-max);
  margin: 0 auto;
}

.ui-page__header {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-end;
  gap: var(--ui-space-3);
  padding-bottom: var(--ui-space-3);
}

.ui-page__titles {
  min-width: 0;
}

.ui-page__actions {
  display: flex;
  align-items: center;
  gap: var(--ui-space-2);
  margin-left: auto;
}

.ui-page-title {
  font-family: var(--ui-font-display);
  font-size: var(--ui-fs-2xl);
  font-weight: var(--ui-fw-bold);
  line-height: var(--ui-lh-tight);
  letter-spacing: var(--ui-tracking-tight);
  color: var(--ui-text);
  margin: 0;
}

.ui-page-subtitle {
  margin-top: var(--ui-space-1);
  font-size: var(--ui-fs-xs);
  color: var(--ui-text-3);
}

.ui-section-title {
  font-family: var(--ui-font-display);
  font-size: var(--ui-fs-xl);
  font-weight: var(--ui-fw-bold);
  line-height: var(--ui-lh-snug);
  color: var(--ui-text);
  margin: 0;
}

.ui-label {
  font-size: var(--ui-fs-3xs);
  font-weight: var(--ui-fw-semibold);
  letter-spacing: var(--ui-tracking-caps);
  text-transform: uppercase;
  color: var(--ui-text-3);
}

/* Section tab bar. Scrolls rather than wraps, which #530 requires for
   the 13 admin tabs on a narrow screen. */
.ui-tabs {
  display: flex;
  gap: var(--ui-space-1);
  border-bottom: 1px solid var(--ui-line);
  overflow-x: auto;
  overscroll-behavior-x: contain;
  scrollbar-width: none;
  scroll-snap-type: x proximity;
}

.ui-tabs::-webkit-scrollbar {
  display: none;
}

.ui-tabs__link {
  flex: none;
  scroll-snap-align: start;
  padding: var(--ui-space-2) var(--ui-space-3);
  margin-bottom: -1px;
  border-bottom: 2px solid transparent;
  color: var(--ui-text-3);
  text-decoration: none;
  font-size: var(--ui-fs-sm);
  font-weight: var(--ui-fw-medium);
  white-space: nowrap;
}

.ui-tabs__link:hover {
  color: var(--ui-text);
}

.ui-tabs__link[aria-current] {
  color: var(--ui-text);
  border-bottom-color: var(--ui-accent-line);
}

.ui-tabs__count {
  margin-left: var(--ui-space-1);
  font-family: var(--ui-font-mono);
  font-size: var(--ui-fs-3xs);
  color: var(--ui-text-3);
}

.ui-subtabs {
  display: flex;
  gap: var(--ui-space-1);
  padding: var(--ui-space-2) 0;
  overflow-x: auto;
  scrollbar-width: none;
}

.ui-subtabs__link {
  flex: none;
  padding: var(--ui-space-1) var(--ui-space-3);
  border-radius: var(--ui-radius-pill);
  color: var(--ui-text-3);
  text-decoration: none;
  font-size: var(--ui-fs-xs);
  white-space: nowrap;
}

.ui-subtabs__link[aria-current] {
  background: var(--ui-selected);
  color: var(--ui-text);
}

@media (min-width: 1200px) {
  .ui-page {
    padding: var(--ui-space-6) var(--ui-space-8);
  }
}

/* ============================================================
   Buttons
   ============================================================ */

.ui-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--ui-space-2);
  min-height: 34px;
  padding: 0 var(--ui-space-3);
  border: 1px solid var(--ui-control-line);
  border-radius: var(--ui-radius-sm);
  background: transparent;
  color: var(--ui-text);
  font: inherit;
  font-size: var(--ui-fs-sm);
  font-weight: var(--ui-fw-medium);
  line-height: 1;
  text-decoration: none;
  cursor: pointer;
  white-space: nowrap;
  transition: background var(--ui-dur-1) var(--ui-ease-out),
    border-color var(--ui-dur-1) var(--ui-ease-out);
}

.ui-btn:hover {
  background: var(--ui-hover);
}

.ui-btn:active {
  background: var(--ui-active);
}

.ui-btn[disabled],
.ui-btn[aria-disabled="true"] {
  color: var(--ui-text-disabled);
  border-color: var(--ui-line-strong);
  cursor: not-allowed;
  background: transparent;
}

.ui-btn--primary {
  background: var(--ui-accent-fill);
  border-color: var(--ui-accent-fill);
  color: var(--ui-accent-on);
  font-weight: var(--ui-fw-semibold);
}

.ui-btn--primary:hover {
  background: var(--ui-accent-hover);
  border-color: var(--ui-accent-hover);
}

.ui-btn--danger {
  background: var(--ui-danger-fill);
  border-color: var(--ui-danger-fill);
  color: var(--ui-danger-on);
  font-weight: var(--ui-fw-semibold);
}

/* Bordered but not filled: the default for row-level actions. */
.ui-btn--quiet {
  border-color: var(--ui-line-strong);
  color: var(--ui-text-2);
}

/* No border at all: toolbar icons, close buttons. */
.ui-btn--ghost {
  border-color: transparent;
  color: var(--ui-text-2);
}

.ui-btn--sm {
  min-height: 26px;
  padding: 0 var(--ui-space-2);
  font-size: var(--ui-fs-2xs);
}

.ui-btn--lg {
  min-height: var(--ui-tap-min);
  padding: 0 var(--ui-space-5);
  font-size: var(--ui-fs-md);
}

.ui-btn--icon {
  padding: 0;
  width: 34px;
  min-height: 34px;
}

.ui-btn--icon.ui-btn--sm {
  width: 26px;
}

.ui-btn--block {
  width: 100%;
}

.ui-btn__icon {
  width: 16px;
  height: 16px;
  flex: none;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.75;
}

/* Joined buttons. It scrolls rather than wraps or squeezes, the same
   choice .ui-tabs makes and for the same reason: a wrapped group loses
   the joined border it exists for, and a squeezed one truncates the
   labels. /tpia puts its five panel tabs in one of these, 461px of
   button in a 328px column at 375, which was the last 117px of that
   page's document scroll (#555). Without max-width an inline-flex box
   sizes to its content and overflow has nothing to act on. */
.ui-btn-group {
  display: inline-flex;
  max-width: 100%;
  overflow-x: auto;
  overscroll-behavior-x: contain;
  scrollbar-width: none;
}

.ui-btn-group::-webkit-scrollbar {
  display: none;
}

.ui-btn-group > .ui-btn {
  flex: none;
  border-radius: 0;
  margin-left: -1px;
}

.ui-btn-group > .ui-btn:first-child {
  border-radius: var(--ui-radius-sm) 0 0 var(--ui-radius-sm);
  margin-left: 0;
}

.ui-btn-group > .ui-btn:last-child {
  border-radius: 0 var(--ui-radius-sm) var(--ui-radius-sm) 0;
}

.ui-kbd {
  display: inline-block;
  min-width: 1.6em;
  padding: 2px 5px;
  border: 1px solid var(--ui-line-strong);
  border-bottom-width: 2px;
  border-radius: var(--ui-radius-xs);
  background: var(--ui-surface-raised);
  font-family: var(--ui-font-mono);
  font-size: var(--ui-fs-3xs);
  line-height: 1.3;
  color: var(--ui-text-2);
  text-align: center;
}

/* ============================================================
   Forms (#521 validation, #522 dictation)
   ============================================================
   Markup contract:
     <div class="ui-field">
       <label class="ui-field__label" for="x">Title</label>
       <input class="ui-input" id="x" aria-invalid="true"
              aria-describedby="x-err">
       <p class="ui-field__error" id="x-err">Required.</p>
     </div>
   The server sets aria-invalid and renders .ui-field__error in the
   blur-validation fragment; no state class is needed. The error message
   is hidden until aria-invalid is set, so the same fragment shape works
   for the valid case with the message left empty.

   #521 wires that up: the input carries hx-post to the form's validate
   URL with hx-trigger="blur changed", hx-target="closest .ui-field" and
   hx-swap="outerHTML", and the response is the same ui_field partial
   the drawer rendered. One round trip per field, one code path for the
   rule, and the rule cannot drift from the one the submit runs. */

.ui-field {
  display: flex;
  flex-direction: column;
  gap: var(--ui-space-1);
  min-width: 0;
}

.ui-field__label {
  font-size: var(--ui-fs-xs);
  font-weight: var(--ui-fw-medium);
  color: var(--ui-text-2);
}

.ui-field__label[data-required]::after {
  content: " *";
  color: var(--ui-danger-text);
}

.ui-field__hint {
  font-size: var(--ui-fs-2xs);
  color: var(--ui-text-3);
}

/* A field the viewer may read but not write (#525: a story that reached
   the complete state). Same box metrics as .ui-input so a card of
   editable fields and a card of frozen ones line up. */
.ui-field__static {
  padding: var(--ui-space-2) 0;
  font-size: var(--ui-fs-sm);
  color: var(--ui-text);
  white-space: pre-wrap;
  overflow-wrap: anywhere;
}

.ui-field__error {
  display: none;
  font-size: var(--ui-fs-2xs);
  color: var(--ui-danger-text);
}

.ui-field:has([aria-invalid="true"]) .ui-field__error,
.ui-field.is-invalid .ui-field__error {
  display: block;
}

.ui-input,
.ui-textarea,
.ui-select {
  width: 100%;
  min-width: 0;
  min-height: 34px;
  padding: var(--ui-space-2) var(--ui-space-3);
  border: 1px solid var(--ui-control-line);
  border-radius: var(--ui-radius-sm);
  background: var(--ui-field);
  color: var(--ui-text);
  font: inherit;
  font-size: var(--ui-fs-sm);
}

.ui-input::placeholder,
.ui-textarea::placeholder {
  color: var(--ui-text-3);
}

.ui-input:hover,
.ui-textarea:hover,
.ui-select:hover {
  border-color: var(--ui-text-3);
}

.ui-input[aria-invalid="true"],
.ui-textarea[aria-invalid="true"],
.ui-select[aria-invalid="true"] {
  border-color: var(--ui-danger);
}

.ui-input[disabled],
.ui-textarea[disabled],
.ui-select[disabled] {
  color: var(--ui-text-disabled);
  background: var(--ui-surface);
  cursor: not-allowed;
}

.ui-input--mono {
  font-family: var(--ui-font-mono);
}

.ui-textarea {
  min-height: 6rem;
  line-height: var(--ui-lh-normal);
  resize: vertical;
}

.ui-select {
  appearance: none;
  padding-right: var(--ui-space-8);
  background-image: linear-gradient(45deg, transparent 50%, currentColor 50%),
    linear-gradient(135deg, currentColor 50%, transparent 50%);
  background-position: right 18px center, right 13px center;
  background-size: 5px 5px, 5px 5px;
  background-repeat: no-repeat;
}

/* Input with a leading icon or a trailing button (search, dictation). */
.ui-inputgroup {
  position: relative;
  display: flex;
  align-items: center;
  min-width: 0;
}

.ui-inputgroup > .ui-input {
  padding-right: var(--ui-space-10);
}

.ui-inputgroup__end {
  position: absolute;
  right: var(--ui-space-1);
  display: flex;
  gap: var(--ui-space-1);
}

/* Dictation mic (#522). States: idle, .is-recording, [aria-busy="true"]
   while the whisperX job runs. The recording colour is --ui-recording,
   a one-line alias of --ui-live. */
.ui-mic {
  border: none;
  background: transparent;
  color: var(--ui-text-3);
  cursor: pointer;
  padding: var(--ui-space-1);
  border-radius: var(--ui-radius-sm);
  line-height: 0;
}

.ui-mic:hover {
  color: var(--ui-text);
  background: var(--ui-hover);
}

.ui-mic.is-recording {
  color: var(--ui-recording);
  animation: ui-pulse 1.4s var(--ui-ease-in-out) infinite;
}

.ui-mic[aria-busy="true"] {
  color: var(--ui-accent-text);
}

.ui-checkbox,
.ui-radio {
  display: inline-flex;
  align-items: center;
  gap: var(--ui-space-2);
  font-size: var(--ui-fs-sm);
  cursor: pointer;
}

/* Both spellings, deliberately. The class is meant to sit on a wrapping
   <label> (16 places do that), but four list views put it straight on the
   bare checkbox that heads a bulk-select column, where there is no text to
   wrap: media list, records inbox and records settings_group. A descendant
   selector alone cannot match an input that carries the class itself, so
   those four rendered at the browser's 13px default with the browser's
   default accent instead of 16px and --ui-accent-fill. They are the
   highest-traffic checkboxes in the app, so they are the ones that must
   not be the odd ones out (#498). */
.ui-checkbox input,
input.ui-checkbox,
.ui-radio input,
input.ui-radio {
  width: 16px;
  height: 16px;
  flex: none;
  accent-color: var(--ui-accent-fill);
  cursor: pointer;
}

.ui-switch {
  display: inline-flex;
  align-items: center;
  gap: var(--ui-space-2);
  cursor: pointer;
}

.ui-switch__track {
  position: relative;
  width: 34px;
  height: 20px;
  flex: none;
  border-radius: var(--ui-radius-pill);
  background: var(--ui-line-strong);
  border: 1px solid var(--ui-control-line);
  transition: background var(--ui-dur-1) var(--ui-ease-out);
}

.ui-switch__track::after {
  content: "";
  position: absolute;
  top: 2px;
  left: 2px;
  width: 14px;
  height: 14px;
  border-radius: 50%;
  background: var(--ui-text);
  transition: transform var(--ui-dur-1) var(--ui-ease-out);
}

.ui-switch input {
  position: absolute;
  opacity: 0;
  pointer-events: none;
}

.ui-switch input:checked + .ui-switch__track {
  background: var(--ui-accent-fill);
  border-color: var(--ui-accent-fill);
}

.ui-switch input:checked + .ui-switch__track::after {
  transform: translateX(14px);
  background: var(--ui-accent-on);
}

/* Chips: story keywords (#524), applied filters, picked entities. */
.ui-chips {
  display: flex;
  flex-wrap: wrap;
  gap: var(--ui-space-1);
  min-width: 0;
}

.ui-chip {
  display: inline-flex;
  align-items: center;
  gap: var(--ui-space-1);
  padding: 2px var(--ui-space-2);
  border: 1px solid var(--ui-line-strong);
  border-radius: var(--ui-radius-pill);
  background: var(--ui-surface-raised);
  font-size: var(--ui-fs-2xs);
  color: var(--ui-text-2);
  max-width: 100%;
}

.ui-chip__remove {
  border: none;
  background: none;
  color: var(--ui-text-3);
  cursor: pointer;
  padding: 0 2px;
  line-height: 1;
}

.ui-chip__remove:hover {
  color: var(--ui-danger-text);
}

/* Filter bar above a list. */
.ui-filterbar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--ui-space-2);
  padding: var(--ui-space-3) 0;
}

/* ============================================================
   Card, stat, empty state, skeleton, progress
   ============================================================ */

.ui-card {
  background: var(--ui-surface);
  border: 1px solid var(--ui-line);
  border-radius: var(--ui-radius-md);
  min-width: 0;
}

.ui-card--raised {
  background: var(--ui-surface-raised);
  box-shadow: var(--ui-shadow-1);
}

.ui-card--link {
  display: block;
  color: inherit;
  text-decoration: none;
  transition: border-color var(--ui-dur-1) var(--ui-ease-out);
}

.ui-card--link:hover {
  border-color: var(--ui-accent-line);
}

.ui-card__header {
  display: flex;
  align-items: center;
  gap: var(--ui-space-2);
  padding: var(--ui-space-3) var(--ui-space-4);
  border-bottom: 1px solid var(--ui-line);
}

.ui-card__title {
  font-size: var(--ui-fs-lg);
  font-weight: var(--ui-fw-semibold);
  line-height: var(--ui-lh-snug);
  min-width: 0;
}

.ui-card__body {
  padding: var(--ui-space-4);
}

.ui-card__body--flush {
  padding: 0;
}

.ui-card__footer {
  display: flex;
  align-items: center;
  gap: var(--ui-space-2);
  padding: var(--ui-space-3) var(--ui-space-4);
  border-top: 1px solid var(--ui-line);
}

/* Stat card. The accent bar is the only decoration; the value is
   monospace so a row of stats aligns on the digit. */
.ui-stat {
  position: relative;
  padding: var(--ui-space-4);
  padding-left: calc(var(--ui-space-4) + 3px);
  background: var(--ui-surface);
  border: 1px solid var(--ui-line);
  border-radius: var(--ui-radius-md);
  overflow: hidden;
}

.ui-stat::before {
  content: "";
  position: absolute;
  inset: 0 auto 0 0;
  width: 3px;
  background: var(--ui-stat-accent, var(--ui-line-strong));
}

.ui-stat--accent { --ui-stat-accent: var(--ui-accent-line); }
.ui-stat--ok     { --ui-stat-accent: var(--ui-ok); }
.ui-stat--warn   { --ui-stat-accent: var(--ui-warn); }
.ui-stat--danger { --ui-stat-accent: var(--ui-danger); }
.ui-stat--live   { --ui-stat-accent: var(--ui-live); }

.ui-stat__label {
  font-size: var(--ui-fs-3xs);
  font-weight: var(--ui-fw-semibold);
  letter-spacing: var(--ui-tracking-caps);
  text-transform: uppercase;
  color: var(--ui-text-3);
}

.ui-stat__value {
  font-family: var(--ui-font-mono);
  font-variant-numeric: tabular-nums;
  font-size: var(--ui-fs-3xl);
  font-weight: var(--ui-fw-semibold);
  line-height: var(--ui-lh-tight);
  color: var(--ui-text);
  margin-top: var(--ui-space-1);
}

.ui-stat__meta {
  margin-top: var(--ui-space-1);
  font-size: var(--ui-fs-2xs);
  color: var(--ui-text-3);
}

.ui-empty {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--ui-space-2);
  padding: var(--ui-space-12) var(--ui-space-4);
  text-align: center;
  color: var(--ui-text-3);
}

.ui-empty__icon {
  width: 32px;
  height: 32px;
  color: var(--ui-text-3);
  fill: none;
  stroke: currentColor;
  stroke-width: 1.5;
}

.ui-empty__title {
  font-size: var(--ui-fs-lg);
  font-weight: var(--ui-fw-semibold);
  color: var(--ui-text);
}

.ui-empty__body {
  max-width: 48ch;
  font-size: var(--ui-fs-sm);
}

.ui-skeleton {
  display: block;
  border-radius: var(--ui-radius-sm);
  background: linear-gradient(
    90deg,
    var(--ui-skeleton-base) 0%,
    var(--ui-skeleton-sheen) 50%,
    var(--ui-skeleton-base) 100%
  );
  background-size: 200% 100%;
  animation: ui-shimmer 1.4s linear infinite;
}

.ui-skeleton--text {
  height: 0.8em;
  margin: 0.35em 0;
}

.ui-skeleton--title {
  height: 1.2em;
  width: 40%;
  margin: 0.3em 0;
}

.ui-skeleton--block {
  height: 96px;
}

.ui-skeleton--circle {
  width: 32px;
  height: 32px;
  border-radius: 50%;
}

.ui-skeleton--w50 { width: 50%; }
.ui-skeleton--w75 { width: 75%; }

/* Progress. The server writes the width as a custom property, so a
   chunked upload (#523) only needs to swap one attribute:
     <div class="ui-progress__bar" style="--ui-progress:42%"> */
.ui-progress {
  height: 6px;
  border-radius: var(--ui-radius-pill);
  background: var(--ui-line);
  overflow: hidden;
}

.ui-progress__bar {
  height: 100%;
  width: var(--ui-progress, 0%);
  background: var(--ui-accent-fill);
  border-radius: inherit;
  transition: width var(--ui-dur-3) var(--ui-ease-out);
}

.ui-progress--ok .ui-progress__bar     { background: var(--ui-ok); }
.ui-progress--warn .ui-progress__bar   { background: var(--ui-warn); }
.ui-progress--danger .ui-progress__bar { background: var(--ui-danger); }

/* Indeterminate: no percentage known yet. */
.ui-progress--pending .ui-progress__bar {
  width: 35%;
  animation: ui-indeterminate 1.2s var(--ui-ease-in-out) infinite;
}

/* Request indicator pinned to the top of the viewport. Every page that
   boosts through the shell shares one of these, and it has to sit
   outside #ui-main: the boosted swap replaces that element's children,
   so an indicator inside it survives exactly one navigation. Pair with
   .htmx-indicator, which supplies the fade (#519 had this as five
   inline declarations in shell.html). */
.ui-progress--top {
  position: fixed;
  inset: 0 0 auto 0;
  z-index: var(--ui-z-toast);
  height: 3px;
  border-radius: 0;
  background: transparent;
}

/* ============================================================
   Badge and status dot
   ============================================================
   One badge class, six status variants. Red is --live only; failure is
   orange. Sections must not invent a seventh variant: add it here. */

.ui-badge {
  display: inline-flex;
  align-items: center;
  gap: var(--ui-space-1);
  padding: 1px var(--ui-space-2);
  border-radius: var(--ui-radius-pill);
  background: var(--ui-badge-tint, var(--ui-surface-raised));
  color: var(--ui-badge-text, var(--ui-text-2));
  font-size: var(--ui-fs-2xs);
  font-weight: var(--ui-fw-medium);
  line-height: 1.6;
  white-space: nowrap;
}

.ui-badge--neutral {
  --ui-badge-tint: var(--ui-surface-raised);
  --ui-badge-text: var(--ui-text-2);
  border: 1px solid var(--ui-line-strong);
}

.ui-badge--info {
  --ui-badge-tint: var(--ui-accent-tint);
  --ui-badge-text: var(--ui-accent-text);
}

.ui-badge--ok {
  --ui-badge-tint: var(--ui-ok-tint);
  --ui-badge-text: var(--ui-ok-text);
}

.ui-badge--warn {
  --ui-badge-tint: var(--ui-warn-tint);
  --ui-badge-text: var(--ui-warn-text);
}

.ui-badge--danger {
  --ui-badge-tint: var(--ui-danger-tint);
  --ui-badge-text: var(--ui-danger-text);
}

.ui-badge--live {
  --ui-badge-tint: var(--ui-live-fill);
  --ui-badge-text: var(--ui-live-on);
  font-weight: var(--ui-fw-semibold);
  letter-spacing: var(--ui-tracking-caps);
  text-transform: uppercase;
}

.ui-badge--mono {
  font-family: var(--ui-font-mono);
  font-size: var(--ui-fs-3xs);
}

.ui-dot {
  width: 8px;
  height: 8px;
  flex: none;
  border-radius: 50%;
  background: var(--ui-text-3);
  display: inline-block;
}

.ui-dot--ok     { background: var(--ui-ok); }
.ui-dot--warn   { background: var(--ui-warn); }
.ui-dot--danger { background: var(--ui-danger); }
.ui-dot--info   { background: var(--ui-accent); }

/* The pulse is reserved for live, so motion in the corner of the eye
   always means a camera is up. */
.ui-dot--live {
  background: var(--ui-live);
  animation: ui-pulse 1.6s var(--ui-ease-in-out) infinite;
}

/* ============================================================
   Table
   ============================================================
   Markup contract:
     <div class="ui-tablewrap">
       <table class="ui-table ui-table--dense">
         <thead><tr><th aria-sort="ascending">
           <a class="ui-table__sort" href="?sort=name">Name</a>
         </th>…
   aria-sort on the th is the single source of truth for the arrow, so a
   server-rendered sort and a client-side one paint identically. */

.ui-tablewrap {
  overflow-x: auto;
  overscroll-behavior-x: contain;
  border: 1px solid var(--ui-line);
  border-radius: var(--ui-radius-md);
  background: var(--ui-surface);
}

.ui-table {
  width: 100%;
  border-collapse: collapse;
  font-size: var(--ui-fs-sm);
}

.ui-table th,
.ui-table td {
  padding: var(--ui-space-2) var(--ui-space-3);
  text-align: left;
  vertical-align: middle;
  border-bottom: 1px solid var(--ui-line);
}

.ui-table thead th {
  position: relative;
  height: var(--ui-row-h-dense);
  background: var(--ui-surface-raised);
  color: var(--ui-text-3);
  font-size: var(--ui-fs-3xs);
  font-weight: var(--ui-fw-semibold);
  letter-spacing: var(--ui-tracking-caps);
  text-transform: uppercase;
  white-space: nowrap;
}

.ui-table tbody tr {
  height: var(--ui-row-h);
}

.ui-table tbody tr:last-child td {
  border-bottom: none;
}

.ui-table tbody tr:hover {
  background: var(--ui-hover);
}

.ui-table tbody tr[aria-selected="true"] {
  background: var(--ui-selected);
}

/* Keyboard triage cursor (#527: j/k through the records inbox). */
.ui-table tbody tr.is-cursor {
  box-shadow: inset 3px 0 0 0 var(--ui-accent-line);
  background: var(--ui-hover);
}

.ui-table--dense th,
.ui-table--dense td {
  padding: var(--ui-space-1) var(--ui-space-2);
  font-size: var(--ui-fs-xs);
}

.ui-table--dense tbody tr {
  height: var(--ui-row-h-dense);
}

.ui-table--sticky thead th {
  position: sticky;
  top: 0;
  z-index: var(--ui-z-sticky);
}

.ui-table__sort {
  display: inline-flex;
  align-items: center;
  gap: var(--ui-space-1);
  color: inherit;
  text-decoration: none;
}

.ui-table__sort:hover {
  color: var(--ui-text);
}

.ui-table th[aria-sort] .ui-table__sort {
  color: var(--ui-text);
}

.ui-table th[aria-sort="ascending"] .ui-table__sort::after {
  content: "\2191";
}

.ui-table th[aria-sort="descending"] .ui-table__sort::after {
  content: "\2193";
}

.ui-table__num,
.ui-table td.ui-num {
  font-family: var(--ui-font-mono);
  font-variant-numeric: tabular-nums;
  text-align: right;
}

.ui-table__actions {
  width: 1%;
  white-space: nowrap;
  text-align: right;
}

/* Row actions stay hidden until the row is hovered or something inside
   it has focus, which keeps a 12-column table readable. They remain in
   the accessibility tree and reappear on keyboard focus. */
.ui-table__actions .ui-btn {
  opacity: 0;
}

.ui-table tbody tr:hover .ui-table__actions .ui-btn,
.ui-table__actions:focus-within .ui-btn,
.ui-table tbody tr.is-cursor .ui-table__actions .ui-btn {
  opacity: 1;
}

@media (hover: none) {
  .ui-table__actions .ui-btn {
    opacity: 1;
  }
}

/* Under 768px a wide table becomes a stack of labelled rows. Each td
   carries data-label with its column heading. Opt in per table; a table
   that is genuinely a grid keeps horizontal scroll instead. */
@media (max-width: 767px) {
  .ui-table--stack thead {
    display: none;
  }

  .ui-table--stack tbody tr {
    display: block;
    height: auto;
    padding: var(--ui-space-2);
    border-bottom: 1px solid var(--ui-line);
  }

  .ui-table--stack tbody td,
  .ui-table--stack tbody th[scope="row"] {
    display: flex;
    justify-content: space-between;
    gap: var(--ui-space-3);
    border: none;
    padding: var(--ui-space-1) 0;
    text-align: right;
    font-weight: inherit;
  }

  .ui-table--stack tbody td::before,
  .ui-table--stack tbody th[scope="row"]::before {
    content: attr(data-label);
    color: var(--ui-text-3);
    font-size: var(--ui-fs-2xs);
    letter-spacing: var(--ui-tracking-caps);
    text-transform: uppercase;
    text-align: left;
  }
}

/* ============================================================
   Menu / dropdown
   ============================================================ */

.ui-menu {
  min-width: 180px;
  padding: var(--ui-space-1);
  background: var(--ui-overlay);
  border: 1px solid var(--ui-line-strong);
  border-radius: var(--ui-radius-md);
  box-shadow: var(--ui-shadow-2);
  z-index: var(--ui-z-dropdown);
}

.ui-menu__item {
  display: flex;
  align-items: center;
  gap: var(--ui-space-2);
  width: 100%;
  padding: var(--ui-space-2) var(--ui-space-3);
  border: none;
  border-radius: var(--ui-radius-sm);
  background: none;
  color: var(--ui-text);
  font: inherit;
  font-size: var(--ui-fs-sm);
  text-align: left;
  text-decoration: none;
  cursor: pointer;
}

.ui-menu__item:hover,
.ui-menu__item[aria-selected="true"] {
  background: var(--ui-hover);
}

.ui-menu__item--danger {
  color: var(--ui-danger-text);
}

.ui-menu__sep {
  height: 1px;
  margin: var(--ui-space-1) 0;
  background: var(--ui-line);
  border: none;
}

/* ============================================================
   Scrim, drawer, sheet (#521)
   ============================================================
   Markup contract:
     <div class="ui-drawer" role="dialog" aria-modal="true"
          aria-labelledby="d-title">
       <div class="ui-drawer__scrim" data-ui-dismiss></div>
       <div class="ui-drawer__panel">
         <header class="ui-drawer__header">…</header>
         <div class="ui-drawer__body">…</div>
         <footer class="ui-drawer__footer">…</footer>
       </div>
     </div>
   Behavior owned by #521: insert the fragment into #ui-drawer-mount, add
   .is-open on the next frame to trigger the transition, add
   .is-scroll-locked to .ui-root, trap focus in the panel, and on Esc run
   the dirty check before removing. Exit: drop .is-open, remove after
   transitionend. Nothing here opens or closes anything by itself.
   ui-drawer.js implements that; see window.uiDrawer there.

   A drawer that lives in the page rather than in the mount (the shell's
   More sheet) carries the hidden attribute while closed, and the
   display:flex below would otherwise paint it anyway. The reset at the
   top of this file handles that for every component; see the note on
   .ui-root [hidden] before adding a display value to a new overlay. */

.ui-drawer {
  position: fixed;
  inset: 0;
  z-index: var(--ui-z-drawer);
  display: flex;
  justify-content: flex-end;
}

.ui-drawer__scrim {
  position: absolute;
  inset: 0;
  background: var(--ui-scrim);
  opacity: 0;
  transition: opacity var(--ui-dur-2) var(--ui-ease-out);
}

.ui-drawer.is-open .ui-drawer__scrim {
  opacity: 1;
}

.ui-drawer__panel {
  position: relative;
  display: flex;
  flex-direction: column;
  width: min(var(--ui-drawer-w), 100vw);
  max-width: 100vw;
  height: 100%;
  background: var(--ui-overlay);
  border-left: 1px solid var(--ui-line-strong);
  box-shadow: var(--ui-shadow-3);
  transform: translateX(100%);
  transition: transform var(--ui-dur-3) var(--ui-ease-out);
}

.ui-drawer.is-open .ui-drawer__panel {
  transform: translateX(0);
}

.ui-drawer__header {
  display: flex;
  align-items: center;
  gap: var(--ui-space-2);
  padding: var(--ui-space-4);
  border-bottom: 1px solid var(--ui-line);
}

.ui-drawer__title {
  font-size: var(--ui-fs-lg);
  font-weight: var(--ui-fw-semibold);
  min-width: 0;
}

.ui-drawer__body {
  flex: 1;
  overflow-y: auto;
  overscroll-behavior: contain;
  padding: var(--ui-space-4);
  display: flex;
  flex-direction: column;
  gap: var(--ui-space-4);
}

.ui-drawer__footer {
  display: flex;
  justify-content: flex-end;
  gap: var(--ui-space-2);
  padding: var(--ui-space-4);
  border-top: 1px solid var(--ui-line);
  padding-bottom: calc(var(--ui-space-4) + env(safe-area-inset-bottom, 0px));
}

/* A quick-create drawer is one <form> wrapping the body and the footer,
   so the form has to carry the panel's column layout or the body stops
   scrolling and the footer floats. */
.ui-drawer__form {
  display: flex;
  flex-direction: column;
  flex: 1;
  min-height: 0;
}

/* Dirty-state confirm, painted inside the panel rather than through
   window.confirm so Esc and the two buttons stay inside the focus trap.
   ui-drawer.js inserts it, moves focus to Keep editing, and removes it
   on either answer. */
.ui-drawer__confirm {
  position: absolute;
  inset: 0;
  z-index: 1;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: var(--ui-space-4);
  background: var(--ui-scrim);
}

.ui-drawer__confirmbox {
  display: flex;
  flex-direction: column;
  gap: var(--ui-space-3);
  width: 100%;
  max-width: 22rem;
  padding: var(--ui-space-4);
  background: var(--ui-overlay);
  border: 1px solid var(--ui-line-strong);
  border-radius: var(--ui-radius-md);
  box-shadow: var(--ui-shadow-2);
  font-size: var(--ui-fs-sm);
}

/* Sheet variant: rises from the bottom at every width and is only as
   tall as its contents. This is the shape #519 wanted for the phone
   "more" menu, which it built out of a full-height drawer because the
   variant did not exist yet. A list of destinations is the case it is
   for; a form is a .ui-drawer. */
.ui-drawer--sheet {
  align-items: flex-end;
  justify-content: center;
}

.ui-drawer--sheet .ui-drawer__panel {
  width: min(var(--ui-sheet-w, 480px), 100%);
  height: auto;
  max-height: 85dvh;
  border-left: 1px solid var(--ui-line-strong);
  border-top: 1px solid var(--ui-line-strong);
  border-radius: var(--ui-radius-lg) var(--ui-radius-lg) 0 0;
  transform: translateY(100%);
}

.ui-drawer--sheet.is-open .ui-drawer__panel {
  transform: translateY(0);
}

.ui-drawer--sheet .ui-drawer__body {
  padding-bottom: calc(var(--ui-space-4) + env(safe-area-inset-bottom, 0px));
}

/* Row list inside a sheet: destinations or actions, one per row, at the
   44px tap target the rest of the phone layout uses. */
.ui-drawer__list {
  display: flex;
  flex-direction: column;
  gap: var(--ui-space-05);
}

.ui-drawer__list > .ui-menu__item {
  min-height: var(--ui-tap-min);
}

/* Phone: the drawer is a full-screen sheet that rises from the bottom. */
@media (max-width: 767px) {
  .ui-drawer {
    align-items: flex-end;
  }

  .ui-drawer__panel {
    width: 100%;
    height: 100%;
    border-left: none;
    border-top: 1px solid var(--ui-line-strong);
    border-radius: 0;
    transform: translateY(100%);
  }

  .ui-drawer.is-open .ui-drawer__panel {
    transform: translateY(0);
  }

  /* The sheet keeps its own height and its rounded top edge here; only
     the width goes full-bleed. */
  .ui-drawer--sheet .ui-drawer__panel {
    width: 100%;
    height: auto;
    border-left: none;
    border-radius: var(--ui-radius-lg) var(--ui-radius-lg) 0 0;
  }
}

/* ============================================================
   Typeahead picker (#521)
   ============================================================
   Markup contract (WAI-ARIA combobox):
     <div class="ui-typeahead">
       <input class="ui-input" role="combobox" aria-expanded="true"
              aria-controls="ta-menu" aria-autocomplete="list">
       <ul class="ui-typeahead__menu" id="ta-menu" role="listbox">
         <li class="ui-typeahead__option" role="option"
             aria-selected="true">…</li>
         <li class="ui-typeahead__create" role="option">+ Create "…"</li>
       </ul>
     </div>
   The menu is rendered by the server as an hx-get fragment keyed on the
   input value; the create row is the last option so arrow-down always
   reaches it.

   ui-typeahead.js drives it from data attributes on the root:
   data-ui-typeahead marks it, data-ui-source is the search URL,
   data-ui-create is the inline-create URL, data-ui-name is the hidden
   input the picked id lands in. The menu carries hidden while it is
   closed; the value the form submits is the hidden input, never the
   text the user typed. */

.ui-typeahead {
  position: relative;
  min-width: 0;
}

/* In-flight search. The input keeps its own text, so the only feedback
   is this: no layout shift, no disabled field. The element also carries
   .ui-spinner, which is declared further down the file and would win at
   equal specificity, so both rules below name the parent. */
.ui-typeahead .ui-typeahead__spinner {
  position: absolute;
  top: 9px;
  right: var(--ui-space-3);
  display: none;
}

.ui-typeahead.is-busy .ui-typeahead__spinner {
  display: inline-block;
}

.ui-typeahead__menu {
  position: absolute;
  left: 0;
  right: 0;
  top: calc(100% + var(--ui-space-1));
  z-index: var(--ui-z-dropdown);
  max-height: 18rem;
  overflow-y: auto;
  overscroll-behavior: contain;
  margin: 0;
  padding: var(--ui-space-1);
  list-style: none;
  background: var(--ui-overlay);
  border: 1px solid var(--ui-line-strong);
  border-radius: var(--ui-radius-md);
  box-shadow: var(--ui-shadow-2);
}

.ui-typeahead__option {
  display: flex;
  align-items: center;
  gap: var(--ui-space-2);
  padding: var(--ui-space-2) var(--ui-space-3);
  border-radius: var(--ui-radius-sm);
  cursor: pointer;
  min-width: 0;
}

.ui-typeahead__option:hover,
.ui-typeahead__option[aria-selected="true"] {
  background: var(--ui-selected);
}

.ui-typeahead__label {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.ui-typeahead__meta {
  margin-left: auto;
  font-size: var(--ui-fs-2xs);
  color: var(--ui-text-3);
  white-space: nowrap;
}

.ui-typeahead__create {
  display: flex;
  align-items: center;
  gap: var(--ui-space-2);
  padding: var(--ui-space-2) var(--ui-space-3);
  margin-top: var(--ui-space-1);
  border-top: 1px solid var(--ui-line);
  border-radius: var(--ui-radius-sm);
  color: var(--ui-accent-text);
  font-weight: var(--ui-fw-medium);
  cursor: pointer;
}

.ui-typeahead__create:hover,
.ui-typeahead__create[aria-selected="true"] {
  background: var(--ui-accent-tint);
}

.ui-typeahead__empty {
  padding: var(--ui-space-3);
  color: var(--ui-text-3);
  font-size: var(--ui-fs-xs);
}

/* ============================================================
   Command palette (#520)
   ============================================================ */

.ui-palette {
  position: fixed;
  inset: 0;
  z-index: var(--ui-z-palette);
  display: flex;
  justify-content: center;
  align-items: flex-start;
  padding: var(--ui-space-4);
}

.ui-palette__scrim {
  position: absolute;
  inset: 0;
  background: var(--ui-scrim);
}

.ui-palette__dialog {
  position: relative;
  width: min(var(--ui-palette-w), 100%);
  margin-top: 10vh;
  max-height: 70vh;
  display: flex;
  flex-direction: column;
  background: var(--ui-overlay);
  border: 1px solid var(--ui-line-strong);
  border-radius: var(--ui-radius-lg);
  box-shadow: var(--ui-shadow-3);
  overflow: hidden;
}

.ui-palette__input {
  width: 100%;
  padding: var(--ui-space-4);
  border: none;
  border-bottom: 1px solid var(--ui-line);
  background: transparent;
  color: var(--ui-text);
  font: inherit;
  font-size: var(--ui-fs-lg);
}

.ui-palette__input:focus {
  outline: none;
  box-shadow: none;
}

.ui-palette__results {
  flex: 1;
  overflow-y: auto;
  overscroll-behavior: contain;
  padding: var(--ui-space-1);
  margin: 0;
  list-style: none;
}

.ui-palette__group {
  padding: var(--ui-space-2) var(--ui-space-3) var(--ui-space-1);
  font-size: var(--ui-fs-3xs);
  font-weight: var(--ui-fw-semibold);
  letter-spacing: var(--ui-tracking-caps);
  text-transform: uppercase;
  color: var(--ui-text-3);
}

.ui-palette__item {
  display: flex;
  align-items: center;
  gap: var(--ui-space-3);
  padding: var(--ui-space-2) var(--ui-space-3);
  border-radius: var(--ui-radius-sm);
  color: var(--ui-text);
  text-decoration: none;
  cursor: pointer;
  min-width: 0;
}

.ui-palette__item:hover,
.ui-palette__item[aria-selected="true"] {
  background: var(--ui-selected);
}

/* The two text parts of a result row, folded in from #520's own sheet
   so there is one source for the class. Both truncate: a story titled
   with the whole first paragraph would otherwise push .ui-palette__kind
   past the edge of the dialog and take the row's meaning with it. */
.ui-palette__label {
  flex: 1 1 auto;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.ui-palette__sub {
  flex: 0 1 auto;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-size: var(--ui-fs-2xs);
  color: var(--ui-text-3);
}

.ui-palette__empty,
.ui-palette__note {
  margin: 0;
  padding: var(--ui-space-3);
  font-size: var(--ui-fs-xs);
  color: var(--ui-text-3);
}

.ui-palette__note {
  border-top: 1px solid var(--ui-line);
}

/* Scrollable body for a dialog that is not the results listbox, which is
   what the "?" shortcut map renders. .ui-palette__dialog is a flex column
   with overflow:hidden, so a long map needs a pane of its own to scroll. */
.ui-palette__pane {
  padding: var(--ui-space-4);
  overflow-y: auto;
}

.ui-palette__kind {
  font-size: var(--ui-fs-3xs);
  color: var(--ui-text-3);
  margin-left: auto;
  white-space: nowrap;
}

.ui-palette__foot {
  display: flex;
  gap: var(--ui-space-3);
  padding: var(--ui-space-2) var(--ui-space-3);
  border-top: 1px solid var(--ui-line);
  font-size: var(--ui-fs-2xs);
  color: var(--ui-text-3);
}

/* Shortcut map shown by "?" (#520). */
.ui-shortcuts {
  display: grid;
  grid-template-columns: max-content 1fr;
  gap: var(--ui-space-2) var(--ui-space-4);
  align-items: center;
  font-size: var(--ui-fs-sm);
}

/* The key column. A chord is several .ui-kbd caps ("Ctrl" then "K")
   that have to read as one shortcut, so they share a cell and stay on
   one line. Right-aligned, which lines the last cap of every row up
   against the descriptions. */
.ui-shortcuts__key {
  display: flex;
  align-items: center;
  justify-content: flex-end;
  gap: var(--ui-space-1);
  white-space: nowrap;
}

/* ============================================================
   Toast (#521)
   ============================================================
   Server contract: responses carry
     HX-Trigger: {"ui:toast":{"level":"ok","message":"Case filed",
                              "action":{"label":"Open","href":"/…"}}}
   level is one of ok | warn | danger | info. The listener in #521 builds
   one .ui-toast per event, appends it to #ui-toasts, and removes it
   after its timeout or on close. Anything longer-lived than a toast is a
   page-level .ui-banner instead.

   #521 kept that shape and added three optional keys: "title" for the
   bold first line, "timeout" in milliseconds (0 pins the toast until it
   is closed), and an array value for more than one toast on a single
   response. Go writes it through web.TriggerToast; ui-toast.js reads it
   off the htmx event and window.uiToast() does the same from script. A
   danger toast defaults to no timeout, because the one message a user
   has to be able to read twice is the one that says the write failed. */

.ui-toast-region {
  position: fixed;
  right: var(--ui-space-4);
  bottom: var(--ui-space-4);
  z-index: var(--ui-z-toast);
  display: flex;
  flex-direction: column;
  gap: var(--ui-space-2);
  width: min(380px, calc(100vw - var(--ui-space-8)));
  pointer-events: none;
}

.ui-toast {
  display: flex;
  align-items: flex-start;
  gap: var(--ui-space-3);
  padding: var(--ui-space-3);
  background: var(--ui-overlay);
  border: 1px solid var(--ui-line-strong);
  border-left: 3px solid var(--ui-toast-accent, var(--ui-line-strong));
  border-radius: var(--ui-radius-md);
  box-shadow: var(--ui-shadow-2);
  pointer-events: auto;
  animation: ui-toast-in var(--ui-dur-3) var(--ui-ease-out);
}

.ui-toast--ok     { --ui-toast-accent: var(--ui-ok); }
.ui-toast--warn   { --ui-toast-accent: var(--ui-warn); }
.ui-toast--danger { --ui-toast-accent: var(--ui-danger); }
.ui-toast--info   { --ui-toast-accent: var(--ui-accent-line); }

.ui-toast__body {
  flex: 1;
  min-width: 0;
  font-size: var(--ui-fs-sm);
}

.ui-toast__title {
  font-weight: var(--ui-fw-semibold);
}

.ui-toast__msg {
  color: var(--ui-text-2);
}

.ui-toast__action {
  margin-top: var(--ui-space-2);
}

.ui-toast__close {
  border: none;
  background: none;
  color: var(--ui-text-3);
  cursor: pointer;
  line-height: 1;
  padding: 2px;
}

.ui-toast.is-leaving {
  opacity: 0;
  transform: translateY(4px);
  transition: opacity var(--ui-dur-2) var(--ui-ease-out),
    transform var(--ui-dur-2) var(--ui-ease-out);
}

@media (max-width: 767px) {
  .ui-toast-region {
    right: var(--ui-space-2);
    left: var(--ui-space-2);
    bottom: calc(var(--ui-tabbar-h) + var(--ui-space-2)
      + env(safe-area-inset-bottom, 0px));
    width: auto;
  }
}

/* Page-level banner: state that outlives a toast (a worker is down, a
   job is stuck). Same status vocabulary, no timeout. */
.ui-banner {
  display: flex;
  align-items: center;
  gap: var(--ui-space-3);
  padding: var(--ui-space-3) var(--ui-space-4);
  border: 1px solid var(--ui-line);
  border-left: 3px solid var(--ui-banner-accent, var(--ui-line-strong));
  border-radius: var(--ui-radius-md);
  background: var(--ui-surface);
  font-size: var(--ui-fs-sm);
}

.ui-banner--ok     { --ui-banner-accent: var(--ui-ok); background: var(--ui-ok-tint); }
.ui-banner--warn   { --ui-banner-accent: var(--ui-warn); background: var(--ui-warn-tint); }
.ui-banner--danger { --ui-banner-accent: var(--ui-danger); background: var(--ui-danger-tint); }
.ui-banner--info   { --ui-banner-accent: var(--ui-accent-line); background: var(--ui-accent-tint); }

/* ============================================================
   Transcript (#523 media detail, click to seek)
   ============================================================ */

.ui-transcript {
  max-height: 60vh;
  overflow-y: auto;
  overscroll-behavior: contain;
  font-size: var(--ui-fs-md);
  line-height: var(--ui-lh-loose);
}

.ui-transcript__cue {
  display: grid;
  grid-template-columns: max-content minmax(0, 1fr);
  gap: var(--ui-space-3);
  padding: var(--ui-space-1) var(--ui-space-2);
  border-radius: var(--ui-radius-sm);
  cursor: pointer;
  scroll-margin-block: var(--ui-space-8);
}

.ui-transcript__cue:hover {
  background: var(--ui-hover);
}

.ui-transcript__cue[aria-current="true"] {
  background: var(--ui-selected);
  box-shadow: inset 2px 0 0 0 var(--ui-accent-line);
}

.ui-transcript__ts {
  font-family: var(--ui-font-mono);
  font-variant-numeric: tabular-nums;
  font-size: var(--ui-fs-2xs);
  color: var(--ui-text-3);
  padding-top: 0.35em;
}

.ui-transcript__speaker {
  font-weight: var(--ui-fw-semibold);
  color: var(--ui-accent-text);
  margin-right: var(--ui-space-1);
}

/* Two-column detail layout: content plus a metadata rail. Collapses to
   one column below 1200px. */
.ui-detail {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  gap: var(--ui-space-4);
}

@media (min-width: 1200px) {
  .ui-detail {
    grid-template-columns: minmax(0, 1fr) 320px;
    align-items: start;
  }
}

/* ============================================================
   htmx integration (#519 vendors htmx)
   ============================================================ */

.ui-root .htmx-indicator {
  opacity: 0;
  transition: opacity var(--ui-dur-2) var(--ui-ease-out);
}

.ui-root .htmx-request .htmx-indicator,
.ui-root .htmx-indicator.htmx-request {
  opacity: 1;
}

/* Anything mid-request dims and stops taking clicks, so a double submit
   is not possible without per-page JS. */
.ui-root [aria-busy="true"] {
  cursor: progress;
}

.ui-root .htmx-request.ui-busy-dim {
  opacity: 0.55;
  pointer-events: none;
}

.ui-spinner {
  width: 16px;
  height: 16px;
  border: 2px solid var(--ui-line-strong);
  border-top-color: var(--ui-accent);
  border-radius: 50%;
  animation: ui-spin 700ms linear infinite;
  display: inline-block;
}

/* The boosted content region fades on swap. Paired with
   hx-swap="innerHTML transition:true" in the shell. */
.ui-root .htmx-swapping .ui-app__main {
  opacity: 0.6;
}

/* ============================================================
   Animation
   ============================================================ */

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

@keyframes ui-shimmer {
  from { background-position: 200% 0; }
  to   { background-position: -200% 0; }
}

@keyframes ui-pulse {
  0%, 100% { opacity: 1; }
  50%      { opacity: 0.35; }
}

@keyframes ui-indeterminate {
  0%   { transform: translateX(-100%); }
  100% { transform: translateX(320%); }
}

@keyframes ui-toast-in {
  from { opacity: 0; transform: translateY(8px); }
  to   { opacity: 1; transform: translateY(0); }
}

@media (prefers-reduced-motion: reduce) {
  .ui-root *,
  .ui-root *::before,
  .ui-root *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
  }

  /* The live dot is a status signal, not decoration. With motion off it
     still has to read as live, so it stops pulsing and takes a ring. */
  .ui-dot--live {
    box-shadow: 0 0 0 2px var(--ui-live-tint);
  }
}

/* Windows high contrast: let the OS palette win and keep the boundaries
   that carry meaning. */
@media (forced-colors: active) {
  .ui-btn,
  .ui-input,
  .ui-textarea,
  .ui-select,
  .ui-card,
  .ui-tablewrap {
    border: 1px solid ButtonBorder;
  }

  .ui-badge {
    border: 1px solid CanvasText;
  }

  .ui-root :focus-visible {
    outline: 2px solid Highlight;
    outline-offset: 1px;
  }
}

/* ============================================================
   Print: tables and transcripts are the two things anyone prints.
   ============================================================ */

@media print {
  .ui-rail,
  .ui-tabbar,
  .ui-toast-region,
  .ui-page__actions,
  .ui-table__actions {
    display: none !important;
  }

  .ui-root {
    background: #fff;
    color: #000;
  }

  .ui-transcript {
    max-height: none;
    overflow: visible;
  }
}

/* Media list: keep filenames, selection and actions in view on phones. */
@media (max-width: 767px) {
  .media-list-table { overflow: visible; }
  .media-list-table table, .media-list-table tbody { display: block; width: 100%; }
  .media-list-table tbody tr { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: .35rem 1rem; height: auto; }
  .media-list-table tbody td { min-width: 0; white-space: normal; overflow-wrap: anywhere; align-items: center; }
  .media-list-table tbody td.media-list-file { grid-column: 1 / -1; grid-row: 1; text-align: left; }
  .media-list-file::before { display: none; }
  .media-list-file a { min-width: 0; width: 100%; }
  .media-list-file img { flex-shrink: 0; }
  .media-list-file .ui-truncate { white-space: normal; overflow: visible; overflow-wrap: anywhere; }
  .media-list-table tbody td.ui-table__actions { display: flex; width: auto; }
  .media-list-table .ui-table__actions .ui-btn { opacity: 1; }
  .media-list-table td > span { min-width: 0; white-space: normal; }
  .media-list-table td::before { flex-shrink: 0; }
}
