/* =====================================================================
   THANH-PHAN.CSS — Thư viện giao diện dùng sẵn của nền tảng
   ---------------------------------------------------------------------
   Copy cả thư mục mau/thu-vien/ vào src/web/ (và src/mobile/) là dùng ngay.
   Đạt chuẩn chuan/quy-chuan-website.txt (token, hiệu ứng, form, bảng,
   thông báo, trạng thái, web & mobile). CSS thuần, không phụ thuộc ngoài,
   chạy tốt cả khi mở file:// lẫn khi server Go phục vụ.

   Đi kèm (do phụ tá khác viết theo cùng hợp đồng đặt tên):
     - thanh-phan.js  : dialog/drawer/snackbar/tabs/accordion/table...
     - combobox.js    : combobox .tv-cbx (menu position:fixed)
   CSS dưới đây phủ ĐỦ mọi class trong hợp đồng, kể cả phần tử JS tự chèn.

   ---------------------------------------------------------------------
   MỤC LỤC (tìm theo số section)
   ---------------------------------------------------------------------
    1. TOKEN (:root) + dark mode + reset nhẹ
    2. NÚT (.tv-btn ...)
    3. Ô NHẬP / FIELD (.tv-field ...)
    4. BẢNG DỮ LIỆU (.tv-table-wrap, 1 dòng/bản ghi, data-type, thu cột, .tv-tt, toolbar, phân trang)
    5. DIALOG / DRAWER (.tv-overlay, .tv-modal, .tv-drawer)
    6. SNACKBAR (.tv-snackbar-host, .tv-snackbar)
    7. BANNER / BADGE / CHIP
    8. TABS (.tv-tabs)
    9. ACCORDION / THU-MỞ (.tv-acc, .tv-collapse)
   10. TRẠNG THÁI (.tv-spinner, .tv-skeleton, .tv-empty, .tv-error-state)
   11. KHUNG TRANG (.tv-shell, sidebar, mobile shell, .tv-navbot)
   12. COMBOBOX (.tv-cbx ...)
   13. TIỆN ÍCH (.tv-tooltip, .tv-tip + .tv-tip-pop, .tv-kbd, .tv-card, .tv-grid, helper)
   ===================================================================== */


/* =====================================================================
   1. TOKEN (:root) — nguồn duy nhất cho màu/khoảng cách/bo góc/thời lượng.
      Đổi --tv-primary 1 chỗ là đổi cả hệ. CẤM hard-code ngoài token.
   ===================================================================== */
:root {
  /* Màu chủ đạo */
  --tv-primary: #2563eb;            /* màu chính; đổi 1 chỗ đổi cả hệ.
                                       V159: từ #2563eb → #2563eb vì bản cũ chỉ đạt 4,44:1 với chữ
                                       trắng trên nút primary (<4,5 SC 1.4.3). Bản mới 5,17:1. */
  --tv-primary-ink: #ffffff;        /* chữ trên nền primary */
  --tv-primary-soft: #edf2fe;       /* nền nhạt của primary (hover nhẹ, chip) */
  /* Màu nhấn dùng LÀM CHỮ trên nền thường (V166, cuộc #86 tiktokliveapi): --tv-primary thuần chỉ đạt
     2,68–3,13:1 trên nền TỐI vì nó không đổi giá trị theo nhánh trong khi nền thì đổi. Suy từ
     --tv-primary bằng color-mix (không hard-code) để dự án đổi màu chủ đạo vẫn ăn theo:
     nhánh sáng trộn đen (#2563eb → ≈#1d4db7, trùng --tv-info-ink), nhánh tối trộn trắng (≈#87a9f4). */
  --tv-primary-text: color-mix(in srgb, var(--tv-primary) 78%, #000);

  /* Nền & bề mặt & chữ */
  --tv-bg: #f4f6fb;                 /* nền trang */
  --tv-surface: #ffffff;            /* nền thẻ/card/bảng/dialog */
  --tv-ink: #16202e;               /* chữ chính */
  --tv-muted: #5c6b80;             /* chữ phụ/mờ. V159: từ #64748b (chỉ 4,40:1 trên nền trang) */
  --tv-line: #e4e8f0;              /* kẻ TRANG TRÍ: chia hàng, viền thẻ — được miễn ngưỡng 3:1 */
  --tv-line-ctl: #7f8c9f;          /* viền NHẬN DẠNG control (ô nhập, select, textarea, nút viền).
                                      SC 1.4.11 đòi ≥3:1 — bản này 3,41 nền thẻ / 3,16 nền trang.
                                      CẤM dùng --tv-line cho các control đó (bản cũ chỉ 1,23). */

  /* Màu ngữ nghĩa + nền nhạt tương ứng.
     BA TẦNG, dùng đúng tầng — đây là chỗ sai nhiều nhất trước V159:
       --tv-*        : màu NHẬN DẠNG, cho nền chip, chấm trạng thái, viền, icon. KHÔNG dùng làm CHỮ
                       trên nền sáng: đo được ok 3,30 và warn 3,19 — RỚT ngưỡng 4,5 của §2.3f.
       --tv-*-ink    : chính màu đó đã LÀM TỐI để dùng LÀM CHỮ trên nền sáng (đo 4,64–6,26 ĐẠT).
       --tv-*-soft   : nền nhạt tương ứng. */
  --tv-ok: #16a34a;    --tv-ok-ink: #117f3a;    --tv-ok-soft: #dcfce7;   /* thành công */
  --tv-warn: #d97706;  --tv-warn-ink: #874a04;  --tv-warn-soft: #fef3c7; /* cảnh báo */
  --tv-err: #dc2626;   --tv-err-ink: #ac1e1e;   --tv-err-soft: #fee2e2;  /* lỗi */
  --tv-info: #2563eb;  --tv-info-ink: #1d4db7;  --tv-info-soft: #dbeafe; /* thông tin */

  /* Bo góc — TẬP ĐÓNG {0, 6, 10, tròn} theo chuan/quy-tac-ui.md §2.4a.
     V159: 14/10 → 10/6 sau khi render 4 khung + so 3 phương án tận mắt; 14px cho nút hình viên
     thuốc, lệch mặt bằng Azure/Cloudflare/Google Cloud (4–8px). */
  --tv-r: 10px;        /* thẻ, dialog, drawer, menu, toast */
  --tv-r-sm: 6px;      /* nút, chip, ô nhập */

  /* Độ nổi (elevation) */
  --tv-shadow-1: 0 1px 2px rgba(16, 32, 46, .06), 0 1px 3px rgba(16, 32, 46, .08);
  --tv-shadow-2: 0 8px 24px rgba(16, 32, 46, .12), 0 2px 8px rgba(16, 32, 46, .08);

  /* Thời lượng & easing */
  --tv-dur-1: 150ms;   /* hover/focus/nhấn */
  --tv-dur-2: 300ms;   /* dialog/drawer/menu */
  --tv-ease: cubic-bezier(.22, .61, .36, 1);   /* easing mượt, không giật */

  /* Thang khoảng cách 4/8/12/16/24/32 */
  --tv-s1: 4px;
  --tv-s2: 8px;
  --tv-s3: 12px;
  --tv-s4: 16px;
  --tv-s5: 24px;
  --tv-s6: 32px;

  /* Type scale — TẬP ĐÓNG {12,14,16,20,28} theo §2.2a.
     V159: 12.5 → 12 (12.5 là nửa pixel, kết xuất không nhất quán, không hệ lớn nào dùng) + thêm bậc 28. */
  --tv-fs-xs: 11px;   /* CHỈ chữ phụ NGẮN: caption, chip, mã, dấu thời gian — CẤM cho câu/đoạn người dùng phải đọc kỹ (cuộc #105) */
  --tv-fs-sm: 12px;
  --tv-fs-md: 14px;
  --tv-fs-lg: 16px;
  --tv-fs-xl: 20px;
  --tv-fs-xxl: 28px;   /* chỉ cho số liệu lớn (KPI) hoặc màn trống */
  --tv-fs-hero: 40px;  /* CHỈ trang CÔNG KHAI (landing/giới thiệu) — cấm dùng trong màn công cụ */

  /* Ring focus. V159: bản cũ là quầng primary 35% trong suốt — đo ra 1,60:1, RỚT SC 1.4.11 (≥3:1).
     Bản mới: khe 2px màu nền + vòng ĐẶC 2px màu primary = 5,17:1, vẫn nhẹ mắt. */
  --tv-ring: 0 0 0 2px var(--tv-surface), 0 0 0 4px var(--tv-primary);

  color-scheme: light dark;
}

/* LƯỚI AN TOÀN LINK TRẦN (V167 mục 3, mozmail đề nghị cuộc #74 — ĐO trên 31 khung TRƯỚC khi cho
   vào): <a> không mang class nào rơi về màu link mặc định của trình duyệt (#0000EE), trên nền tối
   đo được 1,47:1 — người dựng không viết một dòng CSS nào vẫn dính. Kéo về --tv-primary-text (tự
   đổi theo nhánh). Số đo 2026-08-15: 31/32 khung không đổi, index.html 1→0 vi phạm, 0 vi phạm mới
   sinh ra từ rule này. Chỉ chạm a KHÔNG class — mọi link đã có kiểu riêng giữ nguyên. */
a:not([class]) { color: var(--tv-primary-text); }

/* --- Dark mode: sinh từ token. Áp cho máy đặt tối MÀ chưa chọn data-theme. --- */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme]) {
    --tv-primary-ink: #ffffff;
    --tv-primary-soft: #1c2c4d;
    --tv-primary-text: color-mix(in srgb, var(--tv-primary) 55%, #fff);
    --tv-bg: #0f141b;
    --tv-surface: #1a212b;
    --tv-ink: #e7edf5;
    --tv-muted: #93a1b5;
    --tv-line: #2b3542;
    --tv-line-ctl: #6e7c90;   /* V159: viền control nền tối, đo 3,82 nền thẻ / 4,36 nền trang */
    /* Chữ trạng thái trên nền TỐI phải SÁNG lên, không tối đi (đo 6,55–7,71 ĐẠT) */
    --tv-ok-ink: #7fcc9b;  --tv-warn-ink: #eab476;  --tv-err-ink: #ec8888;  --tv-info-ink: #87a9f4;
    --tv-ok-soft: #0f2e1c;
    --tv-warn-soft: #3a2a09;
    --tv-err-soft: #3a1414;
    --tv-info-soft: #12233f;
    --tv-shadow-1: 0 1px 2px rgba(0, 0, 0, .4), 0 1px 3px rgba(0, 0, 0, .5);
    --tv-shadow-2: 0 8px 24px rgba(0, 0, 0, .55), 0 2px 8px rgba(0, 0, 0, .5);
  }
}

/* --- Override thủ công qua nút bật/tắt: data-theme luôn thắng media --- */
:root[data-theme="dark"] {
  --tv-primary-ink: #ffffff;
  --tv-primary-soft: #1c2c4d;
  --tv-primary-text: color-mix(in srgb, var(--tv-primary) 55%, #fff);
  --tv-bg: #0f141b;
  --tv-surface: #1a212b;
  --tv-ink: #e7edf5;
  --tv-muted: #93a1b5;
  --tv-line: #2b3542;
  --tv-line-ctl: #6e7c90;   /* V159: viền control nền tối, đo 3,82 nền thẻ / 4,36 nền trang */
  --tv-ok-ink: #7fcc9b;  --tv-warn-ink: #eab476;  --tv-err-ink: #ec8888;  --tv-info-ink: #87a9f4;
  --tv-ok-soft: #0f2e1c;
  --tv-warn-soft: #3a2a09;
  --tv-err-soft: #3a1414;
  --tv-info-soft: #12233f;
  --tv-shadow-1: 0 1px 2px rgba(0, 0, 0, .4), 0 1px 3px rgba(0, 0, 0, .5);
  --tv-shadow-2: 0 8px 24px rgba(0, 0, 0, .55), 0 2px 8px rgba(0, 0, 0, .5);
}
:root[data-theme="light"] {
  /* Ép sáng kể cả máy đang để tối — dùng lại đúng giá trị mặc định sáng */
  --tv-primary-soft: #edf2fe;
  --tv-primary-text: color-mix(in srgb, var(--tv-primary) 78%, #000);
  --tv-bg: #f4f6fb;
  --tv-surface: #ffffff;
  --tv-ink: #16202e;
  --tv-muted: #5c6b80;
  --tv-line: #e4e8f0;
  --tv-line-ctl: #7f8c9f;
  --tv-ok-ink: #117f3a;  --tv-warn-ink: #874a04;  --tv-err-ink: #ac1e1e;  --tv-info-ink: #1d4db7;
  --tv-ok-soft: #dcfce7;
  --tv-warn-soft: #fef3c7;
  --tv-err-soft: #fee2e2;
  --tv-info-soft: #dbeafe;
  --tv-shadow-1: 0 1px 2px rgba(16, 32, 46, .06), 0 1px 3px rgba(16, 32, 46, .08);
  --tv-shadow-2: 0 8px 24px rgba(16, 32, 46, .12), 0 2px 8px rgba(16, 32, 46, .08);
}

/* --- Reset nhẹ + nền chữ toàn cục (an toàn nếu dự án chưa có reset riêng) --- */
*, *::before, *::after { box-sizing: border-box; }
/* Thuộc tính hidden phải LUÔN thắng — nhiều thành phần thư viện đặt display:flex,
   không có dòng này thì el.hidden=true bị CSS đè và phần tử vẫn hiện (lỗi thật đã gặp). */
[hidden] { display: none !important; }

body {
  margin: 0;
  background: var(--tv-bg);
  color: var(--tv-ink);
  font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
  font-size: var(--tv-fs-md);
  line-height: 1.5;
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
}

/* V159 — CHUẨN HOÁ ĐỘ ĐẬM. §2.2b chỉ cho 400 và 600, nhưng MẶC ĐỊNH CỦA TRÌNH DUYỆT cho
   b/strong/h1..h6/th/dt là `bold` = 700. Không có khối này thì mọi trang đều vi phạm mà chẳng ai
   viết dòng CSS nào sai — lỗi vô hình. Dùng :where() để độ ưu tiên bằng 0, dự án ghi đè dễ dàng. */
:where(b, strong, h1, h2, h3, h4, h5, h6, th, dt, optgroup) { font-weight: 600; }

/* V159 — control phải THỪA KẾ font của trang. Không có dòng này thì Chrome áp mặc định UA
   `13.3333px Arial` cho button/input/select/textarea: cỡ chữ nằm ngoài thang đóng §2.2a ở MỌI
   nút mà dự án không khai font — lại là một lỗi vô hình không ai viết sai dòng nào. */
:where(button, input, select, textarea, optgroup) { font: inherit; }

/* V163 — CHỮ MÁY (code/kbd/samp/pre). Cùng họ lỗi vô hình với hai khối trên: Chrome áp cỡ mặc
   định cho font monospace là 13,33px, tức MỌI trang có một thẻ <code> đều vi phạm §2.2a mà không
   ai viết sai dòng nào (đo được trên chính thu-bang.html của thư viện). 12px vì chữ đơn cách nhìn
   to hơn chữ thường cùng cỡ. */
:where(code, kbd, samp, pre) { font-size: var(--tv-fs-sm); }

/* Tiêu đề dùng đúng thang cỡ chữ đóng + line-height 1.2 của §2.2a/§2.2e */
:where(h1) { font-size: var(--tv-fs-xl); line-height: 1.2; }
:where(h2) { font-size: var(--tv-fs-lg); line-height: 1.2; }
:where(h3, h4, h5, h6) { font-size: var(--tv-fs-md); line-height: 1.2; }

/* Focus-visible ring rõ cho MỌI phần tử tương tác (chỉ khi dùng bàn phím) */
:where(a, button, input, select, textarea, [tabindex]):focus-visible {
  outline: none;
  box-shadow: var(--tv-ring);
  border-radius: var(--tv-r-sm);
}

/* Tôn trọng người đã tắt chuyển động: bỏ mọi animation/transition */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: .001ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: .001ms !important;
    scroll-behavior: auto !important;
  }
}


