/* tasky.bot — shared design system */
/* Discord Blurple × Light Minimal */

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

:root {
  /* Canvas */
  --bg:          #f9fafb;
  --surface:     #ffffff;
  --surface-2:   #f3f4f6;

  /* Text */
  --fg:          #111827;
  --fg-2:        #374151;
  --muted:       #6b7280;
  --muted-2:     #9ca3af;

  /* Borders */
  --border:      #e5e7eb;
  --border-2:    #d1d5db;

  /* Discord Blurple accent */
  --accent:      #5865F2;
  --accent-600:  #4752c4;
  --accent-700:  #3c45a5;
  --accent-50:   #eef0fd;
  --accent-100:  #dce0fb;
  --accent-200:  #bcc4f8;
  --accent-300:  #9ba8f4;
  /* RGB triplet of --accent for rgba() tints (dark-mode shades, focus rings).
     MUST be kept in sync with --accent — the tone blocks below override both. */
  --accent-rgb:  88,101,242;

  /* Status */
  --success:     #16a34a;
  --success-bg:  #dcfce7;
  --success-100: #bbf7d0;
  --warning:     #d97706;
  --warning-bg:  #fef3c7;
  --danger:      #dc2626;
  --danger-bg:   #fee2e2;
  --danger-50:   #fff1f2;
  --danger-200:  #fecaca;
  /* Priority text — AA-contrast on-surface shades. The raw --warning/--success
     only hit ~3.2:1 as text on the white table (fail AA); these darker ramp
     steps pass. Overridden lighter in dark mode below. */
  --prio-urgent: var(--danger);
  --prio-high:   var(--danger);
  --prio-medium: #b45309;
  --prio-low:    #15803d;
  --info:        #0284c7;
  --info-bg:     #e0f2fe;

  /* Discord status */
  --online:      #3ba55d;
  --idle:        #faa61a;
  --dnd:         #ed4245;
  --offline:     #747f8d;

  /* Layout */
  --sidebar-w:   264px;
  --topbar-h:    64px;
  --bottomnav-h: 56px;   /* native bottom tab bar (excl. safe-area inset) */
  --content-max: 1212px;       /* shared content-width cap for sidebar-less pages (.page) — keeps content readable on large screens */
  --content-narrow: 800px;     /* shared content column for sidebar pages (settings/integrations/discord/reports) — SINGLE source of truth; pages must NOT hardcode their own max-width */
  --content-dash: 1040px;      /* dashboard/overview column — narrower than --content-max so the workspace + project "Tổng quan" don't stretch edge-to-edge on wide screens */

  /* Z-index scale — page CSS must use these tokens instead of magic numbers. */
  --z-raised: 1; --z-sticky: 30; --z-topbar: 50; --z-nav: 100; --z-dropdown: 200; --z-modal: 300; --z-toast: 500;

  /* Shape */
  --radius:      8px;
  --radius-lg:   20px;
  --radius-xl:   16px;
  --radius-full: 9999px;

  /* Form controls — ONE canonical height for every interactive control (inputs,
     selects, buttons) so the UI lines up. Don't hard-code per-control heights.
     --control-h: default · --control-h-sm: compact (table-inline, .btn-sm). */
  --control-h:     36px;
  --control-h-sm:  30px;
  --control-pad-x: 12px;

  /* Spacing — 4px base grid. Pick from these; don't use off-grid 5/7/9/11/18px.
     (Existing code predates this; new/edited UI should snap to the scale.) */
  --space-1:  4px;
  --space-2:  8px;
  --space-3:  12px;
  --space-4:  16px;
  --space-5:  20px;
  --space-6:  24px;
  --space-8:  32px;
  --space-10: 40px;
  --space-12: 48px;

  /* Motion — durations + easings. Don't invent 0.1/0.14/0.25s one-offs.
     (--t-fast/--t-base were self-referencing → every transition/animation ran at
     0s = instant snap. That was THE reason motion felt dead across the app.) */
  --t-instant: 0.08s;   /* pressed states / tap feedback */
  --t-fast:    0.12s;   /* hover / press / small state */
  --t-base:    0.18s;   /* enter / layout / nav */
  --t-slow:    0.32s;   /* sheets / large surfaces */
  --ease:       cubic-bezier(0.22, 1, 0.36, 1);   /* general ease-out */
  --ease-ios:   cubic-bezier(0.32, 0.72, 0, 1);   /* iOS nav push/pop curve */
  --ease-spring: cubic-bezier(0.34, 1.4, 0.64, 1); /* gentle overshoot (sheets/pop) */

  /* Typography — brand fonts loaded in index.html (Google Fonts).
     IBM Plex Sans for body, Bricolage Grotesque for display/headings. */
  --font:         'IBM Plex Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
  --font-display: 'Bricolage Grotesque', 'IBM Plex Sans', system-ui, sans-serif;
  --font-mono:    'JetBrains Mono', 'Fira Code', ui-monospace, Menlo, monospace;

  /* Type scale — the standard ramp. New UI should pick from THESE, not invent
     half-pixel sizes (the codebase had ~24 distinct sizes incl. 11.5/12.5/13.5,
     so adjacent text differed by 0.5px and hierarchy read as mush). Steps:
     micro-label → meta → body → emphasis → subhead → title → display. */
  --fs-2xs: 11px;   /* uppercase micro-labels, badges */
  --fs-xs:  12px;   /* meta, captions, table sub-text */
  --fs-sm:  13px;   /* secondary body */
  --fs-md:  14px;   /* base body */
  --fs-lg:  16px;   /* card/section titles */
  --fs-xl:  20px;   /* page titles */
  --fs-2xl: 26px;   /* hero / big numbers */
  --lh-tight: 1.2;  /* headings + numbers */
  --lh-body:  1.55; /* prose / descriptions */

  /* Shadows */
  --shadow-sm:   0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04);
  --shadow-md:   0 4px 12px rgba(0,0,0,0.08), 0 2px 4px rgba(0,0,0,0.04);
  --shadow-lg:   0 8px 24px rgba(0,0,0,0.10), 0 4px 8px rgba(0,0,0,0.04);
}

/* ═══════════════════════════════════════════════════════════════
   ACCENT TONE — user-selectable app tone (Cài đặt → Giao diện)
   ═══════════════════════════════════════════════════════════════
   The whole app runs on the --accent* family (colour standard: bars/rings,
   buttons, links, active states all read these tokens), so a tone only has to
   swap that family. Each tone sets: the 3 solids (accent / 600 hover / 700
   press), the light tints (50–300, color-mix in family), and --accent-rgb
   (feeds the DARK-mode tints + focus rings — rgba(var(--accent-rgb), α) — so
   dark mode re-tones automatically). No [data-tone] (or "blurple") = default.
   These blocks must stay ABOVE the dark-theme token block so dark tints win
   when both attributes are present. Applied by window.applyTone (main.js),
   persisted as localStorage tasky_tone. */
