:root {
  /* ==========================================================================
     ECVALLA Helpdesk — "Command Deck" theme
     A deep-space, glass-and-gold visual language: MODAD/ECVALLA's own dark-
     charcoal + champagne-gold identity (sampled from the real ecvalla.com
     site — see the brand-accent note below) pushed further into a premium,
     futuristic "mission control" feel, plus the existing violet→blue→teal
     AI gradient (--ai-grad, already used for AI-assisted UI) now used more
     broadly as the system's "intelligence" signature glow. Two accents,
     one story: gold = the brand's real-estate heritage, the cool gradient
     glow = the AI layer running underneath it. Nothing here is an invented
     third identity — it's the two colors already established in this
     codebase, used with more conviction.
     ========================================================================== */

  /* ---- categorical palette (dataviz skill reference) — same hues, lifted
     a touch in lightness so they still read clearly on a near-black ground
     instead of the previous warm-ivory one ---- */
  --cat-1: #3a8ee6;
  --cat-2: #ef7c46;
  --cat-3: #2cc48d;
  --cat-4: #f0ac1f;
  --cat-5: #ef8fb6;
  --cat-6: #2ecc71;
  --cat-7: #8f7de8;
  --cat-8: #f0605f;

  --status-good:     #2ecc71;
  --status-warning:  #fbbf3d;
  --status-serious:  #ff9466;
  --status-critical: #ff5c5c;

  /* ---- deep space ground ---- */
  --void:           #05060a;
  --page-plane:     #0a0c14;
  --surface:        rgba(255,255,255,.035);
  --surface-solid:  #12141f;
  --surface-2:      rgba(255,255,255,.06);
  --ink-primary:    #f4f1ea;
  --ink-secondary:  #a9acbb;
  --ink-muted:      #6d7185;
  --gridline:       rgba(255,255,255,.09);
  --baseline:       rgba(255,255,255,.16);
  --border:         rgba(255,255,255,.10);
  --border-soft:    rgba(255,255,255,.08);
  --glass-highlight: rgba(255,255,255,.06);

  /* ---- ECVALLA/MODAD brand gold — the same hue already sourced from the
     real ecvalla.com site, brightened slightly so it reads as a glow
     against near-black rather than just a dark-mode text color ---- */
  --brand-dark: #05060a;
  --brand-primary: #0d0f1a;
  --brand-accent: #d8b370;
  --brand-accent-strong: #e8c988;
  --brand-accent-soft: rgba(216,179,112,.14);
  --brand-glow: rgba(216,179,112,.40);
  /* Gold used as FOREGROUND text/icon color (links, outline buttons, the
     topbar notification bell) — same light gold as --brand-accent-strong
     in dark mode, redefined to a much deeper bronze under the light theme
     below so it stays readable on a white page. Gold used as a
     BACKGROUND (buttons, "mine" chat bubbles, the sidebar, which always
     carries its own dark text) never uses this token — it's unaffected
     by the toggle on purpose. */
  --brand-accent-ink: var(--brand-accent-strong);
  --status-warning-ink: var(--status-warning);

  /* ---- AI signature glow — the same gradient already reserved for
     AI-assisted UI, now the system's ambient "intelligence" accent ---- */
  --ai-grad: linear-gradient(120deg, #6a5bd6, #2a78d6 55%, #17b3a3);
  --ai-glow: rgba(106,91,214,.35);
  --ai-glow-2: rgba(23,179,163,.30);
}

/* ---------------------------------------------------------------------
   Light theme — same component structure and the same two brand accents
   (gold + the AI gradient); only the "ground" (page background, glass
   surfaces, borders, ink) flips from near-black to near-white. Toggled
   from the topbar sun/moon button (base.html), remembered per-browser in
   localStorage — see the inline script in base.html's <head> that sets
   this attribute before first paint so a saved choice never flashes dark
   first. Deliberately does NOT touch the sidebar, the login screen or
   the public landing page — those keep the dark "Command Deck" look in
   both modes (no toggle is offered on any of them), so only the tokens
   the authenticated content area actually renders with are redefined
   here.
   --------------------------------------------------------------------- */
:root[data-bs-theme="light"] {
  --void:            #eef0f5;
  --page-plane:      #f2f3f7;
  --surface:         rgba(20,24,42,.035);
  --surface-solid:   #ffffff;
  --surface-2:       rgba(20,24,42,.055);
  --ink-primary:     #1b1d2a;
  --ink-secondary:   #565a70;
  --ink-muted:       #8a8fa3;
  --gridline:        rgba(20,24,42,.10);
  --baseline:        rgba(20,24,42,.16);
  --border:          rgba(20,24,42,.12);
  --border-soft:     rgba(20,24,42,.08);
  --glass-highlight: rgba(20,24,42,.04);
  --brand-accent-ink: #7a5710;
  --status-warning-ink: #a1690a;
}
/* The ambient gold/AI "nebula" glow behind every page reads as a rich
   accent against near-black; against a light page it just looks like a
   muddy stain, so light mode drops it and relies on the flat --page-plane
   color instead. */
:root[data-bs-theme="light"] body { background-image: none; }

/* Every font-size, padding and gap in this file is written in rem, so
   this one line re-scales the whole app at once — every element's size
   RELATIVE to every other stays exactly as tuned, just ~12% smaller
   overall. That's the fix for "the tools/fonts feel too big" everywhere
   at once (dense tables, form fields, badges, buttons, the icon picker
   grid) instead of hand-tuning dozens of components out of sync with
   each other. 16px (browser default) was simply too loose for a
   dense admin tool; 14px is the same convention GitHub/Linear-style
   dashboards use. */
html { font-size: 14px; }

* { font-family: 'Cairo', 'Segoe UI', sans-serif; }

html, body { background: var(--page-plane); }
body {
  color: var(--ink-primary);
  min-height: 100vh;
  /* Ambient nebula — three soft, fixed glow blobs (gold + the two AI
     gradient hues) behind everything. Pure CSS, no image/canvas, so it
     costs nothing on pages that render a lot of data (the tickets table,
     reports). The literal motion/starfield lives only on the login screen
     (see login.html) — a daily-use work tool should not have something
     drifting behind a table someone is trying to read all day. */
  background-image:
    radial-gradient(ellipse 900px 600px at 85% -10%, var(--brand-glow) 0%, transparent 60%),
    radial-gradient(ellipse 700px 500px at -10% 15%, var(--ai-glow) 0%, transparent 55%),
    radial-gradient(ellipse 800px 700px at 100% 100%, var(--ai-glow-2) 0%, transparent 55%);
  background-repeat: no-repeat;
  background-attachment: fixed;
}
::selection { background: var(--brand-accent); color: var(--brand-dark); }

/* Thin, dark scrollbars — a default light scrollbar track is the one thing
   that visibly breaks a dark UI on Windows/Chrome if left alone. */
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--surface-2); border-radius: 6px; }
::-webkit-scrollbar-thumb:hover { background: var(--border); }

/* ---------------------------------------------------------------- login */
.login-wrapper {
  min-height: 100vh;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  position: relative;
  overflow: hidden;
  background: radial-gradient(ellipse 1200px 800px at 50% 0%, #12142280, transparent 65%), var(--brand-dark);
}
.login-wrapper canvas#loginBg {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  z-index: 0;
}
.login-wrapper::after {
  /* soft vignette so the card always stays readable over the animation */
  content: "";
  position: absolute; inset: 0;
  background: radial-gradient(circle at 50% 42%, rgba(10,10,16,.10), rgba(5,6,10,.82) 72%);
  z-index: 1;
  pointer-events: none;
}
.login-wordmark {
  position: relative; z-index: 2;
  text-align: center;
  margin-bottom: 1.75rem;
}
.login-wordmark .mark {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 52px; height: 52px;
  border-radius: 14px;
  border: 1px solid var(--brand-accent);
  color: var(--brand-accent);
  font-size: 1.4rem;
  margin-bottom: 1rem;
  box-shadow: 0 0 28px var(--brand-glow), inset 0 0 14px rgba(216,179,112,.12);
  background: rgba(216,179,112,.05);
}
.login-wordmark .name {
  color: #fff;
  font-weight: 800;
  font-size: 1.8rem;
  letter-spacing: .2em;
  text-transform: uppercase;
  text-shadow: 0 0 24px rgba(255,255,255,.15);
}
.login-wordmark .tagline {
  color: var(--brand-accent-strong);
  font-size: .74rem;
  letter-spacing: .16em;
  text-transform: uppercase;
  margin-top: .4rem;
  opacity: .95;
}
/* A clear, visible (not tiny) look at the AI assistant right at the door —
   the one AI-forward literal image on the whole site outside the chat
   widget itself, placed here deliberately: this renders once per session
   (login), so it costs nothing on the pages people actually work in all
   day, unlike a per-page hero image would. Real copy only (the same
   strings the chat widget itself uses) — no invented claim about what it
   can do. */