/* =====================================================================
   2. NÚT — .tv-btn và các biến thể
   ===================================================================== */
.tv-btn {
  position: relative;
  /* <a class="tv-btn"> keeps the UA underline unless cleared here: the base rule below only
     styles a:not([class]), so a link-shaped button showed underlined text on the accent fill.
     Harmless on <button>. Reported by deepseek.citah.xyz, thread #98. */
  text-decoration: none;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--tv-s2);
  min-height: 40px;
  padding: 0 var(--tv-s4);
  border: 1px solid transparent;
  border-radius: var(--tv-r-sm);
  background: var(--tv-primary-soft);
  color: var(--tv-ink);
  font: inherit;
  font-weight: 600;
  line-height: 1;
  cursor: pointer;
  user-select: none;
  overflow: hidden;                 /* chứa hiệu ứng nhấn */
  transition:
    background var(--tv-dur-1) var(--tv-ease),
    color var(--tv-dur-1) var(--tv-ease),
    border-color var(--tv-dur-1) var(--tv-ease),
    box-shadow var(--tv-dur-1) var(--tv-ease),
    transform var(--tv-dur-1) var(--tv-ease);
}
.tv-btn:hover { filter: brightness(.97); }
.tv-btn:active { transform: translateY(1px) scale(.985); }   /* hiệu ứng nhấn */

/* Ripple đơn giản: một lớp phủ bung ra khi :active */
.tv-btn::after {
  content: "";
  position: absolute;
  inset: 0;
  background: radial-gradient(circle, currentColor 12%, transparent 12.5%) center/220% 220% no-repeat;
  opacity: 0;
  transition: opacity var(--tv-dur-2) var(--tv-ease), background-size var(--tv-dur-2) var(--tv-ease);
  pointer-events: none;
}
.tv-btn:active::after {
  opacity: .18;
  background-size: 0 0;
  transition: none;
}

/* Biến thể */
.tv-btn--primary {
  background: var(--tv-primary);
  color: var(--tv-primary-ink);
  box-shadow: var(--tv-shadow-1);
}
.tv-btn--primary:hover { filter: brightness(1.05); }

.tv-btn--secondary {                /* viền, nền trong suốt */
  background: var(--tv-surface);
  color: var(--tv-primary-text);
  border-color: var(--tv-line-ctl);   /* V159: viền nhận dạng control, ≥3:1 */
}
.tv-btn--secondary:hover { border-color: var(--tv-primary); background: var(--tv-primary-soft); }

.tv-btn--danger {
  background: var(--tv-err);
  color: #fff;
  box-shadow: var(--tv-shadow-1);
}
.tv-btn--danger:hover { filter: brightness(1.05); }

.tv-btn--ghost {                    /* trong suốt hoàn toàn */
  background: transparent;
  color: var(--tv-ink);
}
.tv-btn--ghost:hover { background: color-mix(in srgb, var(--tv-ink) 8%, transparent); }

.tv-btn--icon {                     /* nút vuông chỉ icon */
  padding: 0;
  width: 40px;
  min-height: 40px;
  border-radius: var(--tv-r-sm);
}

/* Trạng thái vô hiệu hoá */
.tv-btn[disabled],
.tv-btn:disabled {
  opacity: .55;
  cursor: not-allowed;
  pointer-events: none;
  box-shadow: none;
}

/* Đang tải: ẩn chữ, hiện spinner con .tv-btn__spin ở giữa */
.tv-btn.is-loading {
  color: transparent !important;
  pointer-events: none;
}
.tv-btn.is-loading > * { visibility: hidden; }
.tv-btn__spin {
  position: absolute;
  top: 50%;
  left: 50%;
  width: 16px;
  height: 16px;
  margin: -8px 0 0 -8px;
  border: 2px solid currentColor;
  border-right-color: transparent;
  border-radius: 50%;
  color: var(--tv-primary-ink);
  visibility: visible !important;
  animation: tv-spin .7s linear infinite;
}
/* Spinner cho nút không phải primary lấy màu primary cho dễ thấy */
.tv-btn--secondary.is-loading .tv-btn__spin,
.tv-btn--ghost.is-loading .tv-btn__spin { color: var(--tv-primary-text); }


/* =====================================================================
   3. Ô NHẬP / FIELD — .tv-field (label + wrap + input + X + lỗi + hint)
   ===================================================================== */
.tv-field {
  display: flex;
  flex-direction: column;
  gap: var(--tv-s1);
  margin-bottom: var(--tv-s4);
}

.tv-field__label {
  font-size: var(--tv-fs-sm);
  font-weight: 600;
  color: var(--tv-ink);
}

.tv-field__wrap {                   /* bọc input để đặt nút X ở cuối */
  position: relative;
  display: flex;
  align-items: center;
}

.tv-field__input {                  /* dùng chung input / textarea / select */
  width: 100%;
  min-height: 40px;
  padding: var(--tv-s2) var(--tv-s3);
  /* V159: viền NHẬN DẠNG control phải ≥3:1 (SC 1.4.11). --tv-line chỉ 1,23:1 — dành cho kẻ
     TRANG TRÍ. Xem chuan/quy-tac-ui.md §2.3f. */
  border: 1px solid var(--tv-line-ctl);
  border-radius: var(--tv-r-sm);
  background: var(--tv-surface);
  color: var(--tv-ink);
  font: inherit;
  transition:
    border-color var(--tv-dur-1) var(--tv-ease),
    box-shadow var(--tv-dur-1) var(--tv-ease),
    background var(--tv-dur-1) var(--tv-ease);
}
.tv-field__input::placeholder { color: var(--tv-muted); }
.tv-field__input:focus {
  outline: none;
  border-color: var(--tv-primary);
  box-shadow: var(--tv-ring);
}
textarea.tv-field__input { min-height: 96px; resize: vertical; line-height: 1.5; }
select.tv-field__input { cursor: pointer; appearance: none; padding-right: var(--tv-s6); }

/* Chừa chỗ cho nút X khi wrap có nội dung */
.tv-field__wrap.has-value .tv-field__input { padding-right: 38px; }

/* Nút X xoá nhanh — chỉ hiện khi wrap có class .has-value */
.tv-field__clear {
  position: absolute;
  right: var(--tv-s2);
  top: 50%;
  transform: translateY(-50%);
  display: none;                    /* mặc định ẩn */
  align-items: center;
  justify-content: center;
  width: 24px;
  height: 24px;
  padding: 0;
  border: none;
  border-radius: 50%;
  background: transparent;
  color: var(--tv-muted);
  cursor: pointer;
  transition: background var(--tv-dur-1) var(--tv-ease), color var(--tv-dur-1) var(--tv-ease);
}
.tv-field__wrap.has-value .tv-field__clear { display: inline-flex; }
.tv-field__clear:hover { background: color-mix(in srgb, var(--tv-ink) 10%, transparent); color: var(--tv-ink); }
/* QUY ƯỚC: ký tự × do JS/markup chèn vào nút — CSS KHÔNG thêm ::before (tránh "××"). */
.tv-field__clear { font-size: var(--tv-fs-lg); line-height: 1; }

/* Chữ lỗi (đỏ) + gợi ý (mờ) */
/* §3.5(b) — DÒNG LỖI PHẢI ĐƯỢC DÀNH SẴN CHỖ, KHÔNG display:none.
   Vì sao (ca thật mail.mozmail.org cuộc #59, project-default tái lập được trên chính
   layout/quan-ly.html): dòng lỗi hiện ra giữa mousedown và mouseup thì bố cục tụt ~19-22px,
   trình duyệt dời đích của sự kiện click lên thẻ tổ tiên chung → NUỐT CÚ BẤM ĐẦU TIÊN của mọi
   thứ bấm được nằm ngay dưới ô nhập (nút, tab, panel thu-mở), và KHÔNG có một dòng lỗi console
   nào. Giá phải trả: mỗi ô nhập cao thêm ~18px. Đã chạy thật ở mail.mozmail.org từ V028 tới
   2026-08-13 không ai phàn nàn; anh Đạo chốt khử gốc ở V163.
   Chỗ nào THẬT SỰ không có ô cho một dòng (bộ lọc một hàng chẳng hạn) thì khai tay
   .tv-field--khong-cho-loi — nhưng khai xong là nhận lại lớp lỗi nuốt-cú-bấm ở chỗ đó. */
.tv-field__error {
  display: block;
  min-height: calc(var(--tv-fs-sm) * 1.5);   /* 18px với --tv-fs-sm 12px */
  font-size: var(--tv-fs-sm);
  line-height: 1.5;
  color: var(--tv-err-ink);
}
.tv-field--khong-cho-loi .tv-field__error { display: none; min-height: 0; }
.tv-field__hint {
  font-size: var(--tv-fs-sm);
  color: var(--tv-muted);
}

/* Trạng thái sai: viền đỏ + hiện chữ lỗi */
.tv-field.is-invalid .tv-field__input {
  border-color: var(--tv-err);
  background: color-mix(in srgb, var(--tv-err-soft) 55%, var(--tv-surface));
}
.tv-field.is-invalid .tv-field__input:focus {
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--tv-err) 30%, transparent);
}
/* (không cần .is-invalid .tv-field__error{display:block} nữa — dòng lỗi LUÔN có chỗ,
   chỉ đổi CHỮ. Đổi chữ không làm bố cục xê dịch, đó chính là mục đích của §3.5(b).) */

/* disabled / readonly */
.tv-field__input[disabled],
.tv-field__input:disabled {
  background: color-mix(in srgb, var(--tv-line) 30%, var(--tv-surface));
  color: var(--tv-muted);
  cursor: not-allowed;
}
.tv-field__input[readonly] {
  background: color-mix(in srgb, var(--tv-line) 22%, var(--tv-surface));
  cursor: default;
}


/* =====================================================================
   4. BẢNG DỮ LIỆU — thành phần MẶC ĐỊNH của nền tảng (dựng lại 2026-08-09)
   ---------------------------------------------------------------------
   Ba việc mục này bảo đảm, dự án KHÔNG phải làm gì thêm:
     (1) MỘT BẢN GHI = ĐÚNG MỘT DÒNG.
     (2) BỀ RỘNG CỘT BÁM GIÁ TRỊ (khai data-type trên <th>).
     (3) MỘT BẢN MÃ CHẠY CẢ MÀN TO LẪN ĐIỆN THOẠI: khung hẹp tự thu cột
         phụ theo data-priority, phần thu xem lại bằng nút mở rộng.
   Cách dùng + bảng tra data-type/data-priority: HUONG-DAN-BUILD.txt mục 3.12.
   ===================================================================== */

/* ---------------------------------------------------------------------
   1. KHUNG BẢNG — vùng cuộn riêng + mốc đo cho @container
   --------------------------------------------------------------------- */
.tv-table-wrap {
  container-type: inline-size;      /* mốc đo = chính khung này */
  container-name: tvbang;
  position: relative;
  overflow: auto;                   /* bảng rộng hơn khung thì VUỐT NGANG, không bóp cột */
  border: 1px solid var(--tv-line);
  border-radius: var(--tv-r);
  background: var(--tv-surface);
  min-height: 240px;
  box-shadow: var(--tv-shadow-1);
  --tv-row-h: 44px;                 /* nhịp một hàng; đổi ở đây là đổi cả bảng */
}
/* CẢNH BÁO cho ai bọc bảng bằng class KHÁC (ca thật CCProxy cuộc #81): --tv-row-h khai ở ĐÂY,
   nên quên bọc là biến RỖNG → `height: var(--tv-row-h)` KHÔNG có giá trị lùi thành khai báo VÔ HIỆU và trình duyệt
   BỎ IM (không phải lấy 0, không cảnh báo) → mỗi bảng một nhịp, đầu bảng thấp hơn thân, mắt chủ dự
   án bắt ngay còn phép đo thì im. Từ V163 mọi chỗ dùng đều có GIÁ TRỊ LÙI 44px nên hết lệch hàng;
   nhưng bọc sai vẫn MẤT @container (thu cột) và MẤT js-thu của TV.scan() — TV.kiemUI() nay báo
   [CHẶN] đúng ca này. */