html[data-tone="ocean"] {
  --accent: #2563eb; --accent-600: #1d4ed8; --accent-700: #1e40af; --accent-rgb: 37,99,235;
  --accent-50:  color-mix(in srgb, var(--accent) 10%, #fff);
  --accent-100: color-mix(in srgb, var(--accent) 20%, #fff);
  --accent-200: color-mix(in srgb, var(--accent) 40%, #fff);
  --accent-300: color-mix(in srgb, var(--accent) 60%, #fff);
}
html[data-tone="teal"] {
  --accent: #0f766e; --accent-600: #115e59; --accent-700: #134e4a; --accent-rgb: 15,118,110;
  --accent-50:  color-mix(in srgb, var(--accent) 10%, #fff);
  --accent-100: color-mix(in srgb, var(--accent) 20%, #fff);
  --accent-200: color-mix(in srgb, var(--accent) 40%, #fff);
  --accent-300: color-mix(in srgb, var(--accent) 60%, #fff);
}
html[data-tone="violet"] {
  --accent: #7c3aed; --accent-600: #6d28d9; --accent-700: #5b21b6; --accent-rgb: 124,58,237;
  --accent-50:  color-mix(in srgb, var(--accent) 10%, #fff);
  --accent-100: color-mix(in srgb, var(--accent) 20%, #fff);
  --accent-200: color-mix(in srgb, var(--accent) 40%, #fff);
  --accent-300: color-mix(in srgb, var(--accent) 60%, #fff);
}
html[data-tone="rose"] {
  --accent: #db2777; --accent-600: #be185d; --accent-700: #9d174d; --accent-rgb: 219,39,119;
  --accent-50:  color-mix(in srgb, var(--accent) 10%, #fff);
  --accent-100: color-mix(in srgb, var(--accent) 20%, #fff);
  --accent-200: color-mix(in srgb, var(--accent) 40%, #fff);
  --accent-300: color-mix(in srgb, var(--accent) 60%, #fff);
}
html[data-tone="graphite"] {
  --accent: #4b5563; --accent-600: #374151; --accent-700: #1f2937; --accent-rgb: 75,85,99;
  --accent-50:  color-mix(in srgb, var(--accent) 10%, #fff);
  --accent-100: color-mix(in srgb, var(--accent) 20%, #fff);
  --accent-200: color-mix(in srgb, var(--accent) 40%, #fff);
  --accent-300: color-mix(in srgb, var(--accent) 60%, #fff);
}

/* ─── Status-badge tone (Cài đặt → Giao diện → Huy hiệu trạng thái) ──────────
   data-badges="tone" = monochrome status indicators: the progress-circle glyph
   already encodes the state by FILL AMOUNT (hollow → ½ → ¾ → check), so a
   single accent hue stays readable and the list calms down. Default (no attr)
   keeps the semantic multi-colour set (info/amber/green). todo/cancelled stay
   muted in both modes. Applied by window.applyBadges (main.js), persisted as
   localStorage tasky_badges. */
html[data-badges="tone"] .ts-in_progress,
html[data-badges="tone"] .ts-in_review,
html[data-badges="tone"] .ts-done { color: var(--accent); }
/* Native (iOS) status chips follow the same rule. */
html[data-badges="tone"] body.native #tasks-pane .nt-st-in_progress,
html[data-badges="tone"] body.native #tasks-pane .nt-st-in_review,
html[data-badges="tone"] body.native #tasks-pane .nt-st-done {
  color: var(--accent); background: color-mix(in srgb, var(--accent) 12%, transparent);
  border-color: color-mix(in srgb, var(--accent) 28%, transparent);
}
/* Completion glyphs (the round "done" check on every list) + the mark-done CTA
   also follow the tone. These live in per-page injectPageCSS but the classes are
   global, so one rule here recolours them all; the higher specificity (html[..]
   + class + class) beats the page rules regardless of load order. Default keeps
   them semantic green. */
html[data-badges="tone"] .done-circle.done,
html[data-badges="tone"] .task-check.done,
html[data-badges="tone"] .agenda-check.done,
html[data-badges="tone"] .cal-detail-check.done,
html[data-badges="tone"] #task-detail-page .td-done-toggle.is-done {
  background: var(--accent); border-color: var(--accent);
}
html[data-badges="tone"] .done-circle:hover:not(.done):not(.cancelled),
html[data-badges="tone"] .task-check:hover:not(.done):not(.cancelled),
html[data-badges="tone"] #task-detail-page .td-done-toggle:hover:not(.is-done) { border-color: var(--accent); }
/* The task-detail "Hoàn thành" button (green while actionable). Once done it's a
   neutral "Mở lại", so only the actionable state re-tones. */
html[data-badges="tone"] .td-action-complete:not(.is-done) {
  background: var(--accent); border-color: var(--accent);
}
html[data-badges="tone"] .td-action-complete:not(.is-done):hover {
  background: var(--accent-600); border-color: var(--accent-600);
}
/* Task-list ROW status tints + left rails + hover also go monochrome-accent in
   tone mode (default keeps the info/amber/green set). The glyph fill still tells
   the states apart, so a single hue reads calm instead of a status rainbow. */
html[data-badges="tone"] .pd-task-row.st-in_progress { --st-rail: var(--accent-300); --row-bg: color-mix(in srgb, var(--accent) 5%, var(--surface)); }
html[data-badges="tone"] .pd-task-row.st-in_review   { --st-rail: var(--accent);     --row-bg: color-mix(in srgb, var(--accent) 6%, var(--surface)); }
html[data-badges="tone"] .pd-task-row.st-done        { --st-rail: var(--accent);     --row-bg: color-mix(in srgb, var(--accent) 4%, var(--surface)); }
html[data-badges="tone"] .pd-task-row.st-in_progress td { background: color-mix(in srgb, var(--accent) 5%, transparent); }
html[data-badges="tone"] .pd-task-row.st-in_review td   { background: color-mix(in srgb, var(--accent) 6%, transparent); }
html[data-badges="tone"] .pd-task-row.st-done td        { background: color-mix(in srgb, var(--accent) 4%, transparent); }
/* Row hover follows the tone too (was a neutral grey that looked off-palette). */
html[data-badges="tone"] #tasks-pane .data-table tbody tr:hover td {
  background: color-mix(in srgb, var(--accent) 8%, var(--surface));
}

/* ─── Base ─────────────────────────────── */
html, body {
  height: 100%;
  /* The shell (`.app`) owns the viewport; `.page` (#app-page) is the ONLY
     scroller. Pin the document so it can never scroll — otherwise on browsers
     with a dynamic toolbar (iOS Safari, Android Chrome) `100vh` exceeds the
     visible area and the whole page scrolls, dragging the sticky topbar +
     sidebar out of view ("header/menu bar scroll" bug). */
  overflow: hidden;
  overscroll-behavior: none;
  font-family: var(--font);
  font-size: 14px;
  color: var(--fg);
  background: var(--bg);
  line-height: 1.5;
  -webkit-font-smoothing: antialiased;
  font-feature-settings: 'ss01' on, 'cv11' on;
}

/* Display typeface (Bricolage) for page titles + section headers + topbar/
   sidebar branding text. Body text stays on IBM Plex Sans.
   The attribute selectors below extend it to every page's section/card/panel
   title (each page invented its own `*-card-title` / `*-panel-title` /
   `*-section-title` class) so the SECOND level of hierarchy is consistently
   on the display face — not just the page H1. */
h1, h2, h3,
.page-title,
.settings-panel-title,
.td-section-title,
.auth-title,
.auth-brand-name,
.logo-name,
.bot-name,
[class*="card-title"],
[class*="panel-title"],
[class*="section-title"] {
  font-family: var(--font-display);
  letter-spacing: -0.01em;
}

/* Big numbers (stat values, KPIs) get the display face + tabular figures so
   dashboards align and read as intentional, not body text in bold. */
[class*="stat-value"],
[class*="stat-num"],
[class*="mini-value"] {
  font-family: var(--font-display);
  font-variant-numeric: tabular-nums;
  letter-spacing: -0.01em;
}

a { color: inherit; text-decoration: none; }
button { cursor: pointer; font-family: inherit; }
input, select, textarea { font-family: inherit; }

/* Accessibility: a single, consistent keyboard-focus ring. :focus-visible only
   fires for keyboard/AT users, so it never shows on mouse click. Components may
   restyle it but must not remove a visible focus indicator. */
:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
  border-radius: var(--radius);
}

/* ─── App shell ─────────────────────────── */
.app {
  display: grid;
  /* `minmax(0, 1fr)` (not the default `1fr` = `minmax(auto, 1fr)`): an `auto`
     minimum lets the column grow to its content's MIN-CONTENT width, so a wide
     child (a long table, an un-shrinkable button row) blows the track past the
     viewport. With `.app` `overflow:hidden` that overflow is clipped, not
     scrollable → content cut off at the right on narrow screens. `minmax(0,…)`
     pins the track to the available width and lets children scroll/ellipsis. */
  grid-template-columns: var(--sidebar-w) minmax(0, 1fr);
  grid-template-rows: var(--topbar-h) 1fr;
  height: 100vh;        /* fallback for browsers without dvh */
  height: 100dvh;       /* match the VISIBLE viewport on dynamic-toolbar browsers */
  overflow: hidden;
}

/* ─── Sidebar ───────────────────────────── */
.sidebar {
  grid-column: 1;
  grid-row: 1 / -1;
  background: var(--surface);
  border-right: 1px solid var(--border);
  display: flex;
  flex-direction: row;   /* dual-sidebar: rail + nav-col */
  overflow: hidden;
  position: sticky;
  top: 0;
  height: 100vh;
  height: 100dvh;
  z-index: 40;
}

/* ── Icon rail (narrow left column) ───── */
.sidebar-rail {
  width: 52px;
  flex-shrink: 0;
  border-right: 1px solid var(--border);
  display: flex;
  flex-direction: column;
  align-items: center;
  padding: var(--space-3) 0 10px;
  gap: 6px;
  height: 100vh;
  height: 100dvh;
  overflow-y: auto;
  scrollbar-width: none;
  background: var(--surface);
}
.sidebar-rail::-webkit-scrollbar { display: none; }

/* Rail: workspace button (logo icon) */
.rail-ws {
  width: 36px;
  height: 36px;
  border-radius: 10px;
  background: var(--accent);
  display: flex;
  align-items: center;
  justify-content: center;
  color: white;
  font-weight: 700;
  font-size: 14px;
  letter-spacing: -0.5px;
  flex-shrink: 0;
  text-decoration: none;
  transition: opacity var(--t-fast);
  position: relative;
}
.rail-ws:hover { opacity: 0.85; }
.rail-ws img {
  width: 100%; height: 100%;
  object-fit: contain;
  display: block;
  border-radius: inherit;
}
.rail-ws.ws-active {
  border-radius: 14px;
  box-shadow: 0 0 0 2.5px var(--surface), 0 0 0 4px var(--accent-300);
}
.rail-ws.in-project {
  border-radius: 10px;
  opacity: 0.65;
  background: var(--accent);
}
.rail-ws.in-project:hover { opacity: 1; }

/* Rail: separator */
.rail-sep {
  width: 24px;
  height: 1px;
  background: var(--border);
  margin: 6px 0;
  flex-shrink: 0;
}

/* Rail: project icon */
.rail-proj {
  width: 36px;
  height: 36px;
  border-radius: 10px;
  display: flex;
  align-items: center;
  justify-content: center;
  color: white;
  font-weight: 700;
  font-size: 12px;
  flex-shrink: 0;
  text-decoration: none;
  transition: box-shadow var(--t-fast) var(--ease);
  position: relative;
  /* iOS: press-and-hold a rail shortcut opens the pin menu (main.js long-press).
     Without these the WebView fires its own link callout / text-selection on the
     hold, which fights that menu. touch-action keeps taps snappy. */
  -webkit-touch-callout: none;
  user-select: none;
  -webkit-user-select: none;
  touch-action: manipulation;
  /* Show the project's chosen colour as-is here — the desaturation made a
     customised colour read as "wrong" in the rail. */
}
/* "No colour" project: neutral pill (surface bg set inline) with muted glyph + a
   hairline border — mirrors the settings "không màu" preview, not the accent
   fallback. */
.rail-proj--nc { color: var(--muted); border: 1px solid var(--border); }
/* Pinned shortcut — a clear pin-ICON badge at the top-right corner (was a tiny
   accent dot that was barely visible on the coloured tile). White circle + accent
   thumbtack SVG so "pinned" reads at a glance. */
.rail-proj.is-pinned::after {
  /* Offset at the top-right CORNER, straddling the tile edge so it's clear of the
     centred logo (user: "lệch góc so với logo"). The offset stays SMALL and the
     rail carries matching padding so the badge sits within the scroll box and
     isn't clipped (the earlier full -5px outdent got cut off on a scrolled rail). */
  content: ''; position: absolute; top: -3px; right: -3px;
  width: 15px; height: 15px; border-radius: 50%;
  background: var(--surface); box-shadow: 0 1px 3px rgba(0,0,0,.28);
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 24 24' fill='none' stroke='%235865f2' stroke-width='2.4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 17v5'/%3E%3Cpath d='M9 10.8V4h6v6.8l3 3.2V17H6v-3z'/%3E%3C/svg%3E");
  background-repeat: no-repeat; background-position: center;
}
/* Hover/active must NOT change the pill's geometry (the old 10→14px radius
   morph read as the icon "resizing" under the cursor). A quiet ring instead;
   the active state's accent ring stays the stronger signal. */
.rail-proj:hover { box-shadow: 0 0 0 2px var(--surface), 0 0 0 3.5px var(--border-2); }
/* Rail right-click menu (pin / unpin a project shortcut) */
.rail-pin-menu {
  position: fixed; z-index: var(--z-dropdown); min-width: 160px; padding: 4px;
  background: var(--surface); border: 1px solid var(--border);
  border-radius: var(--radius); box-shadow: var(--shadow-lg);
}
.rail-pin-item {
  display: flex; align-items: center; gap: 8px; width: 100%; text-align: left;
  padding: 8px 10px; border: none; background: none; cursor: pointer;
  font-size: var(--fs-sm); color: var(--fg); border-radius: calc(var(--radius) - 2px);
}
.rail-pin-item:hover { background: var(--surface-2); color: var(--accent); }
.rail-proj.active {
  box-shadow: 0 0 0 2.5px var(--surface), 0 0 0 4px var(--accent);
}

/* ─── Project avatar (emoji / uploaded image) ────────────────────────────────
   Shared by .project-icon/.list-icon/.rail-proj/.ov-proj-icon/#pd-sidebar-icon
   via src/lib/project-icon.js. Emoji scales with the box's own font-size; an
   uploaded image fills the box edge-to-edge and drops the colour tint +
   saturate filter so the photo stays crisp. */
.proj-emoji { font-size: 1.4em; line-height: 1; display: inline-flex; }
.proj-icon-img { width: 100%; height: 100%; object-fit: cover; border-radius: inherit; display: block; }
.has-img, *:has(> .proj-icon-img) {
  background: transparent !important;
  filter: none !important;
  padding: 0 !important;
  overflow: hidden;
}

/* Rail: "All projects" pill */
.rail-all {
  width: 36px;
  height: 36px;
  border-radius: 10px;
  display: flex;
  align-items: center;
  justify-content: center;
  color: var(--muted);
  flex-shrink: 0;
  text-decoration: none;
  border: 1.5px dashed var(--border-2);
  transition: color var(--t-fast), border-color var(--t-fast);
  background: transparent;
  margin-top: 2px;
}
.rail-all:hover { color: var(--fg); border-color: var(--muted); }

/* Rail: spacer to push bottom items down */
.rail-spacer { flex: 1; }

/* Rail: user avatar at bottom */
.rail-user {
  width: 32px;
  height: 32px;
  border-radius: 50%;
  background: var(--accent-100);
  display: flex;
  align-items: center;
  justify-content: center;
  color: var(--accent-700);
  font-size: 11px;
  font-weight: 700;
  flex-shrink: 0;
  cursor: pointer;
}

/* ── Nav column (right of rail) ─────── */
.sidebar-nav-col {
  flex: 1;
  display: flex;
  flex-direction: column;
  overflow-y: auto;
  overflow-x: hidden;
  min-width: 0;
}

.sidebar-logo {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 0 var(--space-4);
  height: var(--topbar-h);
  border-bottom: 1px solid var(--border);
  flex-shrink: 0;
}

.logo-icon {
  width: 32px;
  height: 32px;
  background: var(--accent);
  border-radius: var(--radius);
  display: flex;
  align-items: center;
  justify-content: center;
  color: white;
  font-weight: 700;
  font-size: 14px;
  letter-spacing: -0.5px;
  flex-shrink: 0;
}

.logo-name {
  font-size: 14px;
  font-weight: 700;
  color: var(--fg);
  letter-spacing: -0.3px;
}

.logo-name .dot { color: var(--accent); }

.sidebar-section {
  padding: var(--space-4) var(--space-3) var(--space-1);
  font-size: 11px;
  font-weight: 600;
  color: var(--muted-2);
  text-transform: uppercase;
  letter-spacing: 0.6px;
}

/* ─── Workspace switcher (top of the sidebar nav column) ────────────────── */
.ws-switcher {
  display: flex; align-items: center; gap: var(--space-2);
  width: calc(100% - var(--space-4)); height: 40px;
  margin: var(--space-3) var(--space-2) 0; padding: 0 var(--space-2);
  background: var(--surface-2); border: 1px solid var(--border);
  border-radius: var(--radius); color: var(--fg); cursor: pointer;
  transition: background var(--t-fast) var(--ease), border-color var(--t-fast) var(--ease);
}
.ws-switcher:hover { background: var(--surface); border-color: var(--border-2); }
/* Native drawer head: brand + workspace name, no control chrome. Same box the
   switcher occupied so the nav below it doesn't shift. */
.ws-brand {
  display: flex; align-items: center; gap: var(--space-2);
  height: 40px; margin: var(--space-3) var(--space-2) 0; padding: 0 var(--space-2);
}
.ws-brand-logo { width: 26px; height: 26px; border-radius: var(--radius); flex: none; }
.ws-brand-name {
  font-family: var(--font-display); font-size: var(--fs-lg); font-weight: 700;
  color: var(--fg); line-height: var(--lh-tight);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.ws-switcher-ava {
  flex: none; width: 24px; height: 24px; border-radius: var(--radius);
  display: grid; place-items: center; background: var(--accent);
  color: #fff; font-weight: 700; font-size: var(--fs-xs);
}
.ws-switcher-name {
  flex: 1; min-width: 0; text-align: left; font-weight: 600; font-size: var(--fs-sm);
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.ws-switcher-caret { flex: none; color: var(--muted); }

/* Workspace role badge — colour-coded so the access level (admin = sees all,
   member/viewer = only their projects) is unmistakable. */
.sb-role {
  display: flex; align-items: center; gap: 6px; flex-wrap: wrap;
  margin: 8px 0 4px; padding: 6px 10px; border-radius: var(--radius);
  font-size: var(--fs-xs); font-weight: 600; line-height: 1.3;
  border: 1px solid transparent;
}
.sb-role svg { flex: none; }
.sb-role-label { font-weight: 700; }
.sb-role-sub { flex-basis: 100%; font-weight: 500; font-size: var(--fs-2xs); opacity: .85; }
.sb-role-owner  { color: #b45309;  background: color-mix(in srgb, #f59e0b 14%, transparent); border-color: color-mix(in srgb, #f59e0b 38%, transparent); }
[data-theme="dark"] .sb-role-owner { color: #fbbf24; }
.sb-role-admin  { color: var(--accent);  background: var(--accent-50);  border-color: color-mix(in srgb, var(--accent) 28%, transparent); }
.sb-role-member { color: var(--success); background: var(--success-bg); border-color: color-mix(in srgb, var(--success) 26%, transparent); }
.sb-role-viewer { color: var(--warning); background: var(--warning-bg); border-color: color-mix(in srgb, var(--warning) 30%, transparent); }

.ws-menu {
  background: var(--surface); border: 1px solid var(--border);
  border-radius: var(--radius-lg); box-shadow: var(--shadow-lg);
  padding: var(--space-2); min-width: 240px; max-width: 320px;
}
.ws-menu-label {
  font-size: var(--fs-2xs); font-weight: 600; text-transform: uppercase;
  letter-spacing: 0.04em; color: var(--muted-2);
  padding: var(--space-1) var(--space-2) var(--space-2);
}
.ws-menu-list { display: flex; flex-direction: column; gap: 2px; max-height: 320px; overflow: auto; }
.ws-menu-item {
  display: flex; align-items: center; gap: var(--space-2); width: 100%;
  padding: var(--space-2); border: none; border-radius: var(--radius);
  background: transparent; color: var(--fg); text-align: left; cursor: pointer;
  transition: background var(--t-fast) var(--ease);
}
.ws-menu-item:hover { background: var(--surface-2); }
.ws-menu-item.active { background: var(--accent-50); }
.ws-menu-ava {
  flex: none; width: 28px; height: 28px; border-radius: var(--radius);
  display: grid; place-items: center; background: var(--accent);
  color: #fff; font-weight: 700; font-size: var(--fs-xs);
}
.ws-menu-item-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 1px; }
.ws-menu-item-name { font-size: var(--fs-sm); font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ws-menu-item-role { font-size: var(--fs-2xs); color: var(--muted); text-transform: capitalize; }
.ws-menu-check { flex: none; color: var(--accent); }
.ws-menu-footer { margin-top: var(--space-2); border-top: 1px solid var(--border); padding-top: var(--space-1); }
.ws-menu-create, .ws-menu-action {
  display: flex; align-items: center; gap: var(--space-2); width: 100%;
  padding: var(--space-2); border: none; border-radius: var(--radius);
  background: transparent; font-size: var(--fs-sm); font-weight: 500;
  text-align: left; cursor: pointer; color: var(--fg);
  transition: background var(--t-fast) var(--ease);
}
.ws-menu-create { color: var(--accent); font-weight: 600; }
.ws-menu-create:hover, .ws-menu-action:hover { background: var(--surface-2); }
.ws-menu-action.danger { color: var(--danger); }
.ws-menu-action.danger:hover { background: color-mix(in srgb, var(--danger) 12%, transparent); }

.sidebar-nav { list-style: none; padding: var(--space-2) var(--space-2) 0; }

.nav-item { margin-bottom: 2px; }

.nav-link {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: var(--space-2) 10px;
  border-radius: var(--radius);
  font-size: 13px;
  font-weight: 500;
  color: var(--muted);
  transition: background var(--t-fast), color var(--t-fast);
}
.nav-link svg { flex-shrink: 0; }
.nav-link:hover { background: var(--bg); color: var(--fg); }
/* Direct child only, so an active parent group doesn't tint its nested items. */
.nav-item.active > .nav-link {
  background: var(--accent-50);
  color: var(--accent);
}

/* ─── Nested sub-menu (e.g. Discord → its sub-areas) ───────────────────────
   Revealed only while the parent group is active (i.e. you're in that area). */
.nav-sub {
  list-style: none;
  margin: 2px 0 6px 22px;
  padding-left: 10px;
  border-left: 1px solid var(--border);
  display: none;
}
.nav-item.nav-group.active > .nav-sub { display: block; }
.nav-sub .nav-link { font-size: 12.5px; padding: 6px 10px; gap: 9px; }
.nav-sub .nav-link svg { width: 14px; height: 14px; opacity: 0.85; }

.nav-badge {
  margin-left: auto;
  /* COUNT badge, not an alarm — follows the accent tone (colour standard: red
     is reserved for overdue/destructive; nav counts were wrongly red). */
  background: var(--accent);
  color: white;
  font-size: var(--fs-2xs);
  font-weight: 700;
  padding: 1px 6px;
  border-radius: var(--radius-full);
  min-width: 18px;
  text-align: center;
}

.sidebar-bot-status {
  margin-top: auto;
  padding: var(--space-3);
  border-top: 1px solid var(--border);
}

.bot-status-card {
  background: var(--bg);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 10px var(--space-3);
  display: flex;
  align-items: center;
  gap: 10px;
}

.bot-avatar {
  width: 32px;
  height: 32px;
  background: var(--accent);
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
  position: relative;
}

.bot-avatar-inner { color: white; font-size: 14px; }

.status-dot {
  width: 10px;
  height: 10px;
  border-radius: 50%;
  border: 2px solid var(--bg);
  position: absolute;
  bottom: -1px;
  right: -1px;
}
.status-dot.online { background: var(--online); }
.status-dot.offline { background: var(--offline); }
.status-dot.idle { background: var(--idle); }

.bot-info { flex: 1; min-width: 0; }
.bot-name { font-size: 12px; font-weight: 600; color: var(--fg); }
.bot-status-text { font-size: 11px; color: var(--muted); }

/* ─── Topbar ────────────────────────────── */
.topbar {
  grid-column: 2;
  grid-row: 1;
  background: var(--surface);
  border-bottom: 1px solid var(--border);
  display: flex;
  align-items: center;
  padding: 0 var(--space-6);
  gap: var(--space-4);
  position: sticky;
  top: 0;
  z-index: var(--z-sticky);
}

.topbar-title {
  font-size: 14px;
  font-weight: 600;
  color: var(--fg);
}

/* Mobile brand block (logo + contextual title). Hidden on desktop; the spacer
   takes over the flex:1 role so search/actions stay right-aligned. */
.topbar-brand { display: none; align-items: center; gap: var(--space-2); min-width: 0; }
.topbar-brand-logo { display: inline-flex; flex-shrink: 0; line-height: 0; }
.topbar-brand-logo img { width: 26px; height: 26px; border-radius: var(--radius); display: block; }
.topbar-spacer { flex: 1; min-width: 0; }
/* Workspace name by the logo — native only (web has the sidebar switcher). */
.topbar-ws { display: none; }
body.native .topbar-ws {
  display: block; min-width: 0;
  font-size: var(--fs-lg); font-weight: 700; color: var(--fg);
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
/* On native the workspace name is the brand text → drop the per-route title so
   they don't compete (the bottom nav already shows the active section). */
body.native .topbar-title { display: none; }
/* The notification bell is redundant on native — the bottom-nav "Thông báo" tab
   (with its unread dot) + the full-page /notifications replace it. Web keeps it. */
body.native .topbar-notif { display: none; }
body.native .topbar-chat { display: none; }   /* web-only: native has the bottom-nav chat tab */

.topbar-search {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  /* White field to match the app's real inputs (.form-input) — the grey inset
     read as a different, disabled-looking control. The 1px border-2 delineates
     it from the topbar. */
  background: var(--surface);
  border: 1px solid var(--border-2);
  border-radius: var(--radius);
  /* Fixed control height (like .filter-input) — the inner borderless input has
     no height of its own, so without this the input's line-height + padding
     inflated the box (~48px). align-items:center vertically centres the input. */
  height: var(--control-h);
  padding: 0 var(--space-3);
  flex: 0 1 280px;
  transition: border-color var(--t-fast), box-shadow var(--t-fast);
  position: relative;
}
.topbar-search:focus-within {
  border-color: var(--border-2);
  box-shadow: none;
}
.topbar-search input {
  border: none;
  background: none;
  outline: none;
  font-size: var(--fs-sm);
  color: var(--fg);
  width: 100%;
}
/* Search boxes = a bordered surface wrapping a borderless <input> (+ icon). The
   inner input has no height of its own, so with the box's align-items:center it
   renders as a short line-height-tall field floating in a taller box (looks small,
   and only that line is the click target). Stretch every such input to the box
   height so it fills the surface and the whole control is the hit-area. ADD NEW
   SEARCH-BOX WRAPPERS HERE so every search input in the app stays consistent. */
.topbar-search input,
.filter-input input,
.chat-search-wrap .chat-search-input,
.am-search-wrap .am-search-input,
#modal-invite .inv-search input {
  align-self: stretch;
  height: auto;
  padding: 0;
  line-height: var(--control-h);
}
.topbar-search input:focus-visible {
  outline: none;
}
.topbar-search input::placeholder { color: var(--muted-2); }
.topbar-search svg { color: var(--muted-2); flex-shrink: 0; }

.topbar-actions { display: flex; align-items: center; gap: var(--space-2); }

.icon-btn {
  width: 36px;
  height: 36px;
  border-radius: var(--radius);
  border: none;
  background: transparent;
  display: flex;
  align-items: center;
  justify-content: center;
  color: var(--muted);
  position: relative;
  transition: background var(--t-fast), color var(--t-fast);
}
.icon-btn:hover { background: var(--bg); color: var(--fg); }

.notif-dot {
  width: 7px;
  height: 7px;
  /* accent-tone, not alarm red — unread is new-activity, follows the tone */
  background: var(--accent);
  border-radius: 50%;
  border: 2px solid var(--surface);
  position: absolute;
  top: 5px;
  right: 5px;
}

.user-avatar {
  width: 32px;
  height: 32px;
  border-radius: 50%;
  background: var(--accent);
  color: white;
  font-size: 12px;
  font-weight: 700;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  flex-shrink: 0;
}

.hamburger {
  display: none;
  width: 36px;
  height: 36px;
  border-radius: var(--radius);
  border: none;
  background: transparent;
  align-items: center;
  justify-content: center;
  color: var(--muted);
  cursor: pointer;
}

/* ─── Page content ──────────────────────── */
.page {
  grid-column: 2;
  grid-row: 2;
  /* Cap content at --content-dash (the ONE app-wide content width) and center it:
     side gutters grow once the viewport is wider, otherwise a flat 24px gutter.
     Was --content-max (1212), which read as "too wide" and inconsistent with the
     dashboard/list pages already on --content-dash. Full-bleed pages (chat/auth)
     override this with `padding: 0` and own their own cap. */
  padding-block: 24px;
  padding-inline: max(24px, calc((100% - var(--content-dash)) / 2));
  overflow-y: auto;
  overflow-x: hidden;
  /* CRITICAL: a grid item's default `min-height: auto` lets it grow past its
     `1fr` track to fit content. Then `.app`/body `overflow:hidden` clips that
     overflow instead of letting `.page` scroll → the bottom of tall pages is
     cut off and unreachable. `min-height: 0` pins `.page` to the 1fr track so
     it scrolls internally. (Chromium implies this for overflow:auto grid items;
     Safari/Firefox do not — hence the "list cut at bottom" bug.) */
  min-height: 0;
  /* Horizontal twin of the above: without `min-width: 0` the grid item grows to
     its content's min-content width and overflows the `minmax(0,1fr)` track on
     narrow screens (the ".page is 352px wide at a 320px viewport" overflow). */
  min-width: 0;
}

/* ─── Dedicated settings shell (body.settings-mode) ──────────────────────────
   On /settings, /integrations and /discord-settings the main sidebar + topbar
   are replaced by a slim header carrying only the app logo (→ back to the app),
   so the settings area reads as one focused place rather than a page bolted into
   the workspace chrome. The grid collapses to a single column. */
.settings-topbar { display: none; }
body.settings-mode .app { grid-template-columns: 1fr; }
body.settings-mode #app-sidebar,
body.settings-mode #app-topbar,
body.settings-mode #sidebar-overlay,
body.settings-mode .sidebar-overlay { display: none !important; }
body.settings-mode .settings-topbar {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  grid-column: 1;
  grid-row: 1;
  height: var(--topbar-h);
  /* Full-width header: logo flush-left, close flush-right, matching the
     full-width content/layout below (was capped to --content-max → on wide
     screens the logo + X were inset hundreds of px from the edges). */
  padding-inline: var(--space-6);
  background: var(--surface);
  border-bottom: 1px solid var(--border);
  position: sticky;
  top: 0;
  z-index: var(--z-sticky);
}
/* Reserve the scrollbar gutter so switching between sub-sections of different
   heights (e.g. the Discord bot tabs) doesn't toggle the scrollbar and shift the
   centered content + sidebar sideways ("nhảy nhẹ"). */
body.settings-mode #app-page { grid-column: 1; grid-row: 2; scrollbar-gutter: stable; }
.settings-topbar-back {
  display: inline-flex; align-items: center; gap: var(--space-2);
  text-decoration: none; color: var(--fg); font-weight: 700;
  border-radius: var(--radius); padding: 4px 8px 4px 4px; margin-left: -4px;
  transition: background var(--t-fast) var(--ease);
}
.settings-topbar-back:hover { background: var(--surface-2); }
.settings-topbar-back img { width: 26px; height: 26px; border-radius: 7px; display: block; }
.settings-topbar-word { font-family: var(--font-display); font-size: var(--fs-lg); letter-spacing: -0.01em; }
.settings-topbar-title {
  font-weight: 600; color: var(--muted); font-size: var(--fs-sm);
  padding-left: var(--space-3); border-left: 1px solid var(--border);
}
.settings-topbar-close {
  margin-left: auto; display: inline-flex; align-items: center; justify-content: center;
  width: 34px; height: 34px; border-radius: var(--radius); color: var(--muted);
  transition: background var(--t-fast) var(--ease), color var(--t-fast) var(--ease);
}
.settings-topbar-close:hover { background: var(--surface-2); color: var(--fg); }
@media (max-width: 640px) {
  .settings-topbar-title { display: none; }
}

/* Floating bottom action bars (bulk-select on /tasks, /team, project-detail)
   are `position:fixed` and live in the #app-modals portal, so they overlay the
   scroller with no reserved space and hide the last rows / pager when scrolled
   to the bottom. Whenever one is visible, pad the scroller so its content can
   always scroll clear of the bar. One rule covers every page. */
body:has(.bulk-bar.visible) .page,
body:has(.member-bulk-bar.visible) .page {
  padding-bottom: 96px;
}

/* ─── Per-section save buttons → replaced by the shared floating save bar ─────
   The save bar (src/lib/save-bar.js) now appears on any edit + handles saving on
   all the section-edit pages, so the inline per-section "Lưu" buttons are
   redundant. Hidden (NOT removed) because attachSectionSaveBar still clicks them
   programmatically to run each section's existing save logic; click() works on
   display:none. Targeted by the save fn name so no other primary button is hit. */
.btn-primary[onclick^="saveProfile"],
.btn-primary[onclick^="saveSettings"],
.btn-primary[onclick^="saveSec"],
.btn-primary[onclick^="saveDiscordSettings"],
.btn-primary[onclick^="saveProjectSettings"] { display: none !important; }
/* Drop the now-orphaned "Hủy" sitting beside a hidden profile save button. */
#section-profile div:has(> .btn-primary[onclick^="saveProfile"]) .btn-ghost { display: none; }

/* ─── Bulk-action bar (GLOBAL + theme-safe) ──────────────────────────────────
   Floating multi-select action bar, shared by /tasks + project tasks tab. Lives
   here (not in a page's injectPageCSS) so it's styled on EVERY page — the project
   tab's bar was unstyled on a direct visit. Uses surface tokens so it works in
   both light + dark mode (the old `background: var(--fg)` flipped to a washed-out
   pale bar in dark mode). Matches the shared save-bar pill. */
.bulk-bar {
  position: fixed; bottom: -80px; left: 50%; transform: translateX(-50%);
  background: var(--surface); color: var(--fg);
  border: 1px solid var(--border-2); border-radius: var(--radius-full);
  padding: 8px 10px 8px var(--space-4);
  display: flex; align-items: center; gap: var(--space-3);
  box-shadow: var(--shadow-lg); z-index: 95;   /* below the modal/popover dialog layer (100/101), above content */
  transition: bottom 0.22s ease; white-space: nowrap; min-width: 340px;
}
.bulk-bar.visible { bottom: 24px; }
.bulk-count { font-size: 13px; font-weight: 600; color: var(--fg); padding-right: var(--space-3); border-right: 1px solid var(--border); }
.bulk-actions { display: flex; gap: 6px; }
.bulk-btn {
  padding: 5px var(--space-3); border-radius: var(--radius);
  border: 1px solid var(--border); background: var(--surface-2); color: var(--fg);
  font-size: 12px; font-weight: 500; cursor: pointer;
  transition: background var(--t-fast), border-color var(--t-fast);
}
.bulk-btn:hover { background: var(--bg); border-color: var(--border-2); }
.bulk-btn-danger { color: var(--danger); border-color: color-mix(in srgb, var(--danger) 30%, transparent); }
.bulk-btn-danger:hover { background: color-mix(in srgb, var(--danger) 12%, transparent); }
.bulk-dismiss {
  margin-left: var(--space-1); padding: var(--space-1); border: none; background: none;
  color: var(--muted); cursor: pointer; border-radius: var(--radius);
  transition: background var(--t-fast), color var(--t-fast);
}
.bulk-dismiss:hover { color: var(--fg); background: var(--surface-2); }
@media (max-width: 600px) {
  .bulk-bar {
    min-width: 0; left: 12px; right: 12px; transform: none;
    flex-wrap: wrap; justify-content: center; padding: 10px var(--space-3); gap: var(--space-2);
  }
  .bulk-bar.visible { bottom: 12px; }
  .bulk-count { width: 100%; text-align: center; }
  .bulk-actions { flex-wrap: wrap; justify-content: center; }
  .bulk-btn { flex: 1 1 auto; min-height: 36px; }
}

/* Bulk-action popover (GLOBAL) — replaces native prompt/confirm; theme-safe. */
.bulk-popover {
  position: fixed; z-index: var(--z-modal); min-width: 200px; max-height: 320px; overflow-y: auto;
  background: var(--surface); border: 1px solid var(--border-2); border-radius: var(--radius-lg);
  box-shadow: var(--shadow-lg); padding: 6px;
}
.bulk-popover-title { font-size: 12px; font-weight: 600; color: var(--muted); padding: 6px var(--space-2) var(--space-1); }
.bulk-popover-desc { font-size: 12px; color: var(--muted); padding: 0 var(--space-2) var(--space-2); }
.bulk-popover-item {
  display: block; width: 100%; text-align: left; padding: 7px var(--space-2); border: none;
  background: none; border-radius: var(--radius); font-size: 13px; color: var(--fg); cursor: pointer;
}
.bulk-popover-item:hover { background: var(--accent-50); color: var(--accent); }
.bulk-popover-empty { font-size: 12px; color: var(--muted); padding: var(--space-2); }
.bulk-popover-row { display: flex; gap: 6px; padding: var(--space-1); }
.bulk-popover-cancel, .bulk-popover-danger { flex: 1; padding: 7px; border-radius: var(--radius); font-size: 13px; font-weight: 600; cursor: pointer; border: 1px solid var(--border-2); background: var(--surface); color: var(--fg); }
.bulk-popover-danger { background: var(--danger); border-color: var(--danger); color: #fff; }
@media (max-width: 600px) { .bulk-popover { max-width: calc(100vw - 24px); } }

/* ─── Scroll utilities ──────────────────── */
.table-scroll {
  overflow-x: auto;
  /* Explicit overflow-y:hidden (not the 'auto' it would otherwise compute to when
     overflow-x is auto) keeps this a HORIZONTAL-only scroller — content fits its
     own height so nothing clips, and a vertical touch swipe bubbles to the page
     instead of getting trapped in here (the iOS "can't scroll the list" bug). */
  overflow-y: hidden;
  -webkit-overflow-scrolling: touch;
}
.table-scroll::-webkit-scrollbar { height: 6px; }
.table-scroll::-webkit-scrollbar-track { background: var(--bg); }
.table-scroll::-webkit-scrollbar-thumb { background: var(--border-2); border-radius: 3px; }

.page-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: var(--space-6);
}

.page-title { font-size: 20px; font-weight: 700; color: var(--fg); letter-spacing: -0.3px; }
.page-subtitle { font-size: 13px; color: var(--muted); margin-top: 2px; }

/* ─── Buttons ───────────────────────────── */
.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 6px;
  height: var(--control-h);
  padding: 0 14px;
  border-radius: var(--radius);
  font-size: var(--fs-sm);
  font-weight: 500;
  border: 1px solid transparent;   /* keeps bordered/borderless variants the same height */
  cursor: pointer;
  transition: background var(--t-fast), box-shadow var(--t-fast), transform 0.08s, border-color var(--t-fast);
  white-space: nowrap;
  line-height: 1;
}
.btn:active { transform: scale(0.98); }
.btn svg { flex-shrink: 0; }
/* Disabled = faded + not-allowed (design-system rule; was unimplemented, so a
   disabled primary button looked fully active). Keeps the tinted bg so it reads
   correctly in dark mode too. */
.btn:disabled, .btn[disabled] { opacity: 0.5; cursor: not-allowed; }
.btn:disabled:active, .btn[disabled]:active { transform: none; }

.btn-primary {
  background: var(--accent);
  color: white;
}
.btn-primary:hover { background: var(--accent-600); }

.btn-secondary {
  background: var(--surface);
  color: var(--fg-2);
  border: 1px solid var(--border-2);
}
.btn-secondary:hover { background: var(--bg); }

.btn-ghost {
  background: transparent;
  color: var(--muted);
}
.btn-ghost:hover { background: var(--bg); color: var(--fg); }

.btn-danger {
  background: var(--danger-bg);
  color: var(--danger);
}
.btn-danger:hover { background: #fca5a5; }

.btn-sm { height: var(--control-h-sm); padding: 0 10px; font-size: var(--fs-xs); }
.btn-icon { width: var(--control-h); height: var(--control-h); padding: 0; }

/* ─── Badges / Pills ────────────────────── */
.badge {
  display: inline-flex;
  align-items: center;
  gap: var(--space-1);
  padding: 2px var(--space-2);
  border-radius: var(--radius-full);
  font-size: 12px;
  font-weight: 600;
  line-height: 1.4;
  white-space: nowrap;   /* never wrap — a wrapped pill loses its shape (esp. in tight table cells on mobile) */
}
.badge-dot {
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: currentColor;
  flex-shrink: 0;        /* keep the status dot perfectly round next to nowrap label */
}

/* Each variant gets a hue-matched 1px edge so the soft pale tints stay clearly
   distinct from one another (and from adjacent pills) on small/dense screens —
   pale fills alone were easy to confuse. box-sizing:border-box → no size change. */
.badge-default   { background: var(--surface-2); color: var(--fg-2); border: 1px solid var(--border); }
.badge-accent    { background: var(--accent-50);  color: var(--accent);  border: 1px solid color-mix(in srgb, var(--accent) 28%, transparent); }
.badge-success   { background: var(--success-bg); color: var(--success); border: 1px solid color-mix(in srgb, var(--success) 30%, transparent); }
.badge-warning   { background: var(--warning-bg); color: var(--warning); border: 1px solid color-mix(in srgb, var(--warning) 32%, transparent); }
/* Softened: full --danger-bg (#fee2e2) read as shouting next to red text; a
   10% mix keeps the state legible without the hot-pink surface. */
.badge-danger    { background: color-mix(in srgb, var(--danger) 10%, var(--surface));  color: var(--danger);  border: 1px solid color-mix(in srgb, var(--danger) 24%, transparent); }
.badge-info      { background: var(--info-bg);    color: var(--info);    border: 1px solid color-mix(in srgb, var(--info) 30%, transparent); }

/* Priority */
.priority-high   { color: var(--danger); }
.priority-medium { color: var(--warning); }
.priority-low    { color: var(--muted); }

/* ─── Stats cards ───────────────────────── */
.stats-grid {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: var(--space-4);
  margin-bottom: var(--space-6);
}

.stat-card {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  padding: var(--space-5);
}

.stat-label {
  font-size: 12px;
  font-weight: 500;
  color: var(--muted);
  text-transform: uppercase;
  letter-spacing: 0.4px;
  margin-bottom: var(--space-2);
}

.stat-value {
  font-size: 26px;
  font-weight: 700;
  color: var(--fg);
  letter-spacing: -0.5px;
  line-height: 1;
  font-variant-numeric: tabular-nums;
}

.stat-change {
  font-size: 12px;
  color: var(--muted);
  margin-top: 6px;
  display: flex;
  align-items: center;
  gap: var(--space-1);
}

.stat-change.up   { color: var(--success); }
.stat-change.down, .stat-change.dn { color: var(--danger); }
.stat-change.neutral               { color: var(--muted); }

.stat-icon {
  width: 36px;
  height: 36px;
  border-radius: var(--radius);
  display: flex;
  align-items: center;
  justify-content: center;
  margin-bottom: var(--space-3);
}

/* Horizontal compact stat — used in overview strip + team summary */
.stat-mini {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  padding: 14px var(--space-4);
  display: flex;
  align-items: center;
  gap: var(--space-3);
}
.stat-mini-icon {
  width: 36px;
  height: 36px;
  border-radius: var(--radius);
  display: flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
}
.stat-mini-body { min-width: 0; }
.stat-mini-value {
  font-size: var(--fs-xl);
  font-weight: 700;
  line-height: 1;
  color: var(--fg);
  font-variant-numeric: tabular-nums;
}
.stat-mini-label { font-size: var(--fs-xs); color: var(--muted); margin-top: 3px; }

/* ─── Cards ─────────────────────────────── */
.card {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
}

.card-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: var(--space-4) var(--space-5);
  border-bottom: 1px solid var(--border);
}

/* Canonical card-header title — ONE size app-wide. Was 14px/600 (same as body
   text, so headers didn't read as headers); now --fs-lg/700 with the display
   face (applied by the [class*="card-title"] rule above). Every card header +
   panel title snaps to this; page-local title classes must match, not re-set. */
.card-title { font-size: var(--fs-lg); font-weight: 700; color: var(--fg); line-height: var(--lh-tight); }
.card-subtitle { font-size: var(--fs-xs); color: var(--muted); margin-top: 2px; font-weight: 400; }
.card-body { padding: var(--space-5); }

/* ─── Two-column layout ─────────────────── */
.two-col {
  display: grid;
  grid-template-columns: 1fr 340px;
  gap: var(--space-5);
}

.three-col {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: var(--space-5);
}

/* ─── Task items ────────────────────────── */
.task-list { list-style: none; }

.task-item {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  padding: var(--space-3) 0;
  border-bottom: 1px solid var(--border);
  transition: background 0.1s;
}
.task-item:last-child { border-bottom: none; }

.task-checkbox {
  width: 18px;
  height: 18px;
  border: 2px solid var(--border-2);
  border-radius: 5px;
  flex-shrink: 0;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  transition: all var(--t-fast);
  background: transparent;
  -webkit-appearance: none;
  appearance: none;
}
.task-checkbox:checked {
  background: var(--accent);
  border-color: var(--accent);
}
.task-checkbox:checked::after {
  content: '';
  width: 5px;
  height: 9px;
  border: 2px solid white;
  border-top: none;
  border-left: none;
  transform: rotate(45deg) translate(-1px, -1px);
  display: block;
}

.task-content { flex: 1; min-width: 0; }
.task-title { font-size: 13px; font-weight: 500; color: var(--fg); }
.task-title.done { text-decoration: line-through; color: var(--muted); }
.task-meta {
  display: flex;
  align-items: center;
  gap: 10px;
  margin-top: var(--space-1);
  font-size: 12px;
  color: var(--muted);
}
.task-meta-icon { display: flex; align-items: center; gap: var(--space-1); }

.task-assignee {
  width: 24px;
  height: 24px;
  border-radius: 50%;
  font-size: 11px;
  font-weight: 700;
  display: flex;
  align-items: center;
  justify-content: center;
  color: white;
  flex-shrink: 0;
}

/* ─── Avatar initials colors ─────────────────────────────────────────────────
   Muted, harmonious identity palette. The old set was full-saturation
   blurple/green/amber/red/blue/violet — six loud hues repeated on every avatar
   across every page, the main source of "too many colors". These are
   desaturated, medium-dark tones (white text stays legible) that read as one
   cohesive family while still distinguishing users. */
.av-0 { background: #5a61b8; }  /* indigo  */
.av-1 { background: #4e7e6c; }  /* sage    */
.av-2 { background: #927244; }  /* ochre   */
.av-3 { background: #9e565a; }  /* clay    */
.av-4 { background: #4e6f90; }  /* steel   */
.av-5 { background: #74608f; }  /* mauve   */

/* ─── Shared avatar (lib/avatar.js — avatarHtml) ──────────────────────────────
   ONE avatar recipe for every page. Pages must NOT hand-roll initials/colors:
   use avatarHtml(user) which pairs this class with the av-N identity palette
   above (or the user's avatar_url). Sizes via modifier, never inline width. */
.t-av {
  width: 28px; height: 28px; border-radius: 50%; flex-shrink: 0;
  display: inline-flex; align-items: center; justify-content: center;
  font-size: var(--fs-2xs); font-weight: 700; color: #fff;
  background-size: cover; background-position: center;
  overflow: hidden; user-select: none;
}
.t-av-sm { width: 22px; height: 22px; font-size: 9px; }
.t-av-lg { width: 36px; height: 36px; font-size: var(--fs-sm); }
.t-av-xl { width: 44px; height: 44px; font-size: var(--fs-lg); }

/* ─── Presence status dot (shared — was duplicated per page) ── */
.status-cell { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; }
.status-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; background: var(--offline); }
.status-dot.online  { background: var(--online); }
.status-dot.idle    { background: var(--idle); }
.status-dot.offline { background: var(--offline); }
.status-dot.dnd     { background: var(--dnd); }

/* ─── Stat strip (shared grid wrapper for .stat-mini cards) ───────────────────
   .stat-mini (below, ~L1100) is THE stat-card primitive — pages must not invent
   .xx-stat variants. This wrapper is the shared responsive grid; render cards
   with lib/stat-cards.js (statStripHtml). Icon tiles stay NEUTRAL
   (--surface-2/--muted) per the minimal-colour standard; colour only for state. */
.stat-strip { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: var(--space-3); }
@media (max-width: 640px) { .stat-strip { grid-template-columns: 1fr 1fr; } }

/* ─── Progress bar ──────────────────────── */
.progress-bar {
  height: 6px;
  background: var(--border);
  border-radius: var(--radius-full);
  overflow: hidden;
}
.progress-fill {
  height: 100%;
  background: var(--accent);
  border-radius: var(--radius-full);
  /* No width transition: these bars are rendered once with their final inline
     width (`width:43%`). With a transition, any layout pass that resolves the
     bar's width from 0→N animates it, so a paint captured mid-animation shows a
     fill that's SHORTER than the % label beside it (43% label, half-filled bar).
     A data bar must always read true; the animation bought nothing here. */
}
/* COLOUR STANDARD: quantitative fills (bars/rings) are ALWAYS accent — green is
   reserved for done-state glyphs (checks, toasts, row tints), never measurement.
   The .success class is kept for callers but no longer changes the hue. */
.progress-fill.success { background: var(--accent); }
.progress-fill.warning { background: var(--warning); }

/* ─── Checkbox (shared component) ─────────────────────────────────────────────
   ONE data-list checkbox everywhere, matching the /tasks selection checkbox (the
   reference). Use class="checkbox" for a row checkbox and class="row-select-all"
   for a header select-all (it also renders an indeterminate dash). `.task-checkbox`
   is the legacy alias kept for the task lists. Global (not page-scoped) so every
   list gets the same control without depending on /tasks being visited first. */
.checkbox, .task-checkbox, .row-select-all {
  appearance: none; -webkit-appearance: none; margin: 0; padding: 0;
  width: 18px; height: 18px; flex-shrink: 0; border-radius: 4px;
  border: 1.5px solid var(--border-2); background: var(--surface);
  display: inline-flex; align-items: center; justify-content: center;
  cursor: pointer; vertical-align: middle; position: relative;
  transition: background-color var(--t-fast), border-color var(--t-fast);
  touch-action: manipulation;
}
.checkbox:hover, .task-checkbox:hover, .row-select-all:hover { border-color: var(--accent); }
.checkbox:checked, .task-checkbox:checked, .row-select-all:checked,
.checkbox:indeterminate, .row-select-all:indeterminate { background: var(--accent); border-color: var(--accent); }
.checkbox:checked::after, .task-checkbox:checked::after, .row-select-all:checked::after {
  content: ''; width: 4.5px; height: 8px; border: solid #fff; border-width: 0 2px 2px 0;
  transform: rotate(45deg); margin-top: -1.5px;
}
.checkbox:indeterminate::after, .row-select-all:indeterminate::after {
  content: ''; width: 8px; height: 2px; background: #fff; border-radius: 1px;
}
/* ── Disambiguate select vs done (option C) ─────────────────────────────────
   Two controls stay, but they can no longer be read as twins:
   • bulk-select checkbox = SMALL + DIMMED at rest; full strength on row
     hover, when checked, or on keyboard focus.
   • done circle = LARGER, and hovering it PREVIEWS the completed state
     (green ring + green check appears) so its job is obvious before you click. */
.data-table tbody td.bulk-cell .task-checkbox,
.data-table tbody td.pd-bulk-cell .task-checkbox {
  width: 14px; height: 14px; opacity: .35;
  transition: opacity var(--t-fast) var(--ease);
}
.data-table tbody tr:hover td.bulk-cell .task-checkbox,
.data-table tbody tr:hover td.pd-bulk-cell .task-checkbox,
.data-table tbody td.bulk-cell .task-checkbox:checked,
.data-table tbody td.pd-bulk-cell .task-checkbox:checked,
.data-table tbody td.bulk-cell .task-checkbox:focus-visible,
.data-table tbody td.pd-bulk-cell .task-checkbox:focus-visible { opacity: 1; }
/* Done circle: bigger + hover previews the done state (green check fades in).
   ::before = an invisible ~40px hit halo, so a slightly-off tap still lands on
   the button instead of the row (which opens the task page). */
.data-table tbody .done-circle::before,
.data-table tbody .task-check::before {
  content: ''; position: absolute; inset: -10px; border-radius: 50%;
}
.data-table tbody .done-circle,
.data-table tbody .task-check {
  width: 20px; height: 20px; position: relative;
  transition: border-color var(--t-fast) var(--ease), background var(--t-fast) var(--ease), transform var(--t-fast) var(--ease);
}
.data-table tbody .done-circle:hover:not(.done):not(.cancelled),
.data-table tbody .task-check:hover:not(.done):not(.cancelled) {
  border-color: var(--success); background: var(--success-bg); transform: scale(1.08);
}
.data-table tbody .done-circle:hover:not(.done):not(.cancelled)::after,
.data-table tbody .task-check:hover:not(.done):not(.cancelled)::after {
  content: '✓'; position: absolute; inset: 0;
  display: flex; align-items: center; justify-content: center;
  font-size: 11px; font-weight: 800; color: var(--success); line-height: 1;
}

.checkbox:focus-visible, .task-checkbox:focus-visible, .row-select-all:focus-visible {
  outline: 2px solid var(--accent); outline-offset: 2px;
}
.checkbox:disabled { opacity: .5; cursor: not-allowed; }
@media (max-width: 640px) {
  .checkbox, .task-checkbox, .row-select-all { width: 22px; height: 22px; }
}

/* ─── Table ─────────────────────────────── */
.data-table { width: 100%; border-collapse: collapse; }
.data-table th {
  text-align: left;
  font-size: var(--fs-xs);
  font-weight: 600;
  color: var(--muted);
  /* STANDARD: table COLUMN HEADERS are sentence case, not ALL-CAPS — the
     uppercase headers read as shouty/dated. Sentence case + muted is calmer and
     more legible (esp. for Vietnamese diacritics). Applies to every .data-table. */
  text-transform: none;
  letter-spacing: normal;
  padding: 10px var(--space-3);
  border-bottom: 1px solid var(--border);
  background: var(--bg);
}

/* ─── Click-to-sort column headers (shared: /tasks + project tasks) ─────────── */
th.th-sort { cursor: pointer; user-select: none; white-space: nowrap; transition: color var(--t-fast) var(--ease); }
th.th-sort:hover { color: var(--fg-2); }
th.th-sort.th-sort-asc, th.th-sort.th-sort-desc { color: var(--accent); }
th.th-sort .th-sort-ind { margin-left: 4px; font-size: 9px; line-height: 1; display: inline-block; }
/* The ↕ hint shows BY DEFAULT (faint) so users know the column is clickable;
   it brightens on hover and becomes a directional ↑/↓ when that column is the
   active sort. */
th.th-sort .th-sort-ind::after { content: '↕'; opacity: 0.32; }
th.th-sort:hover .th-sort-ind::after { content: '↕'; opacity: 0.6; }
th.th-sort.th-sort-asc  .th-sort-ind::after { content: '↑'; opacity: 1; }
th.th-sort.th-sort-desc .th-sort-ind::after { content: '↓'; opacity: 1; }

/* ─── Global Custom Select Popover (.pf-pop) ──────────────────────────────── */
.pf-pop { position: relative; display: inline-flex; }
.pf-menu {
  z-index: 1000;
  background: var(--surface); border: 1px solid var(--border);
  border-radius: var(--radius); box-shadow: var(--shadow-lg);
  min-width: 140px; max-width: 280px; width: max-content;
  max-height: 50vh; overflow-y: auto; padding: 4px 0;
}
.pf-opt {
  display: flex; align-items: center; gap: 6px;
  width: 100%; padding: 6px 10px;
  background: none; border: none; font-size: var(--fs-sm); color: var(--fg);
  text-align: left; cursor: pointer; transition: background var(--t-fast);
}
.pf-opt:hover { background: var(--bg); }
.pf-opt.is-on { color: var(--accent); font-weight: 600; }
.pf-opt-check { width: 14px; height: 14px; opacity: 0; color: var(--accent); display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.pf-opt.is-on .pf-opt-check { opacity: 1; }
.pf-opt-name { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }

/* ─── Priority chip ─────────────────────────────────────────────────────────
   Priority is shown as a flag ICON + coloured label (NOT a pill) so it reads as
   clearly different from the STATUS pill (`.badge` + dot). Both being pills was
   confusing. Shared by /tasks + project task table + recent-tasks. */
.prio-chip { display: inline-flex; align-items: center; font-size: var(--fs-sm); font-weight: 400; white-space: nowrap; }
.prio-chip.prio-urgent { color: var(--prio-urgent); }
.prio-chip.prio-high   { color: var(--prio-high); }
.prio-chip.prio-medium { color: var(--prio-medium); }
.prio-chip.prio-low    { color: var(--prio-low); }
.prio-chip.prio-none   { color: var(--muted-2); }
/* Completed rows: dim the priority chip too (was staying bright while the title
   got struck through). Covers both task tables + kanban done cards. */
.st-done .prio-chip, tr.row-done .prio-chip, .kanban-card.is-done .kanban-prio-chip { opacity: .45; }

.data-table td {
  padding: var(--space-3);
  border-bottom: 1px solid var(--border);
  font-size: 13px;
  color: var(--fg-2);
  vertical-align: middle;
}
/* Long task titles clamp to 2 lines + ellipsis instead of wrapping into a tall,
   ~1-word-per-line column on narrow screens (shared by /tasks + project tasks). */
.tt-clamp {
  /* Two-line clamp inside the title cell (matches the project list's
     .pd-title-text). The old collapse-to-a-sliver problem is prevented by the
     title COLUMN carrying a min-width (td.task-name) — with that floor the clamp
     is safe and long titles wrap to 2 lines instead of truncating on one. */
  flex: 1; min-width: 0;
  display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
  overflow: hidden; text-overflow: ellipsis; overflow-wrap: anywhere;
}

/* ─── Overview task rows (GLOBAL + shared) ───────────────────────────────────
   The compact task-list row used by BOTH the workspace overview ("Nhiệm vụ hôm
   nay") AND the project overview ("Nhiệm vụ gần đây") so they stay identical.
   Lives here (not a page's injectPageCSS) so both inherit one definition. */
/* Shared "recent tasks" MINI TABLE-LIST row (src/lib/task-row.js). A CSS grid
   with FIXED columns so every row lines up like a table (status · title ·
   [#project] · priority dot · due). Used by workspace overview, project overview
   tab + member detail — one consistent layout everywhere. */
/* 2-line task row (title + meta) — mirrors the /work-reports/plan assigned-task
   rows so a recent task reads identically everywhere it's listed. */
.ov-task-row {
  display: flex; align-items: flex-start; gap: 10px;
  /* Full-bleed rows: the row bleeds OUT into its container's horizontal padding
     (so the divider/hover span the whole card edge-to-edge, like /work-reports/plan)
     while its content stays inset. Each list wrapper sets --ov-pad to the
     container's own horizontal padding; unset = flush (no bleed). */
  /* Compact vertical rhythm — was --space-3 (12px), which stacked with the
     title + meta line made each row ~68px, oversized for the app's scale. */
  padding: var(--space-2) var(--ov-pad, 0px);
  margin-inline: calc(-1 * var(--ov-pad, 0px));
  border-bottom: 1px solid var(--border);
  transition: background 0.1s; text-decoration: none; color: inherit;
}
.ov-task-row:last-child { border-bottom: none; }
.ov-task-status { display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; margin-top: 1px; }
.ov-task-status .task-status-ico { width: 15px; height: 15px; }
.ov-task-row > .task-checkbox, .ov-task-row > .done-circle { flex-shrink: 0; margin-top: 1px; }
.ov-task-main { flex: 1; min-width: 0; }
.ov-task-title { font-size: var(--fs-md); font-weight: 500; line-height: var(--lh-tight); color: var(--fg); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ov-task-title.done, .ov-task-row.is-done .ov-task-title { text-decoration: line-through; color: var(--muted); }
.ov-task-row:hover .ov-task-title:not(.done) { text-decoration: underline; text-decoration-color: var(--muted-2); text-underline-offset: 2px; }
/* Meta line: status · #project · priority · due · tags */
.ov-task-meta { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-1) var(--space-3); margin-top: 2px; font-size: var(--fs-xs); }
.ov-task-meta .sb { display: inline-flex; align-items: center; gap: 4px; }
.ov-task-status-txt { color: var(--muted); }
.ov-task-proj { color: var(--muted); font-family: var(--font-mono); font-size: var(--fs-xs); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 200px; }
.ov-task-due { color: var(--muted); display: inline-flex; align-items: center; gap: 4px; white-space: nowrap; }
.ov-task-due.overdue { color: var(--danger); font-weight: 600; }
.ov-task-due.today   { color: var(--warning); font-weight: 600; }
.ov-task-due svg { opacity: .7; }
.ov-task-meta .prio-chip { font-size: var(--fs-xs); }
/* Assignee chip (avatar + name) in the meta line */
.ov-task-assignee { display: inline-flex; align-items: center; gap: 4px; color: var(--fg-2); }
.ov-task-assignee .ov-av { width: 16px; height: 16px; border-radius: 50%; font-size: 8px; font-weight: 700; color: #fff; display: inline-flex; align-items: center; justify-content: center; background-size: cover; background-position: center; flex-shrink: 0; }
.ov-task-assignee-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 120px; }
.ov-task-assignee-more { color: var(--muted); }
.ov-task-assignee.is-unassigned { color: var(--muted-2); }
/* Overdue = a light danger "#hashtag" beside the task title (kept for /tasks). */
/* Quieter (500): the due column already carries the red date — this inline tag
   is a secondary echo, not the alarm itself. */
.task-overdue-tag { color: var(--danger); font-size: var(--fs-xs); font-weight: 500; white-space: nowrap; flex-shrink: 0; }
/* Legacy status dot (kept for any caller still using it). */
.ov-task-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
/* Columnar header retired (rows carry their own meta). */
.ov-task-head { display: none; }
/* Round per-row complete toggle — ONE shared style so the "done" control reads
   the same on /tasks, the workspace overview and the project overview (empty
   circle → green-filled + check when done; muted-filled when cancelled). */
.done-circle {
  width: 18px; height: 18px; flex-shrink: 0; border-radius: 50%;
  border: 1.5px solid var(--border-2); background: var(--surface);
  display: inline-flex; align-items: center; justify-content: center;
  color: #fff; cursor: pointer; padding: 0;
  transition: background-color var(--t-fast), border-color var(--t-fast), color var(--t-fast), transform var(--t-fast);
  touch-action: manipulation;
}
.done-circle:hover:not(.done):not(.cancelled) { border-color: var(--success); }
.done-circle.done { background: var(--success); border-color: var(--success); }
.done-circle.cancelled { background: var(--muted-2); border-color: var(--muted-2); color: #fff; }

/* Quick-template cards in the new-task modal (GLOBAL so /tasks + the shared
   new-task-modal lib used by the project page render identically). */
.template-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: var(--space-2); margin-bottom: var(--space-4); }
.template-card {
  border: 1px solid var(--border); border-radius: var(--radius); padding: 10px var(--space-3);
  cursor: pointer; transition: border-color var(--t-fast), background var(--t-fast); text-align: left;
  background: var(--surface);
}
.template-card:hover { border-color: var(--accent); background: var(--accent-50); }
.template-card-name { font-size: 12px; font-weight: 600; color: var(--fg); margin-bottom: 2px; }
.template-card-desc { font-size: 12px; color: var(--muted); }

/* ── Due-date CHIP (src/lib/due-chip.js) — THE due-date control ─────────────
   One pill, one click: empty = dashed "Chưa có hạn chót" → click opens the
   calendar directly; set = solid pill with the date + inline ×. The raw date
   input (+ its flatpickr altInput) hides inside .due-chip-io. */
.due-chip-row { display: flex; align-items: center; gap: var(--space-2); flex-wrap: nowrap; position: relative; min-width: 0; }
.due-chip { min-width: 0; flex-shrink: 1; }
.due-chip .dc-txt { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
.due-chip-io { position: absolute; width: 0; height: 0; overflow: hidden; opacity: 0; pointer-events: none; }
/* display:inline-flex would override the [hidden] UA rule — make hidden win. */
.due-chip[hidden], .due-chip-sep[hidden] { display: none !important; }
.btn[hidden] { display: none !important; }  /* .btn's inline-flex also beat [hidden] */
.due-chip {
  display: inline-flex; align-items: center; gap: 7px;
  height: var(--control-h-sm); padding: 0 12px; border-radius: var(--radius-full);
  border: 1.5px dashed var(--border-2); background: transparent;
  color: var(--muted); font-size: var(--fs-sm); font-family: var(--font);
  cursor: pointer; white-space: nowrap;
  transition: border-color var(--t-fast) var(--ease), color var(--t-fast) var(--ease), background var(--t-fast) var(--ease);
}
.due-chip:hover { border-color: var(--accent-300); color: var(--accent); background: var(--accent-50); }
.due-chip:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.due-chip.is-set { border-style: solid; border-color: var(--border); background: var(--surface-2); color: var(--fg); font-weight: 500; }
.due-chip.is-overdue { color: var(--danger); border-color: color-mix(in srgb, var(--danger) 40%, transparent); }
.due-chip .dc-x {
  display: inline-flex; align-items: center; justify-content: center;
  width: 16px; height: 16px; border-radius: var(--radius-full);
  color: var(--muted-2); margin-right: -5px; flex-shrink: 0;
  transition: background var(--t-fast), color var(--t-fast);
}
.due-chip .dc-x:hover { background: var(--danger-bg); color: var(--danger); }
.due-chip-ghost { height: 26px; padding: 0 10px; font-size: var(--fs-xs); }
.due-chip .dc-prefix { color: var(--muted); font-weight: 400; }
/* Mode switch inside the due-chip calendar footer (Một ngày | Khoảng ngày). */
.dcr-foot { display: flex; gap: 3px; padding: 6px; border-top: 1px solid var(--border); }
.dcr-mode-btn { flex: 1; height: var(--control-h-sm); border: none; background: none; border-radius: calc(var(--radius) - 2px);
  font-family: var(--font); font-size: var(--fs-xs); font-weight: 600; color: var(--muted); cursor: pointer;
  transition: background var(--t-fast), color var(--t-fast); }
.dcr-mode-btn:hover { color: var(--fg); background: var(--surface-2); }
.dcr-mode-btn.active { background: var(--accent-50); color: var(--accent); }
.due-chip-sep { color: var(--muted-2); font-size: var(--fs-xs); flex-shrink: 0; }
@media (max-width: 768px) { .due-chip { height: 34px; } }

/* Due-date widget — Basecamp-style progressive disclosure: NO date by default
   (just a subtle "add due date" button), which reveals the date field(s). An
   optional "add start date" toggle extends it to a range; ✕ clears it. */
.due-add-btn {
  display: inline-flex; align-items: center; gap: 8px;
  height: var(--control-h); padding: 0 var(--control-pad-x);
  background: var(--surface); border: 1px dashed var(--border-2);
  border-radius: var(--radius); cursor: pointer;
  font-size: var(--fs-sm); font-weight: 500; color: var(--muted);
  transition: border-color var(--t-fast), color var(--t-fast), background var(--t-fast);
}
.due-add-btn:hover { border-color: var(--accent); color: var(--accent); background: var(--accent-50); }
.due-add-btn svg { flex-shrink: 0; opacity: 0.8; }
.due-reveal[hidden] { display: none; }
.due-inputs { display: flex; align-items: flex-end; gap: var(--space-2); }
.due-col { flex: 1 1 0; display: flex; flex-direction: column; gap: var(--space-1); min-width: 0; }
.due-clear {
  flex-shrink: 0; width: var(--control-h); height: var(--control-h);
  display: inline-flex; align-items: center; justify-content: center;
  background: var(--surface); border: 1px solid var(--border);
  border-radius: var(--radius); cursor: pointer; color: var(--muted);
  transition: color var(--t-fast), border-color var(--t-fast), background var(--t-fast);
}
.due-clear:hover { color: var(--danger); border-color: var(--danger); background: var(--danger-bg); }
/* `.due-col { display:flex }` would otherwise override the UA `[hidden]` rule
   (author display beats it), leaving the start-date field visible when collapsed
   → looked like two date inputs. Re-assert hidden with higher specificity. */
.due-col[hidden] { display: none; }
.due-mini-label { font-size: var(--fs-xs); font-weight: 600; color: var(--muted); }
.due-start-toggle {
  display: inline-flex; align-items: center; gap: 6px;
  margin-top: var(--space-2); padding: 0;
  background: none; border: none; cursor: pointer;
  font-size: var(--fs-sm); font-weight: 600; color: var(--accent);
  transition: opacity var(--t-fast);
}
.due-start-toggle:hover { opacity: 0.75; }
.due-start-toggle svg { flex-shrink: 0; }

/* Date inputs (flatpickr alt input) get a calendar glyph as a left PREFIX. */
.has-date-icon {
  padding-left: 34px !important;
  background-repeat: no-repeat !important;
  background-position: 12px center !important;
  background-size: 15px 15px !important;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='15' height='15' viewBox='0 0 24 24' fill='none' stroke='%239aa1ac' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='4' width='18' height='18' rx='2' ry='2'/%3E%3Cline x1='16' y1='2' x2='16' y2='6'/%3E%3Cline x1='8' y1='2' x2='8' y2='6'/%3E%3Cline x1='3' y1='10' x2='21' y2='10'/%3E%3C/svg%3E") !important;
}
.data-table tr:last-child td { border-bottom: none; }
.data-table tbody tr.st-cancelled td { background: var(--surface-2); }
.data-table tbody tr:hover td { background: var(--bg); }

/* ─── Tabs ──────────────────────────────── */
/* Tabs + view-toggle on one row (the /tasks reference toolbar). On desktop the
   toggle sits right of the tabs; on phones it wraps ABOVE them (right-aligned)
   so the tab labels keep their full width instead of being squeezed. */
.tabs-toolbar {
  display: flex;
  align-items: flex-end;
  border-bottom: 1px solid var(--border);
  margin-bottom: var(--space-5);
}
.tabs-toolbar .tabs { border-bottom: none; margin-bottom: 0; min-width: 0; }
.tabs-toolbar .view-toggle { margin: 0 0 8px auto; flex-shrink: 0; }
@media (max-width: 640px) {
  .tabs-toolbar { flex-wrap: wrap; }
  .tabs-toolbar .view-toggle { order: 1; margin-left: auto; }
  .tabs-toolbar .tabs { order: 2; flex: 1 1 100%; }
}

.tabs {
  display: flex;
  align-items: center;
  gap: 2px;
  border-bottom: 1px solid var(--border);
  margin-bottom: var(--space-5);
  /* Don't let tabs get cut off on narrow screens — scroll horizontally instead
     (the last tab, e.g. "Hoàn thành", was clipped with no way to reach it). */
  overflow-x: auto;
  scrollbar-width: none;            /* Firefox */
  -webkit-overflow-scrolling: touch;
}
.tabs::-webkit-scrollbar { display: none; }   /* Chrome/Safari */

.tab-btn, .team-tab {
  flex: none;                       /* never shrink → keep full label, scroll instead */
  white-space: nowrap;
  padding: 10px 14px;
  font-size: 13px;
  font-weight: 500;
  color: var(--muted);
  background: transparent;
  border: none;
  border-bottom: 2px solid transparent;
  margin-bottom: -1px;
  cursor: pointer;
  transition: color var(--t-fast), border-color var(--t-fast);
  white-space: nowrap;
  display: inline-flex;
  align-items: center;
  gap: 6px;
}
.tab-btn:hover, .team-tab:hover { color: var(--fg); }
.tab-btn.active, .team-tab.active { color: var(--accent); border-color: var(--accent); }

.tab-count, .team-tab-count {
  display: inline-block;
  background: var(--border);
  color: var(--muted);
  font-size: 11px;
  font-weight: 700;
  padding: 0px 5px;
  border-radius: var(--radius-full);
  margin-left: 2px;
  border: 1px solid transparent;
}
.tab-btn.active .tab-count,
.team-tab.active .team-tab-count { background: var(--accent-50); color: var(--accent); border-color: var(--accent-100); }

/* ─── Filter bar ────────────────────────── */
.filter-bar {
  display: flex;
  align-items: center;
  gap: 10px;
  margin-bottom: var(--space-4);
  flex-wrap: wrap;
}
/* Standalone page-level filter/search bar — one that is NOT the padded top
   section of a .list-card (those sit on the card's own surface). Without this it
   floats bare on the grey canvas; the modifier gives it its own white card so it
   matches the /tasks + list-card treatment. */
.filter-bar.filter-bar-card {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  box-shadow: var(--shadow-card);
  padding: var(--space-3) var(--space-4);
}

.filter-input {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  /* White field (STANDARD: all search/inputs are white, not grey) — matches the
     .filter-select beside it (also --surface) and the app's .form-input; the 1px
     border delineates it from the list-card. */
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  height: var(--control-h);
  padding: 0 var(--control-pad-x);
  flex: 0 1 220px;
  transition: border-color var(--t-fast), box-shadow var(--t-fast);
}
.filter-input:focus-within {
  border-color: var(--border);
  box-shadow: none;
}
.filter-input input {
  border: none;
  background: none;
  outline: none;
  font-size: var(--fs-md);
  color: var(--fg);
  width: 100%;
}
.filter-input input:focus-visible {
  outline: none;
}
.filter-input svg { color: var(--muted-2); flex-shrink: 0; }

.filter-select {
  border: 1px solid var(--border);
  border-radius: var(--radius);
  height: var(--control-h);
  padding: 0 var(--control-pad-x);
  font-size: var(--fs-md);
  color: var(--fg);
  background: var(--surface);
  outline: none;
  cursor: pointer;
}

/* Sort dropdown with a leading sort icon (shared: /tasks + project Tasks tab). */
.sort-select { position: relative; display: inline-flex; align-items: center; }
.sort-select > svg { position: absolute; left: 11px; color: var(--muted-2); pointer-events: none; }
.sort-select select { padding-left: 32px; }

/* ─── Two-section list card ─────────────────────────────────────────────────
   The canonical "filtered list" card: a PADDED top section (the .filter-bar:
   search + dropdowns) divided from a FULL-BLEED bottom section (the table/list,
   no left/right padding — the cells' own padding insets the content). Use on
   ANY page that shows a search/filter + a table list (tasks, project tasks,
   team, project members). Pattern:
     <div class="card list-card">
       <div class="filter-bar"> …search + .filter-select… </div>
       <div class="table-scroll"><table class="data-table">…</table></div>
     </div>
   A board/grid alternate view inside the same card should set its own padding. */
.list-card { padding: 0; overflow: hidden; }
.list-card > .filter-bar { margin: 0; padding: var(--space-4); border-bottom: 1px solid var(--border); }

/* ─── View toggle ───────────────────────── */
.view-toggle {
  display: flex;
  align-items: stretch;
  height: var(--control-h);
  background: var(--bg);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 3px;
  gap: 2px;
}
.view-btn {
  padding: 0 10px;
  border: none;
  background: transparent;
  border-radius: 5px;
  font-size: var(--fs-sm);
  font-weight: 500;
  color: var(--muted);
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 5px;
  white-space: nowrap;
  transition: background var(--t-fast), color var(--t-fast);
}
.view-btn.active { background: var(--surface); color: var(--fg); box-shadow: var(--shadow-sm); }

/* ─── Discord synced badge ──────────────── */
.discord-synced {
  display: inline-flex;
  align-items: center;
  gap: var(--space-1);
  font-size: 11px;
  color: var(--accent);
}

/* ─── Kanban board ──────────────────────────────────────────────────────────
   Discord-channel aesthetic: each column reads as its own "channel" with a
   colored header chip + thin top-rail accent. Cards are quiet by default and
   light up on hover with a soft inner glow keyed off the priority color. */
.kanban-board {
  display: grid;
  /* auto-flow column → every status column stays on ONE horizontal row
     regardless of how many there are (3 in projects, 4 on /tasks). Drag-drop
     needs the columns side-by-side; a fixed repeat(N) wraps the surplus. */
  grid-auto-flow: column;
  grid-auto-columns: minmax(0, 1fr);
  gap: 14px;
  margin-top: var(--space-5);
}
.kanban-col {
  min-height: 200px;
  display: flex;
  flex-direction: column;
  background: var(--bg);
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  overflow: hidden;
  position: relative;
  isolation: isolate; /* keeps top-rail accent above header bg */
}
/* Thin top-rail accent — pulls the column's hue across the whole card,
   not just a 3px sliver on the left. Reads as a tabbed channel header. */
.kanban-col::before {
  content: '';
  position: absolute;
  left: 0; right: 0; top: 0;
  height: 3px;
  background: var(--col-accent, var(--border-2));
  z-index: var(--z-raised);
}
.kanban-col-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: var(--space-3) var(--space-3) 10px;
  background: var(--surface);
  border-bottom: 1px solid var(--border);
  gap: var(--space-2);
}
/* Column title becomes a colored pill chip — the column's identity, full stop. */
.kanban-col-title {
  font-size: 11px;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.7px;
  color: var(--col-accent, var(--fg));
  background: color-mix(in srgb, var(--col-accent, var(--border-2)) 12%, transparent);
  padding: 5px 10px 5px 9px;
  border-radius: var(--radius-full);
  display: inline-flex;
  align-items: center;
  gap: 6px;
  line-height: 1;
  white-space: nowrap;
  max-width: calc(100% - 40px);
  overflow: hidden;
  text-overflow: ellipsis;
}
.kanban-col-dot {
  width: 6px; height: 6px; border-radius: 50%;
  background: currentColor;
  box-shadow: 0 0 0 3px color-mix(in srgb, currentColor 20%, transparent);
  flex-shrink: 0;
}
/* Count badge tinted to match the column chip — keeps visual cohesion */
.kanban-col-header .tab-count {
  font-size: 11px;
  font-weight: 700;
  background: color-mix(in srgb, var(--col-accent, var(--border-2)) 14%, transparent);
  color: var(--col-accent, var(--fg-2));
  padding: 3px var(--space-2);
  border-radius: var(--radius-full);
  min-width: 22px;
  text-align: center;
  line-height: 1.2;
  flex-shrink: 0;
}
.kanban-col-body {
  flex: 1;
  padding: 10px;
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
  min-height: 280px;
}
/* Empty-column placeholder — an inviting "inbox is clear" frame. Rendered as an
   explicit `.kanban-col-empty` element (see TasksKanban.jsx), not a `:empty`
   pseudo, so a stray Preact placeholder node can't blank it out. SVG inlined via
   mask so it picks up theme/colour tokens automatically. */
.kanban-col-empty {
  flex: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  text-align: center;
  gap: 10px;
  padding: 36px 18px;
  border: 1px dashed var(--border);
  border-radius: var(--radius);
  font-size: 12px;
  font-weight: 500;
  color: var(--muted);
  letter-spacing: 0.2px;
  background:
    radial-gradient(circle at center, color-mix(in srgb, var(--col-accent, var(--border)) 6%, transparent) 0%, transparent 60%);
}
.kanban-col-empty-icon {
  width: 36px;
  height: 36px;
  background: color-mix(in srgb, var(--col-accent, var(--muted-2)) 70%, transparent);
  -webkit-mask: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'><path d='M22 12h-6l-2 3h-4l-2-3H2'/><path d='M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z'/></svg>") center / contain no-repeat;
          mask: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'><path d='M22 12h-6l-2 3h-4l-2-3H2'/><path d='M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z'/></svg>") center / contain no-repeat;
}

/* ─── Card ───────────────────────────────────────────────────────────────── */
.kanban-card {
  --card-accent: var(--border-2);
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 10px var(--space-3) 10px var(--space-3);
  cursor: grab;
  position: relative;
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
  transition: transform 0.14s ease, box-shadow 0.14s ease, border-color 0.14s ease;
  box-shadow: 0 1px 0 rgba(15, 23, 42, 0.02);
}
.kanban-card.prio-high   { --card-accent: var(--danger); }
.kanban-card.prio-medium { --card-accent: var(--warning, #f59e0b); }
.kanban-card.prio-low    { --card-accent: var(--success); }

/* Drag-grip — appears on hover so the card hints "I'm draggable" without
   noise at rest. Two stacked rows of dots, classic kanban affordance. */
.kanban-card::before {
  content: '';
  position: absolute;
  top: 12px;
  right: 10px;
  width: 8px;
  height: 14px;
  opacity: 0;
  transition: opacity 0.14s ease;
  background-image:
    radial-gradient(circle, var(--muted-2) 1px, transparent 1px);
  background-size: 4px 4px;
  background-position: 0 0;
  pointer-events: none;
}
.kanban-card:hover::before { opacity: 0.55; }
.kanban-card:hover {
  border-color: color-mix(in srgb, var(--card-accent) 35%, var(--border));
  box-shadow:
    0 4px 14px rgba(15, 23, 42, 0.07),
    inset 0 0 0 1px color-mix(in srgb, var(--card-accent) 18%, transparent);
  transform: translateY(-1px);
}
.kanban-card:active { transform: translateY(0); cursor: grabbing; }

.kanban-card-title {
  font-size: 13px;
  font-weight: 600;
  color: var(--fg);
  line-height: 1.4;
  padding-right: 14px; /* clear the grip indicator */
  display: -webkit-box;
  -webkit-line-clamp: 3;
  -webkit-box-orient: vertical;
  overflow: hidden;
}
.kanban-card-meta {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-2);
  /* Always one row — priority/due/assignees never wrap to a second line. The
     tags side shrinks/ellipsises; the foot (due + avatars) keeps its size. */
  flex-wrap: nowrap;
  min-width: 0;
}
.kanban-card-tags {
  display: flex;
  gap: 5px;
  flex-wrap: nowrap;
  align-items: center;
  min-width: 0;
  overflow: hidden;
}
.kanban-card-tags > * { flex-shrink: 0; }
.kanban-card-tags .kanban-prio-chip { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
.kanban-card-foot {
  display: flex;
  align-items: center;
  gap: 6px;
  margin-left: auto;
  flex-shrink: 0;
}

/* Priority — coloured text only (no dot, no pill). Hue comes from the card's
   per-priority --card-accent. */
.kanban-prio-chip {
  display: inline-flex;
  align-items: center;
  font-size: 11px;
  font-weight: 700;
  text-transform: capitalize;
  color: var(--card-accent);
  line-height: 1.2;
  letter-spacing: 0.1px;
  white-space: nowrap;
}

/* Due-date chip — calendar icon + date. Goes red when overdue. */
.kanban-due-chip {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  font-size: 11px;
  font-weight: 600;
  padding: 2.5px var(--space-2);
  border-radius: var(--radius-full);
  background: var(--surface-2);
  color: var(--muted);
  line-height: 1.2;
  white-space: nowrap;
}
.kanban-due-chip::before {
  content: '';
  width: 11px;
  height: 11px;
  background: currentColor;
  -webkit-mask: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><rect x='3' y='4' width='18' height='18' rx='2' ry='2'/><line x1='16' y1='2' x2='16' y2='6'/><line x1='8' y1='2' x2='8' y2='6'/><line x1='3' y1='10' x2='21' y2='10'/></svg>") center / contain no-repeat;
          mask: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><rect x='3' y='4' width='18' height='18' rx='2' ry='2'/><line x1='16' y1='2' x2='16' y2='6'/><line x1='8' y1='2' x2='8' y2='6'/><line x1='3' y1='10' x2='21' y2='10'/></svg>") center / contain no-repeat;
  flex-shrink: 0;
}
.kanban-due-chip.is-overdue {
  background: color-mix(in srgb, var(--danger) 12%, transparent);
  color: var(--danger);
}

/* Overdue flame badge — sits next to the priority chip in the tag row. */
.kanban-overdue-badge {
  display: inline-flex;
  align-items: center;
  gap: var(--space-1);
  font-size: 11px;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.4px;
  padding: 2.5px 7px;
  border-radius: var(--radius-full);
  background: color-mix(in srgb, var(--danger) 14%, transparent);
  color: var(--danger);
  line-height: 1.2;
}
.kanban-overdue-badge::before {
  content: '';
  width: 4px;
  height: 4px;
  border-radius: 50%;
  background: var(--danger);
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--danger) 20%, transparent);
  animation: kanban-pulse 1.8s ease-in-out infinite;
}
@keyframes kanban-pulse {
  0%, 100% { box-shadow: 0 0 0 3px color-mix(in srgb, var(--danger) 20%, transparent); }
  50%      { box-shadow: 0 0 0 5px color-mix(in srgb, var(--danger) 8%,  transparent); }
}

/* Done state — quieter title, faded chips */
.kanban-card.is-done .kanban-card-title { text-decoration: line-through; color: var(--muted); }
.kanban-card.is-done { opacity: 0.78; }

/* ─── Drag state ───────────────────────────────────────────────────────────
   Pairs with the existing .kanban-dragging class (rotate already there). */
.kanban-dragging {
  opacity: 0.92;
  box-shadow:
    0 12px 32px rgba(0,0,0,0.18),
    0 0 0 2px color-mix(in srgb, var(--card-accent) 55%, transparent),
    0 0 0 6px color-mix(in srgb, var(--card-accent) 18%, transparent) !important;
  border-color: color-mix(in srgb, var(--card-accent) 60%, var(--border)) !important;
}
.kanban-ghost {
  background: color-mix(in srgb, var(--card-accent, var(--accent)) 8%, transparent) !important;
  border: 1px dashed color-mix(in srgb, var(--card-accent, var(--accent)) 55%, transparent) !important;
  box-shadow: none !important;
}
.kanban-ghost .kanban-card-title,
.kanban-ghost .kanban-card-meta { opacity: 0.35; }

/* ─── Dark theme polish ─────────────────────────────────────────────────── */
[data-theme="dark"] .kanban-card { box-shadow: 0 1px 0 rgba(255,255,255,0.02); }
[data-theme="dark"] .kanban-card:hover {
  box-shadow:
    0 6px 18px rgba(0,0,0,0.45),
    inset 0 0 0 1px color-mix(in srgb, var(--card-accent) 28%, transparent);
}
[data-theme="dark"] .kanban-due-chip { background: var(--surface-2); }
[data-theme="dark"] .kanban-col-empty { background: rgba(255,255,255,0.015); }

/* ─── Responsive ───────────────────────────────────────────────────────────
   Tablet: collapse to horizontal swipe. Mobile: keep first column sticky-left
   so the user always knows their anchor while panning. */
@media (max-width: 1100px) {
  .kanban-board {
    grid-auto-columns: minmax(260px, 1fr);
    overflow-x: auto;
    padding-bottom: 6px;
    scroll-snap-type: x proximity;
    -webkit-overflow-scrolling: touch;
  }
  .kanban-col { scroll-snap-align: start; }
}
@media (max-width: 768px) {
  .kanban-board {
    grid-auto-columns: minmax(240px, 1fr);
    gap: 10px;
    padding-left: 2px;
  }
  .kanban-card { padding: 9px 11px 9px 11px; gap: 7px; }
  .kanban-card-title { font-size: 12px; -webkit-line-clamp: 2; }
  /* First column sticks to the left edge as you swipe — visual anchor. */
  .kanban-col:first-child {
    position: sticky;
    left: 0;
    z-index: 2;
    box-shadow: 4px 0 12px -8px rgba(0,0,0,0.18);
  }
  [data-theme="dark"] .kanban-col:first-child {
    box-shadow: 4px 0 12px -6px rgba(0,0,0,0.55);
  }
}

/* ─── Activity feed ─────────────────────── */
.activity-feed { list-style: none; }

.activity-item {
  display: flex;
  gap: var(--space-3);
  padding: var(--space-3) 0;
  border-bottom: 1px solid var(--border);
}
.activity-item:last-child { border-bottom: none; }

.activity-icon {
  width: 32px;
  height: 32px;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
}

.activity-body { flex: 1; min-width: 0; }
.activity-text { font-size: 13px; color: var(--fg-2); line-height: 1.4; }
.activity-text strong { color: var(--fg); font-weight: 600; }
.activity-time { font-size: 12px; color: var(--muted); margin-top: 3px; }

/* ─── Discord components ────────────────── */
.discord-card {
  background: var(--accent-50);
  border: 1px solid var(--accent-100);
  border-radius: var(--radius-lg);
  padding: var(--space-5);
}

.discord-server-card {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  padding: var(--space-4);
  display: flex;
  align-items: center;
  gap: 14px;
  cursor: pointer;
  transition: border-color var(--t-fast), box-shadow var(--t-fast);
}
.discord-server-card:hover { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-50); }
.discord-server-card.selected { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-50); }

.server-icon {
  width: 48px;
  height: 48px;
  border-radius: var(--radius);
  background: var(--accent);
  display: flex;
  align-items: center;
  justify-content: center;
  color: white;
  font-size: 16px;
  font-weight: 700;
  flex-shrink: 0;
}

.channel-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 10px 0;
  border-bottom: 1px solid var(--border);
}
.channel-row:last-child { border-bottom: none; }
.channel-name { font-size: 13px; color: var(--fg); display: flex; align-items: center; gap: 6px; }
.channel-hash { color: var(--muted); font-size: 16px; font-weight: 400; }

.toggle {
  position: relative;
  width: 38px;
  height: 22px;
  flex-shrink: 0;
}
.toggle input { opacity: 0; width: 0; height: 0; }
.toggle-track {
  position: absolute;
  inset: 0;
  background: var(--border-2);
  border-radius: var(--radius-full);
  cursor: pointer;
  transition: background 0.2s;
}
.toggle input:checked + .toggle-track { background: var(--accent); }
.toggle-thumb {
  position: absolute;
  top: 3px;
  left: 3px;
  width: 16px;
  height: 16px;
  border-radius: 50%;
  background: white;
  box-shadow: var(--shadow-sm);
  transition: transform 0.2s;
  pointer-events: none;
}
.toggle input:checked ~ .toggle-thumb { transform: translateX(16px); }

/* ─── Command preview ───────────────────── */
.cmd-block {
  background: #1e2124;
  border-radius: var(--radius);
  padding: 14px var(--space-4);
  font-family: var(--font-mono);
  font-size: 12px;
  color: #dcddde;
  line-height: 1.6;
  margin-bottom: var(--space-2);
}
.cmd-slash { color: #5865F2; }
.cmd-param { color: #faa61a; }
.cmd-val   { color: #3ba55d; }

/* ─── Empty state ───────────────────────── */
.empty-state {
  text-align: center;
  padding: var(--space-12) var(--space-6);
}
.empty-icon {
  width: 56px;
  height: 56px;
  background: var(--bg);
  border-radius: var(--radius-xl);
  display: flex;
  align-items: center;
  justify-content: center;
  margin: 0 auto var(--space-4);
  color: var(--muted-2);
}
.empty-title { font-size: var(--fs-md); font-weight: 600; color: var(--fg); margin-bottom: 6px; }
.empty-desc  { font-size: var(--fs-sm); color: var(--muted); }
.empty-cta   { margin-top: var(--space-4); display: flex; justify-content: center; }

/* ─── Modal ─────────────────────────────── */
.modal-overlay {
  display: none;
  position: fixed;
  inset: 0;
  background: rgba(0,0,0,0.4);
  z-index: var(--z-nav);
  align-items: center;
  justify-content: center;
  padding: var(--space-6);
}
.modal-overlay.open { display: flex; }

.modal {
  background: var(--surface);
  border-radius: var(--radius-xl);
  box-shadow: var(--shadow-lg);
  width: 100%;
  max-width: 480px;
  /* dvh, not vh: on mobile 90vh is measured against the LARGE viewport, so a
     tall modal's footer hid under the browser address bar (see the dvh
     invariant in CLAUDE.md). min() keeps the desktop behaviour identical. */
  max-height: min(90vh, calc(100dvh - 24px));
  overflow-y: auto;
}

.modal-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: var(--space-5) var(--space-6) var(--space-4);
  border-bottom: 1px solid var(--border);
}
.modal-title { font-size: 14px; font-weight: 700; color: var(--fg); }
.modal-close {
  width: 28px;
  height: 28px;
  border: none;
  background: var(--bg);
  border-radius: var(--radius);
  display: flex;
  align-items: center;
  justify-content: center;
  color: var(--muted);
  cursor: pointer;
}
.modal-close:hover { color: var(--fg); background: var(--border); }
.modal-body { padding: var(--space-5) var(--space-6); }
.modal-footer {
  display: flex;
  justify-content: flex-end;
  gap: 10px;
  padding: var(--space-4) var(--space-6);
  border-top: 1px solid var(--border);
}

/* ─── Form fields ───────────────────────── */
.form-group { margin-bottom: var(--space-4); }
.form-label {
  display: block;
  font-size: 12px;
  font-weight: 600;
  color: var(--fg-2);
  margin-bottom: 6px;
}
.form-input {
  width: 100%;
  height: var(--control-h);
  padding: 0 var(--control-pad-x);
  border: 1px solid var(--border-2);
  border-radius: var(--radius);
  font-size: var(--fs-md);
  color: var(--fg);
  outline: none;
  transition: border-color 0.15s;
  background: var(--surface);
}
/* No "active" highlight on a focused text input: a click no longer paints the
   accent border + glow ring. Keyboard a11y is preserved by the global
   `input:focus-visible` outline below. */
.form-input:focus { border-color: var(--border-2); box-shadow: none; }
/* Kill the native number-spinner arrows on ALL number inputs (the work-report
   giờ/phút + task-detail estimate fields use inputmode=numeric, never the
   stepper). WebKit showed a clipped up/down spinner on the "phút" box that read
   as a broken, inconsistent control ("hour min ở báo cáo lỗi style"). */
input[type="number"]::-webkit-inner-spin-button,
input[type="number"]::-webkit-outer-spin-button { -webkit-appearance: none; appearance: none; margin: 0; }
input[type="number"] { -moz-appearance: textfield; appearance: textfield; }
.form-select {
  width: 100%;
  height: var(--control-h);
  padding: 0 var(--control-pad-x);
  border: 1px solid var(--border-2);
  border-radius: var(--radius);
  font-size: var(--fs-md);
  color: var(--fg);
  outline: none;
  transition: border-color 0.15s;
  background: var(--surface);
  cursor: pointer;
}
.form-select:focus { border-color: var(--border-2); box-shadow: none; }

/* Custom dropdown chevron with breathing room on the right — the native browser
   arrow sat flush against the edge (user: "icon quá sát right"). Applies to every
   select app-wide. background-image comes AFTER the `background:` shorthand in the
   base rules so it wins; the surface colour stays from those rules. */
.form-select, .filter-select, select.form-input {
  -webkit-appearance: none; appearance: none;
  padding-right: 36px !important;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%239aa1ac' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E") !important;
  background-repeat: no-repeat !important;
  background-position: right 13px center !important;
  background-size: 14px 14px !important;
}

/* ─── Control text line box (Vietnamese-safe, sub-pixel-safe) ─────────────────
   Vietnamese stacks diacritics tall (ế, ằ, ậ, ộ) and has descenders (g, y);
   a RATIO like 1.5 (→ 21px line box in a 34px content box) leaves 6.5px leading
   per side — a half-pixel the browser rounds, shearing ~1px off tall glyphs.

   TEXT INPUTS: fill the content box exactly (line-height = control height − 2×1px
   border) → integer leading (0), glyph centred by its own font metrics, no clip.
   ONLY the classed controls that actually set `height: var(--control-h)` get this
   — applying it to bare `input[type=…]` inflated padding-sized inputs that have NO
   fixed height (the borderless topbar search, the `.auth-field` login inputs) to
   ~48–54px. Those size from their own padding + the browser's `normal` centering,
   which is VN-safe in a tall-enough box. */
.form-input, .filter-input {
  line-height: calc(var(--control-h) - 2px);
}
/* NATIVE <select>: must use `line-height: normal` so the UA vertically-centres
   the selected value itself. An explicit tall line-height (e.g. 34px) positions
   the value too low and CLIPS it in Safari/WebKit, which honours line-height for
   the select value differently than Chromium (the "select text cut in half" bug).
   `normal` is safe everywhere and VN diacritics still fit the 36px control.
   Listed LAST + bare `select` so it wins for any select that also has .form-input. */
select,
.form-select, .filter-select, select.form-input {
  line-height: normal;
}
/* Multi-line controls can't be fixed-height — keep consistent padding/font only. */
textarea.form-input, textarea { height: auto; min-height: 72px; padding: 8px var(--control-pad-x); line-height: 1.5; }
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-3); }

/* iOS Safari auto-zooms into any focused input/textarea/select whose font-size
   is below 16px. Catch-all rule on mobile — every input/textarea/select gets
   16px regardless of class or page-injected overrides. Checkbox/radio/file/
   range/color don't render text-input field UI so the zoom rule doesn't apply
   to them; explicitly revert them to inherit so the bump above doesn't pad
   their visible size. */
@media (max-width: 768px) {
  input,
  textarea,
  select {
    font-size: 16px !important;
  }
  input[type="checkbox"],
  input[type="radio"],
  input[type="file"],
  input[type="range"],
  input[type="color"] {
    font-size: inherit !important;
  }
}

/* ─── Avatar group ──────────────────────── */
.avatar-group { display: flex; }
.avatar-group .task-assignee {
  border: 2px solid var(--surface);
  margin-left: -6px;
}
.avatar-group .task-assignee:first-child { margin-left: 0; }

/* ─── Responsive ────────────────────────── */
@media (max-width: 1024px) {
  .stats-grid { grid-template-columns: repeat(2, 1fr); }
  .two-col { grid-template-columns: 1fr; }
  .three-col { grid-template-columns: repeat(2, 1fr); }
}

@media (max-width: 768px) {
  .app {
    grid-template-columns: minmax(0, 1fr);
    grid-template-rows: var(--topbar-h) 1fr;
    height: 100dvh;
  }

  .sidebar {
    position: fixed;
    left: 0;
    top: 0;
    width: var(--sidebar-w);
    /* Slide via transform (GPU-composited) instead of `left` — animating `left`
       forces layout+paint every frame, which is the main source of drawer jank
       on the iOS WKWebView. translate3d + backface-visibility promote it to a
       hardware layer WebKit can move on the compositor thread. */
    transform: translate3d(-100%, 0, 0);
    transition: transform 0.28s cubic-bezier(0.22, 1, 0.36, 1);
    will-change: transform;
    -webkit-backface-visibility: hidden;
    backface-visibility: hidden;
    z-index: var(--z-topbar);
    box-shadow: var(--shadow-lg);
    flex-direction: row; /* keep dual layout on mobile too */
  }
  .sidebar.open { transform: translate3d(0, 0, 0); }
  .sidebar-rail { height: 100dvh; }

  .sidebar-overlay {
    display: none;
    position: fixed;
    inset: 0;
    background: rgba(0,0,0,0.3);
    z-index: 45;
  }
  .sidebar-overlay.open { display: block; }

  /* ── Native (iOS) push drawer ──────────────────────────────────────────────
     On the native shell the menu PUSHES the screen aside (iOS panel feel) rather
     than overlaying it: the content columns (topbar + page + bottom nav) slide
     right by the drawer width while the drawer slides in from the left — their
     edges stay glued, so it reads as one shove. Only the content siblings are
     transformed (NOT #app), so the position:fixed sidebar stays anchored to the
     viewport. Mobile web keeps the overlay. */
  body.native #app-topbar,
  body.native #app-page,
  body.native #app-bottomnav {
    /* Same timing/curve as the drawer so their edges stay glued through the
       whole push. translate3d (below) keeps it on the compositor. */
    transition: transform 0.28s cubic-bezier(0.22, 1, 0.36, 1);
    -webkit-backface-visibility: hidden;
    backface-visibility: hidden;
  }
  body.native #app.sidebar-open #app-topbar,
  body.native #app.sidebar-open #app-page,
  body.native #app.sidebar-open #app-bottomnav {
    transform: translate3d(var(--sidebar-w), 0, 0);
  }
  /* Promote the pushed content to a GPU layer ONLY for the duration of the
     open/close transition (JS toggles `.sidebar-animating` for ~320ms) — so the
     big #app-page scroller isn't permanently layer-promoted (memory) yet the
     animation runs on the compositor thread. */
  body.native #app.sidebar-animating #app-topbar,
  body.native #app.sidebar-animating #app-page,
  body.native #app.sidebar-animating #app-bottomnav {
    will-change: transform;
  }
  /* The scrim now dims the pushed content (tap to close) but not the drawer. */
  body.native #app.sidebar-open .sidebar-overlay { background: rgba(8,10,20,0.35); }

  /* Tighter inset + gap on phones so the hamburger, logo and title read as one
     group (the desktop 24px pad + 16px gap left the menu icon floating far from
     the logo). The hamburger's own 8px inner padding already spaces it. */
  .topbar { grid-column: 1; padding: 0 var(--space-3); gap: var(--space-2); }
  .page   { grid-column: 1; }
  .hamburger { display: flex; }
  /* Reclaim the hamburger's inner padding so the logo sits close to the icon
     (icon↔logo was ~24px: 8px button pad + 16px gap). */
  .topbar-brand { gap: var(--space-1); margin-left: calc(-1 * var(--space-1)); }
  .topbar-search { display: none; }
  /* Show the app logo + page/project context once the sidebar is collapsed. */
  .topbar-brand { display: flex; }
  .topbar-title { font-size: 15px; font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }

  /* ── Touch targets: one comfortable tap size on mobile so controls still line
     up (40px primary · 36px compact). Overrides the desktop --control-h. ── */
  .btn-sm { min-height: 36px; }
  .btn, .btn-icon { min-height: 40px; }
  .btn-icon { min-width: 40px; }
  .form-input, .form-select, .filter-select, .filter-input, input[type="text"], input[type="email"],
  input[type="search"], input[type="date"], select, textarea { min-height: 40px; }
  .tab, .team-tab, .pd-tab, .detail-tab, .filter-pill, .cal-view-btn, .view-toggle-btn { min-height: 36px; }
  /* `.view-btn` lives INSIDE the fixed-height `.view-toggle` pill and stretches to
     fill it (align-items:stretch). A min-height here forced the active button
     TALLER than the container → the white pill overflowed and broke the rounded
     shape on mobile. Instead size the CONTAINER for a comfortable tap target and
     let the buttons stretch to fit. */
  .view-toggle { height: 40px; }
  .am-search-wrap { height: 40px; }
  .task-checkbox, .done-circle, .row-select { min-width: 22px; min-height: 22px; }
}

@media (max-width: 600px) {
  .stats-grid { grid-template-columns: repeat(2, 1fr); gap: 10px; }
  .three-col  { grid-template-columns: 1fr; }
  .page { --page-pad-x: 14px; padding: 12px var(--page-pad-x); }   /* tight phone inset (density overrides collapse below) */
  .page-header { flex-direction: column; align-items: flex-start; gap: var(--space-3); }
  .filter-bar { flex-direction: column; align-items: stretch; }
  /* Pages set an inline `flex: … 220px` basis on these controls for the desktop
     ROW layout. In the mobile COLUMN layout that basis is read as HEIGHT, which
     ballooned the search box (~220px tall). Reset the basis (override the inline
     flex with !important) so each control falls back to its --control-h height
     token; align-items:stretch already gives them full width. */
  .filter-bar > .filter-input,
  .filter-bar > .filter-select,
  .filter-bar > .sort-select,
  .filter-bar > .view-toggle { flex: 0 0 auto !important; }
}

@media (max-width: 390px) {
  .stats-grid { grid-template-columns: 1fr 1fr; gap: var(--space-2); }
  .stat-value { font-size: 20px; }
}

/* In-app dialogs (confirm/prompt): stack actions full-width for thumb reach */
@media (max-width: 480px) {
  #app-dialog-host .modal-footer { flex-direction: column-reverse; }
  #app-dialog-host .modal-footer .btn { width: 100%; }
}

/* ─── Popover system ─────────────────────── */
.popover-wrap {
  position: relative;
}

.popover {
  position: absolute;
  top: calc(100% + 8px);
  right: 0;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  box-shadow: 0 8px 32px rgba(0,0,0,0.12), 0 2px 8px rgba(0,0,0,0.06);
  z-index: var(--z-dropdown);
  opacity: 0;
  visibility: hidden;
  transform: translateY(-6px) scale(0.97);
  transform-origin: top right;
  transition: opacity 0.15s ease, visibility 0.15s ease, transform 0.15s ease;
  pointer-events: none;
  /* Never taller than the space under the topbar. A long notification list then
     scrolls INSIDE the popover (via `.notif-list`) instead of extending past the
     bottom of the screen — where the app-shell `overflow:hidden` clipped it and
     the last item was unreachable, visually colliding with the page beneath. */
  display: flex;
  flex-direction: column;
  max-height: calc(100dvh - var(--topbar-h) - 24px);
}
.popover.open {
  opacity: 1;
  visibility: visible;
  transform: translateY(0) scale(1);
  pointer-events: auto;
}

.popover-hd {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 14px var(--space-4) 10px;
  border-bottom: 1px solid var(--border);
}
.popover-hd-title {
  font-size: 13px;
  font-weight: 600;
  color: var(--fg);
}
.popover-hd-action {
  font-size: 12px;
  color: var(--accent);
  background: none;
  border: none;
  cursor: pointer;
  padding: 0;
  font-weight: 500;
}
.popover-hd-action:hover { text-decoration: underline; }

.popover-divider {
  height: 1px;
  background: var(--border);
  margin: var(--space-1) 0;
}

.popover-ft {
  padding: 10px var(--space-4) var(--space-3);
  border-top: 1px solid var(--border);
}
.popover-ft-link {
  font-size: 12px;
  color: var(--accent);
  text-decoration: none;
  font-weight: 500;
}
.popover-ft-link:hover { text-decoration: underline; }

/* ─── Notification popover ───────────────── */
.notif-popover {
  width: 340px;
}

.notif-list {
  max-height: 320px;
  overflow-y: auto;
  /* Shrink below 320px when the popover hits its viewport cap (flex column),
     so the header + footer stay visible and the list scrolls within. */
  flex: 0 1 auto;
  min-height: 0;
}
.notif-empty {
  padding: var(--space-6) var(--space-4);
  text-align: center;
  font-size: var(--fs-sm);
  color: var(--muted);
}

.notif-item {
  display: flex;
  align-items: flex-start;
  gap: 10px;
  padding: 10px var(--space-4);
  position: relative;
  transition: background 0.1s;
  cursor: pointer;
}
/* Event rows stay WHITE at rest (the unread dot + weight is the unread cue, not a
   coloured fill); hover is a subtle neutral inset, never the grey canvas. */
.notif-item { background: var(--surface); }
.notif-item:hover { background: var(--surface-2); }
.notif-item.unread { background: var(--surface); }
.notif-item.unread:hover { background: var(--surface-2); }

.notif-avatar {
  width: 32px;
  height: 32px;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  color: white;
  font-size: 12px;
  font-weight: 700;
  flex-shrink: 0;
}

.notif-body { flex: 1; min-width: 0; }
.notif-text {
  font-size: 12px;
  color: var(--fg);
  line-height: 1.45;
  overflow-wrap: anywhere;
}
.notif-text strong { font-weight: 600; }
.notif-time {
  font-size: 12px;
  color: var(--muted);
}

/* ─── Notification meta line: project chip + time ────────────────────────────
   A task notification says who did what, not WHERE — the chip answers that.
   Shared by the topbar bell AND the /notifications page (web + native), so the
   project reads the same in all three; the icon uses the shared project-icon
   helpers, so its colour/emoji matches the rail and the project header. Rows
   with no project (leave, work reports) simply render the time alone. */
.notif-meta {
  display: flex; align-items: center; gap: var(--space-2);
  margin-top: 3px; min-width: 0;
}
.notif-proj {
  display: inline-flex; align-items: center; gap: 5px; min-width: 0; flex-shrink: 1;
}
.notif-proj-ic {
  width: 16px; height: 16px; border-radius: 5px; flex-shrink: 0;
  display: inline-flex; align-items: center; justify-content: center;
  font-size: 9px; font-weight: 700; line-height: 1; overflow: hidden;
}
.notif-proj-ic .proj-emoji { font-size: 10px; }
.notif-proj-ic .proj-icon-img { width: 100%; height: 100%; object-fit: cover; }
.notif-proj-name {
  font-size: 12px; font-weight: 500; color: var(--fg-2);
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
/* The time follows the chip; a bullet separates them only when both are there. */
.notif-proj + .notif-time::before,
.notif-proj + .nf-time::before { content: '·'; margin-right: var(--space-2); color: var(--muted-2); }

.notif-unread-dot {
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: var(--accent);
  flex-shrink: 0;
  margin-top: 5px;
}

/* ─── Avatar / user menu popover ────────── */
.avatar-trigger { cursor: pointer; }
.avatar-popover { width: 220px; padding: var(--space-2) 0; }
.avatar-popover .popover-hd,
.avatar-popover .popover-ft { display: none; }

.avatar-menu-user {
  display: flex;
  align-items: flex-start;
  gap: 10px;
  padding: var(--space-3) var(--space-4) 14px;
  /* No border-bottom — the markup already places a .popover-divider after this
     block; having both drew a DOUBLE line. The divider is the single separator
     (consistent with the other sections). */
}
.avatar-menu-user > div:last-child { min-width: 0; }
.avatar-menu-name {
  font-size: 13px;
  font-weight: 600;
  color: var(--fg);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.avatar-menu-email {
  font-size: 12px;
  color: var(--muted);
  margin-top: 1px;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.avatar-menu-item {
  display: flex;
  align-items: center;
  gap: 9px;
  padding: var(--space-2) var(--space-4);
  font-size: 13px;
  color: var(--fg);
  text-decoration: none;
  background: none;
  border: none;
  width: 100%;
  text-align: left;
  cursor: pointer;
  transition: background 0.1s;
  border-radius: 0;
}
.avatar-menu-item:hover { background: var(--bg); }
.avatar-menu-item svg { color: var(--muted); flex-shrink: 0; }

.avatar-menu-badge {
  margin-left: auto;
  font-size: 11px;
  font-weight: 600;
  color: var(--muted);
  background: var(--bg);
  border: 1px solid var(--border);
  border-radius: 4px;
  padding: 1px 5px;
}

.avatar-menu-signout { color: var(--danger); }
.avatar-menu-signout svg { color: var(--danger); }
.avatar-menu-signout:hover { background: color-mix(in srgb, var(--danger) 8%, transparent); }

/* ─── Search dropdown ────────────────────── */
.search-dropdown {
  position: absolute;
  top: calc(100% + 6px);
  left: 0;
  right: 0;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 10px;
  box-shadow: 0 8px 32px rgba(0,0,0,0.12);
  z-index: var(--z-dropdown);
  overflow: hidden;
  opacity: 0;
  visibility: hidden;
  transform: translateY(-4px);
  transform-origin: top center;
  transition: opacity 0.15s ease, visibility 0.15s ease, transform 0.15s ease;
  pointer-events: none;
}
.search-dropdown.open {
  opacity: 1;
  visibility: visible;
  transform: translateY(0);
  pointer-events: auto;
}

.search-dropdown-section { padding: 6px 0; }
.search-dropdown-label {
  font-size: 11px;
  font-weight: 600;
  color: var(--muted);
  text-transform: uppercase;
  letter-spacing: 0.5px;
  padding: 6px var(--space-3) var(--space-1);
}
.search-dropdown-item {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  padding: 7px var(--space-3);
  font-size: 13px;
  color: var(--fg);
  text-decoration: none;
  transition: background 0.1s;
  cursor: pointer;
}
.search-dropdown-item:hover,
.search-dropdown-item.kbd-active { background: var(--bg); }
.search-dropdown-item.kbd-active { box-shadow: inset 2px 0 0 var(--accent); }
.search-dropdown-item svg { color: var(--muted); flex-shrink: 0; }
.search-item-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
.search-item-sub { color: var(--muted); font-size: 12px; }
.search-hint { padding: var(--space-3); font-size: 12px; color: var(--muted); text-align: center; }
.search-dropdown-tag {
  margin-left: auto;
  font-size: 11px;
  color: var(--muted);
  background: var(--bg);
  border: 1px solid var(--border);
  border-radius: 4px;
  padding: 1px 6px;
  white-space: nowrap;
}
.search-dropdown-footer {
  padding: var(--space-2) var(--space-3);
  border-top: 1px solid var(--border);
  font-size: 12px;
  color: var(--muted);
  text-align: center;
  background: var(--bg);
}

/* ─── Skeleton loaders — neutral ─── */
/* Foundation: a NEUTRAL cool-grey base (matches the content it replaces) with a
   slanted highlight sweep + soft pulse. (Was brand-blurple, which read as a
   colour mismatch when the neutral content swapped in.)
   Children rendered inside .sk-stagger inherit a staggered animation start via --i. */
:root {
  /* Neutral cool-grey placeholders from the SAME family as the real content
     (--surface-2 #f3f4f6 / --border #e5e7eb). The old blurple tint (#eef1f8 /
     #e6eafd) read as blue while the content loaded in neutral, so the swap
     looked like a colour change ("ko match"). Neutral greys make the swap
     invisible. The white sweep still gives the shimmer. */
  --sk-base:    #eceef2;
  --sk-base-2:  #e3e6ea;
  --sk-sweep:   rgba(255,255,255,0.7);
  --sk-glow:    rgba(0,0,0,0.03);
}

@keyframes sk-sweep {
  0%   { transform: translateX(-120%) skewX(-12deg); }
  60%  { transform: translateX(120%)  skewX(-12deg); }
  100% { transform: translateX(120%)  skewX(-12deg); }
}
@keyframes sk-pulse {
  0%, 100% { background-color: var(--sk-base); }
  50%      { background-color: var(--sk-base-2); }
}

/* Base block — used as either a standalone primitive or via composition helpers. */
.sk {
  position: relative;
  overflow: hidden;
  background-color: var(--sk-base);
  border-radius: 6px;
  animation: sk-pulse 2.4s ease-in-out infinite;
  animation-delay: calc(var(--i, 0) * 90ms);
  isolation: isolate;
}
.sk::after {
  content: '';
  position: absolute;
  inset: 0;
  background: linear-gradient(100deg,
              transparent 25%,
              var(--sk-sweep) 50%,
              transparent 75%);
  transform: translateX(-120%) skewX(-12deg);
  animation: sk-sweep 1.9s cubic-bezier(.4,0,.2,1) infinite;
  animation-delay: calc(var(--i, 0) * 90ms);
}

/* Primitives — set width/height inline or via modifier. */
.sk-line     { height: 12px; border-radius: 4px; }
.sk-line-xs  { height: 8px;  border-radius: 3px; }
.sk-line-sm  { height: 10px; border-radius: 4px; }
.sk-line-lg  { height: 16px; border-radius: 5px; }
.sk-line-xl  { height: 22px; border-radius: 6px; }
.sk-block    { /* set width/height inline */ }
.sk-circle   { border-radius: 50%; }
.sk-pill     { border-radius: var(--radius-full); height: 18px; }
.sk-chip     { height: 22px; border-radius: 6px; }

/* Composition: skeleton card wrapper — matches .card / .ov-stat geometry. */
.sk-card {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  padding: var(--space-4);
}
.sk-card-row {
  display: flex;
  align-items: center;
  gap: var(--space-3);
}
.sk-stack {
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
}
.sk-stack-tight { gap: 6px; }
.sk-stack-loose { gap: var(--space-3); }

/* Auto-stagger: nth-child sets --i so children animate in waves. */
.sk-stagger > *:nth-child(1)  { --i: 0; }
.sk-stagger > *:nth-child(2)  { --i: 1; }
.sk-stagger > *:nth-child(3)  { --i: 2; }
.sk-stagger > *:nth-child(4)  { --i: 3; }
.sk-stagger > *:nth-child(5)  { --i: 4; }
.sk-stagger > *:nth-child(6)  { --i: 5; }
.sk-stagger > *:nth-child(7)  { --i: 6; }
.sk-stagger > *:nth-child(8)  { --i: 7; }
.sk-stagger > *:nth-child(9)  { --i: 8; }
.sk-stagger > *:nth-child(10) { --i: 9; }
.sk-stagger > *:nth-child(11) { --i: 10; }
.sk-stagger > *:nth-child(12) { --i: 11; }

/* Reveal: when real content replaces a skeleton, fade-up subtly to mask the swap. */
@keyframes sk-reveal {
  from { opacity: 0; transform: translateY(4px); }
  to   { opacity: 1; transform: translateY(0); }
}
[data-skeleton-replaced] > * {
  animation: sk-reveal 240ms ease-out backwards;
}

/* Reduced motion: hold static color, no sweep. */
@media (prefers-reduced-motion: reduce) {
  .sk, .sk::after { animation: none !important; }
  .sk::after { display: none; }
}

/* Legacy class kept for any external references. */
.skeleton { background-color: var(--sk-base); border-radius: var(--radius); animation: sk-pulse 2.4s ease-in-out infinite; }

/* ─── Toast / feedback ──────────────────── */
.toast-container {
  position: fixed;
  top: 24px;
  left: 50%;
  transform: translateX(-50%);
  z-index: var(--z-toast);
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--space-2);
  pointer-events: none;
}
/* Native: drop toasts below the status bar / Dynamic Island — top:24px alone
   sits UNDER the notch/camera on a real device (no safe-area accounted for). */
body.native .toast-container { top: calc(var(--space-2) + env(safe-area-inset-top)); }
.toast {
  background: var(--fg);
  color: white;
  padding: 10px var(--space-4);
  border-radius: var(--radius-lg);
  font-size: 13px;
  font-weight: 500;
  box-shadow: var(--shadow-lg);
  display: flex;
  align-items: center;
  gap: var(--space-2);
  pointer-events: auto;
  transform: translateY(8px);
  opacity: 0;
  transition: all 0.2s ease;
}
.toast.show { transform: translateY(0); opacity: 1; }
/* Center-toast: the container is centered via transform, so the toast itself
   only animates opacity + a small drop-in. */
.toast { transform: translateY(-12px); }
.toast.show { transform: translateY(0); }
/* Native: spring the drop-in so it reads like an iOS banner. */
body.native .toast { transition: transform var(--t-slow) var(--ease-spring), opacity var(--t-base) var(--ease); }
/* Success is friendly + calm: the default dark surface with a green ✓ icon, NOT
   a full green banner (which read as loud/unfriendly). Errors/warnings keep the
   solid colour so problems still stand out. */
.toast-ico { font-size: var(--fs-xs); opacity: .9; display: inline-flex; }
.toast.success { background: var(--fg); }
.toast.success .toast-ico { color: var(--success); opacity: 1; }
.toast.danger  { background: var(--danger); }
.toast.warning { background: var(--warning); }

/* ─── Divider ───────────────────────────── */
.divider {
  height: 1px;
  background: var(--border);
  margin: var(--space-4) 0;
}

/* ─── Text utilities ────────────────────── */
.text-muted   { color: var(--muted); }
.text-accent  { color: var(--accent); }
.text-success { color: var(--success); }
.text-danger  { color: var(--danger); }
.text-warning { color: var(--warning); }
.font-mono    { font-family: var(--font-mono); }
.truncate     { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.tabular-nums { font-variant-numeric: tabular-nums; }

/* ─── Focus ring ────────────────────────── */
:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}
button:focus-visible,
a:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
  border-radius: var(--radius);
}

/* ─── Role system ───────────────────────── */

/* Hide admin-only elements for everyone EXCEPT a confirmed admin. FAIL CLOSED
   (was `body.role-member/.role-viewer` — fail-open: if the role class wasn't set
   yet, or defaulted to admin on a popstate, members saw admin controls they then
   couldn't save). Mirrors the project-scoped `body:not(.pd-role-admin)` gate. */
body:not(.role-admin) [data-admin-only] { display: none !important; }

/* Hide member-only elements for admin */
body.role-admin [data-member-only] { display: none !important; }

/* Hide write-actions from viewers (read-only) — visible to admin + member.
   Used by e.g. the team "Invite member" button: members can invite, viewers can't. */
body.role-viewer [data-not-viewer] { display: none !important; }

/* Topbar role badge */
.role-badge-topbar {
  display: inline-flex;
  align-items: center;
  padding: 3px var(--space-2);
  border-radius: var(--radius-full);
  font-size: 11px;
  font-weight: 600;
  letter-spacing: 0.3px;
  line-height: 1;
  white-space: nowrap;
  user-select: none;
}
.role-badge-admin {
  background: var(--accent-50);
  color: var(--accent);
  border: 1px solid var(--accent-100);
}
.role-badge-member {
  background: var(--surface-2);
  color: var(--fg-2);
  border: 1px solid var(--border);
}
.role-badge-viewer {
  background: var(--warning-bg);
  color: var(--warning);
  border: 1px solid #fde68a;
}

/* Role label in avatar popover */
.avatar-menu-role {
  font-size: 11px;
  font-weight: 600;
  margin-top: var(--space-1);
  padding: 2px 7px;
  border-radius: var(--radius-full);
  display: inline-block;
  background: var(--accent-50);
  color: var(--accent);
  border: 1px solid var(--accent-100);
  letter-spacing: 0.2px;
}
.avatar-menu-role:empty { visibility: hidden; }
.avatar-role-member { background: var(--surface-2); color: var(--fg-2); border-color: var(--border); }
.avatar-role-viewer  { background: var(--warning-bg); color: var(--warning); border-color: var(--warning-bg); }

/* Page lock banner (admin-only pages viewed by member) */
.page-lock-banner {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 10px var(--space-4);
  background: var(--warning-bg);
  border: 1px solid var(--warning-bg);
  border-radius: var(--radius-lg);
  font-size: 13px;
  color: var(--warning);
  margin-bottom: var(--space-5);
}
.page-lock-banner svg { flex-shrink: 0; color: var(--warning); }
.page-lock-banner span { flex: 1; }
.page-lock-banner strong { font-weight: 600; }
.page-lock-link {
  font-size: 12px;
  font-weight: 600;
  color: var(--accent);
  text-decoration: none;
  white-space: nowrap;
}
.page-lock-link:hover { text-decoration: underline; }

/* Role card selector (in settings) */
.role-cards {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
  gap: var(--space-3);
  margin-top: var(--space-4);
}
.role-card {
  border: 2px solid var(--border);
  border-radius: var(--radius-lg);
  padding: var(--space-4);
  cursor: pointer;
  transition: border-color 0.15s, background 0.15s;
  background: var(--surface);
  text-align: left;
}
.role-card:hover { border-color: var(--accent-300); background: var(--accent-50); }
.role-card.selected { border-color: var(--accent); background: var(--accent-50); }
/* Read-only reference cards (Settings → Vai trò & Quyền): no self role-change,
   so no pointer/hover affordance — only the current role stays highlighted. */
.role-card--ro { cursor: default; }
.role-card--ro:hover { border-color: var(--border); background: var(--surface); }
.role-card--ro.selected:hover { border-color: var(--accent); background: var(--accent-50); }
/* Owner = gold tier (matches the team list + sidebar owner cue). */
.role-card.selected[data-role="owner"] { border-color: #f59e0b; background: color-mix(in srgb,#f59e0b 12%,transparent); }
.role-card.selected[data-role="owner"] .role-card-title { color: #b45309; }
[data-theme="dark"] .role-card.selected[data-role="owner"] .role-card-title { color: #fbbf24; }
.role-manage-link {
  display: inline-flex; align-items: center; gap: 6px; margin-top: var(--space-3);
  font-size: var(--fs-sm); font-weight: 600; color: var(--accent); text-decoration: none;
}
.role-manage-link:hover { text-decoration: underline; }
.role-card-icon {
  width: 36px; height: 36px;
  border-radius: var(--radius);
  display: flex; align-items: center; justify-content: center;
  margin-bottom: 10px;
}
.role-card-title { font-size: 14px; font-weight: 600; color: var(--fg); margin-bottom: var(--space-1); }
.role-card-desc { font-size: 12px; color: var(--muted); line-height: 1.5; }
.role-card.selected .role-card-title { color: var(--accent); }

/* Current role indicator banner in settings */
.current-role-banner {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  padding: 14px var(--space-4);
  background: var(--accent-50);
  border: 1px solid var(--accent-100);
  border-radius: var(--radius-lg);
  margin-bottom: var(--space-5);
}
.current-role-banner svg { color: var(--accent); flex-shrink: 0; }
.current-role-banner-label { font-size: 13px; color: var(--muted); }
.current-role-banner-value { font-size: 14px; font-weight: 700; color: var(--accent); }
/* Owner gets the gold treatment so the highest tier is unmistakable. */
.current-role-banner.is-owner { background: color-mix(in srgb,#f59e0b 12%,transparent); border-color: color-mix(in srgb,#f59e0b 38%,transparent); }
.current-role-banner.is-owner svg,
.current-role-banner.is-owner .current-role-banner-value { color: #b45309; }
[data-theme="dark"] .current-role-banner.is-owner svg,
[data-theme="dark"] .current-role-banner.is-owner .current-role-banner-value { color: #fbbf24; }

@media (max-width: 640px) {
  .role-cards { grid-template-columns: 1fr; }
  .role-badge-topbar { display: none; }
}

/* ═══════════════════════════════════════════════════════════════
   APPEARANCE SYSTEM — dark mode · font size · density
   ═══════════════════════════════════════════════════════════════ */

/* ─── Dark mode tokens ─── */
html[data-theme="dark"] {
  --bg:        #1a1b1e;
  --surface:   #25262b;
  --surface-2: #2c2d33;
  --fg:        #e8e9ed;
  --fg-2:      #c1c2c7;
  --muted:     #909299;
  --muted-2:   #6c6f7d;
  --border:    #373a40;
  --border-2:  #484c55;
  --shadow-sm: 0 1px 3px rgba(0,0,0,0.4), 0 1px 2px rgba(0,0,0,0.3);
  --shadow-md: 0 4px 12px rgba(0,0,0,0.5), 0 2px 4px rgba(0,0,0,0.3);
  --shadow-lg: 0 8px 24px rgba(0,0,0,0.6), 0 4px 8px rgba(0,0,0,0.3);
  --accent-50:  rgba(var(--accent-rgb),0.15);
  --accent-100: rgba(var(--accent-rgb),0.22);
  --accent-200: rgba(var(--accent-rgb),0.38);
  --accent-300: rgba(var(--accent-rgb),0.55);
  --success-bg: rgba(22,163,74,0.15);
  --success-100: rgba(22,163,74,0.22);
  --warning-bg: rgba(217,119,6,0.15);
  --danger-bg:  rgba(220,38,38,0.15);
  --danger-50:  rgba(220,38,38,0.08);
  --danger-200: rgba(220,38,38,0.28);
  /* Priority text — lighter shades for AA on the dark surface (raw --danger red
     was only 3.1:1 on #25262b). */
  --prio-urgent: #f87171;
  --prio-high:   #f87171;
  --prio-medium: #fbbf24;
  --prio-low:    #4ade80;
  --info-bg:    rgba(2,132,199,0.15);
  /* Skeleton loaders — the light-theme steel-blue base was blinding on the dark
     canvas. Use dark inset shades + a faint light sweep. */
  --sk-base:    #2c2d33;
  --sk-base-2:  #34363d;
  --sk-sweep:   rgba(255,255,255,0.05);
  --sk-glow:    rgba(255,255,255,0.03);
}

html[data-theme="dark"] body {
  background: var(--bg);
  color: var(--fg);
}

html[data-theme="dark"] input,
html[data-theme="dark"] textarea,
html[data-theme="dark"] select {
  background: var(--surface-2);
  color: var(--fg);
  border-color: var(--border);
  color-scheme: dark;
}

html[data-theme="dark"] input::placeholder,
html[data-theme="dark"] textarea::placeholder {
  color: var(--muted-2);
}

/* ─── Auto (follow system) theme ─── */
@media (prefers-color-scheme: dark) {
  html[data-theme="auto"] {
    --bg:        #1a1b1e;
    --surface:   #25262b;
    --surface-2: #2c2d33;
    --fg:        #e8e9ed;
    --fg-2:      #c1c2c7;
    --muted:     #909299;
    --muted-2:   #6c6f7d;
    --border:    #373a40;
    --border-2:  #484c55;
    --shadow-sm: 0 1px 3px rgba(0,0,0,0.4), 0 1px 2px rgba(0,0,0,0.3);
    --shadow-md: 0 4px 12px rgba(0,0,0,0.5), 0 2px 4px rgba(0,0,0,0.3);
    --shadow-lg: 0 8px 24px rgba(0,0,0,0.6), 0 4px 8px rgba(0,0,0,0.3);
    --accent-50:  rgba(var(--accent-rgb),0.15);
    --accent-100: rgba(var(--accent-rgb),0.22);
    --success-bg: rgba(22,163,74,0.15);
    --warning-bg: rgba(217,119,6,0.15);
    --danger-bg:  rgba(220,38,38,0.15);
    --danger-50:  rgba(220,38,38,0.08);
    --info-bg:    rgba(2,132,199,0.15);
  }
  html[data-theme="auto"] body {
    background: var(--bg); color: var(--fg);
  }
  html[data-theme="auto"] input,
  html[data-theme="auto"] textarea,
  html[data-theme="auto"] select {
    background: var(--surface-2);
    color: var(--fg);
    border-color: var(--border);
    color-scheme: dark;
  }
}

/* ─── Font size ─── */
html[data-fs="small"],  html[data-fs="small"]  body { font-size: 13px; }
html[data-fs="default"],html[data-fs="default"] body { font-size: 14px; }
html[data-fs="large"],  html[data-fs="large"]  body { font-size: 14px; }
html[data-fs="xlarge"], html[data-fs="xlarge"] body { font-size: 16px; }

/* Scale component text with base font size */
html[data-fs="small"]  .nav-link,
html[data-fs="small"]  .btn,
html[data-fs="small"]  .form-input,
html[data-fs="small"]  .form-select,
html[data-fs="small"]  .data-table td { font-size: 12px; }

html[data-fs="large"]  .nav-link,
html[data-fs="large"]  .btn,
html[data-fs="large"]  .form-input,
html[data-fs="large"]  .form-select,
html[data-fs="large"]  .data-table td { font-size: 14px; }

html[data-fs="xlarge"] .nav-link,
html[data-fs="xlarge"] .btn,
html[data-fs="xlarge"] .form-input,
html[data-fs="xlarge"] .form-select,
html[data-fs="xlarge"] .data-table td { font-size: 14px; }

/* Topbar title & sidebar text scale */
html[data-fs="small"]  .topbar-title { font-size: 13px; }
html[data-fs="large"]  .topbar-title { font-size: 14px; }
html[data-fs="xlarge"] .topbar-title { font-size: 16px; }

/* ─── Density ─── */
html[data-density="compact"] .nav-link          { padding: 5px var(--space-4); }
/* Keep the density's tighter block padding but PRESERVE the --content-max
   inline centering cap — a flat `padding: … 20px` here silently dropped the cap,
   so uncapped pages (e.g. /projects) ran edge-to-edge on wide screens in this
   density. Mirror .page's own `padding-inline: max(gutter, centring)`. */
html[data-density="compact"] .page              { padding-block: var(--space-4); padding-inline: max(var(--space-5), calc((100% - var(--content-dash)) / 2)); }
html[data-density="compact"] .page-header       { margin-bottom: var(--space-4); }
html[data-density="compact"] .data-table td,
html[data-density="compact"] .data-table th     { padding: 6px var(--space-3); }
html[data-density="compact"] .stat-card         { padding: 14px var(--space-4); }
html[data-density="compact"] .btn               { padding: 5px var(--space-3); }

html[data-density="comfortable"] .nav-link      { padding: 10px var(--space-4); }
html[data-density="comfortable"] .page          { padding-block: var(--space-8); padding-inline: max(var(--space-10), calc((100% - var(--content-dash)) / 2)); }
html[data-density="comfortable"] .page-header   { margin-bottom: var(--space-8); }
html[data-density="comfortable"] .data-table td,
html[data-density="comfortable"] .data-table th { padding: 14px var(--space-4); }
html[data-density="comfortable"] .stat-card     { padding: 22px var(--space-6); }
html[data-density="comfortable"] .btn           { padding: 9px 18px; }

/* Phones: density's DESKTOP page inset (compact 16/20, comfortable 32/40) is far
   too large on a narrow screen. Collapse every density to one tight inset. This
   MUST come after the density rules above (equal specificity → source order
   wins) so it actually overrides them. */
@media (max-width: 600px) {
  html[data-density="compact"] .page,
  html[data-density="comfortable"] .page { padding: 12px 14px; }
  html[data-density="compact"] .page-header,
  html[data-density="comfortable"] .page-header { margin-bottom: var(--space-4); }
}

/* ─── Theme toggle button ─── */
/* Visibility is JS-controlled via syncThemeBtn() in app.js — do NOT use CSS display rules here */
.theme-toggle-btn { position: relative; }
.theme-toggle-btn .icon-sun  { display: none; }  /* safe default: hidden until JS runs */
.theme-toggle-btn .icon-moon { display: block; } /* safe default: moon visible (light mode) */

/* ─── Shared settings layout (settings.html + project-detail.html) ─── */
.settings-layout {
  display: grid;
  grid-template-columns: 220px 1fr 220px;
  gap: 0;
  min-height: 100%;
  align-items: start;
}
@media (max-width: 1200px) {
  .settings-layout {
    grid-template-columns: 220px 1fr;
  }
}
.settings-nav {
  position: sticky;
  top: 0;
  /* Fill the viewport below the topbar so the nav surface + right border reach the
     bottom (was content-height → a gap of canvas showed under the last item). The
     mobile blocks below reset min-height (0/unset) for the horizontal tab bar. */
  min-height: calc(100dvh - var(--topbar-h));
  padding: var(--space-2) var(--space-2) var(--space-6);   /* horizontal inset so active/hover reads as a rounded pill, not a full-bleed block */
  background: var(--surface);
  border-right: 1px solid var(--border);
}
.settings-nav-section {
  font-size: 11px;
  font-weight: 600;
  letter-spacing: 0.6px;
  text-transform: uppercase;
  color: var(--muted-2);          /* match main sidebar section labels (.sidebar-section) */
  padding: var(--space-2) var(--space-4) 6px;
}
.settings-nav > .settings-nav-section:not(:first-child) {
  border-top: 1px solid var(--border);
  margin-top: var(--space-2);
  padding-top: 14px;
}
.settings-nav-item {
  display: flex;
  align-items: center;
  gap: 9px;
  padding: var(--space-2) var(--space-4);
  font-family: inherit;
  /* Item markup swaps between <button> (owning page) and <a> (linking to
     another settings-family page, per settings-shell.js) depending on which
     page is active. UA styles give <button> a different line-height than the
     inherited body 1.5 an <a> gets, so every item in the nav visibly jumped
     height when navigating between pages that own different sections. */
  line-height: inherit;
  font-size: 13px;
  font-weight: 500;
  color: var(--muted);            /* match the main sidebar nav (.nav-link) */
  cursor: pointer;
  border-radius: var(--radius);   /* rounded pill for hover + active */
  transition: all 0.15s;
  border: none;
  background: none;
  width: 100%;
  text-align: left;
  text-decoration: none;
  position: relative;
}
.settings-nav-item:hover { background: var(--bg); color: var(--fg); }
.settings-nav-item.active {
  background: var(--accent-50);   /* match main nav active (.nav-item.active) */
  color: var(--accent);
  font-weight: 600;
}
/* Left accent bar dropped — the active state now reads as a rounded inset pill
   (accent-50 fill + accent text), so the bar was redundant/clashing. */
.settings-nav-item.active::before { display: none; }
.settings-nav-item svg { flex-shrink: 0; opacity: 0.7; }
.settings-nav-item.active svg { opacity: 1; }
.settings-nav-item.danger { color: var(--danger); }
.settings-nav-item.danger:hover { background: var(--danger-50); }
.settings-nav-item.danger svg { opacity: 1; }
/* width:100% so the content FILLS the column (capped at --content-narrow) rather
   than shrink-wrapping its inner content — margin-inline:auto alone made a grid
   item size to its content, so pages with narrow vs wide content rendered at
   different widths ("cái lớn cái nhỏ"). */
.settings-content { padding: var(--space-8) var(--space-10); width: 100%; max-width: var(--content-narrow); margin-inline: auto; }

/* ── Mobile: collapse vertical sidebar to horizontal scrolling tab bar ─── */
@media (max-width: 768px) {
  .settings-layout {
    grid-template-columns: 1fr;
    min-height: auto;
  }
  .settings-nav {
    position: sticky;
    top: 0;
    z-index: 5;
    min-height: 0 !important;
    padding: 0 var(--space-1);
    border-right: none;
    border-bottom: 1px solid var(--border);
    display: flex;
    flex-wrap: nowrap;
    overflow-x: auto;
    overflow-y: hidden;
    scrollbar-width: thin;
    -webkit-overflow-scrolling: touch;
  }
  .settings-nav::-webkit-scrollbar { height: 3px; }
  .settings-nav-section { display: none; }
  .settings-nav > .settings-nav-section:not(:first-child) {
    border-top: none;
    margin-top: 0;
    padding-top: 0;
  }
  .settings-nav-item {
    flex: 0 0 auto;
    padding: var(--space-3) 14px;
    border-bottom: 2px solid transparent;
    white-space: nowrap;
    width: auto;
  }
  .settings-nav-item.active {
    background: transparent;
    color: var(--accent);
    border-bottom-color: var(--accent);
  }
  .settings-nav-item.active::before { display: none; }
  .settings-content { padding: var(--space-5) var(--space-4); max-width: 100%; }
}
.settings-section-panel { display: none; }
.settings-section-panel.active { display: block; }
/* Settings sections are bare form fields (unlike integrations/discord/reports which
   already group content in cards). Wrap each settings section in a surface card so
   the Settings page gets the same "nền" — scoped to the settings page so it does NOT
   over-card integrations/discord (which share .settings-section-panel). */
body[data-page="settings"] .settings-section-panel.active {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  padding: var(--space-6);
  /* The resting card shadow is scoped HERE (not the shared list) because the
     panel is a real card only on /settings — on integrations/discord it's a
     transparent wrapper around inner cards, where a shadow drew a phantom box
     around the whole group ("1 nền bao quanh nhiều card"). */
  box-shadow: var(--shadow-card);
}
.settings-panel-title { font-size: var(--fs-lg); font-weight: 700; color: var(--fg); letter-spacing: -0.3px; margin-bottom: var(--space-1); }
.settings-panel-desc { font-size: 13px; color: var(--muted); margin-bottom: 28px; line-height: 1.5; }
.settings-divider { border: none; border-top: 1px solid var(--border); margin: 28px 0; }
.form-hint { font-size: 12px; color: var(--muted); margin-top: 5px; line-height: 1.4; }
.settings-section-title { font-size: 13px; font-weight: 700; color: var(--muted); text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: var(--space-4); }
/* Danger cards (shared) */
.danger-card { border: 1.5px solid var(--danger-bg); border-radius: var(--radius-lg); padding: var(--space-5); margin-bottom: 14px; background: var(--danger-50); }
.danger-card:last-child { margin-bottom: 0; }
.danger-card-header { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); }
.danger-card-title { font-size: var(--fs-lg); font-weight: 700; color: var(--danger); }
.danger-card-desc { font-size: 12px; color: var(--muted); margin-top: var(--space-1); line-height: 1.4; }
/* Responsive */
@media (max-width: 820px) {
  /* minmax(0,1fr) + min-width:0 so a wide child (sub-nav, long row) can't blow the
     single column past the viewport — it scrolls/ellipsises within instead. */
  .settings-layout { grid-template-columns: minmax(0, 1fr); }
  .settings-nav {
    position: relative; min-height: unset; min-width: 0;
    border-right: none; border-bottom: 1px solid var(--border);
    display: flex; gap: 2px; padding: 10px var(--space-3);
    overflow-x: auto; scrollbar-width: none;
  }
  .settings-nav::-webkit-scrollbar { display: none; }
  .settings-nav-section { display: none; }
  .settings-nav-item { border-radius: var(--radius); white-space: nowrap; padding: var(--space-2) 14px; font-size: 12px; min-height: 38px; display: flex; align-items: center; }
  .settings-nav-item.active::before { display: none; }
  .settings-content { padding: var(--space-6) var(--space-5); min-width: 0; }
  /* The Discord sub-nav (.bot-nav) and any in-content tab strip must scroll, not
     widen the column. */
  .bot-nav { overflow-x: auto; scrollbar-width: none; min-width: 0; }
  .bot-nav::-webkit-scrollbar { display: none; }
}

/* ── Inline images in rendered markdown bodies (task description, docs, reports) ──
   Size-capped preview everywhere — some pages don't inject MARKDOWN_CSS, so this
   global rule guarantees uploads don't blow out the layout. Comments keep their
   tighter .td-comment-text img cap (more specific). Click-to-open: main.js. */
.md-body img, .md-body .md-img {
  max-width: min(100%, 560px); max-height: 420px; width: auto; height: auto;
  object-fit: contain; border-radius: var(--radius); display: block;
  margin: var(--space-1) 0; border: 1px solid var(--border); cursor: zoom-in;
}

/* ── iOS safe areas (Capacitor native shell) ──────────────────────────────
   `body.native` is set in main.js when running inside the Capacitor wrapper
   (see src/lib/platform.js isNative()). env(safe-area-inset-*) resolves to 0
   on non-notched devices / the web, so these rules are harmless there.
   Page modules call injectPageCSS() at runtime (AFTER this stylesheet loads)
   with rules like `body[data-page="…"] #app-page { padding: 0 }` — same
   specificity as the bare `body.native #app-page` rule below, so without
   !important, source order would let that later-injected reset zero out
   our bottom inset on focus-mode routes (settings/chat/auth/onboarding),
   including the bottom-pinned chat composer that can overlap the home
   indicator. !important on padding-bottom only forces our inset to win
   regardless of injection order, while leaving top/left/right padding
   untouched for pages that intentionally strip it.
   `#app-topbar` is `display:none !important` on settings-mode routes
   (replaced by `.settings-topbar`), so it also needs its own top-inset
   rule below — the #app-topbar rule alone doesn't reach it.
   Do NOT add overflow/scroll to #app-page here — only #app-page may scroll
   (see the html,body overflow:hidden rule above). */
/* The app shell is a fixed grid (grid-template-rows: var(--topbar-h) 1fr). Padding-top
   ALONE would eat the 64px topbar row (box-sizing:border-box) and cram the header
   against its bottom border with an empty surface gap above. So GROW the topbar track
   by the inset; padding-top then centers the header content in the 64px below the
   status bar, the surface fills the whole track (incl. behind the status bar), and the
   bottom border lands correctly. Row 2 (1fr) still fills the rest — scroll unaffected. */
/* ── Native type + control scale ──────────────────────────────────────────
   Web's 14px base body reads small on a phone (native iOS apps sit ~17px). Bump
   the design-system tokens on `body.native` so the WHOLE app scales up bigger +
   airier — every component reads these `--fs-*` / `--control-h*` custom props, so
   this one override cascades everywhere. Web is untouched. */
body.native {
  --fs-2xs: 12px;
  --fs-xs:  13px;
  --fs-sm:  15px;
  --fs-md:  16px;   /* base body: 14 → 16 */
  --fs-lg:  18px;
  --fs-xl:  23px;
  --fs-2xl: 30px;
  --control-h:    44px;   /* iOS-comfortable tap height: 36 → 44 */
  --control-h-sm: 38px;
}
body.native .app { grid-template-rows: calc(var(--topbar-h) + env(safe-area-inset-top)) 1fr; background: var(--bg); }
body.native #app-topbar { padding-top: env(safe-area-inset-top); }

/* ── Keyboard overlay mode (Capacitor Keyboard resize:none) ──────────────────
   The keyboard overlays the WebView instead of resizing it (zero relayout →
   smooth). native.js sets --kb-h + body.kb-open on keyboardWillShow. Bottom-
   pinned composers lift with a compositor-only transform on the UIKit keyboard
   curve; scrollers get one-time bottom room so covered content stays reachable. */
html { --kb-h: 0px; }
/* Settings/chat focus header replaces #app-topbar and has an explicit height:var(--topbar-h)
   (app.css ~733) — grow it the same way so its content isn't crammed either. */
body.native .settings-topbar { height: calc(var(--topbar-h) + env(safe-area-inset-top)); padding-top: env(safe-area-inset-top); }

/* ── One surface for every header bar (native) ───────────────────────────────
   There are three kinds of bar in this app — the brand bar (#app-topbar, on
   overview and pushed screens), the focus bar (.settings-topbar, on settings and
   chat), and the pinned page title (.page-header / .cal-header / .nf-head on
   every other tab). They were three different surfaces: white vs grey canvas,
   12 vs 14 vs 24px side padding, hairline vs no hairline. Side by side across the
   tab bar they read as three different apps.
   They agree now: page canvas, page gutter, no hairline. The bar stops being a
   thing you notice and goes back to being the top of the page. */
body.native #app-topbar,
body.native .settings-topbar {
  /* Frosted glass, not flat canvas: a translucent bg + backdrop blur so any
     content scrolling beneath the bar (and under the status bar it grows into)
     reads as softly obscured, the way a native iOS nav bar does. Over the plain
     canvas at rest it looks identical to the old solid fill — the frost only
     shows once something is behind it (i.e. when scrolled). */
  background: color-mix(in srgb, var(--bg) 80%, transparent);
  -webkit-backdrop-filter: saturate(180%) blur(20px);
  backdrop-filter: saturate(180%) blur(20px);
  border-bottom: none;
  padding-inline: var(--page-pad-x, 14px);
  transition: border-color var(--t-base) var(--ease), box-shadow var(--t-base) var(--ease);
}
body.native #app-sidebar { padding-top: env(safe-area-inset-top); }
/* Native drawer: no icon rail. Its whole job was the project shortcuts, and
   those now live in the bottom bar's workspace sheet — one swipe from the
   thumb instead of hamburger → aim at a 36px icon. What was left in the rail
   duplicated its neighbours anyway (the logo repeats the workspace switcher
   right beside it; the folder repeats the "Dự án" nav row below it), and
   dropping the 52px strip gives the nav column the full drawer width. */
body.native .sidebar-rail { display: none; }
body.native #app-page { padding-bottom: env(safe-area-inset-bottom) !important; overscroll-behavior-y: contain; }

/* ── Bottom tab bar (native shell) — familiar mobile primary nav ──────────────
   Hidden on web + on focus-mode routes (settings/integrations/discord/chat all
   carry body.settings-mode) and auth/onboarding. On main routes it sits fixed
   at the bottom (above the home indicator) and the page reserves room for it. */
#app-bottomnav { display: none; }
/* FLOATING GLASS CAPSULE (iOS 26 tab-bar language). Not a slab welded to the
   bottom edge: a rounded bar inset from all three sides, with the page visibly
   flowing UNDER and AROUND it. That gap is the whole effect — a full-bleed bar
   reads as chrome, a floating one reads as glass sitting on the content. */
body.native #app-bottomnav {
  display: flex; position: fixed; z-index: 90;
  left:  max(var(--space-3), env(safe-area-inset-left));
  right: max(var(--space-3), env(safe-area-inset-right));
  /* Cap the pill and centre it so it reads as ONE floating bar across iPhone
     sizes instead of stretching edge-to-edge (on a Pro Max the 5 slots drifted
     far apart and looked sparse; on an SE it hugged the edges). max-width +
     margin:auto centre it WITHOUT a transform, so the drawer-open push transform
     (translate3d by --sidebar-w) still works. Small phones fall back to the
     left/right gap when the viewport is narrower than the cap. */
  max-width: 356px;   /* compact floating pill: 5 slots × ~68px reads balanced and
                         consistent on every iPhone (SE→Pro Max) instead of
                         stretching to the screen edges. */
  margin-inline: auto;
  /* Sit just ABOVE the home indicator, not above the whole 34pt safe area — a
     full inset plus a gap put the bar ~46pt off the bottom, which reads as
     floating away from the screen rather than resting on it. iOS keeps roughly
     10-12pt of clearance. The max() keeps a sane gap on devices with no inset. */
  bottom: max(var(--space-2), calc(env(safe-area-inset-bottom) - 16px));
  height: var(--bottomnav-h);
  /* Full capsule. It was cut back to --radius-lg while the bar was 56px tall,
     where a pill meant 28px corners eating the outer tabs' tap area. Icon-only
     dropped the bar to 46px, so the same pill is only 23px and reads tight
     rather than bubbly — the shape the shorter bar wants. */
  border-radius: var(--radius-full);
  /* Breathing room inside the capsule: without it the first/last item's active
     pill runs straight into the rounded corner. */
  padding-inline: var(--space-2);
  /* Opaque fallback first; the @supports block below turns it to glass. */
  background: var(--surface);
  /* No border. The shadow already separates the bar from the content behind it;
     a hairline outline on top of that drew the capsule as an object with an edge
     instead of a pane of glass. */
  border: 0;
  box-shadow: var(--shadow-lg);
  overflow: hidden;                 /* keep the items inside the capsule */
}
@supports ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
  body.native #app-bottomnav {
    /* Low opacity + heavy blur = you can read the content moving underneath,
       which is what makes it feel like glass rather than a tinted panel. The
       inset highlight is the light catching the top edge. */
    background: color-mix(in srgb, var(--surface) 58%, transparent);
    -webkit-backdrop-filter: saturate(200%) blur(28px);
    backdrop-filter: saturate(200%) blur(28px);
    box-shadow: inset 0 1px 0 color-mix(in srgb, #fff 40%, transparent), var(--shadow-lg);
  }
  [data-theme="dark"] body.native #app-bottomnav {
    background: color-mix(in srgb, var(--surface) 52%, transparent);
    box-shadow: inset 0 1px 0 color-mix(in srgb, #fff 10%, transparent), var(--shadow-lg);
  }
}
body.native.settings-mode #app-bottomnav,
body.native.auth-mode #app-bottomnav,
body.native.onboarding-mode #app-bottomnav { display: none; }
/* Reserve space so content isn't hidden behind the bar (only where it shows).
   Includes a --space-4 breathing gap ON TOP of the exact nav height — otherwise
   the last row sits FLUSH against the bar (its subtitle touches the opaque nav
   edge and reads as "covered"). */
body.native:not(.settings-mode):not(.auth-mode):not(.onboarding-mode) #app-page {
  /* Derived from where the bar ACTUALLY sits (same expression as its `bottom`)
     + its height + one gap. Hard-coding "safe-area + space-7" left ~40px of dead
     space under the last row once the bar moved down. */
  padding-bottom: calc(max(var(--space-2), env(safe-area-inset-bottom) - 16px)
                       + var(--bottomnav-h) + var(--space-4)) !important;
}
/* Keyboard overlay mode: one-time scroll room so in-flow inputs (task comments,
   forms, wizards) can scroll clear of the overlaying keyboard. Must sit AFTER
   (and out-important) the safe-area + bottom-nav padding rules above. */
body.native.kb-open #app-page { padding-bottom: calc(var(--kb-h) + 24px) !important; }
/* …and out-specificity the :not()-chained bottom-nav clearance rule above
   ((1,4,1) beats a plain (1,2,1) even among !important rules). */
body.native.kb-open:not(.settings-mode):not(.auth-mode):not(.onboarding-mode) #app-page {
  padding-bottom: calc(var(--kb-h) + 24px) !important;
}

/* ═══ NATIVE (iOS) — soft card elevation + press feedback ═════════════════════
   The web design system is deliberately FLAT: content cards are border-only, no
   resting shadow. iOS reads the opposite way — a card is a physical thing lifted
   off the canvas — so on the native shell (and ONLY there) cards get a soft
   two-layer shadow and their border softens, since the shadow now does the work
   of separating the card from the background.

   Buttons get the iOS press: they shrink slightly under the finger and spring
   back on release. `--ease-spring` overshoots on the way back so the release
   feels elastic, not linear. Touch targets keep their size; only the paint
   scales, so nothing gets harder to hit. */
:root {
  /* Resting card shadow — deliberately LIGHT + SMOOTH. The card already reads as
     a card from its full border; the shadow only needs to lift it a hair off the
     canvas. Keep the ink low (so it never looks "quá đậm" on desktop) but spread
     it over a WIDE blur — a tight 2–3px blur reads as a hard edge ("quá gắt"); a
     wide, low-opacity ambient reads as a soft diffuse lift. One token app-wide. */
  --shadow-card:      0 1px 2px rgba(16, 24, 40, .03), 0 4px 12px rgba(16, 24, 40, .045);
  --shadow-native:    0 1px 2px rgba(16, 24, 40, .04), 0 4px 12px rgba(16, 24, 40, .06);
  --shadow-native-lg: 0 2px 4px rgba(16, 24, 40, .05), 0 8px 20px rgba(16, 24, 40, .09);
  --ease-spring: cubic-bezier(.2, .8, .2, 1.2);
}
/* Dark canvases swallow soft shadows — keep the ink strong so the lift still
   reads, but spread it over a wide blur so the edge stays soft, not harsh. */
[data-theme="dark"] {
  --shadow-card:      0 1px 2px rgba(0, 0, 0, .22), 0 6px 18px rgba(0, 0, 0, .26);
  --shadow-native:    0 1px 2px rgba(0, 0, 0, .30), 0 6px 16px rgba(0, 0, 0, .34);
  --shadow-native-lg: 0 2px 6px rgba(0, 0, 0, .34), 0 10px 24px rgba(0, 0, 0, .42);
}

/* Content cards carry a soft resting shadow app-wide (web + iOS) — they used to
   be flat/border-only on web, but the lift reads better on desktop too and keeps
   the two platforms consistent.
   The card LOOK is duplicated by ~35 bespoke classes across the pages (they each
   re-declare surface + border + radius-lg instead of extending .card), so lifting
   only `.card` left most of the app flat — the project list being the obvious one.
   This is the full set of card-like surfaces. When you add a new card, either
   extend `.card` or add it here, otherwise it will read as flat while
   everything around it is lifted.
   Deliberately NOT in the list: `.filter-bar` (a toolbar, not a card) and the
   notifications feed (`.nf-feed`/`.nf-empty`), which is intentionally flat on
   native — that inbox look was the whole point of the redesign. */
.card,
.stat-mini,
.stat-card,
.role-card,
.sk-card,
.project-card,
.ov-proj-card,
.grp-block,
.section-card,
.pd-hero,
.td-card,
.aside-card,
.md-card,
.md-stat,
.ld-card,
.ld-hero,
.ww-card,
.wr-empty,
.pc-card,
.cal-month,
.cal-week,
.cal-agenda,
.cal-side-card,
.summary-card,
.chart-wrap,
.bot-stat-card,
.team-stat-card,
.connect-card,
.perm-matrix,
.profile-header,
.int-card,
.int-note,
.wr-checkin {
  /* ONE card style everywhere: full 1px var(--border) + a light resting shadow.
     The border is kept at full strength (NOT softened) so inner dividers/table
     lines meet a matching edge — a faded outer border made those inner borders
     read as "cut" where they hit the card boundary. */
  box-shadow: var(--shadow-card);
}

/* iOS (`body.native`) — card border matches desktop EXACTLY: 1px `var(--border)`
   (#e5e7eb). A brief experiment (2026-07-16) bumped native to `var(--border-2)`
   (#d1d5db) to fight retina antialiasing making the thin edge fade, but on-device
   it read too heavy/dark — the user wants the SAME light edge desktop shows, so
   we hold the desktop token. This block therefore just re-affirms `--border` for
   the native card list (the elevation rule above adds their resting shadow); keep
   the selector list in sync with that rule when adding a card surface. The two
   native-only card surfaces — cardable table rows and their filter-bar — set the
   same `var(--border)` at their own declaration sites below (their `border:`
   shorthand comes later in the file and would otherwise reset the color). */
body.native .card,
body.native .stat-mini,
body.native .stat-card,
body.native .role-card,
body.native .sk-card,
body.native .project-card,
body.native .ov-proj-card,
body.native .grp-block,
body.native .section-card,
body.native .pd-hero,
body.native .td-card,
body.native .aside-card,
body.native .md-card,
body.native .md-stat,
body.native .ld-card,
body.native .ld-hero,
body.native .ww-card,
body.native .wr-empty,
body.native .pc-card,
body.native .cal-month,
body.native .cal-week,
body.native .cal-agenda,
body.native .cal-side-card,
body.native .summary-card,
body.native .chart-wrap,
body.native .bot-stat-card,
body.native .team-stat-card,
body.native .connect-card,
body.native .perm-matrix,
body.native .profile-header,
body.native .int-card,
body.native .int-note,
body.native .wr-checkin {
  border-color: var(--border);
}

/* Elevated cards need room for the shadow to FALL. These grids were tuned for
   flat, border-only cards, so 8–10px gaps were fine; with a shadow the upper
   card's blur lands on the next card and the two read as one glued block. Now
   that the shadow is app-wide, widen the gaps app-wide too. */
.ov-stats      { gap: var(--space-4); }
.ov-projects   { gap: var(--space-4); }
.summary-strip { gap: var(--space-4); }
.lv-row + .lv-row { margin-top: var(--space-4); }

/* Press: buttons shrink; a pressable card presses INTO the page (shadow drops). */
body.native .btn,
body.native .btn-icon,
body.native .bn-item,
body.native .chat-rail-ncompose,
body.native .fab,
body.native .card[role="button"],
body.native a.card,
body.native .project-card,
body.native .ov-proj-card {
  transition: transform var(--t-fast) var(--ease-spring),
              box-shadow var(--t-fast) var(--ease);
}
body.native .btn:active,
body.native .btn-icon:active,
body.native .chat-rail-ncompose:active,
body.native .fab:active { transform: scale(.95); }
body.native .bn-item:active { transform: scale(.92); }
body.native .card[role="button"]:active,
body.native a.card:active,
body.native .project-card:active,
body.native .ov-proj-card:active {
  transform: scale(.985);
  box-shadow: 0 1px 2px rgba(16, 24, 40, .06);
}
/* A raised primary button loses its lift while held — it's being pushed down. */
body.native .btn-primary { box-shadow: var(--shadow-native); }
body.native .btn-primary:active { box-shadow: none; }

@media (prefers-reduced-motion: reduce) {
  body.native .btn,
  body.native .btn-icon,
  body.native .bn-item,
  body.native .chat-rail-ncompose,
  body.native .fab,
  body.native .card[role="button"],
  body.native a.card,
  body.native .project-card,
  body.native .ov-proj-card { transition: none; }
  body.native .btn:active,
  body.native .btn-icon:active,
  body.native .bn-item:active,
  body.native .chat-rail-ncompose:active,
  body.native .fab:active,
  body.native .card[role="button"]:active,
  body.native a.card:active,
  body.native .project-card:active,
  body.native .ov-proj-card:active { transform: none; }
}

/* ── Native list-first chat shell integration ────────────────────────────────
   The chat page (src/pages/chat.js) owns its own header in both modes, so on the
   native shell we hide the slim focus header and collapse its grid row to just
   the status-bar strip (no empty bar, no double header). The INBOX (list) keeps
   the primary tab bar like Instagram/X; the THREAD stays full-focus (tab bar
   hidden by the settings-mode rule above). Body classes are set by chat.js. */
body.native.chat-native-list .settings-topbar,
body.native.chat-native-thread .settings-topbar { display: none !important; }
body.native.chat-native-list .app,
body.native.chat-native-thread .app {
  grid-template-rows: env(safe-area-inset-top) 1fr;
  background: var(--surface);
}
body.native.settings-mode.chat-native-list #app-bottomnav { display: flex !important; }
.bn-item {
  flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; align-items: center;
  justify-content: center; gap: 2px; height: var(--bottomnav-h);
  color: var(--muted); text-decoration: none; background: none; border: none;
  cursor: pointer; position: relative; -webkit-tap-highlight-color: transparent;
  transition: color var(--t-fast) var(--ease);
}
/* Five items with labels: the glyph goes back to 24px and the label to 10px, but
   the label gets a hair more room to breathe so "Thông báo"/"Trò chuyện" don't
   crowd their neighbours at five-across. */
.bn-item .bn-ic { width: 24px; height: 24px; }
.bn-menu { -webkit-tap-highlight-color: transparent; }
.bn-item { padding-inline: 2px; }
/* line-height 1 makes the line box exactly the font size, so Vietnamese ink that
   lives OUTSIDE it — the dot under ệ, the tail of ụ, the hook on ổ — falls outside
   the box and `overflow: hidden` shaves it off. (scrollHeight can't see this: it
   measures layout boxes, not ink, so it reports "no overflow" while pixels are
   being cut.) 1.35 gives the diacritics room above and below the baseline. */
.bn-label { font-size: 10px; font-weight: 600; letter-spacing: .01em; line-height: 1.35;
  max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.bn-item.active { color: var(--accent); }
.bn-item:active { opacity: .6; }
/* Tasks count badge on the bottom-nav tab. */
.bn-badge { position: absolute; top: 6px; left: 50%; margin-left: 6px;
  min-width: 16px; height: 16px; padding: 0 4px; border-radius: 8px;
  /* count badge — accent tone, not alarm red (matches .nav-badge) */
  background: var(--accent); color: #fff; font-size: 10px; font-weight: 700;
  line-height: 16px; text-align: center; }
/* Unread dot on the Notifications + Chat tabs (no count — social-app style).
   Accent-tone, not alarm red: "unread" is new-activity, not a danger state, so
   it follows the tone setting like .nf-dot / the count badges (was --danger). */
.bn-dot { position: absolute; top: 7px; left: 50%; margin-left: 5px;
  width: 9px; height: 9px; border-radius: 50%; background: var(--accent);
  box-shadow: 0 0 0 2px var(--surface); }
.bn-dot[hidden] { display: none; }

/* ── Native: icon-only tab bar ───────────────────────────────────────────────
   Labels come off on iOS. Five VN labels at 10px across a bar is a band of tiny
   text that adds no information — the glyphs are conventional (house, check,
   bell, speech bubble) and the workspace avatar is an image, not a word. The
   bar loses 10px of height and the icons get room to breathe.
   The names live on: every tab keeps its aria-label, so VoiceOver still reads
   "Tổng quan", "Nhiệm vụ", … */
/* 54px, not 46: with a fully-rounded bar the corner radius IS half the height,
   so a taller bar is the only way to get a rounder pill. It also gives the 24px
   glyphs room now that the labels are gone. */
body.native { --bottomnav-h: 62px; }
/* Bigger, more comfortable native tab bar (was 54px bar / 24px icon-only glyphs —
   too small/cramped on device). Taller bar + larger glyphs + a larger active pill
   and centre workspace avatar. Web (labelled, 5-across) keeps the 24px glyph. */
body.native .bn-item .bn-ic { width: 28px; height: 28px; }
body.native .bn-ws-face { width: 44px; height: 44px; }
body.native .bn-ws-ava  { width: 38px; height: 38px; font-size: var(--fs-lg); }
body.native .bn-label { display: none; }
body.native .bn-item { gap: 0; }
/* Losing the label costs the strongest "you are here" signal, so the selected
   glyph gets a tinted pill behind it — the same job the label colour did. */
body.native .bn-item::before {
  content: ''; position: absolute; width: 44px; height: 44px;
  /* Centre on the slot, not on the static position — without this the pill
     pinned to the item's top-left and read as a mis-aligned block. */
  left: 50%; top: 50%; transform: translate(-50%, -50%);
  /* A CIRCLE behind the active glyph (was a rounded-rect pill) — matches the
     circular centre workspace hub, so the whole bar speaks one shape language. */
  border-radius: 50%; background: transparent;
  transition: background var(--t-fast) var(--ease);
}
body.native .bn-item.active::before { background: color-mix(in srgb, var(--accent) 13%, transparent); }
/* The absolutely-positioned ::before would otherwise paint over the glyph. */
body.native .bn-item .bn-ic { position: relative; z-index: 1; }
/* Anchored to where the glyph actually sits — (bar 54 − icon 24) / 2 = 15px —
   so the badge/dot clip the icon's top-right corner instead of floating above it. */
body.native .bn-badge { top: 12px; }
body.native .bn-dot   { top: 15px; }

/* ── Centre slot: the workspace avatar ───────────────────────────────────────
   A raised circular button so the bar reads as 2 + hub + 2 (the iOS pattern)
   and the thumb lands on it without aiming. Its face is the workspace avatar:
   identity is always on screen, and one tap opens the switch/create sheet. */
.bn-ws {
  flex: 0 0 auto; width: 60px; height: var(--bottomnav-h);
  display: flex; align-items: center; justify-content: center;
  background: none; border: 0; padding: 0; cursor: pointer;
  -webkit-tap-highlight-color: transparent; touch-action: manipulation;
}
.bn-ws-face {
  width: 40px; height: 40px; border-radius: var(--radius-full);
  display: flex; align-items: center; justify-content: center;
  transition: transform var(--t-fast) var(--ease-spring, var(--ease)),
              box-shadow var(--t-fast) var(--ease);
}
.bn-ws:active .bn-ws-face { transform: scale(.92); }
/* Open = the avatar wears the accent ring, the same "selected" language the
   active tab uses. No lift, no drop shadow: inside a glass pill both would
   read as a second, competing surface. */
.bn-ws.open .bn-ws-face { box-shadow: 0 0 0 2.5px var(--accent); }
.bn-ws-ava {
  width: 34px; height: 34px; border-radius: var(--radius-full); overflow: hidden;
  display: flex; align-items: center; justify-content: center;
  background: var(--accent); color: #fff;
  font-family: var(--font-display); font-size: var(--fs-md); font-weight: 800; line-height: 1;
}
.bn-ws-ava img { width: 100%; height: 100%; object-fit: cover; }

/* ── Workspace sheet (components/ws-sheet.js) ────────────────────────────── */
.wsh-backdrop {
  position: fixed; inset: 0; z-index: 120; background: rgba(8,10,20,0);
  transition: background var(--t-base) var(--ease);
}
.wsh-backdrop.open { background: rgba(8,10,20,.38); }
.wsh-sheet {
  position: fixed; left: 0; right: 0; bottom: 0; z-index: 121;
  background: var(--surface); border-radius: var(--radius-xl) var(--radius-xl) 0 0;
  padding: var(--space-2) var(--space-4) calc(var(--space-5) + env(safe-area-inset-bottom));
  box-shadow: var(--shadow-lg);
  transform: translate3d(0, 100%, 0);
  transition: transform var(--t-base) var(--ease);
  max-height: 82dvh; overflow-y: auto;
}
.wsh-sheet.open { transform: translate3d(0, 0, 0); }
.wsh-grab { width: 36px; height: 4px; border-radius: var(--radius-full);
  background: var(--border-2); margin: var(--space-1) auto var(--space-3); }
/* Section titles carry the SAME token as a card header — --fs-lg / 700 / --fg —
   instead of the 11px uppercase muted eyebrow they were. The `section-title`
   suffix is load-bearing: the [class*="section-title"] rule near the top of this
   file is what hands them the display face, so a bespoke class name would have
   silently rendered them in the body face. */
.wsh-section-title {
  font-size: var(--fs-lg); font-weight: 700; color: var(--fg);
  line-height: var(--lh-tight); padding: var(--space-3) 0 var(--space-2);
}
.wsh-sheet > .wsh-section-title:first-of-type { padding-top: var(--space-1); }
.wsh-ava { display: flex; align-items: center; justify-content: center; flex-shrink: 0;
  border-radius: var(--radius-full); overflow: hidden; background: var(--accent); color: #fff;
  font-family: var(--font-display); font-weight: 800; line-height: 1; }
.wsh-ava img { width: 100%; height: 100%; object-fit: cover; }
.wsh-ava-lg { width: 44px; height: 44px; font-size: var(--fs-xl); }
.wsh-ava-sm { width: 32px; height: 32px; font-size: var(--fs-md); }
.wsh-act-ic { width: 18px; height: 18px; display: flex; color: var(--muted); }
/* ── Project strip: horizontal, scrollable, thumb-height ────────────────────
   Bleeds past the sheet's padding so tiles scroll in and out under the edges
   instead of stopping short of them. */
.wsh-projs {
  display: flex; gap: var(--space-3); overflow-x: auto; overscroll-behavior-x: contain;
  -webkit-overflow-scrolling: touch; scrollbar-width: none;
  margin-inline: calc(var(--space-4) * -1);
  padding: var(--space-1) var(--space-4) var(--space-2);
  scroll-snap-type: x proximity;
}
.wsh-projs::-webkit-scrollbar { display: none; }
.wsh-proj {
  flex: 0 0 auto; width: 68px; display: flex; flex-direction: column; align-items: center;
  gap: var(--space-2); background: none; border: 0; padding: 0; cursor: pointer;
  color: var(--fg); touch-action: manipulation; scroll-snap-align: start;
  transition: transform var(--t-fast) var(--ease);
  /* Long-press a tile to pin (iOS) — block the WebView's callout/selection on
     the hold so it doesn't fight the gesture. */
  -webkit-touch-callout: none; -webkit-user-select: none; user-select: none;
}
.wsh-proj:active { transform: scale(.94); }
/* The project you are currently inside. A ring on the icon (with a surface-gap
   so it doesn't smear into the neighbour) plus the name in full contrast —
   the same "selected" language the workspace row and the tab bar already use. */
.wsh-proj.active .wsh-proj-ic { box-shadow: 0 0 0 2px var(--surface), 0 0 0 4px var(--accent); }
.wsh-proj.active .wsh-proj-name { color: var(--fg); font-weight: 700; }
/* The icon renders through the shared project-icon helpers, so a project's
   colour/emoji matches the rail, /projects and the project header exactly. */
.wsh-proj-ic {
  width: 52px; height: 52px; border-radius: var(--radius-lg); flex-shrink: 0;
  display: flex; align-items: center; justify-content: center; overflow: hidden;
  font-size: var(--fs-lg); font-weight: 700;
  position: relative;
}
/* Pinned project (long-press a tile to pin) — accent dot at the icon's corner,
   mirrors the desktop rail's .rail-proj.is-pinned badge. overflow:hidden on the
   icon clips a ::after inside it, so the badge lives on the tile wrapper. */
.wsh-proj.is-pinned { position: relative; }
.wsh-proj.is-pinned::after {
  /* Inside the tile (was top:-4px, outdented) — the `.wsh-projs` strip is
     overflow-x:auto (→ clips vertically too), so an outdented badge got cut off
     like the desktop rail one. Sit it just inside the icon's top-right corner. */
  content: ''; position: absolute; top: 1px; right: 8px; z-index: 2;
  width: 17px; height: 17px; border-radius: 50%;
  background: var(--surface); box-shadow: 0 1px 3px rgba(0,0,0,.28);
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='11' height='11' viewBox='0 0 24 24' fill='none' stroke='%235865f2' stroke-width='2.4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 17v5'/%3E%3Cpath d='M9 10.8V4h6v6.8l3 3.2V17H6v-3z'/%3E%3C/svg%3E");
  background-repeat: no-repeat; background-position: center;
}
.wsh-proj-ic img { width: 100%; height: 100%; object-fit: cover; }
.wsh-proj-name {
  font-size: var(--fs-2xs); font-weight: 500; color: var(--fg-2); line-height: 1.3;
  max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* "All projects" is a destination, not a project — neutral box, no identity colour. */
.wsh-all-ic { background: var(--surface-2); color: var(--muted); border: 1px solid var(--border); }
.wsh-all-ic svg { width: 22px; height: 22px; }
.wsh-act-ic svg { width: 100%; height: 100%; }
/* No rule under the section title — the title already separates the sections,
   and a line right beneath it made the heading read as a table header. */
.wsh-list { display: flex; flex-direction: column; }
.wsh-row {
  position: relative;
  display: flex; align-items: center; gap: var(--space-3);
  /* width:auto, NOT 100%. With an explicit width the negative inline margins
     can't widen the box — the right one is ignored and the whole row just
     shifts 12px left, so the active fill sat off-centre. As a block-level flex
     child it fills the container and the negative margins bleed BOTH edges. */
  width: auto;
  padding: var(--space-3) 0; background: none; border: 0;
  text-align: left; cursor: pointer; color: var(--fg); touch-action: manipulation;
}
/* No dividers at all. Rows this tall, with an avatar anchoring each one, are
   already separate — the hairlines were drawing structure the layout provided
   for free. What the list actually had to answer is "which one am I in", and a
   filled row says that instantly, where a rule said nothing.
   The fill bleeds past the text column so it reads as a selected row, not a
   highlighted label; the negative margin keeps the text aligned with the
   section titles above it. */
.wsh-row {
  border-radius: var(--radius);
  padding-inline: var(--space-3);
  margin-inline: calc(var(--space-3) * -1);
}
/* Selected = accent tint, the same language the tab bar's active pill uses.
   Press stays --surface-2 below, so tapping another row can't be mistaken for
   having selected it. */
.wsh-row.active { background: color-mix(in srgb, var(--accent) 11%, transparent); }
.wsh-row:active { background: var(--surface-2); }
.wsh-row-body { flex: 1 1 auto; min-width: 0; }
.wsh-row-name { display: block; font-size: var(--fs-md); font-weight: 600;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* The tick alone marks the current workspace. The name used to turn accent
   blue too — double-marking, and against the house rule that names/titles stay
   --fg and accent is reserved for interactive chrome. */
.wsh-row.active .wsh-row-name { color: var(--fg); font-weight: 700; }
/* "Create workspace" sits in the same list so the avatar column keeps aligning,
   but a dashed empty slot says "add one" instead of "here is another one". */
.wsh-ava-new {
  background: transparent; border: 1px dashed var(--border-2); color: var(--muted);
  padding: 7px;
}
.wsh-ava-new svg { width: 100%; height: 100%; }
.wsh-new-ws { border-bottom: 0; }
.wsh-new-ws .wsh-row-name { color: var(--fg-2); font-weight: 500; }
.wsh-check { width: 18px; height: 18px; color: var(--accent); flex-shrink: 0; }
.wsh-check svg { width: 100%; height: 100%; }
/* Closes the section it belongs to, so "Tất cả dự án" reads as the tail of the
   project list rather than a floating row between two sections. */

/* ── Bottom-sheet modals (native) — slide up from the bottom w/ a grabber ─────
   The shared modal system (.modal-overlay/.modal) becomes an iOS-style sheet on
   native. Drag-to-dismiss is wired in native-gestures.js (initSheetDismiss). */
body.native .modal-overlay { align-items: flex-end; padding: 0; }
body.native .modal {
  max-width: 100%; width: 100%;
  border-radius: var(--radius-xl) var(--radius-xl) 0 0;
  max-height: min(92vh, calc(100dvh - env(safe-area-inset-top) - 8px));
  padding-bottom: max(env(safe-area-inset-bottom), var(--space-3));
  animation: sheetUp var(--t-slow) var(--ease-spring);
  will-change: transform;
}
@keyframes sheetUp { from { transform: translateY(100%); } to { transform: none; } }
/* Backdrop fades in alongside the sheet rather than snapping. */
body.native .modal-overlay { animation: sheetFade var(--t-base) var(--ease); }
@keyframes sheetFade { from { opacity: 0; } to { opacity: 1; } }
@media (prefers-reduced-motion: reduce) {
  body.native .modal { animation: none; }
  body.native .modal-overlay { animation: none; }
}
/* Grabber handle at the top of every sheet. */
body.native .modal::before {
  content: ''; display: block; flex-shrink: 0;
  width: 36px; height: 4px; border-radius: 999px;
  background: var(--border-2); margin: 8px auto 2px;
}

/* ── Pull-to-refresh indicator (native) ──────────────────────────────────────
   Lives in #app (survives #app-page route wipes); JS drives its transform +
   opacity from the pull distance. */
body.native .ptr-indicator {
  position: fixed; left: 50%; z-index: 70;
  top: calc(var(--topbar-h) + env(safe-area-inset-top) - 30px);
  width: 34px; height: 34px; display: grid; place-items: center;
  background: var(--surface); border: 1px solid var(--border); border-radius: 50%;
  box-shadow: var(--shadow-md); opacity: 0; transform: translateX(-50%);
  pointer-events: none;
}
body.native .ptr-spin {
  width: 18px; height: 18px; border-radius: 50%;
  border: 2.4px solid var(--border-2); border-top-color: var(--accent);
}
body.native .ptr-indicator.ready .ptr-spin { border-color: var(--accent); border-top-color: transparent; }
body.native .ptr-indicator.spinning .ptr-spin { animation: ptr-rot .7s linear infinite; }
@keyframes ptr-rot { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { body.native .ptr-indicator.spinning .ptr-spin { animation-duration: 1.6s; } }


/* ── Offline banner (native) — a slim bar under the topbar ────────────────────*/
#net-offline-banner {
  position: fixed; left: 0; right: 0; z-index: 88;
  top: calc(var(--topbar-h) + env(safe-area-inset-top));
  padding: 6px var(--space-4); text-align: center;
  font-size: var(--fs-xs); font-weight: 600; color: #fff;
  background: var(--danger); box-shadow: var(--shadow-sm);
}
body.native.settings-mode #net-offline-banner { top: calc(var(--topbar-h) + env(safe-area-inset-top)); }
#net-offline-banner[hidden] { display: none; }

/* ── Swipe-action rows (native) — right = complete (green), left = delete (red) */
body.native .ov-task-row[data-id], body.native .data-table tbody tr[data-id] { will-change: transform; }
body.native .ov-task-row.swipe-complete { background: color-mix(in srgb, var(--success) 16%, var(--surface)); }
body.native .ov-task-row.swipe-delete   { background: color-mix(in srgb, var(--danger) 16%, var(--surface)); }
body.native .data-table tbody tr.swipe-complete td { background: color-mix(in srgb, var(--success) 14%, var(--surface)); }
body.native .data-table tbody tr.swipe-delete td   { background: color-mix(in srgb, var(--danger) 14%, var(--surface)); }

/* ── iOS interaction fixes (native shell) ─────────────────────────────────
   These target the WKWebView, not the notch. Kept in one block so the iOS
   behaviour is auditable in a single place. */

/* 1. NO zoom-on-focus. iOS Safari/WKWebView auto-zooms the page when a
   focused text control has font-size < 16px — the app's controls are 14px
   (--fs-md) / 13px (chat), so every search/comment/form tap j-zoomed the
   layout and left it zoomed. Force the 16px floor on native ONLY (web keeps
   the tighter 14px scale). Covers inputs, selects, textareas, the chat
   composer, and the contenteditable rich editor. Height tokens are unchanged
   — 16px sits fine in the 36–40px controls. */
body.native input:not([type="checkbox"]):not([type="radio"]),
body.native select,
body.native textarea,
body.native .form-input,
body.native .form-select,
body.native .filter-input input,
body.native .filter-select,
body.native .chat-input,
body.native .rich-editor-area,
body.native [contenteditable] { font-size: 16px; }

/* 2. Kill the grey tap-highlight flash on every tappable element (the app
   supplies its own :active / hover / focus-visible states). */
body.native * { -webkit-tap-highlight-color: transparent; }

/* 3. Floating bottom bars must clear the home indicator. bulk-bar (24px /
   mobile 12px) otherwise sat ON the indicator. */
body.native .bulk-bar.visible { bottom: calc(24px + env(safe-area-inset-bottom)); }
/* Save bars are pinned TOP-centre (base .savebar/.td-savebar use `top:`). On
   native the topbar is taller by env(safe-area-inset-top), so anchor below the
   full topbar (mirrors #net-offline-banner) and clear the old bottom value. */
body.native .savebar,
body.native .td-savebar { top: calc(var(--topbar-h) + env(safe-area-inset-top) - var(--space-2)); bottom: auto; }

/* ── /overview native polish (light — the layout is already grouped-inset) ──
   Softer iOS card corners + a touch more breathing room between sections; the
   stat strip and the "Cần chú ý" list already read as grouped-inset. */
body.native[data-page="overview"] .stat-mini { border-radius: var(--radius-lg); }
body.native[data-page="overview"] .ov-main > .card { border-radius: var(--radius-lg); }
body.native[data-page="overview"] .ov-stats { margin-bottom: var(--space-5); }

/* ══ Edge-to-edge feed (native / iOS) — FEED/LIST pages only ════════════════
   User (2026-07-12) rejected full-bleed on mixed-content pages but wants it on
   "trang như notification" — uniform item feeds. So it's scoped ONLY to the
   feed-list containers here (one card wrapping divided rows → spans the screen
   edges like iOS Mail/Messages). Do NOT add task-detail/overview/settings cards. */
body.native[data-page="notifications"] .nf-feed {
  margin-inline: calc(-1 * var(--page-pad-x, 14px));
  border-left: none; border-right: none;
  border-radius: 0;
}

/* ── iOS: only the Overview keeps the header bar ─────────────────────────────
   Every other screen owns its own big page title (Nhiệm vụ / Thông báo / Trò
   chuyện / Dự án …), so the brand bar above it was a second header saying nothing
   — it just ate vertical space on a phone. Overview keeps it (it's the home
   screen: workspace name, account, menu).

   TWO exceptions, both because the bar is doing real work there:
     • body.nav-detail — pushed screens use it as the iOS nav bar (‹ back + the
       page's own actions). Hiding it would strand the user with no back button.
     • body.settings-mode — chat/settings render their own slim header in that row.

   The scroller now spans the FULL height (row 1 collapses to 0), so the list
   scrolls up behind the status bar; the pinned header below grows by the inset
   to cover — and frost — that status-bar strip instead of leaving it a flat
   opaque band. (Previously row 1 was a solid safe-area strip the content never
   reached, which is why the status bar showed no translucency.) */
body.native:not([data-page="overview"]):not(.nav-detail):not(.settings-mode) .topbar {
  display: none !important;
}
body.native:not([data-page="overview"]):not(.nav-detail):not(.settings-mode) .app {
  grid-template-rows: 0 1fr;
}
/* /leave/new is the only screen in this set with no pinned header (a create
   wizard reached from the tab root, kept full-screen rather than nav-detail).
   With the scroller now starting at y:0, its breadcrumb would sit under the
   status bar — give the wizard shell the safe-area clearance directly. The
   class exists only here, so this is inert on every other route. */
body.native .lvn-shell { padding-top: env(safe-area-inset-top); }

/* ── iOS: on the screens with no header bar, the page's OWN title row becomes it ──
   Those screens lost the brand bar, so their title + actions must not scroll away
   with the content — they pin to the top and the list runs under them, exactly
   like a nav bar. The row bleeds to the page edges (cancelling .page's inline
   padding) so nothing shows through beside it, and carries a hairline so the
   boundary is legible while content passes underneath. */
/* The page's own top padding has to move INTO the pinned row, or the row sticks
   12px down and the list scrolls through the gap above it. */
body.native:not([data-page="overview"]):not(.nav-detail):not(.settings-mode) .page {
  padding-top: 0;
}
/* /reports is excluded: it uses the [secondary nav | content] shell (the settings
   family), where the title lives inside the content column — an ancestor clips the
   sticky, and pinning it there would float the title over its own nav. */
/* #app-page is in the selector on purpose: pages inject `#app-page > * { margin-inline:
   auto }` (tasks, calendar), and an injected sheet outranks this one at equal
   specificity — so without an ID here the bleed below is silently dropped and the
   title ends up indented twice (28px instead of 14px). */
body.native:not([data-page="overview"]):not([data-page="reports"]):not(.nav-detail):not(.settings-mode) #app-page .page-header,
body.native[data-page="calendar"] #app-page .cal-header,
body.native[data-page="notifications"] #app-page .nf-head {
  position: sticky;
  top: 0;
  z-index: var(--z-sticky);
  /* Frosted, matching the brand bar above it: the list scrolls UNDER this
     pinned title, so a translucent blurred backdrop (rather than the old opaque
     canvas) lets the content read softly through it — the friendlier iOS feel. */
  background: color-mix(in srgb, var(--bg) 80%, transparent);
  -webkit-backdrop-filter: saturate(180%) blur(20px);
  backdrop-filter: saturate(180%) blur(20px);
  transition: border-color var(--t-base) var(--ease), box-shadow var(--t-base) var(--ease);
  margin-inline: calc(-1 * var(--page-pad-x, 14px));
  /* Grow up into the status-bar inset: the frosted background then covers the
     safe-area strip (content scrolls behind it, so the status bar frosts too),
     while the extra padding-top keeps the title itself clear of the clock/
     battery. On web/non-notched the inset is 0, so this is the old 12px box. */
  padding: calc(12px + env(safe-area-inset-top)) var(--page-pad-x, 14px) 6px;
  margin-bottom: var(--space-3);
  /* No hairline: on a phone the pinned row already reads as the header because the
     content scrolls under it, and a permanent rule under every title made the top
     of each screen look boxed in. */

  /* ONE ROW, and it cannot grow. Every one of these bars used to size itself from
     whatever the page happened to put in it, so the "same" component was 53px on
     /notifications, 68 on /tasks, 95 on /calendar, 100 on /projects — and 312 on
     /team, where the title broke onto two lines, the subtitle wrapped one word per
     line, and the action buttons ran off the right edge of the screen. The phone
     breakpoint stacks .page-header into a column, which is what let it happen.
     Pin it: a row, no wrapping, title truncates, actions keep their size. */
  display: flex;
  flex-direction: row;
  align-items: center;
  flex-wrap: nowrap;
  gap: var(--space-2);
  min-height: calc(var(--topbar-h) + env(safe-area-inset-top));
  overflow: hidden;
}

/* ── Scroll separation for the top chrome (native) ───────────────────────────
   `body.topbar-scrolled` is toggled in main.js once #app-page has scrolled past
   a few px. Only then does a hairline appear under the bar that borders the
   content — at the top the chrome stays clean and seamless (the friendlier
   "khi scroll" behaviour the pinned headers deliberately dropped before). The
   pinned page/cal/nf header is the boundary on most tabs; on overview/reports
   (which have no pinned header) the content scrolls under #app-topbar itself. */
body.native.topbar-scrolled:not([data-page="overview"]):not([data-page="reports"]):not(.nav-detail):not(.settings-mode) #app-page .page-header,
body.native.topbar-scrolled[data-page="calendar"] #app-page .cal-header,
body.native.topbar-scrolled[data-page="notifications"] #app-page .nf-head {
  border-bottom: 1px solid var(--border);
}
body.native.topbar-scrolled[data-page="overview"] #app-topbar,
body.native.topbar-scrolled[data-page="reports"] #app-topbar {
  border-bottom: 1px solid var(--border);
}
body.native.topbar-scrolled.settings-mode .settings-topbar {
  border-bottom: 1px solid var(--border);
}

/* Every one of these headers has the same shape — page-menu.js puts the menu
   button in front of whatever the page rendered:
       [menu] [ title + subtitle ] [ actions… ]
   so the roles are addressed by position. The title block is the ONLY thing that
   may give way; actions keep their width, and nothing wraps. */
body.native #app-page .page-header > :first-child,
body.native #app-page .cal-header > :first-child,
body.native #app-page .nf-head > :first-child { flex: 0 0 auto; }
body.native #app-page .page-header > :nth-child(2),
body.native #app-page .cal-header > :nth-child(2),
body.native #app-page .nf-head > :nth-child(2) { flex: 1 1 auto; min-width: 0; }
body.native #app-page .page-header > :nth-child(n+3),
body.native #app-page .cal-header > :nth-child(n+3),
body.native #app-page .nf-head > :nth-child(n+3) { flex: 0 0 auto; }

body.native #app-page .page-header .page-title,
body.native #app-page .cal-header .cal-title,
body.native #app-page .nf-head .page-title {
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
/* No STATIC content in a pinned header (native, app-wide). The stat/greeting
   subtitle ("183 được giao · 0 quá hạn", "Chào buổi tối, …") is informational, not
   actionable — it doesn't earn a permanent slot in the fixed nav bar. Removed on
   native so the header is title + icon-actions only. The web keeps its subtitle
   (roomy header, and it reads as a page intro there). */
body.native #app-page .page-header .page-subtitle,
body.native #app-page .cal-header .cal-sub,
body.native #app-page .ov-header .ov-sub,
body.native #app-page .pd-hero-desc { display: none; }
/* Overview on native: the title lives in the topbar and the greeting subtitle is
   hidden above, so the "Báo cáo" link was left floating alone at the top of the
   content. Drop it — the whole .ov-header collapses and the stat strip leads. */
body.native #app-page .ov-header .ov-actions { display: none; }
/* …and nothing INSIDE the title block may wrap either. /projects puts a stats row
   under its title with an inline `flex-wrap: wrap`, which broke onto a second line
   and pushed that header to 100px while its neighbours sat at 64. The !important is
   the only way to beat an inline style, and this is the right place to spend it:
   the bar's height is a property of the bar, not of what a page decides to put in
   it. Anything too long ellipsises. */
body.native #app-page .page-header > :nth-child(2),
body.native #app-page .cal-header > :nth-child(2) { overflow: hidden; }
body.native #app-page .page-header > :nth-child(2) > *,
body.native #app-page .cal-header > :nth-child(2) > * {
  flex-wrap: nowrap !important;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
/* ICON-ONLY header actions (native, app-wide). Every action button in a pinned
   header is a square icon — no text label — so the header stays a clean nav bar
   at any width and reads the same on every screen. `font-size: 0` collapses the
   bare-text labels (the pages write them as plain text nodes, not spans, so CSS
   can't `display:none` them individually); the SVGs carry explicit width/height
   attributes so they survive font-size:0 untouched. The label text stays in the
   DOM, so the accessible name is preserved — a screen reader still announces
   "Thêm sự kiện" / "Mời thành viên".
   Scope note: this targets action `.btn`s ONLY. Segmented view toggles
   (`.view-btn`), the calendar prev/next (`.cal-nav-btn`), and the notifications
   "Đọc tất cả" link (`.nf-mark`) are NOT `.btn` action buttons and keep their text
   — an icon there would be less clear, not more. */
body.native #app-page .page-header .btn:not(.page-menu-btn),
body.native #app-page .cal-header .btn:not(.cal-nav-btn) {
  flex: 0 0 auto;
  width: var(--control-h-sm);
  min-width: var(--control-h-sm);
  height: var(--control-h-sm);
  padding: 0;
  gap: 0;
  justify-content: center;
  font-size: 0;
  overflow: hidden;
}
body.native #app-page .page-header .btn .btn-label,
body.native #app-page .cal-header .btn .btn-label { display: none; }
body.native #app-page .page-header .btn svg,
body.native #app-page .cal-header .btn svg { flex: none; }

/* The wrapper some pages put their buttons in must not stretch either. */
body.native #app-page .page-header > :nth-child(n+3),
body.native #app-page .cal-header > :nth-child(n+3) {
  width: auto;
  display: flex;
  align-items: center;
  gap: 6px;
}
/* The notifications page paints itself on --surface, so its pinned row must too,
   or the header reads as a different colour band than the list under it. */
body.native[data-page="notifications"] #app-page .nf-head {
  background: var(--surface);
  margin-inline: 0;
  /* 0 inline: the list below is inset by its own feed/row padding, and the title
     must land on the SAME 14px grid as every other screen's title. padding-top
     MUST carry the status-bar inset (this rule overrides the shared pinned-header
     padding above, which had it) — without it the bar keeps the taller
     min-height but pins the title 12px down, so the title floats low in the bar. */
  padding: calc(12px + env(safe-area-inset-top)) 0 6px;
}

/* ONE title style across these screens — /notifications set the bar (big display
   face) and the others were still rendering the smaller web page-title, so the
   same pinned row looked like two different components depending on the screen. */
body.native:not([data-page="overview"]):not(.nav-detail):not(.settings-mode) .page-title,
body.native[data-page="calendar"] .cal-title,
body.native[data-page="notifications"] .nf-head .page-title {
  font-family: 'Bricolage Grotesque', 'IBM Plex Sans', system-ui, sans-serif;
  font-weight: 800;
  font-size: 24px;
  letter-spacing: -0.02em;
  line-height: var(--lh-tight, 1.2);
  color: var(--fg);
}

/* Overview is the one screen with a header bar, so its hamburger lives there.
   Every other screen carries one next to its own title (lib/page-menu.js). */

/* The title-row hamburger: same weight as the title it sits beside, no chrome —
   it reads as part of the heading, not as a floating control. */
.page-menu-btn {
  flex-shrink: 0; display: inline-flex; align-items: center; justify-content: center;
  width: 34px; height: 34px; margin-left: -6px; margin-right: 2px;
  padding: 0; border: 0; border-radius: var(--radius-full);
  background: none; color: var(--fg); cursor: pointer;
  -webkit-tap-highlight-color: transparent; touch-action: manipulation;
}
.page-menu-btn svg { width: 22px; height: 22px; }
/* [menu][title] pack left; the row's action (if any) still hugs the right edge.
   #app-page is in the selector because the pages inject their own .page-header
   rules (justify-content: space-between), and an injected sheet outranks this one
   at equal specificity — without the ID the button landed ON TOP of the title. */
#app-page .has-page-menu {
  /* The phone breakpoint stacks .page-header into a COLUMN (title over actions),
     which parked the menu button ABOVE the title instead of beside it. This row is
     a nav bar now, so it stays a row. */
  display: flex; flex-direction: row; align-items: center; flex-wrap: nowrap;
  justify-content: flex-start; gap: 6px;
}
#app-page .has-page-menu > :not(.page-menu-btn) { min-width: 0; }
#app-page .has-page-menu > :nth-child(3) { margin-left: auto; }
.page-menu-btn:active { background: color-mix(in srgb, var(--fg) 8%, transparent); }

/* ── iOS nav bar (native) — centered title + contextual back chevron ─────────
   Restyles the persistent #app-topbar to read as a native nav bar: the screen
   title centers (iOS convention); on non-tab-root screens (body.nav-detail, set
   by updateTopbarNav in main.js) the hamburger + logo give way to a back chevron
   that pops the history. Web is untouched (all under body.native). */
.topbar-back { display: none; align-items: center; justify-content: center; background: none; border: none;
  cursor: pointer; color: var(--fg); padding: 0; width: 40px; height: 40px;
  margin-left: calc(-1 * var(--space-2)); border-radius: var(--radius-full);
  -webkit-tap-highlight-color: transparent; }
.topbar-back[hidden] { display: none !important; }
.topbar-back:active { opacity: .55; }
body.native #app-topbar { position: relative; }
body.native .topbar-title {
  position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%);
  max-width: calc(100% - 200px); text-align: center;
  font-weight: 700; font-size: var(--fs-lg); color: var(--fg);
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis; pointer-events: none;
}
/* Only PUSHED detail screens get the centered nav title (with the back chevron).
   Tab-root screens (overview/tasks/projects/calendar) use their own big page
   heading as the title, so a centered brand/name in the bar reads as misplaced. */
body.native:not(.nav-detail) .topbar-title { display: none; }
/* Detail nav bar keeps the back chevron + right actions but NO centered title
   (per product decision) — the entity name shows in the page hero already. */
body.native.nav-detail .topbar-title { display: none; }
body.native.nav-detail .hamburger,
body.native.nav-detail .topbar-brand-logo,
body.native.nav-detail .topbar-ws { display: none; }
body.native.nav-detail .topbar-back { display: inline-flex; }

/* ── iOS overview: the shared topbar IS the pinned page header ────────────────
   Overview is the one tab that keeps #app-topbar (it carries the account avatar
   menu — the only account access on native). To stop it reading as a second,
   different header, it now matches every other tab's [menu][title][actions] bar:
   the brand slot shows the page title "Tổng quan" (set native-only in
   titleForPath) instead of the logo + workspace name, and the avatar stays on
   the right. The page's own big title is hidden so it isn't shown twice; the
   greeting + report button stay in the content below. */
body.native[data-page="overview"] .topbar-brand-logo,
body.native[data-page="overview"] .topbar-ws { display: none; }
body.native[data-page="overview"] .topbar-title {
  display: block;
  position: static;
  transform: none;
  text-align: left;
  max-width: none;
  margin: 0;
  font-family: 'Bricolage Grotesque', 'IBM Plex Sans', system-ui, sans-serif;
  font-weight: 800;
  font-size: 24px;
  letter-spacing: -0.02em;
  color: var(--fg);
  pointer-events: auto;
}
/* The page's big title now lives in the bar — hide the in-content duplicate. */
body.native[data-page="overview"] .ov-title { display: none; }
/* Overview's menu button is the topbar hamburger; every other tab injects a
   .page-menu-btn. Match them exactly so the menu icon reads identically
   app-wide: same --fg colour (not the topbar's muted grey), same 34px box +
   pill radius, same left pull that tucks it against the screen edge, same 22px
   glyph. (The SVG geometry already matches — see topbar.js.) */
body.native[data-page="overview"] .hamburger {
  width: 34px; height: 34px;
  margin-left: -6px; margin-right: 2px;
  border-radius: var(--radius-full);
  color: var(--fg);
}
body.native[data-page="overview"] .hamburger svg { width: 22px; height: 22px; }
body.native[data-page="overview"] .hamburger:active {
  background: color-mix(in srgb, var(--fg) 8%, transparent);
}

/* Native detail: swap the account avatar/notif on the right for the PAGE's own
   actions (populated into #topbar-page-actions by the page). */
.topbar-page-actions { display: none; }
body.native.nav-detail .topbar-actions > .popover-wrap { display: none; }
body.native.nav-detail .topbar-page-actions { display: inline-flex; align-items: center; gap: 2px; }
/* Relocated action buttons read as flat nav-bar icons (drop the in-page chrome).
   Buttons are square 40px tap targets with a 20px glyph — the in-page 13px icon
   looked tiny/floating in the bar ("hiển thị ko đúng"). Colour each glyph
   explicitly so none is white-on-white or invisibly muted. */
body.native.nav-detail .topbar-page-actions .td-action-btn,
body.native.nav-detail .topbar-page-actions .td-more-btn {
  border-color: transparent; background: transparent;
  width: 40px; height: 40px; padding: 0; color: var(--fg-2);
}
body.native.nav-detail .topbar-page-actions .td-action-btn svg,
body.native.nav-detail .topbar-page-actions .td-more-btn svg { width: 20px; height: 20px; }
body.native.nav-detail .topbar-page-actions .td-action-complete svg { color: var(--success); }
body.native.nav-detail .topbar-page-actions .td-action-complete.is-done svg { color: var(--fg-2); }
body.native.nav-detail .topbar-page-actions .td-action-danger svg { color: var(--danger); }
/* Icon-only: hide the TEXT labels but NOT the complete button's icon spans
   (.td-complete-ic). The page's own ≤560 rule hides ALL spans (icons too), so
   re-assert the check↔undo toggle here at higher specificity. */
body.native.nav-detail .topbar-page-actions .td-action-btn span:not(.td-complete-ic) { display: none; }
body.native.nav-detail .topbar-page-actions .td-complete-ic-do { display: inline-flex; }
body.native.nav-detail .topbar-page-actions .td-action-complete.is-done .td-complete-ic-do { display: none; }
body.native.nav-detail .topbar-page-actions .td-action-complete.is-done .td-complete-ic-undo { display: inline-flex; }
/* Text-labelled actions relocated from other detail pages (.btn / .ld-del-btn —
   e.g. work-report "Chỉnh sửa", member "Gỡ", leave "Xoá đơn") read as flat iOS
   bar text buttons; danger variants stay red. !important beats their in-page
   inline styles (member #md-remove sets height/border inline). */
body.native.nav-detail .topbar-page-actions .btn,
body.native.nav-detail .topbar-page-actions .ld-del-btn {
  background: transparent !important; border: none !important; box-shadow: none !important;
  height: 40px !important; min-height: 40px !important; padding: 0 var(--space-2) !important;
  color: var(--accent); font-size: var(--fs-md) !important; font-weight: 500; border-radius: var(--radius);
  width: auto; flex: none;
}
body.native.nav-detail .topbar-page-actions .btn svg,
body.native.nav-detail .topbar-page-actions .ld-del-btn svg { width: 18px; height: 18px; }
body.native.nav-detail .topbar-page-actions .btn-danger,
body.native.nav-detail .topbar-page-actions .btn-secondary#md-remove,
body.native.nav-detail .topbar-page-actions .ld-del-btn { color: var(--danger) !important; }
/* The in-page breadcrumb row is empty once its .td-actions moved to the bar. */
body.native .td-breadcrumb:not(:has(.td-actions)) { display: none; }

/* Native detail pages: the iOS nav bar (back chevron + centered title) replaces
   the desktop breadcrumb. Task/doc detail keep their inline action cluster
   (.td-actions lives INSIDE .td-breadcrumb) as a right-aligned page action bar;
   the other detail pages have a pure-nav breadcrumb line → hide it outright.
   All body.native-gated, so web keeps its breadcrumbs. */
body.native .td-breadcrumb > *:not(.td-actions) { display: none; }
body.native .td-breadcrumb { justify-content: flex-end; margin-bottom: var(--space-3); }
body.native .wd-breadcrumb,
body.native .ld-breadcrumb,
body.native .wp-breadcrumb,
body.native .ww-breadcrumb,
body.native .md-breadcrumb-bar { display: none; }
/* doc-detail wraps its breadcrumb + action cluster in #dd-header; the breadcrumb
   is hidden and the actions move to the bar, so hide the empty wrapper. */
body.native #dd-header { display: none; }

/* ── Tap feedback (native) — the "friendly" half, kept SMALL to avoid jank ─────
   Only genuine buttons dip on :active. We deliberately do NOT press cards/list
   rows: those are the scroll surface, so :active fires the instant a finger
   lands to scroll, making them flicker mid-scroll (the "giật" report). Buttons
   aren't a scroll surface, so their press is safe and reads as responsive. */
body.native .btn,
body.native .btn-icon {
  transition: transform var(--t-instant) var(--ease), opacity var(--t-instant) var(--ease);
}
body.native .btn:active,
body.native .btn-icon:active {
  transform: scale(0.96);
  opacity: 0.8;
}
@media (prefers-reduced-motion: reduce) {
  body.native .btn:active, body.native .btn-icon:active { transform: none; }
}

/* NOTE: page-to-page transitions are intentionally instant (no slide/crossfade).
   Animating the swap plays over an un-loaded page → empty-flash + content pop,
   which reads as jank. See router.js _render for the rationale. Per-element
   motion (hover, sheets, toasts) stays lively via the motion tokens. */

/* ══ Table → card (native) ═══════════════════════════════════════════════════
   A wide desktop <table> that side-scrolls is the #1 "this is a web page" tell
   on a phone. Opt-in `.cardable` tables (see src/lib/native-table.js, which tags
   each <td> with data-label) render as a stack of cards here: header hidden,
   each row a card, each cell a "label — value" line. Web is untouched. */
body.native .data-table.cardable { min-width: 0 !important; border: none; background: none; }
body.native .table-scroll:has(.data-table.cardable) { overflow: visible; }
/* OLD-iOS FIX (<15.4, no :has()): the rule above is dropped there, so the table
   keeps `overflow-x:auto` + its styled 6px scrollbar → a grey bar showed mid-
   screen on card-ified list pages. native-table.js adds `.has-cardable`, matched
   here in a SEPARATE block (a bad :has() in a selector LIST kills the whole rule
   on old WebKit, so these must NOT be comma-joined with the :has() rules). */
body.native .table-scroll.has-cardable { overflow: visible; }
/* Defense-in-depth: no native .table-scroll needs the styled horizontal scrollbar
   (tables are card-ified or overflow-x:hidden), and a visible bar was the reported
   old-iOS symptom — hide it on every iOS version regardless of overflow. */
body.native .table-scroll::-webkit-scrollbar { display: none; }
body.native .table-scroll { scrollbar-width: none; }
body.native .data-table.cardable thead { display: none; }
body.native .data-table.cardable,
body.native .data-table.cardable tbody { display: block; width: 100%; }
body.native .data-table.cardable tbody tr {
  display: block;
  background: var(--surface);
  border: 1px solid var(--border);   /* match desktop card edge exactly */
  border-radius: var(--radius-lg);
  /* These ARE the cards on native (the wrapper goes flat, below) — so they get the
     shared resting card shadow, same as every other card. */
  box-shadow: var(--shadow-card);
  padding: var(--space-1) var(--space-3);
  margin-bottom: var(--space-2);
}
body.native .data-table.cardable tbody tr:active { background: var(--surface-2); }
/* Card-on-card fix: a .list-card wrapping a cardable table (whose rows become
   surface CARDS on native) was ALSO a surface card, so the row-cards sat on a
   duplicate background ("nền item + nền bg"). Make the wrapper transparent so the
   member/report/leave cards float on the page canvas; the filter-bar (if any)
   becomes its own top card. One rule fixes every similar list. */
body.native .list-card:has(.data-table.cardable) {
  /* box-shadow:none is load-bearing — .list-card IS a .card, so the native
     elevation rule gave it a shadow. Zeroing the bg/border but leaving the shadow
     left a phantom shadow box around the whole list (read as a duplicate surface).
     The row-cards inside carry the elevation now; the wrapper is a pure container. */
  background: transparent; border: none; border-radius: 0; box-shadow: none; overflow: visible;
}
body.native .list-card:has(.data-table.cardable) > .filter-bar {
  background: var(--surface); border: 1px solid var(--border);   /* match desktop card edge exactly */
  border-radius: var(--radius-lg); margin-bottom: var(--space-3);
}
/* OLD-iOS twins of the two :has() rules above (see .has-cardable note) — kept in
   their own blocks so old WebKit parses them even though it drops the :has() ones. */
body.native .list-card.has-cardable {
  background: transparent; border: none; border-radius: 0; box-shadow: none; overflow: visible;
}
body.native .list-card.has-cardable > .filter-bar {
  background: var(--surface); border: 1px solid var(--border);
  border-radius: var(--radius-lg); margin-bottom: var(--space-3);
}
body.native .data-table.cardable tbody td {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-3);
  min-height: 40px;
  padding: 7px 0;
  border: none;
  border-bottom: 1px solid var(--border);
  text-align: right;
  white-space: normal;
}
body.native .data-table.cardable tbody td:last-child { border-bottom: none; }
body.native .data-table.cardable tbody td::before {
  content: attr(data-label);
  flex-shrink: 0;
  color: var(--muted);
  font-weight: 500;
  text-align: left;
}
/* Cells with no header label (checkbox / actions column) show value only, and
   render COMPACT (right-aligned, no divider) so a bulk-select checkbox doesn't
   become an orphan full-height row above the card content. */
body.native .data-table.cardable tbody td:not([data-label])::before,
body.native .data-table.cardable tbody td[data-label=""]::before { content: none; }
body.native .data-table.cardable tbody td:not([data-label]),
body.native .data-table.cardable tbody td[data-label=""] {
  justify-content: flex-end; min-height: 0; padding: 3px 0; border-bottom: none;
}

/* ══ Designed entity cards (native) ═══════════════════════════════════════════
   The plain label:value dump reads as a spreadsheet. For record lists that are
   really "a person/entity + a few numbers" (team, project members, reports
   member-detail, leave), opt in with `.nt-entity` on the `.cardable` table and
   tag cells with role classes: `.dtc-id` (avatar-led identity → card header),
   `.dtc-badge` (role/status pill → pinned top-right), `.dtc-stat` (a number →
   joins a 3-up strip, its data-label becomes the caption), `.dtc-hide` (column
   folded away on the card). Layout is pure CSS (flex-wrap + absolute badge), so
   web is untouched and native-table.js needs no change — it still supplies the
   stat captions via data-label. */
body.native .data-table.cardable.nt-entity tbody tr {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  position: relative;
  padding: var(--space-4);
  gap: 0 var(--space-2);
}
/* Columns that don't belong on a phone card (bulk checkbox, redundant status
   text — status already shows as the dot on the avatar). */
body.native .data-table.cardable.nt-entity tbody td.member-check-cell,
body.native .data-table.cardable.nt-entity tbody td.dtc-hide { display: none; }
/* Identity → full-width card header (avatar + name + email), no label. */
body.native .data-table.cardable.nt-entity tbody td.dtc-id {
  flex: 1 1 100%;                     /* own line → the stat strip wraps below */
  min-width: 0; min-height: 0;
  padding: 0 50% 0 0; border-bottom: none;   /* right half reserved for the pinned badge */
  justify-content: flex-start; text-align: left;
}
body.native .data-table.cardable.nt-entity tbody td.dtc-id::before { content: none; }
body.native .nt-entity .member-av,
body.native .nt-entity .md-av { width: 42px; height: 42px; font-size: var(--fs-lg); }
body.native .nt-entity .member-row-name,
body.native .nt-entity .md-name { font-size: var(--fs-lg); font-weight: 700; }
body.native .nt-entity .member-row-email { font-size: 12.5px; }
body.native .nt-entity .md-member { gap: var(--space-3); }
/* rate-bar line stretches full width in the card */
body.native .nt-entity td.dtc-line .md-rate { width: 100%; }
/* Role/status pill → pinned to the card's top-right corner. */
body.native .data-table.cardable.nt-entity tbody td.dtc-badge {
  position: absolute; top: var(--space-4); right: var(--space-4);
  width: auto; max-width: 46%;
  min-height: 0; padding: 0; border-bottom: none;
}
body.native .data-table.cardable.nt-entity tbody td.dtc-badge::before { content: none; }
body.native .data-table.cardable.nt-entity tbody td.dtc-badge .badge {
  max-width: 100%; overflow: hidden; text-overflow: ellipsis;
}
/* Numbers → equal columns in a strip under a hairline; value over caption. */
body.native .data-table.cardable.nt-entity tbody td.dtc-stat {
  flex: 1 1 0;
  min-width: 0; min-height: 0;
  flex-direction: column-reverse;
  align-items: center; justify-content: center; text-align: center;
  gap: 2px;
  margin-top: var(--space-3);
  padding: var(--space-3) 0 0;
  border-top: 1px solid var(--border); border-bottom: none;
  font-family: var(--font-display);
  font-weight: 800; font-size: var(--fs-xl);
  font-variant-numeric: tabular-nums;
  color: var(--fg);
}
body.native .data-table.cardable.nt-entity tbody td.dtc-stat::before {
  content: attr(data-label);
  font-family: var(--font); font-weight: 500;
  font-size: var(--fs-2xs); color: var(--muted); text-align: center;
}
body.native .nt-entity td.dtc-stat.dtc-good { color: var(--success); }
body.native .nt-entity td.dtc-stat.dtc-bad  { color: var(--danger); }
/* A full-width secondary line (e.g. leave reason / dates) — plain, no strip. */
body.native .data-table.cardable.nt-entity tbody td.dtc-line {
  flex: 1 1 100%;
  min-height: 0; padding: var(--space-1) 0 0; border-bottom: none;
  justify-content: flex-start; text-align: left;
  font-size: var(--fs-sm); color: var(--fg-2);
}
body.native .data-table.cardable.nt-entity tbody td.dtc-line::before {
  content: attr(data-label) ":\00a0"; color: var(--muted); font-weight: 500;
}
body.native .data-table.cardable.nt-entity tbody td.dtc-line.dtc-nolabel::before { content: none; }

/* Leave requests: a status-coloured left edge (pending/approved/rejected) +
   larger identity avatar/icon, matching the approved leave-card design. */
body.native .nt-entity tr[data-status="pending"]  { border-left: 3px solid var(--warning); }
body.native .nt-entity tr[data-status="approved"] { border-left: 3px solid var(--success); }
body.native .nt-entity tr[data-status="rejected"] { border-left: 3px solid var(--danger); }
body.native .nt-entity .lv-av { width: 40px; height: 40px; font-size: var(--fs-md); }
body.native .nt-entity .lv-emp-ico { width: 40px; height: 40px; font-size: var(--fs-xl); }
body.native .nt-entity .lv-emp-tname,
body.native .nt-entity .lv-who { font-size: var(--fs-lg); font-weight: 700; }
body.native .nt-entity td.dtc-line .lv-row-actions { justify-content: flex-start; flex-wrap: wrap; }
/* ── Leave card tidy-up (was "lộn xộn") ──
   1. The .dtc-id 50% right reservation is sized for the /team member card (wide
      role pill + email). Leave badges are short ("Chờ duyệt" ≈ 84px), so 50%
      needlessly truncated the type name ("Nghỉ phép năm" → "Nghỉ phép …") and
      wrapped the manager's member name to two lines. Reserve just enough to
      clear the pinned badge.
   2. The row navigates to the request on tap (JS on tr.lv-row), so the trailing
      chevron is redundant chrome — a lone "›" floating in its own line on
      approved/rejected cards read as clutter. Drop it, and collapse the action
      cell when it holds no real button (no [data-act]) so no empty line is left. */
body.native .data-table.cardable.nt-entity tbody tr[data-status] td.dtc-id { padding-right: 112px; }
body.native .nt-entity .lv-emp-chevron { display: none; }
body.native .nt-entity td.dtc-line:has(.lv-row-actions):not(:has([data-act])) { display: none; }

/* ── iOS swipe-to-reveal row actions (shared: leave + project members) ──
   The action controls don't sit in the card any more — the card is a clean row and
   they live in a drawer hidden just off the right edge. A left-swipe (see
   initSwipeReveal in native-gestures.js, which toggles .swipe-open) slides them
   in; a tap outside / scroll / swipe-right snaps them away. The tr clips the
   parked drawer; the cell is pulled out of the card's flex flow so it leaves no
   gap when closed. Opt-in: a row is `.swipe-row`, its action cell `.swipe-drawer`. */
body.native .data-table.cardable.nt-entity tbody tr.swipe-row { overflow: hidden; }
body.native .data-table.cardable.nt-entity tbody tr.swipe-row td.swipe-drawer {
  position: absolute; top: 0; right: 0; bottom: 0;
  width: auto; margin: 0; padding: 0 var(--space-4); border: none;
  display: flex; align-items: center; gap: var(--space-2);
  background: var(--surface-2);
  transform: translateX(101%);
  transition: transform var(--t-base) var(--ease);
  z-index: 3;
}
body.native .data-table.cardable.nt-entity tbody tr.swipe-row.swipe-open td.swipe-drawer { transform: translateX(0); }
body.native .nt-entity tr.swipe-row td.swipe-drawer::before { content: none; }
body.native .nt-entity tr.swipe-row td.swipe-drawer .lv-row-actions { flex-wrap: nowrap; gap: var(--space-2); margin: 0; }
/* Non-admins never get member actions — keep them hidden even though the generic
   drawer rule above is more specific than the base hide (line ~1045 in
   project-detail.js). This selector adds .member-table + :not(.pd-role-admin) so it
   out-specifies the drawer rule and wins. Leave is unaffected (no .member-table). */
body.native:not(.pd-role-admin) .data-table.cardable.nt-entity.member-table tbody tr.swipe-row td.swipe-drawer { display: none; }

/* ══ P0.3 — touch reveal + tap targets (native) ══════════════════════════════
   Controls that only appear on :hover are invisible/unreachable on a phone.
   On native, always reveal them (no hover exists). */
body.native .project-card-kebab,
body.native .grp-drag,
body.native .grp-pin,
body.native .col-kebab-list,
body.native [class*="-kebab"] { opacity: 1 !important; }
/* Minimum comfortable tap size for small icon controls on native. */
body.native .day-btn { min-width: 40px; min-height: 40px; }
body.native .grp-pin,
body.native .grp-drag,
body.native .project-card-kebab { min-width: 40px; min-height: 40px; }

/* ══ Project tab bar on native ═══════════════════════════════════════════════
   HIDDEN on native (user 2026-07-12: "xoá menu này" pointing at the `.pd-tabs`
   section tabs). The project page keeps the FULL app header (logo + hamburger +
   avatar — it's no longer a nav-detail route), so removing this second tab menu
   leaves one clean header. Kept on web. Project opens on its Overview pane. */
body.native .pd-tabs { display: none !important; }

/* ══ Native task lists ═══════════════════════════════════════════════════════
   Interactive task lists stay REAL tables on web (checkbox, quick-add, sort,
   swipe-to-complete, row→task-page). On a phone a desktop table side-scrolls, so
   on native we drop the table shell (block layout, no header, no h-scroll). The
   project Nhiệm vụ tab goes further: PdTasksTable renders a purpose-built 2-line
   touch row (.pd-ntrow) — check · title + priority, then a meta line (status ·
   due chip · assignee) — a real mobile task list, not a stripped grid. */
body.native #tasks-pane .data-table,
body.native #tasks-pane .data-table > tbody,
body.native[data-page="tasks"] .data-table,
body.native[data-page="tasks"] .data-table > tbody { display: block !important; width: 100% !important; min-width: 0 !important; }
body.native #tasks-pane .data-table > thead,
body.native[data-page="tasks"] .data-table > thead { display: none !important; }
/* `visible` would compute back to `auto` (sibling axis is overflow-y:hidden), so
   use `hidden` — the rows already fit, nothing is cut. */
body.native #tasks-pane .table-scroll,
body.native[data-page="tasks"] .table-scroll { overflow-x: hidden !important; }

/* ── Project Nhiệm vụ tab — the designed 2-line native row (.pd-ntrow) ──────── */
body.native #tasks-pane .pd-ntrow {
  display: flex !important; align-items: flex-start; gap: 11px;
  padding: 12px 16px; border-bottom: 1px solid var(--border);
}
body.native #tasks-pane .pd-ntrow:active { background: var(--surface-2); }
body.native #tasks-pane .pd-ntrow > td {
  position: static !important; display: block; padding: 0; border: none; height: auto;
  box-shadow: none !important;   /* drop the desktop status rail on the first cell */
}
/* Kill the per-status row TINT on native (injected .st-* td backgrounds) — the
   pale green/blue box read as a broken partial-width highlight on a phone. The
   coloured status pill + strikethrough already carry the state. */
body.native #tasks-pane .pd-ntrow > td,
body.native #tasks-pane .pd-ntrow.st-done > td,
body.native #tasks-pane .pd-ntrow.st-in_progress > td,
body.native #tasks-pane .pd-ntrow.st-in_review > td,
body.native #tasks-pane .pd-ntrow.row-pinned > td { background: none !important; }
body.native #tasks-pane .pd-ntrow .nt-check { flex: 0 0 auto; margin-top: 1px; }
body.native #tasks-pane .pd-ntrow .task-check { width: 22px; height: 22px; }
/* the data-table hover halo (::before, 39px) reads as a big faint circle here */
body.native #tasks-pane .pd-ntrow .task-check::before { display: none !important; }
body.native #tasks-pane .pd-ntrow .nt-body { flex: 1 1 auto; min-width: 0; }
body.native #tasks-pane .nt-l1 { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; }
body.native #tasks-pane .nt-title {
  font-size: var(--fs-md); font-weight: 500; color: var(--fg); line-height: 1.3;
  overflow-wrap: anywhere; word-break: break-word;
}
body.native #tasks-pane .nt-title.done { text-decoration: line-through; color: var(--muted); }
body.native #tasks-pane .nt-conn { color: var(--muted-2); margin-right: 2px; flex-shrink: 0; }
/* Subtask fix: without flex-sizing, a long subtask title can't sit beside the
   leading "↳" connector (its min-content = full row width), so the connector
   was orphaned on its own line above the title. Let the title flex + shrink so
   connector + title share the first line and the title wraps INSIDE its column. */
body.native #tasks-pane .pd-ntrow.row-subtask .nt-title { flex: 1 1 0; min-width: 0; }
body.native #tasks-pane .nt-l2 { display: flex; align-items: center; gap: 8px; margin-top: 7px; flex-wrap: wrap; }
/* Status = soft-tinted COLOURED pill (one colour per state) — replaces the
   calm grey glyph so status reads at a glance, and drops the second circle
   that duplicated the leading tap-check. */
body.native #tasks-pane .nt-status {
  font-size: var(--fs-2xs); font-weight: 600; line-height: 1.4;
  padding: 2px 9px; border-radius: var(--radius-full); white-space: nowrap;
  border: 1px solid transparent;
}
body.native #tasks-pane .nt-st-todo {
  color: var(--muted); background: var(--surface-2);
  border-color: color-mix(in srgb, var(--muted) 22%, transparent);
}
body.native #tasks-pane .nt-st-in_progress {
  color: var(--info); background: color-mix(in srgb, var(--info) 14%, transparent);
  border-color: color-mix(in srgb, var(--info) 30%, transparent);
}
body.native #tasks-pane .nt-st-in_review {
  color: var(--warning); background: color-mix(in srgb, var(--warning) 15%, transparent);
  border-color: color-mix(in srgb, var(--warning) 32%, transparent);
}
body.native #tasks-pane .nt-st-done {
  color: var(--success); background: color-mix(in srgb, var(--success) 14%, transparent);
  border-color: color-mix(in srgb, var(--success) 30%, transparent);
}
body.native #tasks-pane .nt-st-cancelled {
  color: var(--muted-2); background: var(--surface-2);
  border-color: color-mix(in srgb, var(--muted-2) 22%, transparent);
}
body.native #tasks-pane .nt-due {
  display: inline-flex; align-items: center; gap: 4px;
  font-size: var(--fs-xs); color: var(--muted); white-space: nowrap; font-variant-numeric: tabular-nums;
}
body.native #tasks-pane .nt-due.over {
  color: var(--danger); background: color-mix(in srgb, var(--danger) 14%, transparent);
  padding: 2px 8px; border-radius: var(--radius-full); font-weight: 600;
}
body.native #tasks-pane .nt-tags { display: inline-flex; gap: 4px; flex-wrap: wrap; }
/* Add-subtask is now a press-and-hold on the parent row (see PdTasksTable
   long-press) — the per-row "+" button was removed as it cluttered every card. */
/* Subtasks align FLUSH with the parent (same left edge) — the "↳" connector is
   the hierarchy cue; the old 34px indent left the check/title offset from the
   parent rows (user: "canh thẳng cùng với parent task"). */
body.native #tasks-pane .pd-ntrow.row-subtask { background: none !important; }

/* Quick-add "+ Thêm nhiệm vụ": on native the block table collapses its single
   colspan cell to content width (~148px) → a stray narrow bordered box. Force
   the row + cell to full width and align the trigger with the task rows. */
body.native #tasks-pane .qa-row,
body.native #tasks-pane .qa-row > td { display: block !important; width: 100% !important; }
body.native #tasks-pane .qa-row > td { background: none; border-bottom: 1px solid var(--border); }
body.native #tasks-pane .qa-trigger { padding: 13px 16px; }

/* ── Global /tasks safety net: if its grouped view ever falls back to the plain
   column table, keep it from side-scrolling (drop low-signal cols, avatars-only,
   let the title wrap). No-op when the grouped card view is used. */
body.native[data-page="tasks"] .data-table > tbody > tr { display: flex !important; align-items: center; gap: var(--space-2); width: 100%; }
body.native[data-page="tasks"] .data-table > tbody > tr > td { border-bottom: none; position: static !important; }
body.native[data-page="tasks"] .data-table .tt-clamp { flex: 1 1 auto; min-width: 0 !important; overflow-wrap: anywhere; white-space: normal; }
body.native[data-page="tasks"] .data-table .col-assignee-name,
body.native[data-page="tasks"] .data-table .col-due,
body.native[data-page="tasks"] .data-table .col-prio { flex: 0 0 auto; min-width: 0 !important; width: auto !important; white-space: nowrap; }
body.native[data-page="tasks"] .data-table .bulk-cell,
body.native[data-page="tasks"] .data-table .col-project,
body.native[data-page="tasks"] .data-table .col-created,
body.native[data-page="tasks"] .data-table .avatar-stack-name { display: none; }
body.native[data-page="tasks"] .data-table > tbody > tr:not(:last-child) { border-bottom: 1px solid var(--border); }

/* ─── Post-auth veil (native) ────────────────────────────────────────────────
   Covers the screen while an in-page sign-in hydrates the session and the target
   page's chunk loads. Without it the user watches the login form they just
   submitted sit there and then get swapped — which reads as "it went back to the
   login page". Native only (the web signs in with a full page load). */
.auth-veil {
  position: fixed; inset: 0; z-index: var(--z-dropdown);
  display: flex; align-items: center; justify-content: center;
  background: var(--bg);
}
.auth-veil-spin {
  width: 28px; height: 28px; border-radius: 50%;
  border: 3px solid var(--border); border-top-color: var(--accent);
  animation: authveil .7s linear infinite;
}
@keyframes authveil { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .auth-veil-spin { animation: none; } }