.login-ai-intro {
  position: relative; z-index: 2;
  display: flex; align-items: center; gap: .75rem;
  margin-bottom: 1.5rem; padding: .55rem 1.1rem .55rem .55rem;
  border-radius: 999px;
  background: rgba(255,255,255,.045);
  border: 1px solid rgba(216,179,112,.2);
  backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
  box-shadow: 0 8px 28px rgba(0,0,0,.35);
}
.login-ai-avatar { width: 72px; height: 72px; flex-shrink: 0; }
.login-ai-intro-title { color: #fff; font-weight: 700; font-size: .92rem; }
.login-ai-intro-sub { color: var(--brand-accent-strong); font-size: .74rem; opacity: .9; }

.login-card {
  position: relative; z-index: 2;
  background: linear-gradient(180deg, rgba(255,255,255,.05), rgba(255,255,255,.02));
  backdrop-filter: blur(18px);
  -webkit-backdrop-filter: blur(18px);
  padding: 2.5rem;
  border-radius: 18px;
  width: 100%;
  max-width: 400px;
  border: 1px solid var(--border);
  box-shadow: 0 30px 90px rgba(0,0,0,.55), 0 0 60px rgba(216,179,112,.08), inset 0 1px 0 rgba(255,255,255,.06);
  border-top: 2px solid var(--brand-accent);
}

/* "Sign in with Microsoft" — sits above the plain email/password form
   (see login.html) as the primary path once 365 SSO is configured
   (config.MS_LOGIN_ENABLED). Deliberately its own look, not the gold
   btn-outline-primary used elsewhere: this button identifies a specific
   external identity provider, not an in-app action, so it keeps a
   neutral white surface (matching how Microsoft's own sign-in buttons
   read everywhere else) rather than borrowing the brand's gold. The
   4-square mark is drawn in plain CSS, not an imported logo asset. */
.btn-ms-login {
  display: flex; align-items: center; justify-content: center; gap: .6rem;
  background: #fff; color: #1b1b1b; font-weight: 600; font-size: .92rem;
  padding: .62rem 1rem; border-radius: 8px; border: 1px solid rgba(0,0,0,.08);
  transition: background .15s ease, box-shadow .15s ease, transform .15s ease;
}
.btn-ms-login:hover { background: #f4f4f4; color: #1b1b1b; box-shadow: 0 4px 16px rgba(0,0,0,.18); }
.btn-ms-login:active { transform: translateY(1px); }
.btn-ms-login-logo {
  display: grid; grid-template-columns: 1fr 1fr; gap: 2px;
  width: 17px; height: 17px; flex-shrink: 0;
}
.btn-ms-login-logo span:nth-child(1) { background: #f25022; }
.btn-ms-login-logo span:nth-child(2) { background: #7fba00; }
.btn-ms-login-logo span:nth-child(3) { background: #00a4ef; }
.btn-ms-login-logo span:nth-child(4) { background: #ffb900; }

.login-divider {
  display: flex; align-items: center; gap: .75rem;
  color: rgba(255,255,255,.45); font-size: .74rem; text-transform: uppercase; letter-spacing: .04em;
  margin-bottom: 1rem;
}
.login-divider::before, .login-divider::after {
  content: ""; flex: 1; height: 1px; background: rgba(255,255,255,.16);
}

/* --------------------------------------------------------------- layout */
.sidebar {
  width: 240px;
  flex-shrink: 0;
  height: 100vh;
  /* 100dvh ("dynamic" viewport height) tracks the ACTUAL visible area on
     phones, where 100vh is measured against the browser chrome fully
     collapsed — so as the URL bar/toolbar shows or hides while scrolling,
     a plain 100vh sidebar visibly grows/shrinks/jumps under it. Every
     modern mobile browser supports dvh; this line simply wins the
     cascade over the 100vh above wherever it's understood, and is
     silently ignored (falling back to the line above) anywhere it isn't
     — so desktop is completely unaffected either way. */
  height: 100dvh;
  background: linear-gradient(180deg, #0b0d16, #05060a 85%);
  color: #dbe4f5;
  display: flex;
  flex-direction: column;
  border-inline-end: 1px solid var(--border-soft);
  /* The sidebar itself never scrolls with the page — its box is pinned
     at exactly one viewport tall (see body.app-shell-active below, which
     stops the page/document from scrolling at all). If the nav list
     itself ever grows taller than the screen, IT scrolls internally
     rather than pushing the layout or disappearing off-screen. */
  overflow-y: auto;
}
/* Stacked, not a single squeezed row: the wordmark gets its own full-width
   line (so the brand name itself is NEVER the thing that gets truncated —
   it used to be, when it shared one row with the eyebrow text and the
   admin alerts bell and lost the fight for space), with the eyebrow text
   and, for admins, the alerts bell on a second row underneath. */
.sidebar-brand {
  padding: 1.1rem 1rem;
  color: #fff;
  border-bottom: 1px solid var(--border-soft);
  display: flex;
  flex-direction: column;
  gap: .3rem;
}
.sidebar-brand-top {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: .5rem;
}
.sidebar-brand i {
  color: var(--brand-accent);
  filter: drop-shadow(0 0 6px var(--brand-glow));
}
.sidebar-brand-eyebrow { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* The real ECVALLA wordmark, reproduced as styled text — not the flat
   PNG the brand file was sent as, which carried its own dark backing
   rectangle and looked like a patch against any other background. Same
   thin high-contrast serif, same wide tracking, same small ™ — just
   rendered natively (now in a real bold cut, not a browser-faked one) so
   it sits flush on our own dark surface instead of showing a second,
   mismatched black box. */
.brand-wordmark {
  font-family: 'Playfair Display', 'Cairo', serif;
  font-weight: 700; letter-spacing: .16em; text-transform: uppercase;
  color: #fff; font-size: 1.18rem; white-space: nowrap;
  text-shadow: 0 0 22px var(--brand-glow);
}
.brand-wordmark-lg { font-size: 1.3rem; letter-spacing: .18em; }
.brand-tm {
  font-size: .5em; letter-spacing: 0; vertical-align: super;
  margin-inline-start: .15em; opacity: .85; font-family: 'Cairo', sans-serif;
  text-shadow: none;
}
.sidebar-brand-sep { color: var(--border); font-weight: 400; }
.sidebar-brand-sub {
  color: var(--ink-muted); font-size: .68rem; letter-spacing: .16em;
  text-transform: uppercase; font-weight: 600;
}

/* ------------------------------------------------------------ alerts bell */
/* A round chip the same height/shape as the .topbar-user pill next to it
   (was a bare icon glyph with no container at all — noticeably smaller
   and a different shape than its neighbor, which read as inconsistent). */
.alerts-bell {
  position: relative;
  width: 38px; height: 38px;
  display: inline-flex; align-items: center; justify-content: center;
  color: #8b8fa3;
  border: 1px solid var(--border-soft);
  border-radius: 50%;
  background: var(--surface-2);
  padding: 0;
  transition: background-color .15s ease, border-color .15s ease, box-shadow .15s ease;
}
.alerts-bell i { font-size: .95rem; }
.alerts-bell:hover { color: #fff; }
.alerts-bell.has-alerts { color: var(--brand-accent); filter: drop-shadow(0 0 5px var(--brand-glow)); }
/* Same near-black gradient chip as .topbar-user in dark mode, so the two
   sit as a matched pair instead of two different button styles. */
[data-bs-theme="dark"] .alerts-bell {
  background: linear-gradient(180deg, rgba(9,10,17,.94), rgba(3,4,7,.97));
  border-color: rgba(216,179,112,.22);
  box-shadow: 0 4px 14px rgba(0,0,0,.45), inset 0 1px 0 rgba(255,255,255,.04);
}
[data-bs-theme="dark"] .alerts-bell:hover {
  background: linear-gradient(180deg, rgba(13,15,25,.96), rgba(4,5,9,.98));
  border-color: rgba(216,179,112,.4);
  box-shadow: 0 4px 16px rgba(0,0,0,.5), 0 0 0 1px rgba(216,179,112,.1), inset 0 1px 0 rgba(255,255,255,.05);
}
/* The topbar's personal notification bell sits over the (theme-aware)
   content area, unlike the admin system-alerts bell above it which lives
   in the permanently-dark sidebar — so only this one needs to darken in
   light mode. Wins on specificity over the rule above. */
.topbar .alerts-bell.has-alerts { color: var(--brand-accent-ink); }
/* The personal topbar bell reads as gold at all times (not just when there
   are unread alerts) — a soft, muted gold at rest, full brand gold + glow
   on hover, so it always matches the rest of the gold accent language
   instead of defaulting to plain grey when the inbox is empty. */
.topbar .alerts-bell { color: var(--brand-accent-ink); opacity: .68; }
.topbar .alerts-bell:hover { opacity: 1; color: var(--brand-accent-ink); filter: drop-shadow(0 0 5px var(--brand-glow)); }
.topbar .alerts-bell.has-alerts { opacity: 1; }
.alerts-badge {
  position: absolute; top: -2px; inset-inline-end: -2px;
  background: var(--status-critical); color: #fff;
  font-size: .62rem; font-weight: 700; line-height: 1;
  border-radius: 999px; padding: .15rem .35rem;
  min-width: 16px; text-align: center;
  box-shadow: 0 0 8px rgba(255,92,92,.6);
}
.alerts-menu { width: 340px; max-height: 420px; overflow-y: auto; }
.alert-row { display: flex; align-items: flex-start; gap: .5rem; padding: .3rem 0; }
.alert-row.alert-critical { color: var(--status-critical); }
.alert-row.alert-warning { color: var(--status-warning-ink); }
.alert-row i { margin-top: .15rem; flex-shrink: 0; }
.alert-row .small { color: var(--ink-primary); white-space: normal; line-height: 1.4; }
/* Alerts that link somewhere (see integrations/health.py's "link" key) get
   a trailing chevron and behave like any other clickable dropdown-item;
   ones with no sensible destination (a stale poller, low disk) stay plain
   text so nothing here promises an action that doesn't exist. */
.alert-row-item { white-space: normal; }
.alert-row-chevron { margin-inline-start: auto; align-self: center; font-size: .7rem; opacity: .55; }
[dir="rtl"] .alert-row-chevron { transform: scaleX(-1); }

/* Personal notification bell (topbar, every signed-in user) — reuses the
   admin system-alerts bell's look (.alerts-bell/.alerts-badge/.alerts-menu
   above) so the two read as one family, but each row here is a real link
   to the ticket it's about, not plain informational text. */
.notif-menu { width: 400px; isolation: isolate; }
.notif-markall { color: var(--brand-accent-ink); font-size: .76rem; text-decoration: none; }
.notif-markall:hover { color: var(--brand-accent-ink); text-decoration: underline; }
.notif-item { white-space: normal; padding: .55rem .9rem; border-bottom: 1px solid var(--border-soft); }
.notif-item:last-child { border-bottom: none; }
.notif-item.unread { background: var(--brand-accent-soft); }
.notif-item-text { color: var(--ink-primary); font-size: .84rem; line-height: 1.4; }
.notif-item-time { color: var(--ink-muted); font-size: .72rem; margin-top: .15rem; }

.sidebar .nav-link {
  color: #8b8fa3;
  padding: .7rem 1.25rem;
  border-radius: 0;
  border-inline-start: 3px solid transparent;
  display: flex;
  align-items: center;
  gap: .6rem;
  transition: color .15s ease, background-color .15s ease;
}
.sidebar .nav-link:hover { background: rgba(255,255,255,.04); color: #fff; }
.sidebar .nav-link.active {
  background: var(--brand-accent-soft);
  color: var(--brand-accent-strong);
  border-inline-start-color: var(--brand-accent);
  font-weight: 700;
  box-shadow: inset 0 0 20px rgba(216,179,112,.06);
}
.sidebar .nav-link.active i { filter: drop-shadow(0 0 5px var(--brand-glow)); }
.sidebar .nav-link i { flex-shrink: 0; }

/* Collapsible "Administration" group (admin-only) — a labeled header row
   that toggles a nested <ul> of the config/report items via Bootstrap's own
   collapse plugin (already loaded for the modals elsewhere in this file), so
   the sidebar reads as one short, professional list up top instead of 8+
   ungrouped admin links. Starts collapsed on every other page (decluttered
   by default) but the server auto-expands it (base.html computes
   admin_section_open from request.path) whenever you're actually ON one of
   its own pages, so the active item is never hidden inside a closed group.
   Because the sidebar lives outside #pageContent, a manual expand/collapse
   also simply persists across in-app navigations, since the sidebar node
   itself is never re-rendered by the SPA fetch-swap. */
.nav-section-toggle { cursor: pointer; }
.nav-section-chevron { margin-inline-start: auto; font-size: .8rem; transition: transform .2s ease; }
.nav-section-toggle.collapsed .nav-section-chevron { transform: rotate(-90deg); }
.nav-section-items .nav-link { padding-inline-start: 2.4rem; }

/* "New Ticket" as a compact "+" icon action riding on the Tickets row,
   instead of its own full-width nav row right underneath — same one-click
   access (still opens #newTicketModal exactly as before), just paired
   visually with the list it creates into rather than looking like a
   second, separate destination. */
.nav-item-with-action { display: flex; align-items: center; }
.nav-item-with-action .nav-link { flex: 1 1 auto; }
.nav-link-action {
  flex: 0 0 auto;
  display: flex; align-items: center; justify-content: center;
  width: 2.1rem; height: 2.1rem; margin-inline-end: .6rem;
  color: #8b8fa3; border-radius: 8px; font-size: 1.05rem;
  transition: color .15s ease, background-color .15s ease;
}
.nav-link-action:hover { background: rgba(255,255,255,.06); color: #fff; }

/* -------------------------------------------------- recent-tickets cards
   Replaces a dense table on the employee dashboard with clean, spaced
   cards — a colored left accent (the ticket's own status color, already
   used everywhere else via STATUS_COLORS) does the same job a
   connecting timeline/ladder line would, without the busy fenced-in
   look. */
.recent-ticket-list { display: flex; flex-direction: column; gap: .6rem; }
.recent-ticket-row {
  display: flex; align-items: center; justify-content: space-between; gap: 1rem;
  padding: .85rem 1rem; border-radius: .6rem;
  background: var(--surface-2); border: 1px solid var(--border-soft);
  border-inline-start-width: 3px; border-inline-start-style: solid;
  text-decoration: none; color: inherit;
  transition: transform .15s ease, box-shadow .15s ease, background-color .15s ease;
}
.recent-ticket-row:hover {
  background: var(--surface-solid); color: inherit;
  transform: translateY(-1px); box-shadow: 0 6px 18px rgba(0,0,0,.18);
}
.recent-ticket-main { min-width: 0; }
.recent-ticket-subject {
  font-weight: 600; color: var(--ink-primary);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.recent-ticket-meta { color: var(--ink-muted); font-size: .78rem; margin-top: .2rem; }
.recent-ticket-dot { margin: 0 .3rem; opacity: .6; }
.recent-ticket-row .badge-pill { flex-shrink: 0; }

/* "My Assets" widget on the employee dashboard — equipment currently
   assigned to the viewer. Cards mirror .recent-ticket-row's surface/border
   language so the widget reads as part of the same dashboard, not a
   bolted-on component. */
.my-assets-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
  gap: .6rem;
}
.my-asset-card {
  display: flex; align-items: center; gap: .75rem;
  padding: .75rem .9rem; border-radius: .6rem;
  background: var(--surface-2); border: 1px solid var(--border-soft);
}
.my-asset-icon {
  flex-shrink: 0; width: 2.25rem; height: 2.25rem; border-radius: .5rem;
  display: flex; align-items: center; justify-content: center;
  background: var(--surface-solid); color: var(--brand-accent);
  font-size: 1.1rem;
}
.my-asset-body { min-width: 0; flex: 1; }
.my-asset-name {
  font-weight: 600; color: var(--ink-primary);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.my-asset-tag { color: var(--ink-muted); font-size: .78rem; margin-top: .1rem; }
.my-asset-card .badge-pill { flex-shrink: 0; }

.sidebar-footer {
  margin-top: auto;
  padding: 1rem;
  border-top: 1px solid var(--border-soft);
}
.lang-switch a { color: #9ba4bd; text-decoration: none; font-size: .85rem; }
.lang-switch a.active { color: #fff; font-weight: 700; }
.lang-switch a:hover { color: #fff; }

/* App shell: the sidebar, topbar and user menu stay put; only the page
   body underneath the topbar scrolls — the same pattern as most desktop
   dashboards (Gmail, Notion, etc.), and what fixes the "everything
   scrolls together, including my top bar" feel. Scoped to
   body.app-shell-active (signed-in pages only, set in base.html) so the
   public landing page and the login screen — genuinely long pages meant
   to scroll normally — are completely untouched. */
body.app-shell-active { height: 100vh; height: 100dvh; overflow: hidden; }
.content {
  flex: 1 1 auto;
  min-width: 0; /* lets this flex child actually shrink to the space left by the
                   fixed-width sidebar instead of growing to fit its widest content
                   (a table, a long line) and squeezing the sidebar narrower */
  height: 100vh;
  height: 100dvh; /* see the .sidebar comment above — same mobile-stability fix */
  display: flex;
  flex-direction: column;
  overflow: hidden;
}
.topbar { flex-shrink: 0; }
.content-scroll {
  flex: 1 1 auto;
  min-height: 0; /* lets this flex child actually shrink+scroll instead of stretching .content */
  overflow-y: auto;
}

/* -------------------------------------------------- top bar (lang switch) */
.topbar {
  background: rgba(255,255,255,.02);
  border-bottom: 1px solid var(--border-soft);
  /* No backdrop-filter here (unlike the other glass panels in this
     theme) — it caused a real Chromium compositing bug where the
     personal-notification dropdown below (a solid, opaque z-index:1000
     element that lives inside this bar) let the page content behind it
     bleed through instead of painting solid. Confirmed by A/B: removing
     this one blur fixed it outright; isolation/translateZ layer hacks on
     the dropdown itself did not. The bar's own background is already
     nearly transparent (2% white), so the visual loss is negligible. */
}
.lang-switch-top a { color: var(--ink-secondary); text-decoration: none; font-size: .85rem; font-weight: 600; }
.lang-switch-top a.active { color: var(--brand-accent-ink); }
.lang-switch-top a:hover { color: var(--ink-primary); }
.topbar-user {
  color: var(--ink-secondary);
  background: var(--surface-2);
  border: 1px solid var(--border-soft);
  border-radius: 999px;
  padding: .35rem .9rem .35rem .35rem;
  transition: background-color .15s ease, border-color .15s ease, box-shadow .15s ease;
}
.topbar-user i { color: var(--brand-accent); }
.topbar-user:hover, .topbar-user:focus { color: var(--ink-primary); background: var(--surface-solid); }
/* Dark mode only: a deeper, near-black pill with a faint gold rim instead
   of the flat translucent-white chip above — reads richer/more premium
   against the dashboard's warm dark background. Light mode keeps the
   lighter chip as-is (a near-black pill would look like an error state
   on a white page). */
[data-bs-theme="dark"] .topbar-user {
  background: linear-gradient(180deg, rgba(9,10,17,.94), rgba(3,4,7,.97));
  border-color: rgba(216,179,112,.22);
  box-shadow: 0 4px 14px rgba(0,0,0,.45), inset 0 1px 0 rgba(255,255,255,.04);
}
[data-bs-theme="dark"] .topbar-user:hover, [data-bs-theme="dark"] .topbar-user:focus {
  background: linear-gradient(180deg, rgba(13,15,25,.96), rgba(4,5,9,.98));
  border-color: rgba(216,179,112,.4);
  box-shadow: 0 4px 16px rgba(0,0,0,.5), 0 0 0 1px rgba(216,179,112,.1), inset 0 1px 0 rgba(255,255,255,.05);
}
.user-menu-caret { font-size: .68rem !important; color: var(--ink-muted) !important; transition: transform .18s ease; }
.topbar-user[aria-expanded="true"] .user-menu-caret { transform: rotate(180deg); }
/* The name is the actual identity here — it was inheriting the pill's
   muted --ink-secondary like everything else in the button (chevron,
   role), so it read as gray/recessive instead of the primary piece of
   information it is. Only the name gets bumped to full ink + weight;
   the role is a small gold label underneath it (was --ink-muted, which
   read as almost invisible against the dark pill — switched to the same
   gold used for the icon and outline buttons instead of just brightening
   the gray, so it reads as a badge, not a second gray label competing
   with the name). */
.topbar-user-name { color: var(--ink-primary); font-weight: 600; }
.topbar-user-role {
  color: var(--brand-accent-ink); font-size: .68rem; font-weight: 600; letter-spacing: .06em;
  text-transform: uppercase; margin-inline-start: -.15rem;
}
/* AI-touch signature ring around the signed-in user's own avatar — reuses
   the same gradient already reserved for AI-assisted UI elsewhere
   (ai-hint-box, the AI chat launcher) rather than a one-off accent, so it
   reads as "this workspace is AI-assisted" rather than random decoration.
   Deliberately scoped to just this avatar (not every .avatar-wrap in the
   app) — one signature touch, not a pattern repeated everywhere. */
.topbar-user-avatar-ring {
  border: 2px solid transparent;
  background:
    var(--surface-solid) padding-box,
    var(--ai-grad) border-box;
  animation: aiGlowBreathe 5s ease-in-out infinite;
}
.asset-specs-chevron { transition: transform .18s ease; }
#assetSpecsToggle[aria-expanded="true"] .asset-specs-chevron { transform: rotate(180deg); }

/* ---------------------------------------------------- topbar user menu
   Consolidates what used to be three separate topbar controls (name/role
   link, language switch, light/dark toggle) plus sign-out into one
   dropdown, so the bar itself stays down to two icons (this + the bell). */
.user-menu-panel { width: 260px; padding: .85rem; }
.user-menu-identity {
  display: flex; align-items: center; gap: .65rem;
  padding: .25rem; border-radius: .5rem; margin: -.25rem -.25rem .5rem;
}
.user-menu-identity:hover { background: var(--surface-2); }
.user-menu-identity-text { display: flex; flex-direction: column; min-width: 0; }
.user-menu-name { color: var(--ink-primary); font-weight: 700; font-size: .9rem; }
.user-menu-email {
  color: var(--ink-muted); font-size: .76rem;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.user-menu-divider { border-top: 1px solid var(--border-soft); margin: .65rem 0; }
.user-menu-section-label {
  color: var(--ink-muted); font-size: .68rem; font-weight: 700;
  text-transform: uppercase; letter-spacing: .06em; margin-bottom: .45rem;
}
.user-menu-segmented {
  display: flex; background: var(--surface-2); border-radius: .5rem; padding: .2rem; gap: .2rem;
}
.user-menu-segmented a, .user-menu-segmented button {
  flex: 1 1 0; border: none; background: transparent; color: var(--ink-secondary);
  font-size: .8rem; font-weight: 600; text-decoration: none; text-align: center;
  padding: .35rem .4rem; border-radius: .4rem; cursor: pointer;
}
.user-menu-segmented a.active, .user-menu-segmented button.active {
  background: var(--surface-solid); color: var(--ink-primary);
  box-shadow: 0 1px 3px rgba(0,0,0,.25);
}
.user-menu-segmented a:hover:not(.active), .user-menu-segmented button:hover:not(.active) { color: var(--ink-primary); }
.user-menu-signout {
  /* Reads as its own button, not a third menu row — a soft red-tinted
     fill and border at rest (echoing the same soft-tint chip language
     used for the "unread" notification row and the AI-hint boxes
     elsewhere in the app, just in the critical/red family instead of
     brand gold) so "this ends your session" is legible the instant the
     menu opens, not only on hover. Hover/focus commits to a solid fill —
     the same at-rest-tint / solid-on-hover progression used on the
     critical KPI and alert treatments elsewhere in the app. */
  display: flex; align-items: center; gap: .6rem;
  color: var(--status-critical, #e05a5a); text-decoration: none; font-size: .85rem; font-weight: 600;
  padding: .5rem .6rem; border-radius: .5rem; margin: .2rem -.1rem 0;
  background: rgba(224,90,90,.09);
  border: 1px solid rgba(224,90,90,.24);
  transition: background .15s ease, border-color .15s ease, color .15s ease, box-shadow .15s ease;
}
.user-menu-signout-icon {
  display: inline-flex; align-items: center; justify-content: center;
  width: 26px; height: 26px; border-radius: 50%; flex: none;
  background: rgba(224,90,90,.18); color: var(--status-critical, #e05a5a);
  font-size: .85rem;
  transition: background .15s ease, color .15s ease;
}
.user-menu-signout:hover, .user-menu-signout:focus-visible {
  color: #fff; background: var(--status-critical, #e05a5a);
  border-color: var(--status-critical, #e05a5a);
  box-shadow: 0 2px 12px rgba(224,90,90,.35);
}
.user-menu-signout:hover .user-menu-signout-icon, .user-menu-signout:focus-visible .user-menu-signout-icon {
  background: rgba(255,255,255,.25); color: #fff;
}

/* -------------------------------------------------------------- avatars
   A person icon sits underneath every avatar <img>. When the person has
   no photo yet, the app serves a transparent 1x1 PNG for that <img> (see
   app.py's avatar_image route), so the icon shows through cleanly as a
   deliberate empty state instead of a blank/broken-looking circle. Once a
   real photo is uploaded it just covers the icon. Size is set per usage
   via inline width/height/font-size. */
.avatar-wrap {
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border-radius: 50%;
  background: var(--surface-solid);
  border: 1px solid var(--border);
  overflow: hidden;
  flex-shrink: 0;
}
.avatar-wrap i { color: var(--ink-muted); }
.avatar-wrap img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  border-radius: 50%;
}

/* ------------------------------------------------------------- page head */
.page-header {
  display: flex;
  justify-content: space-between;
  align-items: flex-end;
  margin-bottom: 1.5rem;
}
.page-header .eyebrow {
  color: var(--ink-muted);
  font-size: .82rem;
  text-transform: uppercase;
  letter-spacing: .08em;
}
.page-header h4 { margin: .1rem 0 0; font-weight: 700; letter-spacing: -.005em; }
.page-header-subtitle { color: var(--ink-secondary); font-size: .88rem; margin-top: .3rem; }

/* ----------------------------------------------------------------- cards */
.card-panel {
  background: linear-gradient(180deg, rgba(255,255,255,.045), rgba(255,255,255,.02));
  backdrop-filter: blur(14px);
  -webkit-backdrop-filter: blur(14px);
  border: 1px solid var(--border);
  border-radius: 16px;
  padding: 1.25rem 1.5rem;
  box-shadow: 0 1px 0 rgba(255,255,255,.04) inset, 0 12px 30px rgba(0,0,0,.35);
}
.panel-title {
  font-weight: 700;
  font-size: .95rem;
  color: var(--ink-primary);
  margin-bottom: 1rem;
  display: flex;
  align-items: center;
  justify-content: space-between;
}
.panel-title .subtitle { font-weight: 400; color: var(--ink-muted); font-size: .8rem; }

/* ------------------------------------------------------------- kpi tiles */
.kpi-card {
  background: linear-gradient(180deg, rgba(255,255,255,.045), rgba(255,255,255,.02));
  backdrop-filter: blur(14px);
  border: 1px solid var(--border);
  border-radius: 16px;
  padding: 1.1rem 1.25rem;
  height: 100%;
  display: flex;
  align-items: flex-start;
  gap: .9rem;
  transition: transform .18s ease, box-shadow .18s ease;
}
.kpi-card:hover { transform: translateY(-2px); box-shadow: 0 14px 34px rgba(0,0,0,.4); }
/* A .kpi-card that is also a real link (drills down into the tickets behind
   its number) — same look, just neutralizing the browser's default <a>
   color/underline so it reads as a KPI tile first, a link second. */
.kpi-card-link { color: inherit; text-decoration: none; cursor: pointer; }
.kpi-card-link:hover { color: inherit; }
.kpi-icon {
  width: 44px; height: 44px;
  border-radius: 12px;
  display: flex; align-items: center; justify-content: center;
  font-size: 1.25rem;
  flex-shrink: 0;
}
.kpi-icon.i-total   { background: rgba(58,142,230,.14); color: var(--cat-1); }
.kpi-icon.i-open    { background: rgba(58,142,230,.14); color: var(--cat-1); }
.kpi-icon.i-resolved{ background: rgba(46,204,113,.14); color: var(--status-good); }
.kpi-icon.i-overdue { background: rgba(255,92,92,.14);  color: var(--status-critical); }
.kpi-icon.i-warning { background: rgba(251,191,61,.16); color: var(--status-warning-ink); }
.kpi-icon.i-muted   { background: rgba(109,113,133,.18); color: var(--ink-secondary); }

.kpi-body { min-width: 0; }
.kpi-label { color: var(--ink-secondary); font-size: .82rem; margin-bottom: .15rem; }
.kpi-value { font-size: 1.8rem; font-weight: 800; line-height: 1.1; font-variant-numeric: tabular-nums; }
.kpi-sub { font-size: .76rem; color: var(--ink-muted); margin-top: .2rem; }

/* --------------------------------------------------------------- charts */
.chart-legend {
  display: flex; flex-wrap: wrap; gap: .9rem;
  margin-top: .85rem; font-size: .78rem; color: var(--ink-secondary);
}
.chart-legend .swatch {
  width: 10px; height: 10px; border-radius: 3px; display: inline-block;
  margin-inline-end: .4rem; vertical-align: middle;
}
.stacked-bar {
  display: flex; width: 100%; height: 26px; border-radius: 6px; overflow: hidden;
  background: var(--surface-2);
}
.stacked-bar .seg { height: 100%; display: block; cursor: pointer; transition: filter .12s ease; }
.stacked-bar .seg:hover { filter: brightness(1.18); }
.stacked-bar .seg + .seg { margin-inline-start: 2px; }
.chart-legend a { color: inherit; text-decoration: none; cursor: pointer; border-radius: .3rem; padding: .1rem .3rem; margin: -.1rem -.3rem; transition: background-color .12s ease; }
.chart-legend a:hover { background: var(--surface-2); color: var(--ink-primary); }
#categoryChart { cursor: pointer; }

/* ------------------------------------------------------------ meters */
.meter-row { margin-bottom: .85rem; }
.meter-row:last-child { margin-bottom: 0; }
/* Meter rows that are also drill-down links (priority breakdown, assets
   overview) — same look, just clickable, with a subtle hover lift instead
   of Bootstrap's default link styling. */
a.meter-row { display: block; color: inherit; text-decoration: none; cursor: pointer; border-radius: .5rem; transition: background-color .12s ease; }
a.meter-row:hover { background: var(--surface-2); color: inherit; }
.meter-head {
  display: flex; justify-content: space-between; font-size: .82rem;
  margin-bottom: .3rem; color: var(--ink-secondary);
}
.meter-head strong { color: var(--ink-primary); font-weight: 600; }
.meter-track {
  height: 8px; border-radius: 4px; background: var(--surface-2); overflow: hidden;
}
.meter-fill { height: 100%; border-radius: 4px; }

/* Half-donut "speedometer" gauge — SLA compliance on the dashboard. Same
   good/warning/critical status tokens the old linear meter-fill used
   (just as CSS vars now instead of hardcoded hex, so it also follows the
   light/dark toggle), just drawn as an arc instead of a bar: a single
   glanceable shape for "how are we doing against target" rather than a
   number the reader has to interpret against an implied 100%. The arc is
   one <path> stroked twice (a dim full-length track, then the live
   value on top) using the standard stroke-dasharray/dashoffset reveal
   technique — no charting library needed for one static arc. */
.sla-gauge-wrap {
  position: relative; width: 100%; max-width: 220px; margin: 0 auto;
}
.sla-gauge-track { stroke: var(--surface-2); }
.sla-gauge-fill {
  stroke-linecap: round;
  transition: stroke-dashoffset 1.2s cubic-bezier(.22,.9,.34,1);
}
@media (prefers-reduced-motion: reduce) {
  .sla-gauge-fill { transition: none; }
}
.sla-gauge-readout {
  position: absolute; inset-inline: 0; bottom: 6%;
  display: flex; flex-direction: column; align-items: center;
  line-height: 1.1;
}
.sla-gauge-readout .kpi-value { font-size: 2rem; font-variant-numeric: tabular-nums; }
.sla-gauge-readout .sla-gauge-caption { font-size: .72rem; color: var(--ink-muted); margin-top: .1rem; }

/* ---------------------------------------------------------------- table */
.table { color: var(--ink-primary); }
.table thead th {
  color: var(--ink-muted); font-weight: 700; font-size: .78rem;
  text-transform: uppercase; letter-spacing: .05em; border-top: none;
  border-bottom: 1px solid var(--gridline);
}
.table td { border-color: var(--gridline); vertical-align: middle; font-size: .85rem; line-height: 1.35; }
.table-hover tbody tr:hover { background: rgba(216,179,112,.05); }

/* Sortable column headers (see static/js/table-sort.js) — one shared
   look for every sortable table in the app rather than a bespoke style
   per page. The button inherits the header's own font/case/tracking so
   it reads as part of the header, not a separate control; only its own
   background/outline change on hover/focus. */
.th-sort-btn {
  display: inline-flex; align-items: center; gap: .3rem;
  background: none; border: 0; font: inherit; color: inherit;
  text-transform: inherit; letter-spacing: inherit; cursor: pointer;
  padding: .15rem .3rem; margin: -.15rem -.3rem; border-radius: 4px;
}
.th-sort-btn:hover { background: var(--surface-2); }
.th-sort-btn:hover .th-sort-label { color: var(--ink-primary); }
.th-sort-btn:focus-visible { outline: 2px solid var(--brand-accent); outline-offset: 1px; }
.th-sort-icon { font-size: .7em; color: var(--ink-muted); opacity: .5; flex-shrink: 0; }
.th-sort-btn:hover .th-sort-icon { opacity: .85; }
th[data-sort-dir] .th-sort-icon { color: var(--brand-accent-ink); opacity: 1; }
th[data-sort-dir] .th-sort-label { color: var(--ink-primary); }

/* SLA-urgency row tint on the tickets list — a whole-row wash (not just
   the existing "Overdue"/risk badge) so which tickets need attention is
   readable at a glance while scanning the table, not only after reading
   each row's badge individually. Same --status-critical/--status-warning
   tokens the badges already use (see SLA_RISK_COLORS in app.py), just at
   low alpha as a fill instead of full-strength as text/badge color — never
   a new color, and never color as the ONLY signal: the badge's text label
   ("Overdue" / risk level) still renders on top of the tint for anyone who
   can't distinguish the tint itself. A left edge stripe backs up the tint
   a second way for the same reason. */
tr.row-sla-overdue > td {
  background: color-mix(in srgb, var(--status-critical) 10%, transparent);
  box-shadow: inset 3px 0 0 0 var(--status-critical);
}
tr.row-sla-risk-high > td {
  background: color-mix(in srgb, var(--status-critical) 6%, transparent);
  box-shadow: inset 3px 0 0 0 color-mix(in srgb, var(--status-critical) 55%, transparent);
}
tr.row-sla-risk-medium > td {
  background: color-mix(in srgb, var(--status-warning) 10%, transparent);
  box-shadow: inset 3px 0 0 0 var(--status-warning);
}
tr.row-sla-overdue:hover > td,
tr.row-sla-risk-high:hover > td,
tr.row-sla-risk-medium:hover > td { filter: brightness(0.97); }
:root[data-bs-theme="light"] tr.row-sla-overdue:hover > td,
:root[data-bs-theme="light"] tr.row-sla-risk-high:hover > td,
:root[data-bs-theme="light"] tr.row-sla-risk-medium:hover > td { filter: brightness(0.98); }

.badge-pill {
  display: inline-flex; align-items: center; gap: .35rem;
  padding: .3rem .6rem; border-radius: 999px; font-size: .76rem; font-weight: 600;
  color: #fff;
}
.badge-pill .dot { width: 6px; height: 6px; border-radius: 50%; background: rgba(255,255,255,.85); }

/* Sentiment badges on CSAT Insights are real filter toggles now, not just
   labels — plain <button>s styled to look identical to the old static
   badge, plus a focus ring when the filter they represent is active. */
.sentiment-filter-badge {
  border: 2px solid transparent; cursor: pointer; color: #fff; font-weight: 600;
  padding: .3rem .6rem; border-radius: 999px; font-size: .8rem;
  transition: box-shadow .12s ease, transform .12s ease;
}
.sentiment-filter-badge:hover { transform: translateY(-1px); }
.sentiment-filter-badge-active { border-color: var(--brand-accent); box-shadow: 0 0 0 2px var(--brand-accent-soft); }

.priority-Critical { color: var(--status-critical); font-weight: 700; text-shadow: 0 0 10px rgba(255,92,92,.35); }
.priority-High { color: var(--status-serious); font-weight: 600; }
.priority-Medium { color: var(--ink-secondary); }
.priority-Low { color: var(--ink-muted); }

/* --------------------------------------------------------------- avatar */
.avatar-chip {
  width: 30px; height: 30px; border-radius: 50%;
  background: linear-gradient(145deg, var(--brand-accent-strong), var(--brand-accent));
  color: var(--brand-dark);
  display: inline-flex; align-items: center; justify-content: center;
  font-size: .78rem; font-weight: 700; flex-shrink: 0;
  box-shadow: 0 0 10px var(--brand-glow);
}
.workload-row { display: flex; align-items: center; gap: .6rem; margin-bottom: .75rem; }
.workload-row:last-child { margin-bottom: 0; }
a.workload-row { color: inherit; text-decoration: none; cursor: pointer; border-radius: .5rem; transition: background-color .12s ease; }
a.workload-row:hover { background: var(--surface-2); color: inherit; }
.workload-name { font-size: .85rem; flex-grow: 1; }
.workload-track { width: 90px; height: 6px; border-radius: 3px; background: var(--surface-2); overflow: hidden; }
.workload-fill { height: 100%; background: var(--cat-1); border-radius: 3px; }

/* ------------------------------------------------------------ empty state */
.empty-state {
  text-align: center; padding: 2.5rem 1rem; color: var(--ink-muted);
}
.empty-state i { font-size: 2rem; display: block; margin-bottom: .6rem; opacity: .5; }

/* A bigger, friendlier empty state for a first-time employee's Recent
   Tickets panel — a centered icon badge + headline + CTA, instead of the
   plain one-liner .empty-state above (which stays as-is everywhere else). */
.empty-state-hero { text-align: center; padding: 3rem 1.5rem; }
.empty-state-hero-icon {
  width: 56px; height: 56px; border-radius: 50%;
  background: var(--surface-2); border: 1px solid var(--border);
  display: flex; align-items: center; justify-content: center;
  font-size: 1.5rem; color: var(--ink-muted); margin: 0 auto 1rem;
}

/* ------------------------------------------------------- ticket timeline */
/* A vertical audit-trail timeline (ticket_detail.html) — one dot per
   status/priority/assignee/project/asset change, connected by a rail.
   Read-only display; visible to the ticket's own requester as well as
   staff, so it needs to read clearly at a glance without a legend. */
.ticket-timeline { list-style: none; margin: 0; padding: 0; }
.ticket-timeline-item {
  display: flex; gap: .75rem; position: relative; padding-bottom: 1.1rem;
}
.ticket-timeline-item:last-child { padding-bottom: 0; }
.ticket-timeline-item:not(:last-child)::before {
  content: ""; position: absolute; top: 26px; bottom: 0; inset-inline-start: 13px;
  width: 2px; background: var(--border);
}
.ticket-timeline-dot {
  flex: 0 0 auto; width: 26px; height: 26px; border-radius: 50%;
  background: var(--brand-accent-soft); border: 1px solid var(--brand-accent);
  color: var(--brand-accent-ink);
  display: flex; align-items: center; justify-content: center;
  font-size: .8rem; z-index: 1;
}
.ticket-timeline-content { flex: 1 1 auto; font-size: .875rem; padding-top: .1rem; }
.empty-state-hero-title { font-weight: 700; color: var(--ink-primary); margin-bottom: .3rem; }
.empty-state-hero-sub { color: var(--ink-muted); font-size: .88rem; margin-bottom: 1.25rem; }

/* ------------------------------------------------- employee pending actions
   Real, derived items only — see app.py's dashboard() route for exactly how
   each is computed (last-reply-was-staff / resolved-and-unrated). Never a
   hardcoded or simulated list. */
.pending-actions-list { display: flex; flex-direction: column; gap: .5rem; }
.pending-action-item {
  display: flex; align-items: center; gap: .75rem;
  padding: .7rem .9rem; border: 1px solid var(--border); border-radius: 12px;
  background: var(--surface); text-decoration: none; color: inherit;
  transition: border-color .15s ease, transform .15s ease;
}
.pending-action-item:hover { border-color: var(--brand-accent); transform: translateY(-1px); color: inherit; }
.pending-action-icon {
  width: 36px; height: 36px; border-radius: 10px; flex-shrink: 0;
  display: flex; align-items: center; justify-content: center; font-size: 1rem;
}
.pending-action-icon.reply { background: rgba(58,142,230,.14); color: var(--cat-1); }
.pending-action-icon.rate { background: rgba(251,191,61,.16); color: var(--status-warning-ink); }
.pending-action-text { flex: 1; min-width: 0; font-size: .88rem; color: var(--ink-primary); }
.pending-action-time { font-size: .74rem; color: var(--ink-muted); white-space: nowrap; }

/* --------------------------------------------------------- AI accent (assistive/AI-suggested UI only)
   A slow, continuous shimmer + breathing glow — deliberately subtle and
   deliberately the ONLY place in the app that moves on its own with no
   user action, so motion itself becomes the "this is the AI one" signal.
   Every other animation in this app (page transitions, hovers) is tied to
   something the user just did; this one is reserved for AI-touched UI. */
.ai-suggest-btn {
  border: 1px solid transparent;
  background: var(--ai-grad);
  background-size: 200% 200%;
  color: #fff !important;
  font-weight: 600;
  position: relative;
  box-shadow: 0 0 18px var(--ai-glow);
  animation: aiGradientShift 6s ease-in-out infinite, aiGlowBreathe 3.4s ease-in-out infinite;
}
.ai-suggest-btn:hover { filter: brightness(1.1); color: #fff; box-shadow: 0 0 30px var(--ai-glow); }
.ai-suggest-btn i { margin-inline-end: .3rem; }
.ai-suggest-btn.is-thinking i { animation: aiIconPulse 1s ease-in-out infinite; }
.ai-suggest-status.is-done { color: var(--status-good); animation: aiStatusPop .35s ease both; }

.ai-hint-box {
  border: 1px solid transparent;
  border-radius: 10px;
  padding: .7rem 1rem;
  background:
    linear-gradient(rgba(255,255,255,.03), rgba(255,255,255,.03)) padding-box,
    var(--ai-grad) border-box;
  background-size: 100% 100%, 200% 200%;
  color: var(--ink-secondary);
  display: flex;
  align-items: flex-start;
  gap: .5rem;
  animation: aiGradientShift 7s ease-in-out infinite, aiHintIn .45s ease both;
}
.ai-hint-box i {
  background: var(--ai-grad);
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
  font-size: 1rem;
  margin-top: .1rem;
}
.ai-hint-box strong { color: var(--ink-primary); }
/* The assistant's own face standing in for the generic sparkle icon —
   every AI-derived hint in the app (ticket suggestions, asset notes, CSAT
   themes) now visibly comes from the SAME assistant the chat widget is,
   not an anonymous "AI" glyph. */
.ai-hint-box .ai-avatar-img-wrap { width: 22px; height: 22px; flex-shrink: 0; margin-top: .05rem; }

/* A gentle "nothing here" moment gets the assistant's face too, instead of
   a bare line of muted text — used only on genuinely list-and-search pages
   (tickets, assets) where asking the assistant is a real, relevant next
   step; left alone on plain admin CRUD tables (users, projects) where it
   would just be decoration with no real connection to the assistant. */
.ai-empty-state { display: inline-flex; flex-direction: column; align-items: center; gap: .6rem; }
.ai-empty-state-avatar { width: 56px; height: 56px; opacity: .9; }

/* Block-level sibling of .ai-hint-box for AI-derived content that isn't a
   single icon+sentence hint — a themes list, a report section — same
   shimmering gradient border, no forced flex/icon layout. */
.ai-panel-box {
  border: 1px solid transparent;
  border-radius: 10px;
  padding: .85rem 1.1rem;
  background:
    linear-gradient(rgba(255,255,255,.03), rgba(255,255,255,.03)) padding-box,
    var(--ai-grad) border-box;
  background-size: 100% 100%, 200% 200%;
  animation: aiGradientShift 7s ease-in-out infinite, aiHintIn .45s ease both;
}
.ai-panel-box ul { margin-bottom: 0; }

/* Small static "this is AI-generated" marker placed next to a panel title —
   the panel/box around it carries the motion; the badge itself stays put. */
.ai-badge-pill {
  display: inline-flex; align-items: center; gap: .25rem;
  background: var(--ai-grad); color: #fff !important;
  font-size: .68rem; font-weight: 700; letter-spacing: .02em;
  padding: .15rem .55rem; border-radius: 999px;
  margin-inline-start: .5rem; vertical-align: middle;
}

/* KPI trend badges (this period vs. the immediately preceding one of equal
   length) — used on the Weekly Digest and CSAT Insights report pages. */
.kpi-trend { font-size: .72rem; margin-top: .3rem; display: flex; align-items: center; gap: .2rem; }
.kpi-trend.trend-up { color: var(--status-good); }
.kpi-trend.trend-down { color: var(--status-critical); }
.kpi-trend.trend-flat { color: var(--ink-muted); }

/* Clickable drill-down rows (Weekly Digest's category/workload lists) —
   look like the plain text they replace until hovered, so they read as
   data first and a link second. */
.digest-list-link {
  display: flex; justify-content: space-between; align-items: center; gap: .5rem;
  color: inherit; text-decoration: none; width: 100%;
}
.digest-list-link:hover { color: var(--brand-accent-ink); }
.digest-list-link .digest-list-count { display: flex; align-items: center; gap: .4rem; color: var(--ink-muted); }
.digest-list-link:hover .digest-list-count { color: inherit; }

@keyframes aiGradientShift {
  0%, 100% { background-position: 0% 50%; }
  50% { background-position: 100% 50%; }
}
@keyframes aiGlowBreathe {
  0%, 100% { box-shadow: 0 0 14px var(--ai-glow); }
  50% { box-shadow: 0 0 24px var(--ai-glow); }
}
@keyframes aiIconPulse {
  0%, 100% { opacity: 1; transform: scale(1); }
  50% { opacity: .55; transform: scale(1.15); }
}
@keyframes aiStatusPop {
  from { opacity: 0; transform: translateY(2px); }
  to   { opacity: 1; transform: translateY(0); }
}
@keyframes aiHintIn {
  from { opacity: 0; transform: translateY(4px) scale(.99); }
  to   { opacity: 1; transform: translateY(0) scale(1); }
}
@media (prefers-reduced-motion: reduce) {
  .ai-suggest-btn, .ai-hint-box, .ai-panel-box, .ai-suggest-btn.is-thinking i,
  .topbar-user-avatar-ring { animation: none; }
}

/* ------------------------------------------------------- announcements board
   The dashboard's own "broadcast" widget — every signed-in user sees the
   same short, most-recent-first list of active Announcement rows (see
   models.py's Announcement + app.py's dashboard()). category (general/
   maintenance/outage/urgent) only ever changes icon/accent color here —
   plain, honest severity signaling: the category label is always also
   shown as text (.announce-slide-kind), never color-alone, and the whole
   slide can be read aloud via the Listen button regardless of category. */
.announce-board {
  position: relative;
  display: flex;
  align-items: stretch;
  border-radius: 14px;
  border: 1px solid var(--border);
  background: linear-gradient(180deg, rgba(255,255,255,.035), rgba(255,255,255,.015));
  overflow: hidden;
  isolation: isolate;
}
/* The hologram "anchor" — presents the board the way a broadcast studio
   presents a news reader, standing to one side of the story. Purely
   decorative/ambient (the actual content is fully readable without it),
   so it quietly drops off on narrow screens rather than squeezing the
   announcement text. */
.announce-hologram-col {
  flex-shrink: 0;
  width: 122px;
  display: flex; align-items: center; justify-content: center;
  position: relative; z-index: 1;
  border-inline-end: 1px solid var(--border-soft);
  background: linear-gradient(180deg, rgba(255,255,255,.025), transparent);
}
.announce-main { flex: 1; min-width: 0; position: relative; z-index: 1; }
@media (max-width: 560px) {
  .announce-hologram-col { display: none; }
}
.announce-board::before {
  /* A slow, quiet ambient sheen behind the content — reserved (like the
     rest of this file's .ai-* motion) for AI-touched UI, so this widget
     visibly reads as "alive" without being a distraction while reading. */
  content: "";
  position: absolute; inset: -60%;
  background: conic-gradient(from 0deg, transparent 0deg, var(--ai-glow) 70deg, transparent 170deg);
  opacity: .3;
  animation: announceSheen 16s linear infinite;
  z-index: 0;
  pointer-events: none;
}
.announce-board-head {
  position: relative; z-index: 1;
  display: flex; align-items: center; gap: .6rem;
  padding: .85rem 1.1rem .5rem;
}
.announce-board-title {
  font-weight: 700; font-size: .88rem; color: var(--ink-primary);
  display: flex; align-items: center; gap: .4rem;
}
.announce-live-badge {
  display: inline-flex; align-items: center; gap: .35rem;
  font-size: .64rem; font-weight: 700; letter-spacing: .04em; text-transform: uppercase;
  color: var(--status-critical);
  background: color-mix(in srgb, var(--status-critical) 14%, transparent);
  border: 1px solid color-mix(in srgb, var(--status-critical) 35%, transparent);
  padding: .12rem .5rem; border-radius: 999px;
}
.announce-live-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--status-critical); animation: announceLivePulse 1.6s ease-in-out infinite; }
/* Gap sized so two adjacent dots' invisible ::before tap-targets (each
   expanding ~9px past its own edge, see below) never touch: with the
   enlarged hit-region on BOTH sides only overlap-free once gap >= 2 * 9px.
   1.4rem (=22.4px at the default 16px root) clears that with real margin,
   which also directly answers the "dots are small/hard to hit" feedback —
   confirmed overlap-free via getBoundingClientRect/elementFromPoint probes
   across every active/inactive combination, not just eyeballed. */
.announce-board-dots { margin-inline-start: auto; display: flex; gap: 1.4rem; }
.announce-dot {
  position: relative; /* anchors the ::before enlarged tap target below */
  width: 9px; height: 9px; border-radius: 50%; padding: 0;
  border: 1.5px solid var(--border);
  background: transparent; cursor: pointer;
  transition: background-color .2s ease, border-color .2s ease, width .2s ease, height .2s ease, box-shadow .2s ease;
}
.announce-dot:hover, .announce-dot:focus-visible {
  border-color: var(--ai-glow-2);
  outline: none;
}
.announce-dot::before {
  /* A visible 9px dot reads as "small" per the org's own feedback even
     though it's a deliberate, quiet UI choice — this invisibly triples
     the actual clickable/tappable area (≈27px) without changing how big
     the dot itself looks, so it's easier to hit without becoming louder.
     Sized to exactly match the gap above (see its comment) so neighboring
     dots' invisible zones never overlap and steal each other's clicks. */
  content: ""; position: absolute; inset: -9px;
}
.announce-dot.is-active {
  /* Grown via real width/height, NOT transform:scale() — a transform
     scales the whole box INCLUDING the ::before tap-target above, which
     would silently blow the hit-region past the gap budget the comments
     above size against and let the active dot's invisible zone reach into
     its neighbor's. Changing the actual box size keeps ::before's -9px
     inset exact and layout (the flex gap) accounts for the new size too. */
  width: 13px; height: 13px;
  background: var(--ai-grad); border-color: transparent;
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--ai-glow) 30%, transparent), 0 0 10px var(--ai-glow);
}
@media (prefers-reduced-motion: reduce) {
  .announce-dot { transition: none; }
}

.announce-autoplay-track {
  position: relative; z-index: 1;
  height: 2px; margin: 0 1.1rem .4rem;
  border-radius: 999px;
  background: color-mix(in srgb, var(--border) 60%, transparent);
  overflow: hidden;
}
.announce-autoplay-fill {
  height: 100%; width: 0%;
  background: var(--ai-grad);
  border-radius: inherit;
}
.announce-autoplay-fill.is-running { animation: announceAutoplayFill 6s linear; }
.announce-autoplay-fill.is-paused { animation-play-state: paused; }
@keyframes announceAutoplayFill { from { width: 0%; } to { width: 100%; } }
@media (prefers-reduced-motion: reduce) {
  .announce-autoplay-track { display: none; }
}

.announce-board-track { position: relative; z-index: 1; min-height: 90px; }
.announce-slide { display: none; align-items: flex-start; gap: .8rem; padding: .35rem 1.1rem 1rem; animation: announceSlideIn .4s ease both; }
.announce-slide.is-active { display: flex; }
.announce-slide-icon {
  flex-shrink: 0; width: 40px; height: 40px; border-radius: 11px; margin-top: .1rem;
  display: flex; align-items: center; justify-content: center; font-size: 1.15rem;
}
.announce-slide-body { flex: 1; min-width: 0; }
.announce-slide-title { font-weight: 700; color: var(--ink-primary); font-size: .92rem; margin-bottom: .15rem; }
/* The title's own small "LED signboard" — a dark screen with a single
   glowing monospace line, scrolling like a shop/hotel marquee when the
   text is wider than the screen (see initMarqueeTrack in base.html,
   which measures the rendered text and only turns .is-scrolling on when
   it's actually needed — a short title just sits still). Always the same
   dark screen regardless of the light/dark app theme, same reasoning as
   the page-transition splash: a signboard reads as a physical display,
   not a themed UI panel, so it never flips color with the toggle. */
.announce-marquee-screen {
  position: relative; overflow: hidden;
  background: #04070c;
  border: 1px solid color-mix(in srgb, var(--ai-glow) 45%, transparent);
  border-radius: 6px;
  padding: .38rem .65rem;
  margin-bottom: .35rem;
  box-shadow: inset 0 0 14px rgba(0,0,0,.65), 0 0 10px color-mix(in srgb, var(--ai-glow) 22%, transparent);
}
.announce-marquee-screen::before {
  /* Faint CRT scanline texture — cheap (no image asset), sells "screen"
     rather than "dark rounded box". */
  content: ""; position: absolute; inset: 0; pointer-events: none; z-index: 1;
  background: repeating-linear-gradient(to bottom, rgba(255,255,255,.06) 0px, rgba(255,255,255,.06) 1px, transparent 2px, transparent 3px);
  mix-blend-mode: overlay;
}
.announce-marquee-track {
  position: relative; z-index: 0;
  display: inline-block; white-space: nowrap;
  font-family: 'Courier New', ui-monospace, SFMono-Regular, Menlo, monospace;
  font-weight: 700;
  letter-spacing: .03em;
  font-size: .88rem;
  color: var(--ai-glow-2);
  text-shadow: 0 0 6px var(--ai-glow-2), 0 0 16px var(--ai-glow);
}
.announce-marquee-track.is-scrolling {
  padding-inline-start: 100%;
  animation-name: announceMarqueeScroll;
  animation-timing-function: linear;
  animation-iteration-count: infinite;
}
/* Pause on hover/focus so it's actually readable if someone wants to
   stop and read the whole thing rather than wait for the loop. */
.announce-marquee-screen:hover .announce-marquee-track.is-scrolling,
.announce-marquee-screen:focus-within .announce-marquee-track.is-scrolling {
  animation-play-state: paused;
}
@keyframes announceMarqueeScroll { to { transform: translateX(-100%); } }
@media (prefers-reduced-motion: reduce) {
  .announce-marquee-track.is-scrolling { animation: none; padding-inline-start: 0; }
}
.announce-slide-text { color: var(--ink-secondary); font-size: .84rem; line-height: 1.5; }
.announce-slide-meta { color: var(--ink-muted); font-size: .72rem; margin-top: .45rem; display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; }
.announce-slide-kind { font-size: .68rem; font-weight: 700; padding: .05rem .5rem; border-radius: 999px; }
.announce-listen-btn {
  flex-shrink: 0; width: 32px; height: 32px; border-radius: 9px; margin-top: .1rem;
  display: flex; align-items: center; justify-content: center;
  border: 1px solid var(--border); background: var(--surface); color: var(--ink-secondary);
  cursor: pointer; transition: color .15s ease, border-color .15s ease;
}
.announce-listen-btn:hover { color: var(--brand-accent-ink); border-color: var(--brand-accent); }
.announce-listen-btn.is-speaking { color: #fff; background: var(--ai-grad); border-color: transparent; animation: aiGlowBreathe 1.4s ease-in-out infinite; }
.announce-listen-btn.is-speaking i { animation: aiIconPulse 1s ease-in-out infinite; }

/* Per-announcement AR/EN switch — only rendered when a real AI-generated
   translation actually exists for that announcement (see models.py's
   Announcement.title_translated/body_translated); swaps the visible
   title/body AND what the Listen button reads, so the two never fall out
   of sync with each other. */
.announce-lang-toggle {
  display: inline-flex; align-items: center; gap: .3rem;
  font-size: .68rem; font-weight: 700;
  border: 1px solid var(--border); background: var(--surface); color: var(--ink-secondary);
  padding: .1rem .5rem; border-radius: 999px; cursor: pointer;
  transition: color .15s ease, border-color .15s ease;
}
.announce-lang-toggle:hover { color: var(--brand-accent-ink); border-color: var(--brand-accent); }
.announce-lang-toggle i { font-size: .74rem; }

/* Category → icon/accent color. Four flat, named variants (never a
   generated hue) — a fifth category, if ever added, has to be a
   deliberate choice here, not an accident. --*-ink where one exists
   (warning) keeps the color legible as TEXT in light mode too, matching
   the same convention already used for .alert-row above. */
.announce-cat-general     .announce-slide-icon,
.announce-cat-general     .announce-slide-kind { background: var(--brand-accent-soft); color: var(--brand-accent-ink, var(--brand-accent-strong)); }
.announce-cat-maintenance .announce-slide-icon,
.announce-cat-maintenance .announce-slide-kind { background: color-mix(in srgb, var(--status-warning) 16%, transparent); color: var(--status-warning-ink, var(--status-warning)); }
.announce-cat-outage      .announce-slide-icon,
.announce-cat-outage      .announce-slide-kind { background: color-mix(in srgb, var(--status-serious) 16%, transparent); color: var(--status-serious); }
.announce-cat-urgent      .announce-slide-icon,
.announce-cat-urgent      .announce-slide-kind { background: color-mix(in srgb, var(--status-critical) 18%, transparent); color: var(--status-critical); }
.announce-cat-urgent .announce-slide-icon { animation: announceUrgentPulse 1.6s ease-in-out infinite; }

@keyframes announceSheen { to { transform: rotate(360deg); } }
@keyframes announceLivePulse { 0%, 100% { opacity: 1; } 50% { opacity: .35; } }
@keyframes announceSlideIn { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
@keyframes announceUrgentPulse {
  0%, 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--status-critical) 45%, transparent); }
  50%      { box-shadow: 0 0 0 6px transparent; }
}
@media (prefers-reduced-motion: reduce) {
  .announce-board::before, .announce-live-dot, .announce-slide,
  .announce-listen-btn.is-speaking, .announce-listen-btn.is-speaking i,
  .announce-cat-urgent .announce-slide-icon { animation: none; }
}

/* --------------------------------------------------------- AI hologram avatar
   A premium, "holographic" presentation of this app's own established AI
   assistant face (static/img/ai-avatar.png — already the one face used
   everywhere for AI-touched UI, see .ai-avatar-img above). Never a new
   character or a different visual identity: it's the same image, tinted
   and animated entirely with this app's own AI signature colors
   (--ai-grad/--ai-glow/--ai-glow-2, already used throughout this file for
   "this is the AI part" motion) so it reads as ECVALLA's own assistant
   projected as light, not a generic sci-fi hologram. Reserved for the two
   spots that most benefit from "the assistant is presenting this" —
   the Announcements dashboard widget and the "Compose with AI" box. */
.ai-hologram { position: relative; display: inline-flex; align-items: center; justify-content: center; isolation: isolate; }
.ai-hologram-img {
  position: relative; z-index: 2;
  border-radius: 50%; object-fit: cover; display: block;
  /* Strip the photo's natural skin tone to pure luminance, then the
     .ai-hologram-tint sibling below recolors it entirely from
     --ai-grad via a "color" blend — the classic duotone trick. This is
     what actually sells "projected light" instead of "a photo with
     blinds drawn over it". */
  filter: grayscale(1) contrast(1.2) brightness(1.15)
    drop-shadow(0 0 10px var(--ai-glow)) drop-shadow(0 0 22px var(--ai-glow-2));
  -webkit-mask-image: repeating-linear-gradient(to bottom, #000 0px, #000 2px, rgba(0,0,0,.7) 3px, #000 4px);
          mask-image: repeating-linear-gradient(to bottom, #000 0px, #000 2px, rgba(0,0,0,.7) 3px, #000 4px);
  animation: hologramFloat 4.5s ease-in-out infinite, hologramFlicker 6s ease-in-out infinite, hologramGlitch 6s ease-in-out infinite;
}
/* Recolors the grayscale image using --ai-grad's hue/saturation while
   keeping the photo's own luminance (mix-blend-mode: color) — same
   circle, same mask, painted right after the img so it blends with it. */
.ai-hologram-tint {
  position: absolute; z-index: 3; inset: 0; border-radius: 50%;
  background: var(--ai-grad);
  mix-blend-mode: color;
  opacity: .92;
  -webkit-mask-image: repeating-linear-gradient(to bottom, #000 0px, #000 2px, rgba(0,0,0,.7) 3px, #000 4px);
          mask-image: repeating-linear-gradient(to bottom, #000 0px, #000 2px, rgba(0,0,0,.7) 3px, #000 4px);
  animation: hologramFloat 4.5s ease-in-out infinite, hologramFlicker 6s ease-in-out infinite;
  pointer-events: none;
}
/* A bright scan beam sweeping top-to-bottom through the circle —
   clipped to the same circle so it reads as a projector scanning the
   figure, not a bar floating past it. */
.ai-hologram-beam {
  position: absolute; z-index: 4; inset: 0; border-radius: 50%;
  overflow: hidden; pointer-events: none;
}
.ai-hologram-beam::after {
  content: ""; position: absolute; left: -15%; right: -15%; top: -35%;
  height: 26%;
  background: linear-gradient(to bottom, transparent, var(--ai-glow-2) 42%, #eafffb 50%, var(--ai-glow-2) 58%, transparent);
  opacity: .85;
  mix-blend-mode: screen;
  animation: hologramBeamSweep 3.4s ease-in-out infinite;
}
/* Faint hex/circuit mesh over the figure — the piece that actually reads
   as "projected data", not just a recolored photo. A single repeating
   conic-gradient tile (cheap, no image asset) screen-blended at very low
   opacity so it adds texture without muddying the image underneath. */
.ai-hologram-mesh {
  position: absolute; z-index: 3; inset: 0; border-radius: 50%;
  background-image:
    repeating-linear-gradient(60deg, color-mix(in srgb, var(--ai-glow-2) 70%, transparent) 0 1px, transparent 1px 14px),
    repeating-linear-gradient(-60deg, color-mix(in srgb, var(--ai-glow-2) 70%, transparent) 0 1px, transparent 1px 14px),
    repeating-linear-gradient(0deg, color-mix(in srgb, var(--ai-glow) 55%, transparent) 0 1px, transparent 1px 14px);
  opacity: .16;
  mix-blend-mode: screen;
  -webkit-mask-image: repeating-linear-gradient(to bottom, #000 0px, #000 2px, rgba(0,0,0,.7) 3px, #000 4px);
          mask-image: repeating-linear-gradient(to bottom, #000 0px, #000 2px, rgba(0,0,0,.7) 3px, #000 4px);
  animation: hologramFloat 4.5s ease-in-out infinite;
  pointer-events: none;
}
/* Two thin diagonal light-threads crossing the figure at different angles
   and speeds — reads as data/signal lines rather than decoration, the
   same "neural network" feeling as a connected-dots motif but cheap
   enough to run as two gradient sweeps instead of a canvas particle sim. */
.ai-hologram-threads { position: absolute; z-index: 3; inset: 0; border-radius: 50%; overflow: hidden; pointer-events: none; }
.ai-hologram-threads::before, .ai-hologram-threads::after {
  content: ""; position: absolute; width: 180%; height: 1.5px;
  left: -40%; background: linear-gradient(90deg, transparent, var(--ai-glow-2), #eafffb, var(--ai-glow-2), transparent);
  opacity: 0; mix-blend-mode: screen;
}
.ai-hologram-threads::before { top: 28%; transform: rotate(18deg); animation: hologramThread 5.2s ease-in-out infinite; }
.ai-hologram-threads::after { top: 64%; transform: rotate(-14deg); animation: hologramThread 5.2s ease-in-out infinite 2.6s; }
.ai-hologram-rings {
  position: absolute; z-index: 1; inset: -26%;
  border-radius: 50%;
  background: conic-gradient(from 0deg, transparent 0deg, var(--ai-glow) 45deg, transparent 110deg, var(--ai-glow-2) 210deg, transparent 270deg);
  filter: blur(6px);
  opacity: .95;
  animation: hologramSpin 5s linear infinite;
}
.ai-hologram-rings::after {
  content: ""; position: absolute; inset: 10%; border-radius: 50%;
  background: conic-gradient(from 180deg, transparent 0deg, var(--ai-glow-2) 35deg, transparent 90deg, var(--ai-glow) 200deg, transparent 250deg);
  filter: blur(5px);
  opacity: .8;
  animation: hologramSpin 7.5s linear infinite reverse;
}
.ai-hologram-base {
  position: absolute; z-index: 0; left: 50%; bottom: -8%;
  width: 135%; height: 20%;
  transform: translateX(-50%);
  background: radial-gradient(ellipse at center, var(--ai-glow-2) 0%, var(--ai-glow) 45%, transparent 75%);
  filter: blur(3px);
  animation: hologramBasePulse 2.4s ease-in-out infinite;
}
.ai-hologram-particles { position: absolute; inset: 0; z-index: 1; pointer-events: none; }
.ai-hologram-particles i {
  position: absolute; bottom: 4%; width: 3px; height: 3px; border-radius: 50%; font-style: normal;
  background: var(--ai-glow-2);
  box-shadow: 0 0 5px var(--ai-glow-2);
  opacity: 0;
  animation: hologramParticleRise 3.6s ease-in infinite;
}
.ai-hologram-particles i:nth-child(1) { left: 16%; animation-delay: 0s; }
.ai-hologram-particles i:nth-child(2) { left: 36%; animation-delay: .8s; }
.ai-hologram-particles i:nth-child(3) { left: 55%; animation-delay: 1.6s; }
.ai-hologram-particles i:nth-child(4) { left: 70%; animation-delay: .3s; }
.ai-hologram-particles i:nth-child(5) { left: 86%; animation-delay: 2.2s; }

.ai-hologram-sm { width: 34px; height: 34px; }
.ai-hologram-sm .ai-hologram-img { width: 34px; height: 34px; }
.ai-hologram-lg { width: 84px; height: 84px; }
.ai-hologram-lg .ai-hologram-img { width: 84px; height: 84px; }

@keyframes hologramFloat { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-4px); } }
@keyframes hologramFlicker { 0%, 92%, 100% { opacity: 1; } 94% { opacity: .82; } 96% { opacity: 1; } 97% { opacity: .88; } }
@keyframes hologramSpin { to { transform: rotate(360deg); } }
@keyframes hologramBasePulse {
  0%, 100% { opacity: .55; transform: translateX(-50%) scaleX(1); }
  50%      { opacity: .9;  transform: translateX(-50%) scaleX(1.12); }
}
@keyframes hologramParticleRise {
  0%   { opacity: 0; transform: translateY(0) scale(.6); }
  15%  { opacity: 1; }
  90%  { opacity: 0; }
  100% { opacity: 0; transform: translateY(-34px) scale(1); }
}
@keyframes hologramBeamSweep {
  0%, 8%   { top: -35%; }
  92%, 100% { top: 108%; }
}
/* A brief, subtle chromatic-aberration "signal glitch" at the same two
   moments hologramFlicker already dips opacity — reads as a projection
   losing sync for a frame, not a filter effect sitting on top of a photo.
   0%/100% match the element's normal resting filter exactly. */
@keyframes hologramGlitch {
  0%, 90%, 100% {
    filter: grayscale(1) contrast(1.2) brightness(1.15)
      drop-shadow(0 0 10px var(--ai-glow)) drop-shadow(0 0 22px var(--ai-glow-2));
  }
  92% {
    filter: grayscale(1) contrast(1.3) brightness(1.2)
      drop-shadow(2px 0 0 rgba(255,60,90,.55)) drop-shadow(-2px 0 0 rgba(70,200,255,.55))
      drop-shadow(0 0 14px var(--ai-glow));
  }
  94% {
    filter: grayscale(1) contrast(1.2) brightness(1.15)
      drop-shadow(0 0 10px var(--ai-glow)) drop-shadow(0 0 22px var(--ai-glow-2));
  }
  96% {
    filter: grayscale(1) contrast(1.3) brightness(1.2)
      drop-shadow(-1.5px 0 0 rgba(255,60,90,.5)) drop-shadow(1.5px 0 0 rgba(70,200,255,.5))
      drop-shadow(0 0 14px var(--ai-glow-2));
  }
  98% {
    filter: grayscale(1) contrast(1.2) brightness(1.15)
      drop-shadow(0 0 10px var(--ai-glow)) drop-shadow(0 0 22px var(--ai-glow-2));
  }
}
@keyframes hologramThread {
  0%, 100% { opacity: 0; }
  46%      { opacity: 0; }
  50%      { opacity: .8; }
  58%      { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
  .ai-hologram-img, .ai-hologram-tint, .ai-hologram-rings, .ai-hologram-rings::after,
  .ai-hologram-base, .ai-hologram-particles i, .ai-hologram-beam::after,
  .ai-hologram-mesh, .ai-hologram-threads::before, .ai-hologram-threads::after { animation: none; }
  .ai-hologram-beam, .ai-hologram-threads { display: none; }
  .ai-hologram-img { filter: grayscale(1) contrast(1.2) brightness(1.15)
    drop-shadow(0 0 10px var(--ai-glow)) drop-shadow(0 0 22px var(--ai-glow-2)); }
}

/* Icon picker — a grid of clickable Bootstrap Icons used to set a
   category's icon visually, instead of typing a class name by hand. */
.icon-picker {
  display: grid;
  grid-template-columns: repeat(8, 1fr);
  gap: .4rem;
  margin-bottom: .5rem;
}
.icon-picker-btn {
  border: 1px solid var(--border-soft);
  background: var(--surface);
  border-radius: 8px;
  padding: .35rem 0;
  font-size: .92rem;
  color: var(--ink-secondary);
  cursor: pointer;
  transition: border-color .15s, color .15s, background .15s;
}
.icon-picker-btn:hover {
  border-color: var(--brand-accent);
  color: var(--brand-accent-ink);
}
.icon-picker-btn.active {
  background: var(--brand-accent-soft);
  border-color: var(--brand-accent);
  color: var(--brand-accent-ink);
}

/* Resolution-note template picker — the dropdown-style panel that opens
   under the resolution-note textarea on ticket_detail.html when an agent
   clicks "Pick a template" or "AI suggest" (see that page's own script
   block). Same visual language as the icon picker above (border-soft/
   surface tokens), styled as a scrollable list of buttons rather than a
   grid since each entry needs room for a title + body preview. */
.template-picker-panel {
  border: 1px solid var(--border-soft);
  border-radius: 10px;
  background: var(--surface);
  padding: .5rem;
  margin-top: .4rem;
}
.template-picker-list {
  max-height: 260px;
  overflow-y: auto;
  display: flex;
  flex-direction: column;
  gap: .3rem;
}
.template-picker-item {
  text-align: start;
  border: 1px solid var(--border-soft);
  background: var(--surface-solid, var(--surface-2));
  border-radius: 8px;
  padding: .45rem .6rem;
  cursor: pointer;
  transition: border-color .15s, background .15s;
}
.template-picker-item:hover {
  border-color: var(--brand-accent);
  background: var(--brand-accent-soft);
}
.template-picker-item.ai-suggested {
  border-color: var(--brand-accent);
  box-shadow: 0 0 0 1px var(--brand-accent);
}

/* CSAT star picker — used on ticket_detail.html (web) and csat_public.html
   (the no-login email-link page); same visual language everywhere the
   rating can be given, per the "one feature, same everywhere" request. */
.csat-star-picker {
  display: flex;
  gap: .35rem;
}
.csat-star-btn {
  border: none;
  background: none;
  padding: 0;
  cursor: pointer;
  font-size: 1.7rem;
  line-height: 1;
  color: var(--brand-accent-ink);
  transition: transform .1s;
}
.csat-star-btn i.bi-star { color: var(--ink-muted); }
.csat-star-btn:hover i { color: var(--brand-accent-ink); filter: drop-shadow(0 0 6px var(--brand-glow)); }
.csat-star-btn:hover { transform: scale(1.08); }
.csat-stars-static { font-size: 1.3rem; color: var(--brand-accent-ink); }
.csat-stars-static i.bi-star { color: var(--ink-muted); }

/* ---------------------------------------------------- Bootstrap overrides
   Bootstrap 5.3's own dark mode (data-bs-theme="dark", set on <html> in
   base.html) already re-themes every stock component — forms, dropdowns,
   tables, alerts, modals — with NO per-template changes needed. Only the
   brand-specific pieces below are overridden: swap Bootstrap's default
   blue for the gold accent, and give focus/hover states the glow this
   theme is built around. */
.btn-primary {
  background: linear-gradient(135deg, var(--brand-accent-strong), var(--brand-accent));
  border-color: var(--brand-accent);
  color: var(--brand-dark);
  font-weight: 700;
  box-shadow: 0 0 0 rgba(216,179,112,0);
  transition: box-shadow .2s ease, transform .15s ease, filter .15s ease;
}
.btn-primary:hover, .btn-primary:focus {
  background: linear-gradient(135deg, var(--brand-accent-strong), var(--brand-accent));
  border-color: var(--brand-accent-strong);
  color: var(--brand-dark);
  box-shadow: 0 0 22px var(--brand-glow);
  filter: brightness(1.04);
}
.btn-primary:active { transform: translateY(1px); }

/* The login page's own Sign In button — the single CTA on that screen,
   so it gets a bit more visual weight than the ordinary .btn-primary
   used for everyday buttons throughout the rest of the app: taller,
   a touch of letter-spacing, and a directional arrow that reads as
   "go" rather than a plain form submit. */
.login-submit-btn {
  display: flex; align-items: center; justify-content: center; gap: .55rem;
  padding: .72rem 1rem;
  font-size: 1rem;
  letter-spacing: .02em;
  border-radius: 10px;
}
.login-submit-btn i { font-size: 1.05rem; }
[dir="rtl"] .login-submit-btn i { transform: scaleX(-1); } /* arrow points toward reading-direction "forward" in both languages */
.btn-outline-primary {
  color: var(--brand-accent-ink);
  border-color: var(--brand-accent);
}
.btn-outline-primary:hover {
  background: var(--brand-accent-soft);
  color: var(--brand-accent-ink);
  border-color: var(--brand-accent-strong);
  box-shadow: 0 0 16px var(--brand-glow);
}
a { color: var(--brand-accent-ink); }
a:hover { color: var(--brand-accent-ink); text-decoration: underline; }
.form-control:focus, .form-select:focus {
  border-color: var(--brand-accent);
  box-shadow: 0 0 0 .2rem var(--brand-accent-soft);
}

/* Native "Choose File(s)" button on <input type="file"> — the browser
   renders this piece itself and ignores our dark surface unless we
   target it directly, so left alone it shows as a plain light-grey OS
   button (attachments on the ticket page, the "new ticket" form, the
   profile-photo uploader). Restyled to match the app's own surfaces
   instead of relying on Bootstrap's own (theme-dependent) styling of it. */
input[type="file"]::file-selector-button,
input[type="file"]::-webkit-file-upload-button {
  font: inherit;
  padding: .375rem .75rem;
  margin-inline-end: .75rem;
  border: 1px solid var(--border);
  border-radius: .4rem;
  background: var(--surface-2);
  color: var(--ink-primary);
  cursor: pointer;
  transition: background-color .15s ease-in-out, border-color .15s ease-in-out;
}
input[type="file"]:hover::file-selector-button,
input[type="file"]:hover::-webkit-file-upload-button {
  background: var(--brand-accent-soft);
  border-color: var(--brand-accent);
  color: var(--brand-accent-ink);
}
.form-check-input:checked {
  background-color: var(--brand-accent);
  border-color: var(--brand-accent);
}
.nav-tabs .nav-link.active { color: var(--brand-accent-ink); }
.dropdown-item:hover, .dropdown-item:focus { background-color: var(--surface-2); }
::placeholder { color: var(--ink-muted); opacity: .8; }

/* ==========================================================================
   Public landing page ("/", signed-out visitors only — see app.py's
   dashboard() route). Structurally inspired by a strong reference brief
   (eyebrow label + big two-line headline, a "where it goes" category grid,
   a "what's inside" feature grid, an FAQ accordion, a closing CTA beat) —
   but deliberately NOT grayscale/monochrome like that reference. Every
   accent here is gold or the AI-gradient, the same two colors the rest of
   this app already uses, so the landing page reads as unmistakably ours
   rather than a copy of anyone else's palette.
   ========================================================================== */
.landing-page { max-width: 1180px; margin: 0 auto; padding: 1.5rem 1.5rem 0; }

.landing-topbar { padding: .9rem 0 2.5rem; }
.landing-wordmark { display: flex; align-items: center; gap: .75rem; }
/* Bigger than the shared .brand-wordmark-lg used in the Help Center header
   — this is the company's own public-facing front door, so the mark gets
   real weight here on purpose, not the same size as a page sub-header. */
.landing-wordmark .brand-wordmark { font-size: 1.9rem; letter-spacing: .2em; }
.landing-wordmark .divider { color: var(--border); font-size: 1.3rem; }
.landing-wordmark .sub {
  color: var(--brand-accent-strong); font-size: .82rem; letter-spacing: .18em;
  text-transform: uppercase; font-weight: 700;
}
.landing-signin-btn { white-space: nowrap; }

.landing-eyebrow {
  display: flex; align-items: center; gap: .6rem;
  color: var(--ink-muted); font-size: .74rem; letter-spacing: .16em;
  text-transform: uppercase; font-weight: 700; margin-bottom: 1rem;
}
.landing-eyebrow .rule { width: 22px; height: 1px; background: var(--brand-accent); display: inline-block; }

.landing-hero {
  display: grid; grid-template-columns: 1.1fr .9fr; gap: 3rem;
  align-items: center; padding: 1rem 0 4rem;
}
.landing-headline {
  font-size: 3.1rem; line-height: 1.22; font-weight: 700; letter-spacing: -.01em;
  color: var(--ink-primary); margin-bottom: 1.3rem; text-shadow: 0 0 40px rgba(216,179,112,.10);
}
.landing-hero-desc { color: var(--ink-secondary); font-size: 1.05rem; line-height: 1.7; max-width: 46ch; margin-bottom: 1.5rem; }
.landing-cta-btn {
  background: linear-gradient(135deg, var(--brand-accent-strong), var(--brand-accent));
  border: 1px solid var(--brand-accent); color: var(--brand-dark); font-weight: 700;
  padding: .7rem 1.4rem; border-radius: 12px; display: inline-flex; align-items: center; gap: .5rem;
  box-shadow: 0 0 0 rgba(216,179,112,0); transition: box-shadow .2s ease, filter .15s ease;
}
.landing-cta-btn:hover {
  color: var(--brand-dark); box-shadow: 0 0 26px var(--brand-glow); filter: brightness(1.05);
}

.landing-chip-row { display: flex; flex-wrap: wrap; gap: .5rem; margin: 1.5rem 0; }
.landing-chip {
  display: inline-flex; align-items: center; gap: .4rem;
  background: var(--surface); border: 1px solid var(--border); border-radius: 999px;
  padding: .4rem .85rem; font-size: .82rem; color: var(--ink-secondary);
  text-decoration: none; transition: border-color .15s ease, color .15s ease, box-shadow .15s ease;
}
.landing-chip:hover {
  color: var(--ink-primary); border-color: var(--brand-accent); box-shadow: 0 0 14px var(--brand-glow);
}
.landing-chip i { color: var(--brand-accent-strong); }

.landing-trust-row { display: flex; flex-wrap: wrap; gap: 1.5rem; margin-top: 1.5rem; }
.landing-trust-item { display: flex; align-items: center; gap: .5rem; color: var(--ink-secondary); font-size: .84rem; }
.landing-trust-item i { color: var(--brand-accent-strong); font-size: 1rem; }

/* Hero illustration: real status-badge colors, not decoration invented
   for this page — the same STATUS_COLORS every ticket card uses. A thin
   gold skyline line-art sits behind the cards — a real-estate signature
   for the group, drawn as vector shapes (never a stock photo/video, so
   nothing here is borrowed or fabricated footage). Every card is a real
   link (to sign-in), not inert decoration. */
.landing-hero-art { position: relative; height: 320px; }
.landing-skyline {
  position: absolute; inset: 0; width: 100%; height: 100%;
  color: var(--brand-accent); opacity: .16; pointer-events: none;
}
/* Replaces the old plain "skeleton bars" ticket-card mockup with the actual
   AI assistant — the one thing on this page every visitor should notice
   before they even sign in. Large, clearly sized, animated (idle tilt +
   glow, same as the chat widget/login screen), centered over the skyline
   so the real-estate identity stays visible behind it. */
.landing-ai-showcase {
  position: absolute; inset: 0;
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  gap: 1.1rem; text-decoration: none; color: inherit;
}
.landing-ai-showcase-avatar {
  width: 190px; height: 190px;
  filter: drop-shadow(0 0 46px var(--ai-glow));
}
.landing-ai-showcase-avatar .ai-avatar-img {
  box-shadow:
    0 0 0 3px rgba(216,179,112,.5), 0 20px 50px rgba(0,0,0,.5),
    inset 0 -6px 10px rgba(0,0,0,.35), inset 0 4px 8px rgba(255,255,255,.15);
}
.landing-ai-showcase-caption { text-align: center; }
.landing-ai-showcase-title { color: #fff; font-weight: 800; font-size: 1.15rem; }
.landing-ai-showcase-sub { color: var(--brand-accent-strong); font-size: .82rem; margin-top: .2rem; }
.landing-ai-showcase:hover .landing-ai-showcase-title { color: var(--brand-accent-strong); }

.landing-section { padding: 2.5rem 0; border-top: 1px solid var(--border-soft); }
.landing-section-title {
  font-size: 1.85rem; font-weight: 700; letter-spacing: -.005em;
  color: var(--ink-primary); line-height: 1.3; margin-bottom: .85rem;
}
.landing-section-desc { color: var(--ink-secondary); line-height: 1.7; max-width: 60ch; margin-bottom: 1.75rem; }

/* "How it works" — three numbered steps with connecting arrows; a
   distinct layout beat (not present on the reference brief at all) that
   makes the real routing pipeline (routing.py's resolve_assignee) legible
   as a simple, honest sequence rather than a marketing claim. */
.landing-steps { display: flex; align-items: stretch; gap: 1rem; }
.landing-step {
  flex: 1; background: var(--surface); border: 1px solid var(--border); border-radius: 14px;
  padding: 1.5rem 1.25rem 1.25rem; text-decoration: none; color: inherit; display: block;
  transition: border-color .15s ease, box-shadow .15s ease, transform .15s ease;
}
.landing-step:hover {
  border-color: var(--brand-accent); box-shadow: 0 0 20px var(--brand-glow);
  transform: translateY(-2px); color: inherit;
}
.landing-step-num {
  display: block; font-size: 1.6rem; font-weight: 700; color: var(--brand-accent-strong);
  margin-bottom: .6rem; font-variant-numeric: tabular-nums;
}
.landing-step-arrow {
  display: flex; align-items: center; justify-content: center;
  color: var(--ink-muted); font-size: 1.1rem; flex-shrink: 0;
}

.landing-card-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; }
.landing-card-grid-4 { grid-template-columns: repeat(4, 1fr); }
.landing-dept-card, .landing-feature-card {
  background: var(--surface); border: 1px solid var(--border); border-radius: 14px;
  padding: 1.25rem; text-decoration: none; color: inherit; display: block;
  transition: border-color .15s ease, box-shadow .15s ease, transform .15s ease;
}
a.landing-feature-card:hover, a.landing-dept-card:hover {
  border-color: var(--brand-accent); box-shadow: 0 0 20px var(--brand-glow);
  transform: translateY(-2px); color: inherit;
}
.landing-dept-icon {
  display: inline-flex; align-items: center; justify-content: center;
  width: 40px; height: 40px; border-radius: 10px;
  background: var(--brand-accent-soft); color: var(--brand-accent-strong);
  font-size: 1.1rem; margin-bottom: .85rem;
}
.landing-dept-badge {
  font-size: .68rem; font-weight: 800; letter-spacing: .04em;
  color: var(--brand-dark); background: var(--brand-accent);
  border-radius: 999px; padding: .2rem .55rem; line-height: 1.3; flex-shrink: 0;
}
.landing-dept-name { font-weight: 700; color: var(--ink-primary); margin-bottom: .3rem; }
.landing-dept-desc { color: var(--ink-muted); font-size: .84rem; line-height: 1.5; }

.landing-faq .accordion-item {
  background: var(--surface); border: 1px solid var(--border); border-radius: 12px !important;
  margin-bottom: .6rem; overflow: hidden;
}
.landing-faq .accordion-button {
  background: transparent; color: var(--ink-primary); font-weight: 700; box-shadow: none;
}
.landing-faq .accordion-button:not(.collapsed) { color: var(--brand-accent-strong); background: var(--surface-2); }
.landing-faq .accordion-button::after { filter: invert(1) brightness(2); }
.landing-faq .accordion-body { color: var(--ink-secondary); }

/* Closing beat: an inverted GOLD panel (not a plain light/white card) —
   the deliberate "premium plaque" moment that closes the page. */
.landing-closing-wrap { padding: 1rem 0 3rem; }
.landing-closing-card {
  background: linear-gradient(135deg, var(--brand-accent-strong), var(--brand-accent) 60%, #b98f4e);
  border-radius: 20px; padding: 3rem 2rem; text-align: center;
  box-shadow: 0 20px 60px rgba(216,179,112,.18);
}
.landing-closing-eyebrow {
  color: rgba(10,10,16,.6); font-size: .74rem; letter-spacing: .18em; text-transform: uppercase; font-weight: 800;
}
.landing-closing-title { color: var(--brand-dark); font-size: 2.1rem; font-weight: 700; letter-spacing: -.01em; margin: .5rem 0 .5rem; }
.landing-closing-desc { color: rgba(10,10,16,.72); margin-bottom: 1.5rem; }
.landing-cta-btn-dark {
  background: var(--brand-dark); border: 1px solid var(--brand-dark); color: var(--brand-accent-strong);
}
.landing-cta-btn-dark:hover { color: var(--brand-accent-strong); box-shadow: 0 0 26px rgba(5,6,10,.4); filter: none; }
.landing-closing-footer { color: rgba(10,10,16,.55); font-size: .8rem; margin-top: 1.25rem; }

/* A single help article read on the guest chrome (templates/help_article_
   detail.html) — a plain dark reading surface, deliberately NOT the gold
   "closing plaque" card above (that one's text colors are hardcoded dark
   for its own one-time CTA moment and are illegible reused as a generic
   container — this is its own class for exactly that reason). */
.landing-article-card {
  background: var(--surface); border: 1px solid var(--border); border-radius: 16px;
  padding: 2.25rem 2rem; text-align: start;
}
.help-article-body { color: var(--ink-secondary); line-height: 1.85; }
.help-article-body p { margin-bottom: 1rem; }
.help-article-body p:last-child { margin-bottom: 0; }

@media (max-width: 992px) {
  .landing-hero { grid-template-columns: 1fr; }
  .landing-hero-art { display: none; }
  .landing-card-grid, .landing-card-grid-4 { grid-template-columns: repeat(2, 1fr); }
  .landing-headline { font-size: 2.2rem; }
  .landing-steps { flex-direction: column; }
  .landing-step-arrow { transform: rotate(90deg); padding: .25rem 0; }
}
@media (max-width: 576px) {
  .landing-steps { gap: .75rem; }
}
@media (max-width: 576px) {
  .landing-card-grid, .landing-card-grid-4 { grid-template-columns: 1fr; }
}

/* ==========================================================================
   Ticket reply thread, restyled as a chat — real messages from the real
   Comment table, live-updated by short-polling /tickets/<id>/comments/poll
   (see ticket_detail.html's script block). Not a new chat product bolted
   on: same data, same permissions, just presented as a conversation.
   ========================================================================== */
.chat-live-dot {
  width: 7px; height: 7px; border-radius: 50%; background: var(--status-good);
  box-shadow: 0 0 6px var(--status-good); display: inline-block;
  animation: chatLivePulse 2s ease-in-out infinite;
}
@keyframes chatLivePulse { 0%, 100% { opacity: .4; } 50% { opacity: 1; } }

/* Messenger-style layout: each message is a row (avatar + bubble column),
   mine rows flip to the trailing side, bubbles are fully rounded with one
   corner pinched in as a lightweight "tail" cue, and every row plays a
   short rise-in animation — on first render (a nice cascading reveal,
   gently staggered via --row-index) and again for each bubble the 4s poll
   appends live, so a new reply visibly arrives instead of just appearing. */
.chat-thread { display: flex; flex-direction: column; gap: .55rem; margin-bottom: 1rem; }
.chat-row {
  display: flex; align-items: flex-end; gap: .5rem; max-width: 78%;
  align-self: flex-start;
  animation: chatRowIn .32s cubic-bezier(.22,.9,.34,1) both;
  animation-delay: calc(min(var(--row-index, 0), 12) * 28ms);
}
.chat-row.mine { align-self: flex-end; flex-direction: row-reverse; }
@keyframes chatRowIn {
  from { opacity: 0; transform: translateY(10px) scale(.96); }
  to { opacity: 1; transform: translateY(0) scale(1); }
}
.chat-avatar { width: 30px; height: 30px; font-size: .85rem; margin-bottom: 2px; }
.chat-bubble-col { display: flex; flex-direction: column; min-width: 0; }
.chat-bubble-meta {
  display: flex; gap: .5rem; align-items: baseline;
  font-size: .72rem; color: var(--ink-muted); margin-bottom: .2rem; padding: 0 .3rem;
}
.chat-row.mine .chat-bubble-meta { flex-direction: row-reverse; }
.chat-bubble-meta strong { color: var(--ink-secondary); font-weight: 600; }
.chat-bubble-time { white-space: nowrap; }
.chat-bubble {
  padding: .62rem .95rem; border-radius: 20px;
  background: var(--surface); border: 1px solid var(--border);
  color: var(--ink-primary); line-height: 1.48;
  white-space: pre-wrap; word-break: break-word;
  border-end-start-radius: 6px;
  box-shadow: 0 1px 0 rgba(255,255,255,.02);
}
.chat-row.mine .chat-bubble {
  background: linear-gradient(135deg, var(--brand-accent-strong), var(--brand-accent) 75%);
  color: var(--brand-dark); border-color: transparent; font-weight: 500;
  border-end-start-radius: 20px; border-end-end-radius: 6px;
}
.chat-bubble.internal { border-style: dashed; border-color: var(--status-warning); background: transparent; }
.chat-bubble-attachments { margin-top: .35rem; }

/* Day-separator divider between groups of messages from different calendar
   days — a centered "Today / Yesterday / YYYY-MM-DD" pill with a hairline
   either side, same convention as most chat apps. Rendered once per day
   change both in the initial Jinja loop and by the live-poll JS (see
   ticket_detail.html), so it appears identically whichever path added it. */
.chat-day-divider {
  display: flex; align-items: center; gap: .6rem;
  margin: .3rem 0 .1rem; align-self: stretch;
  color: var(--ink-muted); font-size: .7rem; font-weight: 600;
  letter-spacing: .02em;
}
.chat-day-divider::before, .chat-day-divider::after {
  content: ""; flex: 1 1 auto; height: 1px; background: var(--border-soft);
}
.chat-day-divider span {
  flex: 0 0 auto; padding: .2rem .65rem; border-radius: 999px;
  background: var(--surface-2); border: 1px solid var(--border-soft);
  white-space: nowrap;
}

/* Quick-emoji bar above the reply box — click inserts at the cursor
   (see ticket_detail.html's script block). A light, tactile "Messenger
   reaction picker" feel: pop on hover, squash on click. */
.chat-emoji-bar { display: flex; flex-wrap: wrap; gap: .3rem; margin-bottom: .55rem; }
.chat-emoji-btn {
  border: 1px solid var(--border); background: var(--surface); border-radius: 999px;
  width: 32px; height: 32px; font-size: 1.05rem; line-height: 1;
  display: flex; align-items: center; justify-content: center; padding: 0;
  cursor: pointer; transition: transform .12s ease, border-color .12s ease, background-color .12s ease;
}
.chat-emoji-btn:hover {
  transform: translateY(-3px) scale(1.15); border-color: var(--brand-accent);
  background: var(--brand-accent-soft);
}
.chat-emoji-btn:active { transform: scale(.88); transition-duration: .05s; }

@media (max-width: 576px) { .chat-row { max-width: 92%; } }

/* --------------------------------------------------------- theme toggle */
.theme-toggle-btn {
  color: var(--ink-secondary);
  border: none;
  background: transparent;
  padding: .3rem .55rem;
  font-size: 1rem;
  line-height: 1;
  transition: color .15s ease, transform .2s ease;
}
.theme-toggle-btn:hover { color: var(--brand-accent-ink); transform: rotate(-14deg); }

/* -------------------------------------------------------- ticket type / department badge
   Shown on the category admin list (which department a category's
   tickets belong to) — same tinted-chip language as the KPI icons above,
   one color per real DEPARTMENTS value (never a generated/random hue). */
.dept-badge {
  display: inline-flex; align-items: center;
  padding: .22rem .6rem; border-radius: 999px;
  font-size: .72rem; font-weight: 700; letter-spacing: .02em;
}
.dept-badge[data-dept="IT"]    { background: rgba(58,142,230,.14);  color: var(--cat-1); }
.dept-badge[data-dept="HR"]    { background: rgba(239,143,182,.18); color: var(--cat-5); }
.dept-badge[data-dept="ERP"]   { background: rgba(143,125,232,.18); color: var(--cat-7); }
.dept-badge[data-dept="AI"]    { background: rgba(46,204,113,.14);  color: var(--status-good); }
.dept-badge[data-dept="Other"] { background: var(--surface-2);      color: var(--ink-secondary); }

/* ---------------------------------------------------- global New Ticket modal
   Same "glass card" language as .card-panel, so the modal reads as part
   of this app rather than a stock Bootstrap dialog dropped on top of it. */
#newTicketModal .modal-content {
  background: var(--surface-solid);
  border: 1px solid var(--border);
  border-radius: 16px;
}
#newTicketModal .modal-header { border-bottom: 1px solid var(--border-soft); }
#newTicketModal .modal-title { color: var(--ink-primary); font-weight: 700; display: flex; align-items: center; gap: .5rem; }
#newTicketModal .modal-title i { color: var(--brand-accent-ink); }

/* ------------------------------------------------------- page-load transition
   A quick, subtle fade+rise on the content area every time a page loads —
   plain CSS, no JS navigation interception, so nothing about forms,
   redirects or the back button can break. Layered with the native View
   Transitions API as a progressive enhancement: browsers that support it
   (Chromium) additionally cross-fade between full page navigations on
   this same origin; browsers that don't just get the fade-in below,
   which already works everywhere. Respects reduced-motion. */
@keyframes pageFadeIn {
  from { opacity: 0; transform: translateY(6px); }
  to   { opacity: 1; transform: translateY(0); }
}
.content-scroll { animation: pageFadeIn .38s cubic-bezier(.22,.9,.34,1) both; }
@media (prefers-reduced-motion: reduce) {
  .content-scroll { animation: none; }
}
@view-transition { navigation: auto; }

/* ------------------------------------------------ branded page-transition
   splash. A brief, full-screen ECVALLA mark shown while navigating between
   pages — every authenticated page starts with this covering the screen
   and fades it out once the page is actually ready, and a small inline
   script (see base.html) also shows it right when a nav link is clicked so
   the OLD page doesn't just sit there for the round-trip. Deliberately
   always the dark brand background regardless of the light/dark toggle —
   same "always dark" rule already used for the login/landing screens —
   so the mark never flashes over a bright white backdrop mid-navigation. */
.page-transition-overlay {
  position: fixed; inset: 0; z-index: 2000;
  /* Was a fully solid #0a0c14 — same dark hue, but now slightly
     translucent (.94) and quicker to fade so an in-app navigation reads
     as a brief, light dim rather than a hard flash to black; still
     always this dark brand tone regardless of the light/dark toggle,
     same reasoning as before. */
  background: rgba(10,12,20,.94);
  display: flex; align-items: center; justify-content: center;
  opacity: 1; visibility: visible; pointer-events: auto;
  transition: opacity .08s ease;
}
.page-transition-overlay.is-hidden {
  opacity: 0; visibility: hidden; pointer-events: none;
  transition: opacity .08s ease, visibility 0s linear .08s;
}
.page-transition-mark { text-align: center; }
.page-transition-mark .brand-wordmark { font-size: 1.9rem; }
/* Replaces the old static hairline under the wordmark — a real spinner
   so a slow navigation reads as "working", not a logo that's simply
   sitting there. Gold ring on the brand's own accent, kept small and
   quiet rather than a large attention-grabbing loader. */
.page-transition-spinner {
  width: 30px; height: 30px;
  margin: 1.15rem auto 0;
  border: 3px solid rgba(216,179,112,.18);
  border-top-color: var(--brand-accent, #d8b370);
  border-radius: 50%;
  animation: pageTransitionSpin .75s linear infinite;
}
@keyframes pageTransitionSpin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) {
  .page-transition-overlay, .page-transition-overlay.is-hidden { transition: none; }
  .page-transition-spinner { animation: none; border-top-color: rgba(216,179,112,.18); }
}

/* ------------------------------------------------- category search/filter bar
   Instant client-side filter (no page reload — the whole list is already
   on the page) over the Service Catalog table: free-text search plus a
   Ticket Type and a Status dropdown, combined with AND logic. See
   categories.html's script block. */
.cat-filter-bar, .filter-bar {
  display: flex; flex-wrap: wrap; align-items: center; gap: .6rem;
  margin-bottom: 1rem; padding-bottom: 1rem;
  border-bottom: 1px solid var(--border-soft);
}
.cat-filter-search, .filter-search {
  position: relative; flex: 1 1 220px; min-width: 180px;
}
.cat-filter-search i, .filter-search i {
  position: absolute; top: 50%; transform: translateY(-50%);
  inset-inline-start: .65rem; color: var(--ink-muted); font-size: .82rem; pointer-events: none;
}
.cat-filter-search input, .filter-search input { padding-inline-start: 1.9rem; }
.cat-filter-bar select, .filter-bar select { flex: 0 0 auto; width: auto; min-width: 130px; }
.cat-filter-count, .filter-count { color: var(--ink-muted); font-size: .78rem; white-space: nowrap; margin-inline-start: auto; }

/* Shared "list page" scroll pattern, used by every admin table that can run
   long (categories, users, tickets, assets, help articles, sites/projects,
   leave coverage, ...): filters/actions above the table, and any side form
   next to it, stay in view — only the table's own rows scroll, in a bounded
   box with their header row pinned to the top of that box. A long list no
   longer pushes anything else down the page. */
.table-scroll {
  max-height: min(560px, calc(100vh - 300px));
  overflow-y: auto;
}
.table-scroll thead th {
  position: sticky;
  top: 0;
  z-index: 1;
  background: var(--surface-solid);
}
.sticky-side-panel {
  position: sticky;
  top: 0;
  align-self: flex-start;
}

/* Inline agent-assignment select in the Service Catalog table (categories.html) —
   auto-submits on change, so it needs to look/feel like plain table text until
   focused/hovered rather than like a form control competing for attention. */
.cat-agent-form { margin: 0; min-width: 140px; }
.cat-agent-form select {
  border-color: transparent; background-color: transparent;
  padding-inline-start: .35rem; padding-inline-end: 1.6rem;
  font-size: .82rem; color: var(--ink-primary);
}
.cat-agent-form select:hover { border-color: var(--border); background-color: var(--surface-2); }
.cat-agent-form select:focus { border-color: var(--border); background-color: var(--surface-2); box-shadow: none; }
/* The dropdown POPUP is drawn by the OS/browser chrome, not our page theme, and it
   is almost always a light surface regardless of our dark/light toggle — so each
   <option> needs its own explicit dark-on-light colors here, separate from the
   closed control above, or options inherit our page's light-on-dark text and go
   near-invisible against that light popup. */
.cat-agent-form select option {
  color: #1c2333;
  background-color: #ffffff;
}

/* ============================================================ AI chat assistant
   Site-wide floating assistant (see base.html + /ai/chat in app.py). The
   avatar is a deliberately stylized, illustrated "robot/android" face —
   drawn entirely in SVG/CSS, not a photo — built from the same two accents
   already reserved for "this is AI" elsewhere in this app: gold for the
   brand, the violet→blue→teal gradient for the AI layer itself. Motion is
   concentrated ONLY here (the launcher bubble + its own avatar) rather than
   spread across page backgrounds, on purpose — see the assistant's own
   reasoning: a full animated background would fight the "faster, lighter
   navigation" goal elsewhere in this theme. */
.ai-chat-launcher {
  position: fixed; inset-block-end: 22px; inset-inline-end: 22px; z-index: 1500;
  width: 70px; height: 70px; border-radius: 50%; border: none; padding: 0;
  display: flex; align-items: center; justify-content: center;
  background: radial-gradient(circle at 35% 30%, #1c2440, #0a0c16 72%);
  box-shadow: 0 0 0 1px rgba(216,179,112,.25), 0 6px 24px rgba(0,0,0,.5), 0 0 26px var(--ai-glow);
  cursor: pointer;
  animation: aiGlowBreathe 3.4s ease-in-out infinite;
  transition: transform .15s ease;
  /* Draggable like a Messenger chat head (see the chat-widget script) — this
     stops touch scroll from hijacking the drag gesture on mobile. */
  touch-action: none;
  /* The <img> inside is natively draggable by the browser itself (its own
     "pick up this picture" gesture) — on a real mouse that fires first and
     swallows the drag as a native image-drag-and-drop instead of our own
     pointer-based one below, so the bubble visually snaps back to the same
     spot instead of moving. Belt-and-suspenders with draggable="false" on
     the <img> tag itself. */
  -webkit-user-drag: none; user-drag: none; -webkit-user-select: none; user-select: none;
}
.ai-chat-launcher:hover { transform: translateY(-2px) scale(1.04); }
.ai-chat-launcher .ai-avatar-img-wrap { width: 50px; height: 50px; display: block; }
.ai-chat-launcher img { -webkit-user-drag: none; user-drag: none; }
/* While actively being dragged: no hover lift, no easing lag behind the
   finger/cursor, and a "grabbing" cursor for the desktop mouse case. */
.ai-chat-launcher.is-dragging { transition: none; cursor: grabbing; }
.ai-chat-launcher.is-dragging:hover { transform: none; }
/* One-shot "pop" when a reply arrives while the panel is closed — the
   glow-breathe animation keeps running underneath so the bubble doesn't
   look broken once the pop finishes. */
.ai-chat-launcher.is-popping { animation: aiGlowBreathe 3.4s ease-in-out infinite, aiLauncherPop .55s cubic-bezier(.34,1.56,.64,1); }
@keyframes aiLauncherPop {
  0%   { transform: scale(1) rotate(0deg); }
  30%  { transform: scale(1.22) rotate(-8deg); }
  55%  { transform: scale(.92) rotate(6deg); }
  80%  { transform: scale(1.06) rotate(-2deg); }
  100% { transform: scale(1) rotate(0deg); }
}
/* Generated robot-head artwork (static/img/ai-avatar.png) standing in for a
   live 3D avatar. Idle tilt + a breathing glow keep it visibly "alive"; the
   thinking state (toggled by the chat JS while a reply is in flight) speeds
   both up so it visibly "thinks" between question and answer. */
.ai-avatar-img-wrap { position: relative; border-radius: 50%; overflow: visible; }
.ai-avatar-img {
  width: 100%; height: 100%; object-fit: cover; border-radius: 50%;
  display: block;
  box-shadow: 0 0 0 1.5px rgba(216,179,112,.5), inset 0 -6px 10px rgba(0,0,0,.35), inset 0 4px 8px rgba(255,255,255,.15);
  filter: drop-shadow(0 0 7px var(--ai-glow)) contrast(1.05) saturate(1.05);
  animation: aiHeadIdle 4.5s ease-in-out infinite, aiGlowBreathe 3.4s ease-in-out infinite;
  transform-origin: center bottom;
}
/* Faked sculpted/3D lighting over the flat portrait — a top-left highlight
   and a bottom-right shadow read as volume without needing a real 3D render. */
.ai-avatar-img-wrap::after {
  content: ""; position: absolute; inset: 0; border-radius: 50%; pointer-events: none;
  background:
    radial-gradient(circle at 32% 24%, rgba(255,255,255,.4), rgba(255,255,255,0) 46%),
    radial-gradient(circle at 68% 88%, rgba(0,0,0,.4), rgba(0,0,0,0) 55%);
  mix-blend-mode: overlay;
}
.is-thinking .ai-avatar-img {
  animation: aiHeadThink 1.1s ease-in-out infinite, aiGlowBreathe 1s ease-in-out infinite;
  filter: drop-shadow(0 0 12px var(--ai-glow)) contrast(1.05) saturate(1.05);
}
/* Scoped to the header's own mini-avatar only (not every past message's
   avatar in the transcript, which also lives inside .ai-chat-panel) — the
   assistant visibly "talks" for exactly as long as its voice reply is
   actually playing (see speak()/speakBrowser() toggling this class). */
.ai-chat-panel.is-speaking .ai-avatar-mini .ai-avatar-img {
  animation: aiHeadSpeak .45s ease-in-out infinite, aiGlowBreathe 1.1s ease-in-out infinite;
  filter: drop-shadow(0 0 11px var(--ai-glow)) contrast(1.05) saturate(1.05);
}
@keyframes aiHeadSpeak {
  0%, 100% { transform: scale(1) rotate(0deg); }
  50% { transform: scale(1.06) rotate(2deg); }
}
@keyframes aiHeadIdle {
  0%, 100% { transform: rotate(0deg) translateY(0); }
  50% { transform: rotate(-3deg) translateY(-1px); }
}
@keyframes aiHeadThink {
  0%, 100% { transform: rotate(-4deg) scale(1.02); }
  50% { transform: rotate(4deg) scale(1.02); }
}
.ai-chat-unread-dot {
  position: absolute; top: 2px; inset-inline-end: 2px;
  width: 12px; height: 12px; border-radius: 50%;
  background: var(--status-critical); border: 2px solid var(--surface-solid);
  display: none;
}
.ai-chat-unread-dot.show { display: block; }

.ai-chat-panel {
  position: fixed; inset-block-end: 104px; inset-inline-end: 22px; z-index: 1500;
  width: 360px; max-width: calc(100vw - 32px);
  height: 500px; max-height: calc(100vh - 130px);
  display: flex; flex-direction: column;
  background: var(--surface-solid);
  border: 1px solid var(--border-soft);
  border-radius: 18px;
  box-shadow: 0 20px 60px rgba(0,0,0,.5), 0 0 0 1px rgba(216,179,112,.1);
  overflow: hidden;
  transform-origin: bottom right;
  transition: opacity .16s ease, transform .16s ease;
}
[dir="rtl"] .ai-chat-panel { transform-origin: bottom left; }
.ai-chat-panel[hidden] { display: flex !important; opacity: 0; transform: scale(.92) translateY(8px); pointer-events: none; }
/* While the panel itself is being dragged by its header (see the
   chat-widget script) — no easing lag behind the cursor/finger while
   actively moving it. */
.ai-chat-panel.is-dragging { transition: none; }
.ai-chat-header {
  flex-shrink: 0; display: flex; align-items: center; gap: .6rem;
  padding: .7rem .9rem; background: var(--surface-2); border-bottom: 1px solid var(--border-soft);
  /* Draggable — grab anywhere on the header (not its buttons) to move the
     whole panel, same "chat head" idea as the launcher bubble. */
  cursor: grab; touch-action: none;
}
.ai-chat-panel.is-dragging .ai-chat-header { cursor: grabbing; }
.ai-chat-header .ai-avatar-mini { width: 30px; height: 30px; flex-shrink: 0; }
.ai-chat-header .ai-avatar-mini .ai-avatar-img-wrap { width: 100%; height: 100%; }
.ai-chat-header-text { flex: 1; min-width: 0; }
.ai-chat-header-title { font-weight: 700; font-size: .88rem; color: var(--ink-primary); }
.ai-chat-header-sub { font-size: .7rem; color: var(--ink-muted); }
/* Scoped to the plain close-X only — a bare <button> with no .ai-chat-btn
   class. This used to be written as ".ai-chat-header button", which (by
   specificity) also flattened the lang/voice buttons' circular chrome
   below, making them look like plain unclickable icons instead of buttons. */
#aiChatClose { background: transparent; border: none; color: var(--ink-muted); font-size: 1rem; padding: .2rem; }
#aiChatClose:hover { color: var(--ink-primary); }

.ai-chat-messages { flex: 1 1 auto; overflow-y: auto; padding: .8rem; display: flex; flex-direction: column; gap: .55rem; }
/* Each message is a row: a small assistant-avatar beside the bubble for
   assistant replies (none for the user's own messages), matching the
   header/launcher avatar so the assistant reads as one consistent identity
   throughout the conversation, not just in the header. */
.ai-chat-row { display: flex; align-items: flex-end; gap: .4rem; max-width: 86%; }
.ai-chat-row.assistant { align-self: flex-start; }
.ai-chat-row.user { align-self: flex-end; }
.ai-chat-msg-avatar { width: 22px; height: 22px; flex-shrink: 0; margin-bottom: 1px; }
.ai-chat-msg { max-width: 100%; padding: .5rem .7rem; border-radius: 12px; font-size: .84rem; line-height: 1.45; white-space: pre-wrap; }
.ai-chat-msg.user { background: var(--brand-accent); color: var(--brand-dark); border-end-end-radius: 3px; }
.ai-chat-msg.assistant { background: var(--surface-2); color: var(--ink-primary); border-end-start-radius: 3px; }
.ai-chat-msg.assistant.is-typing { display: flex; gap: .25rem; align-items: center; padding: .65rem .8rem; }
.ai-chat-typing-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--ink-muted); animation: aiTypingBounce 1.1s ease-in-out infinite; }
.ai-chat-typing-dot:nth-child(2) { animation-delay: .15s; }
.ai-chat-typing-dot:nth-child(3) { animation-delay: .3s; }

.ai-chat-inputbar { flex-shrink: 0; display: flex; align-items: flex-end; gap: .4rem; padding: .6rem; border-top: 1px solid var(--border-soft); }
.ai-chat-inputbar textarea {
  flex: 1 1 auto; resize: none; max-height: 90px; min-height: 38px;
  border-radius: 10px; border: 1px solid var(--border-soft); background: var(--surface-2);
  color: var(--ink-primary); font-size: .84rem; padding: .5rem .7rem;
}
.ai-chat-inputbar textarea:focus { outline: none; border-color: var(--brand-accent); }
.ai-chat-btn {
  flex-shrink: 0; width: 36px; height: 36px; border-radius: 50%; border: none;
  display: flex; align-items: center; justify-content: center; font-size: .95rem;
  background: var(--surface-2); color: var(--ink-secondary); cursor: pointer;
  transition: background-color .15s ease, color .15s ease;
}
.ai-chat-btn:hover { background: var(--brand-accent-soft); color: var(--brand-accent-ink); }
.ai-chat-btn.ai-chat-send { background: var(--ai-grad); color: #fff; }
.ai-chat-btn.ai-chat-send:hover { filter: brightness(1.1); color: #fff; }
.ai-chat-btn.ai-chat-mic.is-listening { color: #fff; background: var(--status-critical); animation: aiIconPulse 1s ease-in-out infinite; }
.ai-chat-btn.ai-chat-voice.is-on { color: var(--brand-accent-ink); background: var(--brand-accent-soft); }
/* The header sits on the same --surface-2 tone as a plain .ai-chat-btn's
   own default background, so without a border these two would blend into
   the header instead of reading as distinct, clickable controls. */
.ai-chat-header .ai-chat-btn { border: 1px solid var(--border-soft); }
.ai-chat-btn.ai-chat-lang { font-size: .68rem; font-weight: 700; letter-spacing: .02em; width: 34px; height: 34px; }
.ai-chat-disabled-note { padding: .7rem .9rem; font-size: .78rem; color: var(--ink-muted); border-top: 1px solid var(--border-soft); }

@keyframes aiEyePulse {
  0%, 100% { opacity: 1; }
  50% { opacity: .45; }
}
@keyframes aiTypingBounce {
  0%, 60%, 100% { transform: translateY(0); opacity: .5; }
  30% { transform: translateY(-3px); opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
  .ai-chat-launcher, .ai-chat-launcher.is-popping, .ai-avatar-img, .ai-chat-btn.ai-chat-mic.is-listening,
  .ai-chat-panel.is-speaking .ai-avatar-mini .ai-avatar-img,
  .ai-chat-typing-dot { animation: none; }
}
@media (max-width: 480px) {
  .ai-chat-panel { inset-inline-end: 12px; inset-block-end: 94px; width: calc(100vw - 24px); }
  .ai-chat-launcher { inset-inline-end: 14px; inset-block-end: 14px; }
}