/* Bảng không bao giờ hẹp hơn khung (khỏi hở mảng trắng bên phải).
   THỨ TỰ NHƯỜNG CHỖ khi thiếu bề ngang, theo đúng thứ tự đó:
     1. cột chữ dài co lại (và cắt chữ, có title + nút mở rộng để đọc lại)
     2. cột phụ bị thu theo ưu tiên
     3. hết cách thì vuốt ngang
   Cột mã/ngày/số/tiền/trạng thái KHÔNG nằm trong đường nhường chỗ nào —
   cắt cụt một con số là làm SAI dữ liệu chứ không phải làm gọn. */
table.tv-table {
  width: 100%;
  min-width: 100%;
  table-layout: auto;               /* CỐ Ý auto: cột tự co theo nội dung = yêu cầu (2).
                                       KHÔNG dùng fixed — fixed chia đều rồi tràn đè ô
                                       bên cạnh khi component có min-width (KN-01277). */
  border-collapse: collapse;
  font-size: var(--tv-fs-md);
}


/* ---------------------------------------------------------------------
   2. MỘT HÀNG = MỘT DÒNG
   Chiều cao một hàng do Ô CAO NHẤT quyết định (KN-00456) → chặn TẤT CẢ
   các đường làm ô cao lên: xuống dòng, chip tự rớt hàng, con inline-block
   đội khoảng chân chữ.
   --------------------------------------------------------------------- */
.tv-table th,
.tv-table td {
  white-space: nowrap;              /* không ô nào được xuống dòng */
  overflow-wrap: normal;
  word-break: normal;
  text-align: left;
  vertical-align: middle;
}
.tv-table tbody td {
  height: var(--tv-row-h, 44px);          /* trên <td> đây là chiều cao TỐI THIỂU */
  padding: 0 var(--tv-s4);          /* dọc do height lo; ngang giữ 16px */
  line-height: 1.45;
  border-bottom: 1px solid var(--tv-line);
  color: var(--tv-ink);
}
.tv-table tbody tr { transition: background var(--tv-dur-1) var(--tv-ease); }
.tv-table tbody tr:hover { background: color-mix(in srgb, var(--tv-primary) 6%, transparent); }
.tv-table tbody tr:last-child > td { border-bottom: none; }

/* Đầu bảng ghim. Mốc của position:sticky là TỔ TIÊN CUỘN GẦN NHẤT — ở đây
   chính là .tv-table-wrap, nên top PHẢI là 0. Đặt top theo chiều cao thanh
   tiêu đề trang là sai mốc và sẽ che mất một dòng dữ liệu (KN-00851). */
.tv-table thead th {
  position: sticky;
  top: 0;
  z-index: 2;
  height: var(--tv-row-h, 44px);
  padding: 0 var(--tv-s4);
  font-weight: 600;
  font-size: var(--tv-fs-sm);
  /* KHÔNG dùng thẳng --tv-muted: đo được chỉ 4,48:1 trên nền tiêu đề, trượt
     ngưỡng 4,5 của WCAG. Pha thêm màu chữ chính cho đủ. */
  color: color-mix(in srgb, var(--tv-muted) 74%, var(--tv-ink));
  background: color-mix(in srgb, var(--tv-line) 30%, var(--tv-surface));
  border-bottom: 1px solid var(--tv-line);
}

/* Con của ô: giữ <td> đúng vai table-cell. TUYỆT ĐỐI không đặt display:flex
   thẳng lên <td> — ô sẽ mất vai trò table-cell, tự tính chiều cao và làm bề
   rộng cột sai (KN-01331). Cần xếp ngang thì bọc .tv-o. */
.tv-table td > .tv-o {
  display: flex;
  align-items: center;
  gap: var(--tv-s2);
  min-width: 0;
}
/* Trong hộp đó: phần CHỮ co được và cắt được, mọi thứ khác giữ nguyên kích thước.
   Nhờ vậy ô "tên rất dài + một cái nút" vẫn gọn một dòng mà nút không bị cắt. */
.tv-table td > .tv-o > .tv-cat { flex: 1 1 auto; min-width: 0; }
.tv-table td > .tv-o > :not(.tv-cat) { flex: none; }
/* Ảnh/biểu tượng trong ô: khoá chiều cao, để display:block cho khỏi đội
   khoảng chân chữ của hộp dòng (thủ phạm quen của hàng cao thừa ~8px). */
.tv-table td img,
.tv-table td svg {
  display: block;
  height: calc(var(--tv-row-h, 44px) - 16px);
  width: auto;
  flex: none;
  border-radius: var(--tv-r-sm);
}
/* Nhãn/chip trong ô không được rớt hàng. Nhiều nhãn → hiện 1 cái + đếm phần
   còn lại bằng .tv-them ("Đang bán +3"), đừng để chúng tự xuống dòng. */
.tv-table td .tv-badge,
.tv-table td .tv-chip {
  white-space: nowrap;
  vertical-align: middle;
}

/* ---------------------------------------------------------------------
   Ô CHỌN (checkbox) — thư viện trước V164 KHÔNG có, nên mỗi dự án thả một
   <input type="checkbox"> trần vào <td> và tự chế một kiểu khác nhau.
   Vì sao phải có luật riêng (số đo CCProxy, cuộc góp ý #81):
   checkbox trần trong ô ra height 13px, display:inline-block, VÀ margin
   3px trên/dưới của trình duyệt — cộng thêm khoảng chân chữ của hộp dòng,
   chính nó đội chiều cao hàng lên khi hàng chưa bị khoá chiều cao. Cùng
   một cái bẫy với ảnh/svg trong ô ở trên, chỉ khác thủ phạm.
   --------------------------------------------------------------------- */
/* LƯỚI AN TOÀN: ai thả checkbox/radio trần vào ô vẫn không làm vỡ nhịp hàng.
   Không thay được cho .tv-chk (checkbox trần vẫn 13px, dưới sàn đích bấm
   §1.6), nhưng ít nhất không âm thầm đội hàng. */
.tv-table :is(th, td) > :is(input[type="checkbox"], input[type="radio"]) {
  display: block;
  margin: 0;
  flex: none;
}
/* CÁCH ĐÚNG: bọc trong .tv-chk. Nhãn chữ là tuỳ chọn — trong ô bảng thường
   không có nhãn nhìn thấy, khi đó BẮT BUỘC có aria-label trên input, nếu
   không thì trình đọc màn hình đọc ra một ô câm và §3.2 báo đỏ. */
.tv-chk {
  display: inline-flex;
  align-items: center;
  gap: var(--tv-s2);
  /* Vùng bấm THẬT ≥24×24 (§1.6): ô vuông nhìn thấy giữ 18px cho đẹp, phần
     còn lại là padding của chính cái nhãn — bấm vào nhãn là trình duyệt gạt
     ô, không cần một dòng JS nào. CỐ Ý KHÔNG nong bằng ::after trong suốt
     trên <input>: trình duyệt không vẽ pseudo-element lên ô tích gốc, nên
     cách đó chỉ qua được PHÉP ĐO chứ vùng bấm thật vẫn 18px. */
  min-height: 24px;
  padding: 3px;
  margin: -3px;                      /* trả lại chỗ đã mượn: bố cục không xê dịch */
  cursor: pointer;
  font-size: var(--tv-fs-md);
  line-height: 1.5;
  -webkit-user-select: none;
  user-select: none;
}
.tv-chk > input {
  flex: none;
  width: 18px;
  height: 18px;
  margin: 0;
  accent-color: var(--tv-primary);   /* dùng ô gốc của hệ điều hành, không tự vẽ:
                                        gốc đã có sẵn trạng thái nửa-chọn, phím
                                        Space, và giao diện tương phản cao */
  cursor: pointer;
}
/* CHUẨN Ô TÍCH NỀN TRẮNG (anh Đạo chốt 2026-08-15, V169): trang khai color-scheme
   "light dark" nên ở nhánh TỐI trình duyệt tự vẽ ô CHƯA TÍCH nền đen — không ai
   chủ ý chọn màu đó, nó là hệ quả phụ của việc dùng ô gốc (V164). Ép color-scheme
   light cho riêng ô tích/ô tròn: ô chưa tích nền TRẮNG viền xám ở CẢ HAI nhánh
   (nổi rõ trên nền tối), khi tích accent-color --tv-primary vẫn ăn bình thường,
   và vẫn là ô gốc — nửa-chọn, phím Space, tương phản cao giữ nguyên. */
input[type="checkbox"],
input[type="radio"] { color-scheme: light; }
.tv-chk > input:disabled,
.tv-chk:has(> input:disabled) { cursor: not-allowed; opacity: .55; }

/* Hộp ô chọn trong bảng: cùng họ .tv-o, nhưng canh giữa vì ô này không có chữ */
.tv-table td > .tv-o--chon,
.tv-table th > .tv-o--chon {
  display: flex;
  align-items: center;
  justify-content: center;
  min-width: 0;
}
/* Cột chọn co về đúng bề rộng nội dung, như các cột data-type khác (mục 3) */
.tv-table :is(th, td)[data-type="select"] {
  width: 1%;
  white-space: nowrap;
  padding-left: var(--tv-s3);
  padding-right: var(--tv-s3);
}
/* Hàng ĐANG CHỌN: nền đậm hơn hover để phân biệt được khi vừa trỏ vừa chọn.
   Màu KHÔNG phải kênh duy nhất (§2.3e) — dấu tích trong ô mới là kênh chính. */
.tv-table tbody tr.is-chon > td { background: color-mix(in srgb, var(--tv-primary) 10%, transparent); }
.tv-table tbody tr.is-chon:hover > td { background: color-mix(in srgb, var(--tv-primary) 14%, transparent); }

/* THANH VIỆC HÀNG LOẠT — hiện khi đã chọn ≥1 hàng.
   CHỖ ĐƯỢC DÀNH SẴN, không phải hiện-ra-mới-chiếm-chỗ: thanh này nằm ngay
   trên bảng, nếu nó đẩy bảng tụt xuống giữa mousedown và mouseup thì trình
   duyệt dời đích click và NUỐT CÚ BẤM đầu tiên — đúng cái bẫy §3.5(b) mà
   bản 2026-08-13 vừa khử cho dòng lỗi của ô nhập. Nên: luôn chiếm chỗ, chỉ
   đổi opacity/visibility. */
.tv-bulk {
  display: flex;
  align-items: center;
  flex-wrap: wrap;                   /* màn hẹp: cho xuống hàng, đừng bóp nhãn nút vỡ đôi */
  gap: var(--tv-s2) var(--tv-s3);
  min-height: 44px;
  padding: var(--tv-s2) var(--tv-s3);
  margin-bottom: var(--tv-s2);
  border: 1px solid var(--tv-line);
  border-radius: var(--tv-r-sm);
  background: var(--tv-primary-soft);
  visibility: hidden;
  opacity: 0;
  transition: opacity var(--tv-dur-1) var(--tv-ease);
}
.tv-bulk.is-hien { visibility: visible; opacity: 1; }
.tv-bulk__dem { font-weight: 600; white-space: nowrap; }
.tv-bulk__viec { display: flex; align-items: center; gap: var(--tv-s2); margin-left: auto; }
.tv-bulk__viec .tv-btn { white-space: nowrap; }
/* Ô TRẠNG THÁI — thư viện trước đây KHÔNG có, mỗi dự án tự chế một kiểu và
   thường chế ra thứ cao hai dòng. .tv-badge KHÔNG dùng được ở đây: nó là badge
   đếm trên chuông, position:absolute, thả vào ô bảng là nó thoát ra ngoài khung
   (đo được: tràn 4px khỏi khung bảng). Dùng .tv-tt. */
.tv-tt {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  max-width: 100%;
  height: 24px;
  padding: 0 10px;
  border-radius: 999px;
  background: color-mix(in srgb, var(--tv-ink) 8%, transparent);
  color: var(--tv-ink);
  font-size: var(--tv-fs-sm);
  font-weight: 600;
  line-height: 1;
  white-space: nowrap;
  vertical-align: middle;
}
.tv-tt::before {                    /* chấm màu: phân biệt được cả khi mù màu */
  content: "";
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: currentColor;
  flex: none;
}
/* Tỉ lệ pha KHÔNG phải chọn cho đẹp mà chọn theo SỐ ĐO tương phản: chữ 12,5px
   đậm vẫn tính là chữ thường theo WCAG (chỉ ≥18,66px đậm mới được hạ ngưỡng),
   nên phải đạt 4,5:1. Đo được ở bản trước: vàng 78% chỉ được 4,43 và nhãn mờ
   chỉ được 3,45 — sát ngưỡng mà vẫn trượt, mắt thường không thấy. */
.tv-tt--ok   { background: var(--tv-ok-soft);   color: color-mix(in srgb, var(--tv-ok) 78%, #000); }
.tv-tt--warn { background: var(--tv-warn-soft); color: color-mix(in srgb, var(--tv-warn) 62%, #000); }
.tv-tt--err  { background: var(--tv-err-soft);  color: color-mix(in srgb, var(--tv-err) 78%, #000); }
.tv-tt--info { background: var(--tv-info-soft); color: color-mix(in srgb, var(--tv-info) 78%, #000); }
.tv-tt--mo   { color: color-mix(in srgb, var(--tv-ink) 82%, var(--tv-muted)); }
/* Nền tối. Phải khai HAI đường như thanh-phan.css vẫn làm: theo cài đặt máy
   (khi trang chưa chọn tay) VÀ theo nút bật/tắt data-theme="dark" — thiếu vế
   thứ hai thì trang có nút chuyển nền tối sẽ hiện chữ tối trên nền tối. */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme]) .tv-tt--ok   { color: color-mix(in srgb, var(--tv-ok) 55%, #fff); }
  :root:not([data-theme]) .tv-tt--warn { color: color-mix(in srgb, var(--tv-warn) 55%, #fff); }
  :root:not([data-theme]) .tv-tt--err  { color: color-mix(in srgb, var(--tv-err) 55%, #fff); }
  :root:not([data-theme]) .tv-tt--info { color: color-mix(in srgb, var(--tv-info) 55%, #fff); }
}
:root[data-theme="dark"] .tv-tt--ok   { color: color-mix(in srgb, var(--tv-ok) 55%, #fff); }
:root[data-theme="dark"] .tv-tt--warn { color: color-mix(in srgb, var(--tv-warn) 55%, #fff); }
:root[data-theme="dark"] .tv-tt--err  { color: color-mix(in srgb, var(--tv-err) 55%, #fff); }
:root[data-theme="dark"] .tv-tt--info { color: color-mix(in srgb, var(--tv-info) 55%, #fff); }
/* Nhãn mờ trên nền tối: nền của nó pha từ --tv-ink (sáng) nên chữ phải NGƯỢC lại */
@media (prefers-color-scheme: dark) { :root:not([data-theme]) .tv-tt--mo { color: var(--tv-ink); background: color-mix(in srgb, var(--tv-ink) 14%, transparent); } }
:root[data-theme="dark"] .tv-tt--mo { color: var(--tv-ink); background: color-mix(in srgb, var(--tv-ink) 14%, transparent); }

.tv-them {
  margin-left: var(--tv-s1);
  padding: 1px 6px;
  border-radius: 999px;
  background: color-mix(in srgb, var(--tv-ink) 8%, transparent);
  color: var(--tv-muted);
  font-size: var(--tv-fs-sm);
  font-weight: 600;
}
/* Nút trong bảng: thấp hơn nhịp hàng để không đội chiều cao hàng lên */
.tv-table td .tv-btn {
  min-height: calc(var(--tv-row-h, 44px) - 16px);
  padding-top: 0;
  padding-bottom: 0;
}


/* ---------------------------------------------------------------------
   3. BỀ RỘNG CỘT BÁM GIÁ TRỊ
   Mẹo: cột "co" khai width:1% + nowrap → trình duyệt hạ nó về đúng bề rộng
   nội dung; cột "nở" khai width:100% → nuốt trọn chỗ còn thừa. Nếu KHÔNG
   khai gì thì mọi cột chia đều phần thừa và cột ngắn phình ra vô ích.
   CẤM đặt overflow-wrap:anywhere cho chung th,td: nó hạ bề rộng tối thiểu
   của MỌI cột về một ký tự, cột "Mã"/"Ngày" biến thành chữ dọc (KN-00754).
   --------------------------------------------------------------------- */
.tv-table :is(th,td)[data-type="code"],
.tv-table :is(th,td)[data-type="date"],
.tv-table :is(th,td)[data-type="time"],
.tv-table :is(th,td)[data-type="number"],
.tv-table :is(th,td)[data-type="money"],
.tv-table :is(th,td)[data-type="status"],
.tv-table :is(th,td)[data-type="actions"] { width: 1%; }

/* Cột "nở" nuốt chỗ thừa, và khi thiếu chỗ thì CHÍNH NÓ co lại trước.
   max-width:0 là mẹo CỐT LÕI ở đây, đã đo tận nơi: mặc định bề rộng tối thiểu
   của một ô chữ nowrap bằng CẢ CÂU nên cột không tài nào co được (bảng thử 10
   cột đo được 1497px trong khung 1246px, tràn 251px). Khai max-width:0 thì
   thuật toán bảng coi nhu cầu tối đa của cột là 0, cột nhận đúng phần chỗ còn
   lại và cắt chữ bằng "…" — cùng bảng đó về đúng 1246px, hết tràn.
   min-width là cái SÀN để không còn trơ lại hai ba chữ cái. */
.tv-table :is(th,td)[data-type="name"],
.tv-table :is(th,td)[data-type="long"] {
  width: 100%;
  max-width: 0;
  min-width: var(--tv-cot-min, 14ch);
  overflow: hidden;
  text-overflow: ellipsis;
}
/* ⚠ overflow:hidden ở trên biến ô thành vùng cắt: menu xổ xuống / tấm chú thích
   "?" đặt TRONG cột ten/dai sẽ bị xén cụt (bẫy KN-01365). Những thứ nổi đó phải
   nằm ở cột khác, hoặc chèn ở cấp <body> như .tv-tip vẫn làm.

   Ô nào chứa nút / ô nhập / khối thì bang.js đánh dấu data-atomic="1" và ta TRẢ
   Ô VỀ NGUYÊN VẸN: cắt một ô chữ là bỏ bớt chữ thừa, còn cắt một ô có nút là cắt
   mất chính cái nút. Thà cột rộng ra rồi vuốt ngang. */
.tv-table td[data-atomic="1"] {
  max-width: none;
  overflow: visible;
  text-overflow: clip;
}

/* Số/tiền: căn phải + chữ số đều bề rộng để so hàng dọc thẳng cột */
.tv-table :is(th,td)[data-type="number"],
.tv-table :is(th,td)[data-type="money"] {
  text-align: right;
  font-variant-numeric: tabular-nums;
}
.tv-table :is(th,td)[data-type="code"] { font-variant-numeric: tabular-nums; }
.tv-table :is(th,td)[data-type="actions"] { text-align: right; }
.tv-table td[data-type="actions"] > * { white-space: nowrap; }

/* CỐ Ý KHÔNG có luật "bảng không khai cột nở thì cho cột thao tác nở".
   Đã thử và đã bỏ: trên một màn CŨ chưa khai data-type, luật đó kéo cột thao tác
   từ 171px lên 359px và bóp cột "Tên sản phẩm" từ 253px xuống 166px — đúng cột
   người ta cần đọc nhất lại bị nhường chỗ cho một cột chỉ chứa hai cái nút.
   Không có luật đó thì phần chỗ thừa chia đều theo thuật toán bảng, cột nào chưa
   khai gì vẫn tự nở — hiền hơn nhiều. */

/* .tv-cat — TRẦN CỨNG tuỳ chọn cho một cột chữ, khai qua data-clamp-width trên <th>
   (vd data-clamp-width="24ch"). Dùng khi không muốn cột đó nở quá tay trên màn rộng.
   Không khai thì cột cứ ăn phần chỗ còn lại theo luật ở trên — thường là đủ tốt.
   Chữ bị cắt luôn được bang.js gắn title và bật nút mở rộng của hàng, nên
   không có chữ nào mất im lặng. */
.tv-cat {
  display: block;
  width: 100%;
  max-width: var(--tv-cat-w, none);
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}


/* ---------------------------------------------------------------------
   4. SẮP XẾP THEO CỘT
   --------------------------------------------------------------------- */
.tv-th-sort { cursor: pointer; user-select: none; }
.tv-th-sort:hover { color: var(--tv-ink); }
.tv-th-sort::after {
  content: "\2195";
  margin-left: var(--tv-s1);
  opacity: .35;
  font-size: .9em;
}
.tv-th-sort.is-asc::after  { content: "\2191"; opacity: 1; color: var(--tv-primary-text); }
.tv-th-sort.is-desc::after { content: "\2193"; opacity: 1; color: var(--tv-primary-text); }
.tv-th-sort:focus-visible { outline: 2px solid var(--tv-primary); outline-offset: -2px; }


/* ---------------------------------------------------------------------
   5. THU CỘT THEO KHUNG + HÀNG MỞ RỘNG
   Ẩn bớt cột chứ KHÔNG ghim cột bằng position:sticky: cột ghim luôn đè lên
   cột đứng trước nó và làm bảng không-cuộn trông như vỡ làm hai (KN-01127).
   --------------------------------------------------------------------- */
.tv-mo {                            /* nút mở rộng ở đầu hàng */
  display: none;                    /* chỉ hiện khi thật sự có cột bị thu */
  align-items: center;
  justify-content: center;
  width: 28px;
  height: 28px;
  margin-right: var(--tv-s2);
  padding: 0;
  border: 1px solid var(--tv-line);
  border-radius: var(--tv-r-sm);
  background: var(--tv-surface);
  color: var(--tv-muted);
  font: inherit;
  line-height: 1;
  cursor: pointer;
  flex: none;
  transition: transform var(--tv-dur-1) var(--tv-ease), color var(--tv-dur-1) var(--tv-ease);
}
.tv-mo::before { content: "\203A"; font-size: var(--tv-fs-xl); }   /* › */
.tv-mo[aria-expanded="true"] { transform: rotate(90deg); color: var(--tv-primary-text); }
.tv-mo:focus-visible { outline: 2px solid var(--tv-primary); outline-offset: 1px; }

.tv-chi-tiet > td {
  height: auto;                     /* hàng chi tiết ĐƯỢC PHÉP nhiều dòng */
  white-space: normal;
  padding: var(--tv-s3) var(--tv-s4);
  background: color-mix(in srgb, var(--tv-line) 22%, var(--tv-surface));
}
.tv-dl {
  display: grid;
  /* minmax(0, …) chứ không phải max-content: nhãn ghép từ nhiều cột
     ("Tên sản phẩm / Trạng thái") có thể rất dài và đẩy vỡ khung trên điện thoại */
  grid-template-columns: minmax(0, 34%) minmax(0, 1fr);
  gap: var(--tv-s1) var(--tv-s3);
  margin: 0;
  font-size: var(--tv-fs-sm);
}
@container tvbang (max-width: 420px) {
  .tv-dl { grid-template-columns: 1fr; gap: 0 0; }
  .tv-dl dt { margin-top: var(--tv-s2); }
}
.tv-dl dt { color: var(--tv-muted); overflow-wrap: anywhere; }
.tv-dl dd { margin: 0; overflow-wrap: anywhere; }   /* CHỈ ở đây mới cho ngắt tuỳ ý */
/* Hàng chi tiết là nơi ĐỌC LẠI phần bị thu, nên trong này CẤM cắt chữ tiếp.
   Bẫy đã dính thật: ô nguồn được bọc .tv-cat (trần 22ch), bản sao mang theo
   luôn cái trần đó nên "Công ty TNHH Thương mại Tân Phát" vào tới hàng chi tiết
   vẫn hiện "Công ty TNHH Thương …" — mở ra mà vẫn không đọc được. */
.tv-chi-tiet .tv-cat,
.tv-chi-tiet dd > *:not(.tv-tt):not(.tv-chip):not(.tv-btn) {
  max-width: none;
  overflow: visible;
  text-overflow: clip;
  white-space: normal;
}

/* Nút mở rộng CHỈ hiện khi hàng thật sự đang giấu thứ gì đó — do bang.js đo
   và bật class: .co-cot-thu (khung đang thu cột) hoặc .co-cat (ô đang cắt chữ).
   Nhờ vế .co-cat mà người dùng BÀN PHÍM cũng đọc được chữ bị cắt, không phải
   chỉ người rê được chuột lên xem title (cạm bẫy codex nêu ở mục D-7). */
.tv-table-wrap.co-cot-thu .tv-mo,
.tv-table tbody tr.co-cat .tv-mo { display: inline-flex; }

/* Có cột bị THU (điện thoại) → nút đậm, vì đó là đường DUY NHẤT tới dữ liệu.
   Chỉ có chữ bị CẮT (màn rộng) → nút mờ đi cho khỏi rối cả cột: dấu "…" đã là
   dấu hiệu rồi. Vẫn đi Tab tới được và hiện rõ khi rê chuột hoặc khi được focus,
   nên người dùng bàn phím không mất đường đọc. */
.tv-table-wrap:not(.co-cot-thu) .tv-table tbody tr.co-cat .tv-mo {
  opacity: .28;
  border-color: transparent;
}
.tv-table-wrap:not(.co-cot-thu) .tv-table tbody tr:hover .tv-mo,
.tv-table-wrap:not(.co-cot-thu) .tv-table tbody tr .tv-mo:focus-visible,
.tv-table-wrap:not(.co-cot-thu) .tv-table tbody tr .tv-mo[aria-expanded="true"] {
  opacity: 1;
  border-color: var(--tv-line);
}

/* Thu cột theo bề rộng KHUNG BẢNG.
   CHỈ bật sau khi bang.js đã lắp xong nút mở rộng (class .js-thu). Trang không
   chạy được JS thì bảng giữ ĐỦ cột và vuốt ngang — không mất một chữ nào. */
@container tvbang (max-width: 1000px) {
  .tv-table-wrap.js-thu .tv-table :is(th,td)[data-priority="3"] { display: none; }
}
@container tvbang (max-width: 640px) {
  .tv-table-wrap.js-thu .tv-table :is(th,td)[data-priority="2"] { display: none; }
  .tv-table tbody td,
  .tv-table thead th { padding: 0 var(--tv-s3); }
}

/* Trình duyệt chưa có @container → lùi về @media theo khung nhìn.
   Kém chính xác hơn (không biết bảng đang nằm trong cột hẹp hay không)
   nhưng vẫn giữ được ba yêu cầu trên điện thoại thật.
   ⚠ PHẢI ghi "screen and": @media (max-width:…) trần cũng ăn cả khi IN, mà trang
   in thì có bề rộng giấy — thiếu chữ "screen" là bản in mất sạch cột phụ. */
@supports not (container-type: inline-size) {
  @media screen and (max-width: 1000px) {
    .tv-table-wrap.js-thu .tv-table :is(th,td)[data-priority="3"] { display: none; }
  }
  @media screen and (max-width: 640px) {
    .tv-table-wrap.js-thu .tv-table :is(th,td)[data-priority="2"] { display: none; }
    .tv-table tbody td,
    .tv-table thead th { padding: 0 var(--tv-s3); }
  }
}


/* ---------------------------------------------------------------------
   6. HÀNG THÊM/XOÁ + Ô SỬA NHANH + PHẦN CÒN LẠI GIỮ NGUYÊN HỢP ĐỒNG CŨ
   --------------------------------------------------------------------- */
@keyframes tv-row-enter {
  from { opacity: 0; transform: translateY(-6px); }
  to   { opacity: 1; transform: none; }
}
.tv-table tbody tr.is-enter { animation: tv-row-enter var(--tv-dur-2) var(--tv-ease); }
.tv-table tbody tr.is-leave {
  opacity: 0;
  background: var(--tv-err-soft);
  transition: opacity var(--tv-dur-2) var(--tv-ease), background var(--tv-dur-2) var(--tv-ease);
}

.tv-td-act { text-align: right; white-space: nowrap; }
.tv-td-act .tv-btn { margin-left: var(--tv-s1); }

.tv-td-edit input {
  width: 100%;
  min-width: 0;
  padding: var(--tv-s1) var(--tv-s2);
  border: 1px solid transparent;
  border-radius: var(--tv-r-sm);
  background: transparent;
  color: inherit;
  font: inherit;
  transition: border-color var(--tv-dur-1) var(--tv-ease), background var(--tv-dur-1) var(--tv-ease);
}
.tv-td-edit input:hover { background: color-mix(in srgb, var(--tv-ink) 5%, transparent); }
.tv-td-edit input:focus {
  outline: none;
  border-color: var(--tv-primary);
  background: var(--tv-surface);
  box-shadow: var(--tv-ring);
}

@media (prefers-reduced-motion: reduce) {
  .tv-table tbody tr,
  .tv-table tbody tr.is-leave,
  .tv-td-edit input,
  .tv-mo { transition: none; }
  .tv-table tbody tr.is-enter { animation: none; }
}


/* ---------------------------------------------------------------------
   7. IN GIẤY — bản in KHÔNG được mất cột
   Trên giấy không có ai vuốt ngang và cũng không ai bấm nút mở rộng, nên mọi
   cơ chế thu/cuộn phải tắt hết và bảng in ra ĐỦ cột. Đầu bảng ghim cũng phải
   bỏ, vì sticky khi ngắt trang in sẽ đè lên một dòng dữ liệu.
   --------------------------------------------------------------------- */
@media print {
  .tv-table-wrap {
    overflow: visible;
    min-height: 0;
    border: none;
    box-shadow: none;
    container-type: normal;         /* tắt @container → hết thu cột */
  }
  .tv-table thead th { position: static; background: transparent; }
  /* Đủ cụ thể để thắng luật thu cột ở trên, kể cả nhánh dự phòng */
  .tv-table-wrap.js-thu .tv-table :is(th,td)[data-priority="2"],
  .tv-table-wrap.js-thu .tv-table :is(th,td)[data-priority="3"],
  .tv-table :is(th,td)[data-priority="2"],
  .tv-table :is(th,td)[data-priority="3"] { display: table-cell; }
  .tv-table :is(th,td)[data-type="name"],
  .tv-table :is(th,td)[data-type="long"],
  .tv-cat {
    max-width: none;
    overflow: visible;
    text-overflow: clip;
    white-space: normal;            /* trên giấy cho phép xuống dòng, thà dài còn hơn mất chữ */
  }
  .tv-mo,
  .tv-chi-tiet,
  .tv-pagination { display: none; }
  .tv-table tbody tr { page-break-inside: avoid; break-inside: avoid; }
}


/* Thanh công cụ trên bảng */
.tv-toolbar {
  display: flex;
  align-items: center;
  gap: var(--tv-s3);
  flex-wrap: wrap;
  margin-bottom: var(--tv-s3);
}
.tv-toolbar__search {               /* ô tìm kiếm bên trái, co giãn */
  flex: 1 1 240px;
  min-width: 200px;
  margin-bottom: 0;                 /* huỷ margin của .tv-field nếu tái dùng */
}
.tv-toolbar__actions {              /* nhóm nút bên phải */
  display: flex;
  align-items: center;
  gap: var(--tv-s2);
  margin-left: auto;
}

/* Phân trang góc dưới phải */
.tv-pagination {
  display: flex;
  align-items: center;
  gap: var(--tv-s3);
  flex-wrap: wrap;
  justify-content: flex-end;
  padding: var(--tv-s3) var(--tv-s1);
  font-size: var(--tv-fs-sm);
  color: var(--tv-muted);
}
/* Ô chọn số dòng/trang. V159: các thuộc tính này TRƯỚC ĐÂY chỉ đặt trên DIV BỌC nên thẻ <select>
   thật bên trong không nhận cỡ nào — đo được đích bấm 77×20px, RỚT sàn 24×24 của §1.6.
   Nay đặt thẳng lên chính <select>. */
.tv-pagination__size,
.tv-pagination__size > select {
  min-height: 32px;
  padding: var(--tv-s1) var(--tv-s2);
  border: 1px solid var(--tv-line-ctl);
  border-radius: var(--tv-r-sm);
  background: var(--tv-surface);
  color: var(--tv-ink);
  font: inherit;
  cursor: pointer;
}
.tv-pagination__size { padding: 0; border: 0; min-height: 0; }   /* div bọc không tự vẽ viền nữa */
.tv-pagination__info { margin-right: auto; }   /* đẩy info sang trái, pages sang phải */
.tv-pagination__pages {
  display: inline-flex;
  gap: var(--tv-s1);
}
.tv-pagination__pages .tv-btn {
  min-height: 32px;
  min-width: 32px;
  padding: 0 var(--tv-s2);
  background: var(--tv-surface);
  color: var(--tv-ink);
  border: 1px solid var(--tv-line);
  font-weight: 500;
}
.tv-pagination__pages .tv-btn.is-active {
  background: var(--tv-primary);
  color: var(--tv-primary-ink);
  border-color: var(--tv-primary);
}


/* =====================================================================
   5. DIALOG / DRAWER — JS tự chèn DOM; ẩn mặc định, mở qua .is-open
   ===================================================================== */
.tv-overlay {                       /* backdrop mờ */
  position: fixed;
  inset: 0;
  z-index: 1000;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: var(--tv-s4);
  background: rgba(16, 32, 46, .45);
  opacity: 0;
  visibility: hidden;
  transition: opacity var(--tv-dur-2) var(--tv-ease), visibility var(--tv-dur-2);
}
.tv-overlay.is-open { opacity: 1; visibility: visible; }

/* Modal card giữa màn: fade + scale */
.tv-modal {
  width: min(560px, 100%);
  max-height: min(86vh, 720px);
  display: flex;
  flex-direction: column;
  background: var(--tv-surface);
  border-radius: var(--tv-r);
  box-shadow: var(--tv-shadow-2);
  opacity: 0;
  transform: translateY(8px) scale(.96);
  transition: opacity var(--tv-dur-2) var(--tv-ease), transform var(--tv-dur-2) var(--tv-ease);
}
.tv-modal.is-open { opacity: 1; transform: none; }

.tv-modal__head {
  display: flex;
  align-items: center;
  gap: var(--tv-s3);
  padding: var(--tv-s4) var(--tv-s5);
  border-bottom: 1px solid var(--tv-line);
}
.tv-modal__title {
  flex: 1;
  margin: 0;
  font-size: var(--tv-fs-xl);
  font-weight: 600;
}
.tv-modal__close {
  flex: none;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 34px;
  height: 34px;
  border: none;
  border-radius: 50%;
  background: transparent;
  color: var(--tv-muted);
  font-size: 20px;
  cursor: pointer;
  transition: background var(--tv-dur-1) var(--tv-ease), color var(--tv-dur-1) var(--tv-ease);
}
.tv-modal__close:hover { background: color-mix(in srgb, var(--tv-ink) 10%, transparent); color: var(--tv-ink); }
/* Ký tự × do JS chèn (quy ước chung — không dùng ::before để tránh "××"). */
.tv-modal__close { line-height: 1; }

.tv-modal__body {
  padding: var(--tv-s5);
  overflow-y: auto;                 /* nội dung dài thì cuộn trong body */
  flex: 1;
}

.tv-modal__foot {
  display: flex;
  align-items: center;
  justify-content: flex-end;        /* nút chính bên PHẢI */
  gap: var(--tv-s2);
  padding: var(--tv-s4) var(--tv-s5);
  border-top: 1px solid var(--tv-line);
}
/* "Hủy" đứng bên TRÁI nút chính: dùng --ghost/--secondary đặt trước nút chính trong DOM */

/* Drawer: trượt từ phải */
.tv-drawer {
  position: fixed;
  top: 0;
  bottom: 0;
  width: min(440px, 100%);
  display: flex;
  flex-direction: column;
  background: var(--tv-surface);
  box-shadow: var(--tv-shadow-2);
  z-index: 1001;
  transition: transform var(--tv-dur-2) var(--tv-ease);
}
.tv-drawer--right {
  right: 0;
  transform: translateX(100%);      /* ẩn bên phải */
}
.tv-drawer--right.is-open { transform: none; }
/* Drawer tái dùng các phần head/body/foot của modal nếu cần */
.tv-drawer .tv-modal__body { flex: 1; }


/* =====================================================================
   6. SNACKBAR — JS tự chèn; góc dưới, xếp chồng dọc; hiện qua .is-show
   ===================================================================== */
.tv-snackbar-host {
  position: fixed;
  left: 50%;
  bottom: max(var(--tv-s5), env(safe-area-inset-bottom));
  transform: translateX(-50%);
  z-index: 1100;
  display: flex;
  flex-direction: column-reverse;   /* cái mới nằm dưới cùng, gần mép */
  align-items: center;
  gap: var(--tv-s2);
  width: min(420px, calc(100vw - 2 * var(--tv-s4)));
  pointer-events: none;             /* host không chặn click; từng snackbar bật lại */
}

.tv-snackbar {
  pointer-events: auto;
  display: flex;
  align-items: center;
  gap: var(--tv-s3);
  width: 100%;
  padding: var(--tv-s3) var(--tv-s4);
  border-radius: var(--tv-r-sm);
  background: var(--tv-ink);
  color: #fff;
  box-shadow: var(--tv-shadow-2);
  font-size: var(--tv-fs-md);
  /* Ẩn mặc định: trượt xuống + mờ */
  opacity: 0;
  transform: translateY(16px);
  transition: opacity var(--tv-dur-2) var(--tv-ease), transform var(--tv-dur-2) var(--tv-ease);
}
.tv-snackbar.is-show { opacity: 1; transform: none; }

.tv-snackbar__icon { flex: none; display: inline-flex; font-size: var(--tv-fs-xl); line-height: 1; }
.tv-snackbar__msg  { flex: 1; min-width: 0; }   /* 1 dòng, tự cắt nếu dài */
.tv-snackbar__action {
  flex: none;
  padding: var(--tv-s1) var(--tv-s2);
  border: none;
  border-radius: var(--tv-r-sm);
  background: transparent;
  color: var(--tv-primary-soft);
  font: inherit;
  font-weight: 600;
  cursor: pointer;
  transition: background var(--tv-dur-1) var(--tv-ease);
}
.tv-snackbar__action:hover { background: rgba(255, 255, 255, .15); }
.tv-snackbar__close {
  flex: none;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 26px;
  height: 26px;
  border: none;
  border-radius: 50%;
  background: transparent;
  color: rgba(255, 255, 255, .8);
  font-size: var(--tv-fs-xl);
  cursor: pointer;
  transition: background var(--tv-dur-1) var(--tv-ease);
}
.tv-snackbar__close:hover { background: rgba(255, 255, 255, .18); }
/* Ký tự × do JS chèn (quy ước chung — không dùng ::before để tránh "××"). */
.tv-snackbar__close { line-height: 1; }

/* Loại theo màu ngữ nghĩa: viền trái đậm màu để phân biệt trên nền tối */
.tv-snackbar--ok   { border-left: 4px solid var(--tv-ok); }
.tv-snackbar--err  { border-left: 4px solid var(--tv-err); }
.tv-snackbar--warn { border-left: 4px solid var(--tv-warn); }
.tv-snackbar--info { border-left: 4px solid var(--tv-info); }
.tv-snackbar--ok   .tv-snackbar__icon { color: color-mix(in srgb, var(--tv-ok) 60%, #fff); }
.tv-snackbar--err  .tv-snackbar__icon { color: color-mix(in srgb, var(--tv-err) 55%, #fff); }
.tv-snackbar--warn .tv-snackbar__icon { color: color-mix(in srgb, var(--tv-warn) 55%, #fff); }
.tv-snackbar--info .tv-snackbar__icon { color: color-mix(in srgb, var(--tv-info) 55%, #fff); }


/* =====================================================================
   7. BANNER / BADGE / CHIP
   ===================================================================== */
/* Banner cấp hệ thống ở đầu trang */
.tv-banner {
  display: flex;
  align-items: center;
  gap: var(--tv-s3);
  padding: var(--tv-s3) var(--tv-s4);
  border-radius: var(--tv-r-sm);
  font-size: var(--tv-fs-md);
  border: 1px solid transparent;
}
.tv-banner--err  { background: var(--tv-err-soft);  color: var(--tv-err-ink);  border-color: color-mix(in srgb, var(--tv-err) 35%, transparent); }
.tv-banner--warn { background: var(--tv-warn-soft); color: var(--tv-warn-ink); border-color: color-mix(in srgb, var(--tv-warn) 35%, transparent); }
.tv-banner--info { background: var(--tv-info-soft); color: var(--tv-info-ink); border-color: color-mix(in srgb, var(--tv-info) 35%, transparent); }

/* Badge: chấm số trên icon (đặt trong phần tử position:relative, vd .tv-btn--icon) */
.tv-badge {
  position: absolute;
  top: -4px;
  right: -4px;
  min-width: 18px;
  height: 18px;
  padding: 0 5px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  /* 999px chứ KHÔNG phải 9px: cùng ra một hình tròn trên phần tử cao 18px, nhưng 999px KHAI RÕ
     "bo tròn" — thang bo góc §2.4a có nhánh "tròn", còn con số 9 thì bị đọc là giá trị lạc thang
     (đo được: TV.kiemUI() báo §2.4a cho .tv-badge). Badge 2 chữ số cũng thành viên thuốc, đúng ý. */
  border-radius: 999px;
  background: var(--tv-err);
  color: #fff;
  font-size: 12px;                  /* §2.2a thang đóng {12,14,16,20,28} — trước là 11px */
  font-weight: 600;
  line-height: 1;
  box-shadow: 0 0 0 2px var(--tv-surface);
}

/* Chip / tag nhỏ có nút X */
.tv-chip {
  display: inline-flex;
  align-items: center;
  gap: var(--tv-s1);
  padding: var(--tv-s1) var(--tv-s2);
  border-radius: 999px;
  background: var(--tv-primary-soft);
  color: var(--tv-primary-text);
  font-size: var(--tv-fs-sm);
  font-weight: 600;
  max-width: 100%;
}
.tv-chip__x {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 16px;
  height: 16px;
  border: none;
  border-radius: 50%;
  background: transparent;
  color: inherit;
  cursor: pointer;
  transition: background var(--tv-dur-1) var(--tv-ease);
}
.tv-chip__x:hover { background: color-mix(in srgb, var(--tv-primary) 22%, transparent); }
/* Ký tự × do JS/markup chèn (quy ước chung — không dùng ::before để tránh "××"). */
.tv-chip__x { font-size: 14px; line-height: 1; }


/* =====================================================================
   8. TABS — bar + tab + panel (gạch chân trượt được)
   ===================================================================== */
.tv-tabs { display: flex; flex-direction: column; }

.tv-tabs__bar {
  position: relative;
  display: flex;
  gap: var(--tv-s1);
  border-bottom: 1px solid var(--tv-line);
  overflow-x: auto;
}
.tv-tabs__tab {
  position: relative;
  padding: var(--tv-s3) var(--tv-s4);
  border: none;
  background: transparent;
  color: var(--tv-muted);
  font: inherit;
  font-weight: 600;
  cursor: pointer;
  white-space: nowrap;
  transition: color var(--tv-dur-1) var(--tv-ease);
}
.tv-tabs__tab:hover { color: var(--tv-ink); }
.tv-tabs__tab.is-active { color: var(--tv-primary-text); }
/* Gạch chân trượt: dùng ::after mỗi tab, dài dần khi active */
.tv-tabs__tab::after {
  content: "";
  position: absolute;
  left: var(--tv-s3);
  right: var(--tv-s3);
  bottom: -1px;
  height: 2px;
  background: var(--tv-primary);
  transform: scaleX(0);
  transform-origin: center;
  transition: transform var(--tv-dur-2) var(--tv-ease);
}
.tv-tabs__tab.is-active::after { transform: scaleX(1); }

/* Panel: ẩn mặc định, hiện fade khi có .is-active */
.tv-tabs__panel {
  display: none;
  padding: var(--tv-s4) 0;
  animation: tv-fade-in var(--tv-dur-2) var(--tv-ease);
}
.tv-tabs__panel.is-active { display: block; }
@keyframes tv-fade-in {
  from { opacity: 0; transform: translateY(4px); }
  to   { opacity: 1; transform: none; }
}


/* =====================================================================
   9. ACCORDION / THU-MỞ — .tv-acc và cơ chế chung .tv-collapse
   ===================================================================== */
.tv-acc {
  border: 1px solid var(--tv-line);
  border-radius: var(--tv-r);
  overflow: hidden;
  background: var(--tv-surface);
}
.tv-acc__item + .tv-acc__item { border-top: 1px solid var(--tv-line); }

.tv-acc__head {
  display: flex;
  align-items: center;
  gap: var(--tv-s3);
  width: 100%;
  padding: var(--tv-s3) var(--tv-s4);
  border: none;
  background: transparent;
  color: var(--tv-ink);
  font: inherit;
  font-weight: 600;
  text-align: left;
  cursor: pointer;
  transition: background var(--tv-dur-1) var(--tv-ease);
}
.tv-acc__head:hover { background: color-mix(in srgb, var(--tv-ink) 4%, transparent); }
/* Mũi tên xoay khi mở */
.tv-acc__head::after {
  content: "\203a";                 /* › */
  margin-left: auto;
  font-size: 20px;
  color: var(--tv-muted);
  transform: rotate(90deg);
  transition: transform var(--tv-dur-2) var(--tv-ease);
}
.tv-acc__item.is-open > .tv-acc__head::after,
.tv-acc__head.is-open::after { transform: rotate(-90deg); }

/* Body & .tv-collapse: chuyển động chiều cao mượt bằng grid-rows (0fr→1fr) */
.tv-acc__body,
.tv-collapse {
  display: grid;
  grid-template-rows: 0fr;          /* đóng */
  transition: grid-template-rows var(--tv-dur-2) var(--tv-ease);
}
.tv-acc__body.is-open,
.tv-collapse.is-open { grid-template-rows: 1fr; }
/* Con trực tiếp phải overflow:hidden để hiệu ứng 0fr→1fr chạy đúng */
.tv-acc__body > *,
.tv-collapse > * { overflow: hidden; min-height: 0; }

/* --- PANEL THU–MỞ (.tv-panel + TV.panel) — card có tiêu đề bấm được.
   MẶC ĐỊNH THU GỌN: giao diện gọn, người dùng tự mở đúng phần cần (chuẩn PHẦN 13).
   Dùng cho khối cài đặt, bộ lọc nâng cao, log, chi tiết phụ... --- */
.tv-panel {
  background: var(--tv-surface);
  border: 1px solid var(--tv-line);
  border-radius: var(--tv-r);
  box-shadow: var(--tv-shadow-1);
  overflow: hidden;
}
.tv-panel__head {
  display: flex;
  align-items: center;
  gap: var(--tv-s2);
  width: 100%;
  padding: var(--tv-s3) var(--tv-s4);
  border: none;
  background: transparent;
  color: var(--tv-ink);
  font: inherit;
  font-weight: 600;
  text-align: left;
  cursor: pointer;
  transition: background var(--tv-dur-1) var(--tv-ease);
}
.tv-panel__head:hover { background: color-mix(in srgb, var(--tv-ink) 4%, transparent); }
.tv-panel__head .tv-panel__sub { font-weight: 400; color: var(--tv-muted); font-size: var(--tv-fs-sm); }
.tv-panel__caret {
  margin-left: auto;
  color: var(--tv-muted);
  transition: transform var(--tv-dur-2) var(--tv-ease);
}
.tv-panel.is-open .tv-panel__caret { transform: rotate(180deg); }
.tv-panel__body {
  display: grid;
  grid-template-rows: 0fr;            /* đóng (mặc định THU GỌN) */
  transition: grid-template-rows var(--tv-dur-2) var(--tv-ease);
}
.tv-panel.is-open .tv-panel__body { grid-template-rows: 1fr; }
/* §8.5: KHÔNG chuyển động `padding` (thuộc tính gây bố cục tính lại). Bỏ từ V163 — chiều cao đã
   được hoạt hoá ở thẻ CHA bằng grid-template-rows nên hộp vẫn mở mượt, phần đệm dưới xuất hiện
   tức thì và mắt không phân biệt được (CCProxy đã so ảnh chụp hai bản). Hai dòng này từng đẻ ra
   8 lượt [CHẶN] trên 5 màn của một dự án. */
.tv-panel__body > * { overflow: hidden; min-height: 0; padding: 0 var(--tv-s4); }
.tv-panel.is-open .tv-panel__body > * { padding-bottom: var(--tv-s4); }
/* Đệm trong cho nội dung body: padding-bottom CHỈ khi mở — nếu đặt cố định thì lúc đóng
   phần tử con vẫn cao bằng padding và lộ ~16px chữ bị cắt (lỗi "đè lên nhau"). */
.tv-acc__body > * { padding: 0 var(--tv-s4); }   /* §8.5 — xem ghi chú ở .tv-panel__body > * */
.tv-acc__body.is-open > * { padding-bottom: var(--tv-s4); }


/* --- THANH TIẾN TRÌNH (.tv-progress) — cho upload/download/render/job.
   Markup: <div class="tv-progress"><div class="tv-progress__bar" style="width:42%"></div></div>
   Biến thể: --ok (xong, xanh), --err (lỗi, đỏ), --sm (mảnh), .is-indeterminate (chưa rõ %). --- */
.tv-progress {
  position: relative;
  height: 8px;
  border-radius: 999px;
  background: color-mix(in srgb, var(--tv-ink) 8%, transparent);
  overflow: hidden;
}
.tv-progress--sm { height: 5px; }
.tv-progress__bar {
  height: 100%;
  width: 0;
  border-radius: 999px;
  background: var(--tv-primary);
  transition: width var(--tv-dur-2) var(--tv-ease);
}
.tv-progress--ok .tv-progress__bar { background: var(--tv-ok); }
.tv-progress--err .tv-progress__bar { background: var(--tv-err); }
@keyframes tv-progress-slide { 0% { left: -40%; } 100% { left: 100%; } }
.tv-progress.is-indeterminate .tv-progress__bar {
  position: absolute; width: 40%; animation: tv-progress-slide 1.2s linear infinite;
}
@media (prefers-reduced-motion: reduce) {
  .tv-progress.is-indeterminate .tv-progress__bar { animation: none; width: 100%; opacity: .4; }
}

/* =====================================================================
   10. TRẠNG THÁI — spinner, skeleton, empty, error; đặt trong .tv-state-slot
   ===================================================================== */
@keyframes tv-spin { to { transform: rotate(360deg); } }

.tv-spinner {
  display: inline-block;
  width: 28px;
  height: 28px;
  border: 3px solid color-mix(in srgb, var(--tv-primary) 25%, transparent);
  border-top-color: var(--tv-primary);
  border-radius: 50%;
  animation: tv-spin .7s linear infinite;
}
.tv-spinner--sm { width: 16px; height: 16px; border-width: 2px; }

/* Skeleton: dải shimmer; đứng yên khi reduced-motion */
.tv-skeleton {
  --tv-sk-base: color-mix(in srgb, var(--tv-line) 60%, var(--tv-surface));
  --tv-sk-hi: color-mix(in srgb, var(--tv-line) 20%, var(--tv-surface));
  height: 14px;
  border-radius: var(--tv-r-sm);
  background: linear-gradient(90deg, var(--tv-sk-base) 25%, var(--tv-sk-hi) 37%, var(--tv-sk-base) 63%);
  background-size: 400% 100%;
  animation: tv-shimmer 1.4s ease infinite;
}
@keyframes tv-shimmer { from { background-position: 100% 0; } to { background-position: 0 0; } }
@media (prefers-reduced-motion: reduce) {
  .tv-skeleton { animation: none; background: var(--tv-sk-base); }
}

/* Empty state: icon + chữ + nút gợi ý, căn giữa */
.tv-empty,
.tv-error-state {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: var(--tv-s3);
  padding: var(--tv-s6) var(--tv-s4);
  text-align: center;
  color: var(--tv-muted);
}
.tv-empty__icon,
.tv-error-state__icon { font-size: 40px; opacity: .7; line-height: 1; }
.tv-empty__title { font-size: var(--tv-fs-lg); font-weight: 600; color: var(--tv-ink); }
/* Câu giải thích dưới tiêu đề của trạng thái rỗng (§4 "Trống": 1 câu giải thích + 1 nút bắt đầu).
   THIẾU ĐỊNH NGHĨA cho tới V159/U7 — tai-xuong.html và thu-vien-anh.html đã dùng nó từ lâu, trình
   duyệt bỏ qua class lạ ÂM THẦM nên không ai thấy (đúng bệnh V081). Bề rộng dòng theo §2.2f. */
.tv-empty__desc { font-size: var(--tv-fs-md); color: var(--tv-muted); max-width: 46ch; }
.tv-error-state { color: var(--tv-err-ink); }
.tv-error-state__msg { color: var(--tv-err-ink); }
/* Câu hỏi trong hộp xác nhận (§1.4). Cũng THIẾU ĐỊNH NGHĨA tới V159/U7, trong khi 5 khung gốc
   (quan-ly · audit · chi-tiet · quan-ly-apikey · tai-xuong) đều đang dùng. */
.tv-confirm__msg { margin: 0; color: var(--tv-ink); line-height: 1.5; }

/* Khu chứa trạng thái trong bảng/vùng nội dung: căn giữa, cao tối thiểu */
.tv-state-slot {
  display: flex;
  align-items: center;
  justify-content: center;
  min-height: 240px;
  padding: var(--tv-s4);
}


/* =====================================================================
   11. KHUNG TRANG — shell desktop + sidebar gập + mobile shell + nav dưới
   ===================================================================== */
/* CỘT NỘI DUNG PHẢI LÀ minmax(0, 1fr), KHÔNG PHẢI 1fr.
   Một dải lưới `1fr` có kích thước tối thiểu tự động bằng MIN-CONTENT, nên chỉ
   cần một thứ bên trong không co được (nhóm nút trên thanh tiêu đề, một bảng
   rộng) là cả dải nở ra quá khung và ĐẨY CẢ TRANG TRÔI NGANG. Đây là gốc của
   4 khung gốc từng tràn ngang trên điện thoại: bao-cao 8px · thu-vien-anh 8px ·
   cong-viec 75px · lich 153px. `minmax(0, …)` hạ sàn đó về 0 để dải chịu co. */
.tv-shell {
  display: grid;
  grid-template-columns: 248px minmax(0, 1fr);
  grid-template-rows: auto 1fr;
  grid-template-areas:
    "side top"
    "side main";
  min-height: 100vh;
  /* NGOẠI LỆ §8.5 ĐÃ CÂN NHẮC: gập sidebar buộc phải chuyển động grid-template-columns (thuộc tính
     gây bố cục tính lại). Phương án thay bằng transform làm vùng nội dung không co theo, tệ hơn.
     Chỉ MỘT phần tử trong cả hệ được phép, chạy đúng 300ms, không lặp. Khung dựng ra phải khai
     data-tv-mien="§8.5: sidebar gập" trên phần tử .tv-shell để TV.kiemUI() ghi nhận và ĐẾM. */
  transition: grid-template-columns var(--tv-dur-2) var(--tv-ease);
}
/* Gập sidebar còn dải icon hẹp */
.tv-shell.is-collapsed { grid-template-columns: 68px minmax(0, 1fr); }

.tv-shell__side {
  grid-area: side;
  background: var(--tv-surface);
  border-right: 1px solid var(--tv-line);
  padding: var(--tv-s4) var(--tv-s3);
  overflow: hidden;
}

/* Đầu sidebar [MẪU: Notion / ChatGPT]: tên dự án bên TRÁI, nút ba gạch (gập/mở,
   gắn data-tv-shell-toggle) nằm MÉP PHẢI đầu sidebar — đúng vị trí quen thuộc của
   các nền tảng lớn. Khi gập chỉ còn nút ☰ căn giữa, tên ẩn đi. */
.tv-shell__side-head {
  display: flex;
  align-items: center;
  gap: var(--tv-s2);
  margin-bottom: var(--tv-s4);
  white-space: nowrap;
}
.tv-shell__side-head .tv-shell__brand { order: 1; }
.tv-shell__side-head .tv-shell__toggle { order: 2; margin-left: auto; }
.tv-shell.is-collapsed .tv-shell__side-head { justify-content: center; }
.tv-shell.is-collapsed .tv-shell__side-head .tv-shell__toggle { margin-left: 0; }
.tv-shell__toggle {
  flex: none;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 36px;
  height: 36px;
  border: none;
  border-radius: var(--tv-r-sm);
  background: transparent;
  color: var(--tv-muted);
  font-size: var(--tv-fs-xl);   /* V159: 18px vốn nằm ngoài tập cỡ chữ đóng §2.2a */
  cursor: pointer;
  transition: background var(--tv-dur-1) var(--tv-ease), color var(--tv-dur-1) var(--tv-ease);
}
.tv-shell__toggle:hover { background: var(--tv-primary-soft); color: var(--tv-primary-text); }
.tv-shell__brand { font-weight: 600; color: var(--tv-primary-text); overflow: hidden; text-overflow: ellipsis; }
.tv-shell.is-collapsed .tv-shell__brand { display: none; }
.tv-shell__top {
  grid-area: top;
  display: flex;
  align-items: center;
  gap: var(--tv-s3);
  padding: var(--tv-s3) var(--tv-s5);
  background: var(--tv-surface);
  border-bottom: 1px solid var(--tv-line);
  min-width: 0;                     /* cùng lý do minmax(0,1fr) ở trên */
}
/* Tiêu đề trang nhường chỗ trước, cắt bằng "…" chứ đừng đẩy nhóm nút ra ngoài */
.tv-shell__top > :first-child { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
.tv-shell__top > .tv-shell__actions {
  margin-left: auto;
  display: flex;
  gap: var(--tv-s2);
  align-items: center;
  flex: none;                       /* nhóm nút giữ nguyên kích thước */
}
.tv-shell__main {
  grid-area: main;
  padding: var(--tv-s5);
  overflow: auto;
  min-width: 0;
}

/* Menu điều hướng bên */
.tv-side-nav { display: flex; flex-direction: column; gap: var(--tv-s1); }
.tv-side-nav a,
.tv-side-nav .tv-side-nav__item {
  display: flex;
  align-items: center;
  gap: var(--tv-s3);
  padding: var(--tv-s2) var(--tv-s3);
  border-radius: var(--tv-r-sm);
  color: var(--tv-muted);
  text-decoration: none;
  font-weight: 600;
  white-space: nowrap;
  transition: background var(--tv-dur-1) var(--tv-ease), color var(--tv-dur-1) var(--tv-ease);
}
.tv-side-nav a:hover,
.tv-side-nav .tv-side-nav__item:hover { background: color-mix(in srgb, var(--tv-ink) 5%, transparent); color: var(--tv-ink); }
.tv-side-nav .is-active {
  background: var(--tv-primary-soft);
  color: var(--tv-primary-text);
}
/* Khi sidebar gập: ẩn nhãn chữ, chỉ chừa icon (icon để đầu tiên) */
.tv-shell.is-collapsed .tv-side-nav__label { display: none; }

/* --- MENU AVATAR NGƯỜI DÙNG (TV.userMenu) — nút avatar chữ cái + dropdown góc phải --- */
.tv-usermenu {
  flex: none;
  width: 34px;
  height: 34px;
  border: none;
  border-radius: 50%;
  background: var(--tv-primary-soft);
  color: var(--tv-primary-text);
  font-weight: 600;
  font-size: var(--tv-fs-md);
  cursor: pointer;
  transition: box-shadow var(--tv-dur-1) var(--tv-ease), transform var(--tv-dur-1) var(--tv-ease);
}
.tv-usermenu:hover { box-shadow: var(--tv-ring); }
.tv-usermenu:active { transform: scale(.94); }
.tv-usermenu__menu {
  position: fixed;
  z-index: 1300;
  min-width: 220px;
  background: var(--tv-surface);
  border: 1px solid var(--tv-line);
  border-radius: var(--tv-r-sm);
  box-shadow: var(--tv-shadow-2);
  padding: var(--tv-s1);
  opacity: 0;
  transform: translateY(-4px);
  transition: opacity var(--tv-dur-1) var(--tv-ease), transform var(--tv-dur-1) var(--tv-ease);
}
.tv-usermenu__menu.is-open { opacity: 1; transform: none; }
.tv-usermenu__head { padding: var(--tv-s2) var(--tv-s3); }
.tv-usermenu__name { font-weight: 600; }
.tv-usermenu__mail { color: var(--tv-muted); font-size: var(--tv-fs-sm); }
.tv-usermenu__sep { height: 1px; background: var(--tv-line); margin: var(--tv-s1) 0; }
.tv-usermenu__item {
  display: flex;
  align-items: center;
  gap: var(--tv-s2);
  width: 100%;
  padding: var(--tv-s2) var(--tv-s3);
  border: none;
  border-radius: var(--tv-r-sm);
  background: transparent;
  color: var(--tv-ink);
  font: inherit;
  text-align: left;
  cursor: pointer;
}
.tv-usermenu__item:hover { background: var(--tv-primary-soft); }
.tv-usermenu__item.is-danger { color: var(--tv-err-ink); }
.tv-usermenu__item.is-danger:hover { background: var(--tv-err-soft); }
.tv-usermenu__ic { width: 20px; text-align: center; }

/* --- HEADER CÔNG KHAI (.tv-pubheader) — cho landing/docs/status/giá.
   Dùng: <header class="tv-pubheader"><div class="tv-pubheader__in">
     <a class="tv-pubheader__brand">◆ Tên</a>
     <nav class="tv-pubheader__nav"><a>...</a></nav>
     <div class="tv-pubheader__actions">nút</div></div></header> --- */
.tv-pubheader {
  position: sticky;
  top: 0;
  z-index: 900;
  background: color-mix(in srgb, var(--tv-surface) 86%, transparent);
  backdrop-filter: blur(10px);
  border-bottom: 1px solid var(--tv-line);
}
.tv-pubheader__in {
  max-width: 1080px;
  margin: 0 auto;
  display: flex;
  align-items: center;
  gap: var(--tv-s4);
  padding: 0 var(--tv-s4);
  height: 60px;
}
.tv-pubheader__brand { font-weight: 600; color: var(--tv-primary-text); text-decoration: none; font-size: var(--tv-fs-lg); white-space: nowrap; }
.tv-pubheader__nav { display: flex; gap: var(--tv-s4); margin-left: auto; }
.tv-pubheader__nav a { color: var(--tv-muted); text-decoration: none; font-weight: 600; font-size: var(--tv-fs-md); transition: color var(--tv-dur-1) var(--tv-ease); }
.tv-pubheader__nav a:hover, .tv-pubheader__nav a.is-active { color: var(--tv-ink); }
.tv-pubheader__actions { display: flex; gap: var(--tv-s2); align-items: center; }
@media (max-width: 720px) { .tv-pubheader__nav { display: none; } }

/* --- MOBILE SHELL: app-style 100dvh, cột dọc, nội dung cuộn dọc --- */
.tv-mobile-shell {
  display: flex;
  flex-direction: column;
  width: 100vw;
  height: 100vh;
  height: 100dvh;                   /* chính xác trên trình duyệt mobile */
  overflow: hidden;                 /* chống cuộn ngang */
  padding-top: env(safe-area-inset-top);
  box-sizing: border-box;
}
.tv-mobile-shell__content {
  flex: 1;
  overflow-y: auto;                 /* chỉ cuộn dọc */
  overflow-x: hidden;
  padding: var(--tv-s4);
  /* chừa chỗ cho thanh nav dưới + safe area */
  padding-bottom: calc(64px + env(safe-area-inset-bottom));
}

/* Thanh điều hướng dưới cố định (4–5 tab ≥44px) */
.tv-navbot {
  position: fixed;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 100;
  display: flex;
  background: var(--tv-surface);
  border-top: 1px solid var(--tv-line);
  padding-bottom: env(safe-area-inset-bottom);
  box-sizing: content-box;
}
.tv-navbot__tab {
  flex: 1;
  min-height: 56px;                 /* ≥44px vùng chạm */
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 2px;
  border: none;
  background: transparent;
  color: var(--tv-muted);
  font-size: var(--tv-fs-sm);
  font-weight: 600;
  cursor: pointer;
  transition: color var(--tv-dur-1) var(--tv-ease), background var(--tv-dur-1) var(--tv-ease);
}
.tv-navbot__tab:active { background: color-mix(in srgb, var(--tv-ink) 6%, transparent); }
.tv-navbot__tab.is-active { color: var(--tv-primary-text); }
.tv-navbot__tab .tv-navbot__icon { font-size: 20px; line-height: 1; }  /* §2.2a — trước là 22px */

/* Nút chạm ≥44×44 trên mobile: áp cho mọi nút khi màn nhỏ */
@media (max-width: 640px) {
  .tv-btn { min-height: 44px; }
  .tv-btn--icon { width: 44px; }
  .tv-shell { grid-template-columns: minmax(0, 1fr); grid-template-areas: "top" "main"; }
  .tv-shell__side { display: none; }   /* desktop sidebar ẩn trên mobile — dùng .tv-navbot */
  /* Thanh tiêu đề: nhóm nút XUỐNG DÒNG thay vì đẩy trang trôi ngang. Màn lịch có
     tới 3 cụm nút (đoạn chọn Tháng/Tuần + Tạo sự kiện + nút tối) rộng 248px, cộng
     tiêu đề là quá 375px — không cho xuống dòng thì chỉ còn đường đẩy trang. */
  .tv-shell__top { flex-wrap: wrap; row-gap: var(--tv-s2); padding-left: var(--tv-s4); padding-right: var(--tv-s4); }
  .tv-shell__main { padding: var(--tv-s4); }
  /* Header công khai (landing/docs): nhóm nút cũng phải được xuống dòng */
  .tv-pubheader__in { flex-wrap: wrap; row-gap: var(--tv-s2); }
  .tv-pubheader__actions { flex-wrap: wrap; }
  /* Cho .tv-shell__top xuống dòng là chưa đủ: nhóm nút giữ flex:none nên vẫn nằm MỘT hàng và
     đẩy trang trôi ngang (đo: 2 nút + đổi nền + menu = tràn 52px ở bề rộng 390px). Cùng cách
     đã áp cho .tv-pubheader__actions ngay trên. Nguồn: deepseek.citah.xyz, cuộc #100 mục 3. */
  .tv-shell__top > .tv-shell__actions { flex-wrap: wrap; }
}


/* =====================================================================
   12. COMBOBOX — .tv-cbx (CSS ở đây, JS ở combobox.js).
       Menu .tv-cbx__menu nổi bằng position:fixed do JS đặt toạ độ →
       KHÔNG bị cha overflow cắt.
   ===================================================================== */
.tv-cbx {
  position: relative;
  display: block;
}

/* Control trông như một ô nhập, chứa chip (đa chọn) + input + clear + caret */
.tv-cbx__control {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: var(--tv-s1);
  min-height: 40px;
  padding: var(--tv-s1) var(--tv-s2);
  border: 1px solid var(--tv-line);
  border-radius: var(--tv-r-sm);
  background: var(--tv-surface);
  cursor: text;
  transition: border-color var(--tv-dur-1) var(--tv-ease), box-shadow var(--tv-dur-1) var(--tv-ease);
}
.tv-cbx.is-open .tv-cbx__control,
.tv-cbx__control:focus-within {
  border-color: var(--tv-primary);
  box-shadow: var(--tv-ring);
}
.tv-cbx.is-invalid .tv-cbx__control {
  border-color: var(--tv-err);
}
.tv-cbx.is-invalid .tv-cbx__control:focus-within {
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--tv-err) 30%, transparent);
}

/* CHỈ MỘT VÒNG CHỌN, VÀ Ở NGOÀI (vá 2026-08-07).
   Ô gõ bên trong là <input> nên trình duyệt vẫn coi là "focus bằng bàn phím"
   kể cả khi bấm chuột → luật :focus-visible ở đầu file (mục 0) vẽ thêm cho NÓ
   một vòng nữa, thành vòng nhỏ nằm lọt bên trong khung. Tắt vòng riêng của mọi
   phần tử con; vòng của cả khung ở trên là vòng DUY NHẤT.
   Khung gộp khác muốn cùng nết thì gắn class .tv-ring-ngoai. */
.tv-cbx__control :where(input, button, select, textarea, a, [tabindex]):focus,
.tv-cbx__control :where(input, button, select, textarea, a, [tabindex]):focus-visible,
.tv-ring-ngoai :where(input, button, select, textarea, a, [tabindex]):focus,
.tv-ring-ngoai :where(input, button, select, textarea, a, [tabindex]):focus-visible {
  outline: none;
  box-shadow: none;
}

.tv-cbx.is-disabled .tv-cbx__control {
  background: color-mix(in srgb, var(--tv-line) 30%, var(--tv-surface));
  cursor: not-allowed;
  opacity: .7;
  pointer-events: none;
}

/* Chip trong control dùng lại .tv-chip (mục 7). Input gõ tìm: */
.tv-cbx__input {
  flex: 1;
  min-width: 60px;
  border: none;
  outline: none;
  background: transparent;
  color: var(--tv-ink);
  font: inherit;
  padding: var(--tv-s1);
}
.tv-cbx__input::placeholder { color: var(--tv-muted); }

/* Nút X xoá lựa chọn + mũi tên caret */
.tv-cbx__clear {
  flex: none;
  display: none;                    /* JS bật khi có giá trị */
  align-items: center;
  justify-content: center;
  width: 22px;
  height: 22px;
  border: none;
  border-radius: 50%;
  background: transparent;
  color: var(--tv-muted);
  cursor: pointer;
  transition: background var(--tv-dur-1) var(--tv-ease);
}
.tv-cbx.has-value .tv-cbx__clear { display: inline-flex; }
.tv-cbx__clear:hover { background: color-mix(in srgb, var(--tv-ink) 10%, transparent); color: var(--tv-ink); }
/* LƯU Ý: KHÔNG thêm ::before "×" ở đây — combobox.js đã tự chèn ký tự × vào nút
   (thêm nữa sẽ thành "××"). Phần hiện/ẩn nút do combobox.css + thuộc tính [hidden] quản. */

.tv-cbx__caret {
  flex: none;
  width: 20px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  color: var(--tv-muted);
  cursor: pointer;
}
/* LƯU Ý: KHÔNG thêm ::before "▾" ở đây — combobox.js đã tự chèn mũi tên SVG vào
   thẻ này; thêm nữa sẽ thấy HAI mũi tên "▾ ⌄" nằm cạnh nhau (lỗi đã gặp thật,
   vá 2026-08-07). Cũng KHÔNG đặt pointer-events:none — combobox.js gắn sự kiện
   bấm lên chính mũi tên để đóng/mở menu, chặn chuột thì bấm mũi tên lúc menu
   đang mở sẽ không đóng lại được. */
.tv-cbx__caret svg { transition: transform var(--tv-dur-2) var(--tv-ease); }
.tv-cbx.is-open .tv-cbx__caret svg { transform: rotate(180deg); }
.tv-cbx.is-open .tv-cbx__caret { transform: rotate(180deg); }

/* Menu nổi: JS đặt position:fixed + toạ độ. z-index cao hơn dialog. */
.tv-cbx__menu {
  position: fixed;                  /* JS ghi top/left/width; không bị overflow cha cắt */
  z-index: 1200;
  max-height: 280px;
  overflow-y: auto;
  padding: var(--tv-s1);
  background: var(--tv-surface);
  border: 1px solid var(--tv-line);
  border-radius: var(--tv-r-sm);
  box-shadow: var(--tv-shadow-2);
  /* Ẩn mặc định: fade + slide ngắn */
  opacity: 0;
  transform: translateY(-6px);
  visibility: hidden;
  transition: opacity var(--tv-dur-1) var(--tv-ease), transform var(--tv-dur-1) var(--tv-ease), visibility var(--tv-dur-1);
}
.tv-cbx__menu.is-open {
  opacity: 1;
  transform: none;
  visibility: visible;
}

/* Mỗi lựa chọn: highlight bàn phím .is-hi, đã chọn .is-sel */
.tv-cbx__opt {
  display: flex;
  align-items: center;
  gap: var(--tv-s2);
  padding: var(--tv-s2) var(--tv-s3);
  border-radius: var(--tv-r-sm);
  color: var(--tv-ink);
  cursor: pointer;
  transition: background var(--tv-dur-1) var(--tv-ease);
}
.tv-cbx__opt:hover,
.tv-cbx__opt.is-hi { background: color-mix(in srgb, var(--tv-primary) 10%, transparent); }
.tv-cbx__opt.is-sel { color: var(--tv-primary-text); font-weight: 600; }
.tv-cbx__opt.is-sel::after { content: "\2713"; margin-left: auto; }   /* ✓ */

/* Nhãn nhóm */
.tv-cbx__group-label {
  padding: var(--tv-s2) var(--tv-s3) var(--tv-s1);
  font-size: var(--tv-fs-sm);
  font-weight: 600;
  color: var(--tv-muted);
  text-transform: uppercase;
  letter-spacing: .04em;
}

/* Dòng thông báo: Không tìm thấy / Đang tải / Lỗi tải (+ nút thử lại) */
.tv-cbx__msg {
  display: flex;
  align-items: center;
  gap: var(--tv-s2);
  padding: var(--tv-s3);
  color: var(--tv-muted);
  font-size: var(--tv-fs-sm);
  justify-content: center;
}
.tv-cbx__msg .tv-btn { min-height: 30px; }

/* Dòng "Thêm '...'" cho phép tạo mới */
.tv-cbx__create {
  display: flex;
  align-items: center;
  gap: var(--tv-s2);
  padding: var(--tv-s2) var(--tv-s3);
  border-radius: var(--tv-r-sm);
  color: var(--tv-primary-text);
  font-weight: 600;
  cursor: pointer;
  transition: background var(--tv-dur-1) var(--tv-ease);
}
.tv-cbx__create:hover,
.tv-cbx__create.is-hi { background: var(--tv-primary-soft); }
.tv-cbx__create::before { content: "+"; font-size: 16px; line-height: 1; }


/* =====================================================================
   13. TIỆN ÍCH — tooltip, kbd, card, grid, helper
   ===================================================================== */
/* Tooltip đơn giản qua data-tv-tip (nội dung nằm ở thuộc tính) */
.tv-tooltip {
  position: relative;
}
.tv-tooltip[data-tv-tip]::after {
  content: attr(data-tv-tip);
  position: absolute;
  bottom: calc(100% + 6px);
  left: 50%;
  transform: translateX(-50%) translateY(4px);
  padding: var(--tv-s1) var(--tv-s2);
  border-radius: var(--tv-r-sm);
  background: var(--tv-ink);
  color: #fff;
  font-size: var(--tv-fs-sm);
  white-space: nowrap;
  opacity: 0;
  pointer-events: none;
  z-index: 1300;
  transition: opacity var(--tv-dur-1) var(--tv-ease), transform var(--tv-dur-1) var(--tv-ease);
}
.tv-tooltip[data-tv-tip]:hover::after,
.tv-tooltip[data-tv-tip]:focus-visible::after {
  opacity: 1;
  transform: translateX(-50%) translateY(0);
}

/* ---------------------------------------------------------------------
   CHÚ THÍCH DẤU "?" (.tv-tip + tấm nổi .tv-tip-pop) — dùng cho lời giải
   thích DÀI. Thay cho việc đổ cả đoạn văn xuôi xuống dưới ô nhập.
   Hợp đồng: <button type="button" class="tv-tip" data-tv-tip="..."></button>
   JS (TV.tip) lo TOÀN BỘ toạ độ tấm nổi. CSS dưới đây CHỈ lo diện mạo —
   TUYỆT ĐỐI không khai top/right/bottom/left cho .tv-tip-pop (hai lớp cùng
   định vị một phần tử là bệnh đã trả giá: tấm bị ép dẹp mà phép đo vẫn xanh).
   --------------------------------------------------------------------- */
.tv-tip {
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 17px;
  height: 17px;
  margin-left: var(--tv-s1);
  padding: 0;
  border: 1px solid var(--tv-muted);
  border-radius: 50%;
  background: transparent;
  color: var(--tv-muted);
  font: inherit;
  font-size: 12px;                  /* §2.2a — trước là 11px. Vòng tròn vẫn 17px nên bố cục KHÔNG đổi
                                       (mail.mozmail.org và CCProxy đều đã ghi đè 12px rồi đo lại). */
  font-weight: 600;
  line-height: 1;
  vertical-align: middle;
  cursor: pointer;
  flex: 0 0 auto;
  transition: background var(--tv-dur-1) var(--tv-ease), color var(--tv-dur-1) var(--tv-ease),
              border-color var(--tv-dur-1) var(--tv-ease);
}
/* Vòng tròn NHÌN THẤY chỉ 17px cho khỏi lấn nhãn, nhưng VÙNG CHẠM phải ≥24px
   theo WCAG 2.2 SC 2.5.8 — nong ra bằng lớp phủ trong suốt. Lớp này không có
   chữ nên không đụng luật "ký tự do JS/markup chèn". */
.tv-tip::after {
  content: '';
  position: absolute;
  left: 50%;
  top: 50%;
  width: 24px;
  height: 24px;
  transform: translate(-50%, -50%);
}
.tv-tip:hover,
.tv-tip[aria-expanded="true"] {
  border-color: var(--tv-primary);
  background: var(--tv-primary);
  color: var(--tv-primary-ink);
}
.tv-tip:focus-visible { outline: none; box-shadow: var(--tv-ring); }

/* Tấm nổi: JS chèn 1 cái DUY NHẤT vào <body> rồi dùng lại. Nằm ở body nên
   KHÔNG thừa hưởng white-space:nowrap / text-align:right của ô bảng, và
   không bị overflow:hidden của khối cha xén cụt. */
.tv-tip-pop {
  position: fixed;                 /* toạ độ do JS đặt */
  z-index: 1400;
  /* width: max-content KHÔNG phụ thuộc chỗ trống bên phải điểm neo — tránh
     bẫy shrink-to-fit khiến tấm bị bóp lại khi bám sát mép phải màn hình. */
  width: max-content;
  max-width: min(320px, calc(100vw - 2 * var(--tv-s4)));
  padding: var(--tv-s3);
  border: 1px solid var(--tv-line);
  border-radius: var(--tv-r-sm);
  background: var(--tv-surface);
  box-shadow: var(--tv-shadow-2);
  color: var(--tv-ink);
  font-size: var(--tv-fs-sm);
  line-height: 1.55;
  white-space: normal;             /* mở lại tường minh: 2 thuộc tính KẾ THỪA */
  text-align: left;
  opacity: 0;
  visibility: hidden;
  transform: translateY(4px);
  transition: opacity var(--tv-dur-1) var(--tv-ease), transform var(--tv-dur-1) var(--tv-ease),
              visibility var(--tv-dur-1);
}
.tv-tip-pop.is-mo { opacity: 1; visibility: visible; transform: translateY(0); }
.tv-tip-pop__tieu-de {
  margin: 0 0 var(--tv-s1);
  font-size: var(--tv-fs-sm);
  font-weight: 600;
  color: var(--tv-ink);
}
.tv-tip-pop__than { margin: 0; color: var(--tv-muted); }
.tv-tip-pop__than strong { color: var(--tv-ink); }
/* Mũi tên chỉ về nút "?": JS chỉ truyền toạ độ NGANG qua biến --tv-tip-ax
   và gắn class hướng; CSS lo phần còn lại (khác phần tử với tấm → không đá nhau). */
.tv-tip-pop__mui {
  position: absolute;
  left: var(--tv-tip-ax, 16px);
  width: 9px;
  height: 9px;
  border: 1px solid var(--tv-line);
  background: var(--tv-surface);
  transform: rotate(45deg);
}
.tv-tip-pop.is-duoi .tv-tip-pop__mui { top: -5px; border-right: 0; border-bottom: 0; }
.tv-tip-pop.is-tren .tv-tip-pop__mui { bottom: -5px; border-left: 0; border-top: 0; }

@media (prefers-reduced-motion: reduce) {
  .tv-tip-pop { transition: none; transform: none; }
  .tv-tip-pop.is-mo { transform: none; }
}

/* Phím tắt hiển thị dạng phím */
.tv-kbd {
  display: inline-block;
  min-width: 20px;
  padding: 2px 6px;
  border: 1px solid var(--tv-line);
  border-bottom-width: 2px;
  border-radius: 6px;
  background: color-mix(in srgb, var(--tv-line) 30%, var(--tv-surface));
  color: var(--tv-ink);
  font-family: ui-monospace, "SFMono-Regular", Consolas, monospace;
  font-size: var(--tv-fs-sm);
  line-height: 1.4;
  text-align: center;
}

/* Card cơ bản */
.tv-card {
  background: var(--tv-surface);
  border: 1px solid var(--tv-line);
  border-radius: var(--tv-r);
  box-shadow: var(--tv-shadow-1);
  padding: var(--tv-s5);
}

/* Lưới khoảng cách đều, tự xuống hàng theo bề rộng */
.tv-grid {
  display: grid;
  gap: var(--tv-s4);
  grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
}

/* Helper */
.tv-hide { display: none !important; }
.tv-visually-hidden {               /* ẩn khỏi mắt nhưng vẫn đọc được cho screen reader */
  position: absolute !important;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

/* HẾT — đối chiếu checklist quy-chuan-website.txt */
