/* ============================================================
   Be Vietnam Pro — tự host, subset vietnamese + latin, weight 400/500/600/700/800
   ============================================================ */
@font-face {
  font-family: 'Be Vietnam Pro';
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url('/fonts/be-vietnam-pro-400-vietnamese.woff2') format('woff2');
  unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
@font-face {
  font-family: 'Be Vietnam Pro';
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url('/fonts/be-vietnam-pro-400-latin.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: 'Be Vietnam Pro';
  font-style: normal;
  font-weight: 500;
  font-display: swap;
  src: url('/fonts/be-vietnam-pro-500-vietnamese.woff2') format('woff2');
  unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
@font-face {
  font-family: 'Be Vietnam Pro';
  font-style: normal;
  font-weight: 500;
  font-display: swap;
  src: url('/fonts/be-vietnam-pro-500-latin.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: 'Be Vietnam Pro';
  font-style: normal;
  font-weight: 600;
  font-display: swap;
  src: url('/fonts/be-vietnam-pro-600-vietnamese.woff2') format('woff2');
  unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
@font-face {
  font-family: 'Be Vietnam Pro';
  font-style: normal;
  font-weight: 600;
  font-display: swap;
  src: url('/fonts/be-vietnam-pro-600-latin.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: 'Be Vietnam Pro';
  font-style: normal;
  font-weight: 700;
  font-display: swap;
  src: url('/fonts/be-vietnam-pro-700-vietnamese.woff2') format('woff2');
  unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
@font-face {
  font-family: 'Be Vietnam Pro';
  font-style: normal;
  font-weight: 700;
  font-display: swap;
  src: url('/fonts/be-vietnam-pro-700-latin.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: 'Be Vietnam Pro';
  font-style: normal;
  font-weight: 800;
  font-display: swap;
  src: url('/fonts/be-vietnam-pro-800-vietnamese.woff2') format('woff2');
  unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
@font-face {
  font-family: 'Be Vietnam Pro';
  font-style: normal;
  font-weight: 800;
  font-display: swap;
  src: url('/fonts/be-vietnam-pro-800-latin.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

/* ============================================================
   DESIGN TOKENS — 04_Design_System.md
   Lớp 1 (primitive) → lớp 2 (semantic). Component CHỈ dùng semantic.
   ============================================================ */
:root {
  /* --- Primitive: Slate --- */
  --slate-50:  #F8FAFC; --slate-100: #F1F5F9; --slate-200: #E2E8F0;
  --slate-300: #CBD5E1; --slate-400: #94A3B8; --slate-500: #64748B;
  --slate-600: #475569; --slate-700: #334155; --slate-800: #1E293B;
  --slate-900: #0F172A;
  /* --- Primitive: Green (hành động + thương hiệu) --- */
  --green-50: #E9F9EF; --green-100: #D9F2E3; --green-200: #A6E0BC;
  --green-400: #22C55E; --green-500: #16A34A; --green-600: #15803D; --green-700: #0F6B30;
  /* --- Primitive: Court (XANH THẢM SÂN CẦU LÔNG — primary hành động + thương hiệu) ---
     Xanh lá ngả lam, trầm — màu mặt thảm thi đấu BWF/Yonex. Đây là "xanh lá điềm đạm làm neo
     thương hiệu (không phải xanh 'success' chói)" mà 02_Brand_Identity.md § 7.3/§ 8 đã chốt; code
     trước đây dùng --blue-500 nên lệch với brand doc, đợt này đưa về đúng hướng.

     🔴 Ngả LAM là điều kiện, không phải sở thích: brand doc buộc primary phải "tách khỏi
     màu-ngữ-nghĩa-tiền". Chọn một xanh lá thuần (vd #157A47) sẽ gần như trùng --money-positive
     (#15803D) ⇒ nút "Lưu" và con số tiền dương cùng màu trên cùng màn hình.

     Tương phản đã tính theo công thức WCAG, không ước lượng:
       court-500 trên trắng / trắng trên court-500 = 5,29:1 (blue-500 cũ: 5,17:1 — nhỉnh hơn một chút)
       court-700 trên court-100                    = 7,63:1  (chữ mục nav đang chọn; cặp blue cũ: 7,15:1)
       court-400 trên --surface tối (#1E293B)      = 6,68:1  (blue-400 cũ: 5,75:1)
       court-300 trên --surface tối                = 8,74:1  (hover, phải sáng hơn base)
       trắng trên court-600                        = 7,11:1  (nền đặc ở theme tối; blue-600 cũ: 6,70:1) */
  --court-50: #ECF8F4; --court-100: #D3EDE4; --court-200: #A5DBC8; --court-300: #7FD9BC;
  --court-400: #3EC49E; --court-500: #0E7A5F; --court-600: #0A6450; --court-700: #08503F;
  --court-900: #093A2F; --court-950: #06281F;
  /* --- Primitive: Blue (KHÔNG còn là màu thương hiệu — chỉ là một Ô MÀU PHÂN LOẠI) ---
     🔴 Đừng đổi ramp này sang xanh thảm cho "thống nhất": nó đang phục vụ bánh xe màu avatar
     (--c0), .badge-soft.is-blue, .stat-card__icon.is-blue, --status-info và .app-alert.is-info —
     những chỗ mà xanh dương mang NGHĨA (thông tin / một ô trong dải màu phân biệt người), không
     mang thương hiệu. Đổi ở đây là đổi nghĩa, và làm tên biến nói dối. */
  --blue-50: #EFF6FF; --blue-100: #DBEAFE; --blue-200: #BFDBFE; --blue-300: #93C5FD;
  --blue-400: #60A5FA; --blue-500: #2563EB; --blue-600: #1D4ED8; --blue-700: #1E40AF;
  /* --- Primitive: Violet (accent icon tile) --- */
  --violet-500: #7C3AED; --violet-300: #C4B5FD;
  /* --- Primitive: Red (danger / money âm) --- */
  --red-50: #FEF2F2; --red-100: #FBDADA; --red-200: #F3A8A8;
  --red-400: #F87171; --red-500: #DC2626; --red-600: #B91C1C; --red-700: #8A1919;
  /* --- Primitive: Amber (accent ấm) --- */
  --amber-50: #FFF7ED; --amber-100: #FDE5D1; --amber-200: #FBC496;
  --amber-400: #FB923C; --amber-500: #F97316; --amber-600: #EA580C; --amber-700: #7C380B;

  /* --- Spacing (4px) --- */
  --space-2xs: .25rem; --space-xs: .5rem; --space-sm: .75rem; --space-md: 1rem;
  --space-lg: 1.5rem; --space-xl: 2rem; --space-2xl: 3rem; --space-3xl: 4rem;

  /* --- Radius --- */
  --radius-sm: 8px; --radius-md: 10px; --radius-lg: 16px;
  --radius-btn: 14px; --radius-card: 20px; --radius-pill: 999px;

  /* --- Content width --- */
  --w-narrow: 560px; --w-content: 720px; --w-wide: 960px;

  /* --- Motion --- */
  --motion-instant: 100ms; --motion-fast: 150ms; --motion-base: 250ms;
  --ease-out: cubic-bezier(0.16, 1, 0.3, 1);
  --ease-standard: cubic-bezier(0.4, 0, 0.2, 1);

  /* --- Kích thước khung --- */
  --sidebar-width: 248px; --bottom-nav-height: 60px; --topbar-height: 56px;

  /* --- Sidebar TỐI — độc lập theme sáng/tối, luôn xanh thảm đậm ---
     🔴 Bộ CHỮ phải đổi cùng nền, không được giữ lại bộ xám-lam cũ (#C7D2E4 / #8394B0 / #64789B):
     xám ngả lam đứng trên nền xanh lá cho ra sắc tím bẩn. Bộ dưới đây ngả lục, chọn theo SỐ ĐO chứ
     không theo mắt — tính bằng công thức WCAG trên nền #093A2F:
       --nav-text        8,79:1  (cũ #C7D2E4 trên navy: 10,81:1)
       --nav-text-muted  5,54:1  (cũ #8394B0 trên navy:  5,36:1)
       --nav-group-label 3,69:1  (cũ #64789B trên navy:  3,69:1 — bằng đúng)
     🔴 --nav-text TỤT từ 10,81 xuống 8,79. Vẫn trên cả AA (4,5) lẫn AAA (7), nên chấp nhận được:
     kéo lại đúng 10,81 phải đẩy chữ về gần trắng hẳn, làm nav mất sắc và trông gắt hơn hiện tại.
     Ghi ra đây để lần sau ai đo thấy con số nhỏ hơn thì biết là CÓ CHỦ Ý, không phải sót.
     Nhãn nhóm vốn đã dưới AA từ trước khi đổi màu; ở đây giữ NGUYÊN mức đó, không làm tệ hơn —
     nâng nó lên là việc của một task riêng về tương phản, không nhân tiện làm ở đợt đổi màu. */
  --nav-bg:           linear-gradient(180deg, var(--court-900) 0%, var(--court-950) 100%);
  --nav-text:         #C6DCD3;
  --nav-text-strong:  #F8FAFC;
  --nav-text-muted:   #8FB3A8;
  --nav-group-label:  #6E9287;
  --nav-border:       rgba(148,163,184,.14);
  --nav-hover-bg:     rgba(148,163,184,.10);
  --nav-active-bg:    var(--court-500);
  --nav-active-text:  #FFFFFF;
  --nav-brand-mark-bg: var(--court-500);
}

/* ============================================================
   SEMANTIC — Light (mặc định) + [data-bs-theme="light"]
   ============================================================ */
:root,
[data-bs-theme="light"] {
  --surface:        #FFFFFF;
  --surface-sunken: var(--slate-50);
  --surface-muted:  var(--slate-100);
  --border:         var(--slate-200);
  --border-strong:  var(--slate-300);
  --text:           var(--slate-800);
  --text-strong:    var(--slate-900);
  --text-muted:     var(--slate-600);
  --text-disabled:  var(--slate-400);

  --action-primary:       var(--court-500);
  --action-primary-hover: var(--court-600);
  --action-secondary:     var(--green-500);

  --money-positive: var(--green-600);
  --money-negative: var(--red-500);
  --money-neutral:  var(--slate-800);

  --status-danger:  var(--red-500);
  --status-warning: var(--amber-500);
  --status-info:    var(--blue-500);
  /* 🔴 `--status-warning` là màu VIỀN/NỀN, KHÔNG dùng được làm màu CHỮ ở theme sáng: amber-500
     (#F97316) trên nền trắng chỉ ra 2,8:1 — trượt cả 4,5:1 của chữ thường lẫn 3:1 của chữ ≥20px
     (MB-7, đo ở task 4.6 trên màn Báo cáo công nợ: "CLB nợ lại thành viên" + "CLB nợ họ …₫"). Đây là
     bậc CHỮ của cùng ngữ nghĩa cảnh báo, tách riêng thay vì đổi `--status-warning` — token kia đang
     làm đúng việc của nó ở `border-color` và không có vấn đề tương phản nào ở đó. amber-700 trên
     trắng = 8,65:1. Theme tối có bản đối ứng ngay dưới (khối [data-bs-theme="dark"]). */
  --status-warning-text: var(--amber-700);

  --focus-ring: rgba(14, 122, 95, .38);

  --sidebar-active-bg:   var(--court-100);
  --sidebar-active-text: var(--court-700);

  --elevation-1: 0 1px 2px rgba(15,23,42,.04), 0 2px 6px rgba(15,23,42,.06);
  --elevation-2: 0 2px 4px rgba(15,23,42,.04), 0 8px 24px rgba(15,23,42,.08);
  --elevation-3: 0 8px 16px rgba(15,23,42,.08), 0 24px 48px rgba(15,23,42,.14);

  /* Badge soft — foreground đổi theo theme, background dùng rgba (chung 2 theme) */
  --badge-green-fg: #0F6B30; --badge-blue-fg: #1E40AF; --badge-red-fg: #8A1919;
  --badge-amber-fg: #7C380B; --badge-slate-fg: #475569;

  /* --- Ghi đè biến Bootstrap --- */
  --bs-primary: var(--action-primary);
  --bs-primary-rgb: 14, 122, 95;
  --bs-primary-text-emphasis: var(--court-700);
  --bs-primary-bg-subtle: var(--court-100);
  --bs-primary-border-subtle: var(--court-200);
  /* Bootstrap :root bake --bs-link-color: #0d6efd. `a{}` bên dưới đã tự đặt màu, nhưng .btn-link
     và .link-primary đọc BIẾN này chứ không đọc quy tắc `a` ⇒ vẫn ra xanh dương nếu bỏ qua. */
  --bs-link-color: var(--action-primary);
  --bs-link-color-rgb: 14, 122, 95;
  --bs-link-hover-color: var(--action-primary-hover);
  /* 🔴 `<code>` — Bootstrap bake --bs-code-color: #d63384 (hồng). Trên nền TRANG (--surface-sunken
     #F8FAFC, vì app override --bs-body-bg thành nền chìm) ra ĐÚNG 4,30:1 — con số đã gặp 5 lần ở họ
     nút, nay lặp lại ở một element HTML thuần. Đo ở task 6.1 tại `/clb/{id}/cai-dat`, nơi `<code>`
     in MÃ THAM GIA CLB (BR-MEM-13) — chuỗi ngắn, cỡ .fs-5, là thứ Owner đọc để đọc/gõ cho người
     khác, nên đọc sai một ký tự là hỏng việc thật. Trỏ về token app đã có thay vì bịa hex mới:
     light court-500 trên nền chìm = 5,06:1. Theme tối KHÔNG trượt (Bootstrap tự đổi sang #e685b5 =
     7,13:1 trên #0F172A) nhưng vẫn có bản đối ứng ngay dưới (court-400 = 8,16:1) — luật P5: một màu
     chỉ đúng ở một theme thì phải có bản đối ứng ở theme kia, không để rơi về mặc định Bootstrap. */
  --bs-code-color: var(--action-primary);
  --bs-success: var(--green-500);
  --bs-success-rgb: 22, 163, 74;
  --bs-danger: var(--red-500);
  --bs-danger-rgb: 220, 38, 38;
  --bs-warning: var(--amber-500);
  --bs-warning-rgb: 249, 115, 22;
  --bs-info: var(--blue-500);
  --bs-info-rgb: 37, 99, 235;
  --bs-body-bg: var(--surface-sunken);
  --bs-body-color: var(--text);
  --bs-secondary-color: var(--text-muted);
  --bs-border-color: var(--border);
  --bs-border-radius: var(--radius-md);
  --bs-border-radius-sm: var(--radius-sm);
  --bs-border-radius-lg: var(--radius-lg);
  --bs-body-font-family: 'Be Vietnam Pro', system-ui, -apple-system, 'Segoe UI', sans-serif;
}

/* ============================================================
   SEMANTIC — Dark
   ============================================================ */
[data-bs-theme="dark"] {
  --surface:        #1E293B;
  --surface-sunken: #0F172A;
  --surface-muted:  #334155;
  /* P5: #334155 trùng luôn --surface-muted ⇒ viền .data-card gần như biến mất trên nền tối (~1,5:1,
     dưới ngưỡng 3:1 khuyến nghị cho viền mang nghĩa — WCAG 1.4.11). Nới sáng hơn để ranh giới rõ. */
  --border:         #3E4C63;
  --border-strong:  #475569;
  --text:           #E2E8F0;
  --text-strong:    #F8FAFC;
  --text-muted:     #94A3B8;
  --text-disabled:  #64748B;

  --action-primary:       var(--court-400);
  --action-primary-hover: var(--court-300);
  --action-secondary:     var(--green-400);

  --money-positive: #4ADE80;
  --money-negative: #F87171;
  --money-neutral:  #E2E8F0;

  --status-danger:  #F87171;
  --status-warning: #FB923C;
  --status-info:    #60A5FA;
  /* Theme tối KHÔNG có vấn đề: #FB923C trên nền thẻ #1E293B ra 6,46:1 (đo được `tươngphản = 0` ở
     dark trước khi sửa). Vẫn khai tường minh vì spec § 3.5 cấm để một token rơi về giá trị mặc định
     ở một theme — đọc được ngay rằng "ở dark, màu cảnh báo dùng làm chữ được luôn". */
  --status-warning-text: var(--status-warning);

  --focus-ring: rgba(62, 196, 158, .45);

  --sidebar-active-bg:   rgba(62, 196, 158, .20);
  --sidebar-active-text: var(--court-300);

  --elevation-1: 0 1px 3px rgba(0,0,0,.40), 0 1px 2px rgba(0,0,0,.30);
  --elevation-2: 0 4px 12px rgba(0,0,0,.45), 0 2px 4px rgba(0,0,0,.35);
  --elevation-3: 0 16px 40px rgba(0,0,0,.55), 0 4px 12px rgba(0,0,0,.40);

  --badge-green-fg: #4ADE80; --badge-blue-fg: #93C5FD; --badge-red-fg: #FCA5A5;
  --badge-amber-fg: #FDBA74; --badge-slate-fg: #CBD5E1;

  /* P5 — nền "mềm" (badge-soft/avatar/app-alert) khai báo dùng chung 2 theme bằng rgba() (xem
     comment ở nơi khai báo .badge-soft, .avatar--cN, .app-alert). Công cụ audit (audit.js `bgOf`) đọc
     RAW kênh r/g/b của rgba() và BỎ QUA alpha (coi như nền ĐẶC nguyên bản), nên báo trượt tương phản
     dù mắt nhìn thật vẫn ổn (nền đã hoà rgba lên --surface trong thực tế). Ở đây viết lại chính giá
     trị ĐÃ HOÀ đó thành số đặc — cho công cụ đo đúng, mắt nhìn không đổi. Tính tay bằng công thức
     alpha*fg + (1-alpha)*30,41,59 (=#1E293B=--surface) cho từng cặp màu+alpha gốc, đã đo lại xác
     nhận contrast 6,9–8,3:1 với --badge-*-fg / --violet-300 hiện có (không cần đổi fg). LIGHT không
     định nghĩa các biến này ⇒ var(...,fallback) ở nơi dùng tự trả về đúng rgba() gốc, không đổi gì. */
  --badge-green-bg:  #1D3A3D;
  --badge-blue-bg:   #1F3154;
  --badge-red-bg:    #392929;
  --badge-amber-bg:  #413535;
  --badge-slate-bg:  #293548;
  --badge-violet-bg: #2B2B54;
  --alert-court-bg:   #1C313F;   /* rgba(14,122,95,.10) đã hoà lên --surface — xem .app-alert.is-court */
  --alert-info-bg:    #1F2F4D;
  --alert-success-bg: #1D353D;
  --alert-warning-bg: #383237;
  --alert-danger-bg:  #312929;

  /* --action-primary (court-400) được chọn để làm CHỮ/ICON nổi trên nền tối (6,68:1) — nhưng vài nơi
     tái dùng nó làm NỀN ĐẶC với chữ trắng đè lên (.nav-pills .nav-link.active, .pagination-bar__btn
     .is-active, .step-flow__item .num): court-400 quá sáng để làm nền cho chữ trắng (trắng/court-400
     chỉ 2,19:1 — blue-400 cũ 2,54:1, cùng một cái bẫy). Token riêng cho vai trò "nền đặc" — dark dùng --court-600 (đã có sẵn trong ramp,
     không phải màu mới) đạt 7,11:1 với chữ trắng; LIGHT không định nghĩa ⇒ fallback về
     --action-primary y hệt trước (light vốn đã dùng court-500 làm cả 2 vai trò ổn, không cần tách).
     🔴 KHÔNG áp dụng cho `.action-bar__primary > .btn` (P4, task 0.5) — ngoài phạm vi task này. */
  --action-primary-solid: var(--court-600);

  --bs-primary: var(--action-primary);
  --bs-primary-rgb: 62, 196, 158;
  --bs-primary-text-emphasis: var(--court-300);
  --bs-primary-bg-subtle: rgba(62,196,158,.18);
  --bs-primary-border-subtle: rgba(62,196,158,.32);
  --bs-link-color: var(--action-primary);
  --bs-link-color-rgb: 62, 196, 158;
  --bs-link-hover-color: var(--action-primary-hover);
  /* Bản đối ứng của --bs-code-color ở khối :root (task 6.1): court-400 trên nền chìm tối = 8,16:1. */
  --bs-code-color: var(--action-primary);
  --bs-success: var(--green-400);
  --bs-success-rgb: 34, 197, 94;
  --bs-danger: #F87171;
  --bs-danger-rgb: 248, 113, 113;
  --bs-warning: #FB923C;
  --bs-warning-rgb: 251, 146, 60;
  --bs-info: #60A5FA;
  --bs-info-rgb: 96, 165, 250;
  --bs-body-bg: var(--surface-sunken);
  --bs-body-color: var(--text);
  --bs-secondary-color: var(--text-muted);
  --bs-border-color: var(--border);
  --bs-emphasis-color: var(--text-strong);
}

/* ============================================================
   🔴 GHI ĐÈ COMPONENT BOOTSTRAP BAKE CỨNG HEX PRIMARY (#0d6efd)
   ------------------------------------------------------------
   Đổi `--bs-primary` là KHÔNG ĐỦ. Bootstrap 5.3.3 sinh các component dưới đây bằng mixin, và mixin
   ghi thẳng giá trị hex vào selector chứ KHÔNG tham chiếu `--bs-primary`:

       .btn-primary { --bs-btn-bg: #0d6efd; ... }   ← 11 giá trị hex, không có var() nào

   Hệ quả đo được (2026-08-20, đọc thẳng webjar bootstrap-5.3.3.jar): nút primary của app **chưa bao
   giờ** mang màu token. App khai `--action-primary: #2563EB` nhưng nút thật vẫn là #0d6efd của
   Bootstrap — hai màu xanh dương khác nhau sống song song mà không ai thấy, vì chúng gần giống nhau.
   Chỉ tới khi primary đổi sang XANH LÁ thì sai lệch mới lộ ra: sidebar đổi màu, nút thì không.

   🔴 Đây là lần thứ SÁU của cùng một cái bẫy #0d6efd trong file này (2.1 · 3.3 · 5.6 · nav-tabs ·
   nút Messenger — xem các comment bên dưới). Năm lần trước đều vá theo kiểu SCOPE HẸP cho đúng một
   nút đang trượt AA. Khối này vá tận gốc: liệt kê ĐỦ mọi selector Bootstrap bake hex primary, tìm
   bằng cách grep chính file bootstrap.css chứ không dựa vào trí nhớ.

   ⚠️ Ai nâng cấp Bootstrap: grep lại `#0d6efd` trong bootstrap.css của bản mới rồi đối chiếu danh
   sách này. Selector mới xuất hiện sẽ âm thầm mang màu xanh dương, không có test nào bắt được.
   ============================================================ */

/* --- Nút --- */
.btn-primary {
  --bs-btn-bg: var(--action-primary);
  --bs-btn-border-color: var(--action-primary);
  --bs-btn-hover-bg: var(--action-primary-hover);
  --bs-btn-hover-border-color: var(--action-primary-hover);
  --bs-btn-active-bg: var(--action-primary-hover);
  --bs-btn-active-border-color: var(--action-primary-hover);
  --bs-btn-disabled-bg: var(--action-primary);
  --bs-btn-disabled-border-color: var(--action-primary);
  --bs-btn-focus-shadow-rgb: var(--bs-primary-rgb);
}
/* 🔴 Theme tối: --action-primary (court-400) là màu để làm CHỮ trên nền tối, làm NỀN cho chữ trắng
   chỉ được 2,19:1. Nút nền đặc phải dùng --action-primary-solid (court-600, 7,11:1) — cùng lý do đã
   ghi ở chỗ khai báo token đó. Không có dòng này thì nút primary ở dark trượt AA. */
[data-bs-theme="dark"] .btn-primary {
  --bs-btn-bg: var(--action-primary-solid);
  --bs-btn-border-color: var(--action-primary-solid);
  --bs-btn-hover-bg: var(--court-700);
  --bs-btn-hover-border-color: var(--court-700);
  --bs-btn-active-bg: var(--court-700);
  --bs-btn-active-border-color: var(--court-700);
  --bs-btn-disabled-bg: var(--action-primary-solid);
  --bs-btn-disabled-border-color: var(--action-primary-solid);
}
.btn-outline-primary {
  --bs-btn-color: var(--action-primary);
  --bs-btn-border-color: var(--action-primary);
  --bs-btn-hover-bg: var(--action-primary);
  --bs-btn-hover-border-color: var(--action-primary);
  --bs-btn-active-bg: var(--action-primary);
  --bs-btn-active-border-color: var(--action-primary);
  --bs-btn-disabled-color: var(--action-primary);
  --bs-btn-disabled-border-color: var(--action-primary);
  --bs-btn-focus-shadow-rgb: var(--bs-primary-rgb);
}
[data-bs-theme="dark"] .btn-outline-primary {
  --bs-btn-hover-bg: var(--action-primary-solid);
  --bs-btn-hover-border-color: var(--action-primary-solid);
  --bs-btn-active-bg: var(--action-primary-solid);
  --bs-btn-active-border-color: var(--action-primary-solid);
}

/* --- Ô nhập: viền focus #86b7fe + ô tích #0d6efd --- */
.form-control:focus,
.form-select:focus,
.form-check-input:focus { border-color: var(--action-primary); }
.form-check-input:checked {
  background-color: var(--action-primary);
  border-color: var(--action-primary);
}
.form-check-input[type=checkbox]:indeterminate {
  background-color: var(--action-primary);
  border-color: var(--action-primary);
}
.form-range::-webkit-slider-thumb { background-color: var(--action-primary); }
.form-range::-moz-range-thumb     { background-color: var(--action-primary); }
/* Trạng thái :active của nút kéo dùng một hex KHÁC (#b6d4fe — bậc nhạt), không phải #0d6efd. Hai
   dòng này do BootstrapPrimaryOverrideTest bắt được; đọc mắt thường đã bỏ sót vì chỉ dò #0d6efd. */
.form-range::-webkit-slider-thumb:active { background-color: var(--court-200); }
.form-range::-moz-range-thumb:active     { background-color: var(--court-200); }

/* --- Component khác đặt hex qua biến RIÊNG của chúng (không phải --bs-primary) --- */
.dropdown-menu { --bs-dropdown-link-active-bg: var(--action-primary-solid, var(--action-primary)); }
.nav-pills     { --bs-nav-pills-link-active-bg: var(--action-primary-solid, var(--action-primary)); }
.pagination    { --bs-pagination-active-bg: var(--action-primary-solid, var(--action-primary));
                 --bs-pagination-active-border-color: var(--action-primary-solid, var(--action-primary)); }
.list-group    { --bs-list-group-active-bg: var(--action-primary-solid, var(--action-primary));
                 --bs-list-group-active-border-color: var(--action-primary-solid, var(--action-primary)); }
.progress, .progress-stacked { --bs-progress-bar-bg: var(--action-primary); }

/* ============================================================
   P5 — Dark: fix riêng cho vài component KHÔNG dùng token màu theo cách bình thường
   (Bootstrap bake literal hex ở .btn-outline-secondary; --nav-group-label cố định 2 theme).
   Chỉ áp trong [data-bs-theme="dark"] — LIGHT không đụng tới (MB-8).
   ============================================================ */
/* .btn-outline-secondary của Bootstrap bake CỨNG #6c757d vào --bs-btn-color/--bs-btn-border-color
   ngay trên chính selector (không tham chiếu --bs-secondary) — override --bs-primary/--bs-danger...
   ở :root KHÔNG áp dụng vào đây (đã lộ y hệt ở task 0.5 với .btn-outline-info/.btn-outline-primary).
   #6c757d trên nền tối (--surface/--surface-sunken) chỉ ra 3,12–3,81:1. Dùng --text-muted (đã có,
   đạt 5,7–7:1 với 2 nền đó) thay vì bịa màu mới. */
[data-bs-theme="dark"] .btn-outline-secondary {
  --bs-btn-color: var(--text-muted);
  --bs-btn-border-color: var(--text-muted);
  --bs-btn-disabled-color: var(--text-disabled);
  --bs-btn-disabled-border-color: var(--text-disabled);
}
/* .btn-outline-danger cũng bake CỨNG #dc3545 (--bs-danger của Bootstrap, KHÁC --red-500 #DC2626 của
   app) vào --bs-btn-color/--bs-btn-border-color — cùng cơ chế .btn-outline-secondary ở trên, override
   --bs-danger ở :root KHÔNG tới được. #dc3545 trên nền tối (--surface #1E293B) chỉ 3,23:1 — đo ở nút
   "Vô hiệu hoá tài khoản của tôi" (/toi = auth/ho-so.html) theme dark, task 1.7. Dùng --red-400
   (#F87171) = chính màu danger dark app đã chọn cho --money-negative/--status-danger ⇒ 5,29:1. Hover
   nền đặc --red-600 để chữ trắng đạt ~6,5:1. Chỉ dark; light giữ nguyên #dc3545 (đạt AA ⇒ MB-8 an toàn). */
/* 🔴 Task 1.7 kết luận "light giữ nguyên #dc3545 (đạt AA)" — đúng với NGỮ CẢNH nó đo (nút nằm trong
   .app-card nền trắng: #dc3545/#FFFFFF = 4,53:1, vừa đủ). Đo ở task 5.2 tại `member/sua.html` cho ngữ
   cảnh KHÁC: nút "Cho rời CLB" nằm trong .page-header, tức đứng TRỰC TIẾP trên nền trang
   (--surface-sunken = --slate-50 #F8FAFC) ⇒ chỉ còn **4,33:1**, dưới AA. Cùng cơ chế đã gặp ở task 3.3
   với .btn-outline-primary: một màu "vừa đủ đạt" trên nền trắng sẽ trượt ngay khi đổi sang nền chìm.
   Dùng --red-500 (#DC2626 — chính màu danger của app, không bịa màu mới): **4,62:1**; hover nền đặc
   --red-600 với chữ trắng ~7,6:1. Scope theo 2 ngữ cảnh có nền CHÌM — .page-header và .modal-footer
   (nút "Cho rời CLB" xuất hiện ở CẢ HAI: một nút mở modal trên header, một nút submit trong chân
   modal; đo lần đầu chỉ sửa .page-header thì nút trong modal vẫn 4,33 — bắt được nhờ đo lại với
   BMH_OPEN). Trong card trắng nút này vẫn đạt AA nên không đổi hàng loạt. Đặt TRƯỚC luật dark ngay
   dưới để dark vẫn thắng theo thứ tự nguồn (cùng độ đặc hiệu) và giữ --red-400 của task 1.7.
   🔴 Đây là thay đổi thấy được cả trên DESKTOP: AA không phụ thuộc bề rộng màn hình nên không thể sửa
   riêng cho mobile — cùng lập luận đã ghi ở toast (task 1.2) và .btn-outline-primary (task 3.3). */
.page-header .btn-outline-danger,
.modal-footer .btn-outline-danger {
  --bs-btn-color: var(--red-500);
  --bs-btn-border-color: var(--red-500);
  --bs-btn-hover-bg: var(--red-600);
  --bs-btn-hover-border-color: var(--red-600);
  --bs-btn-active-bg: var(--red-600);
  --bs-btn-active-border-color: var(--red-600);
}
/* Cùng CHÍNH XÁC cơ chế đó cho .btn-outline-secondary, chỉ khác con số: #6c757d đạt 4,69:1 trên nền
   trắng của card nhưng chỉ **4,48:1** trên nền chìm --slate-50 (#F8FAFC) — nền của .page-header và của
   .modal-footer. Mục này đã bị ghi nhận rồi bỏ qua ở 0.6 · 3.4 · 3.5 · 4.1 · 5.1 với lý do "marginal
   Bootstrap default"; đến task 5.2 thì nó rơi đúng vào nút "Hủy" của modal đang đo nên xử luôn, bằng
   ĐÚNG cách theme tối đã xử ở task 0.6: dùng --text-muted (light = --slate-600 #475569) ⇒ **7,24:1**.
   Vẫn scope theo ngữ cảnh nền chìm — trong card trắng nút này đạt AA, không có lý do đổi hàng loạt.
   🔴 Task 4.5 (lần ghi nhận thứ 7 của đúng lỗi này): nút "Tháng trước"/"Tháng sau"/"Xem sổ giao dịch"
   ở báo cáo tài chính đứng TRỰC TIẾP trên nền trang — cũng là nền chìm, cũng 4,48:1, nhưng không nằm
   trong `.page-header` lẫn `.modal-footer` nên 2 selector trên không với tới. Thêm `is-on-sunken` làm
   móc dùng chung cho đúng tình huống đó, thay vì đẻ thêm một selector theo tên trang mỗi lần gặp lại
   (đã gặp ở 0.6 · 3.4 · 3.5 · 4.1 · 5.1 · 5.2 · 4.5). Màn sau gặp nút outline-secondary nằm ngoài
   card thì gắn class này, KHÔNG viết luật mới. */
.page-header .btn-outline-secondary,
.modal-footer .btn-outline-secondary,
.btn-outline-secondary.is-on-sunken {
  --bs-btn-color: var(--text-muted);
  --bs-btn-border-color: var(--text-muted);
}
/* 🔴 `.btn-outline-info` — cùng CƠ CHẾ bake hex, và là ca NẶNG NHẤT của cả họ: đã bị ghi nhận rồi bỏ
   qua từ task 0.5. Bootstrap bake `#0dcaf0` (cyan) thẳng vào chữ/viền, nên `--bs-info:
   var(--blue-500)` mà app khai ở `:root` (dòng ~239) CHƯA BAO GIỜ tới được một nút nào — y hệt chuyện
   `--action-primary` với `#0d6efd` ở khối trên. Đo ở task 5.12: chữ `#0dcaf0` trên nền trắng chỉ
   **1,96:1** — thấp nhất trong mọi mục `MB-7` của Đợt 0-5 (kỷ lục cũ là 2,80:1 của `--status-warning`
   bị dùng làm màu chữ). Sửa = trỏ về đúng token app ĐÃ khai, không bịa hex mới:
     • light: chữ `--status-info` (= `--blue-500` #2563EB) trên trắng = **5,17:1**, trên nền chìm
       #F8FAFC = 4,94:1 — đạt AA ở cả hai nền
     • dark:  chữ `--status-info` #60A5FA trên `--surface` #1E293B = **5,75:1**
   Còn dùng ở: "Vote tham gia" · "Chia sẻ buổi tập" · "Chia sẻ bảng chia tiền" · các LỐI VÀO của luồng
   đăng ký ở `session/buoi.html` + `dashboard/cua-toi.html` — blue ở đó vẫn đúng nghĩa "thông tin
   trung tính" (xem ghi chú ở ramp Blue, dòng ~115).
   🔴 Thay đổi này THẤY ĐƯỢC TRÊN DESKTOP — đúng tiền lệ toast 1.2 / CTA 2.1 / nav-tabs G2: AA không
   phụ thuộc bề rộng màn hình nên không nhét được vào tầng mobile.
   🔴 `.btn-info` (nền đặc) đã từng được sửa ở đây rồi GỠ ĐI ở vòng 2 của task 5.12: người dùng chốt
   luồng "Đăng ký đi tập" chuyển hẳn sang XANH THẢM (xem `.btn-court-soft` ngay dưới), nên không
   template nào còn dùng `.btn-info` — giữ lại chỉ là code chết. Bài học của lần đó vẫn đáng nhớ:
   **nhóm nút NỀN ĐẶC có HAI màu ràng buộc nhau** — Bootstrap để `--bs-btn-color: #000` cho `.btn-info`
   vì cyan rất sáng, nên đổi nền sang một màu đậm mà quên chữ thì chữ đen trên nền đó ra 4,06:1, tức
   vừa sửa xong một vi phạm AA đã đẻ ngay một vi phạm khác ở đúng nút đó.
   `.action-bar__primary > .btn` (tầng mobile) tự ép màu riêng nên không bị ảnh hưởng. */
.btn-outline-info {
  --bs-btn-color: var(--status-info);
  --bs-btn-border-color: var(--status-info);
  --bs-btn-hover-bg: var(--blue-500);
  --bs-btn-hover-border-color: var(--blue-500);
  --bs-btn-hover-color: #fff;
  --bs-btn-active-bg: var(--blue-500);
  --bs-btn-active-border-color: var(--blue-500);
  --bs-btn-active-color: #fff;
  --bs-btn-disabled-color: var(--status-info);
  --bs-btn-disabled-border-color: var(--status-info);
}
[data-bs-theme="dark"] .btn-outline-info {
  --bs-btn-hover-bg: var(--blue-600);
  --bs-btn-hover-border-color: var(--blue-600);
  --bs-btn-active-bg: var(--blue-600);
  --bs-btn-active-border-color: var(--blue-600);
}
/* 🔴 `.btn-court-soft` — nút của luồng "Đăng ký đi tập" (`OQ-54`), thêm ở vòng 2 task 5.12 theo quyết
   định của người dùng: luồng này mang XANH THẢM như phần còn lại của app, không còn xanh dương.
   Vì sao là bậc SOFT (nền tint) chứ không phải `.btn-primary` nền đặc — đây là ràng buộc, không phải
   thẩm mỹ: `AC-SESS-03.6` (`20_Requirements.md`) buộc giao diện phải phân biệt **rõ ràng bằng mắt**
   "Đã đăng ký" với "Đã điểm danh", **không cùng icon, không cùng màu** — và đó là một AC về giao diện
   nhưng nó BẢO VỆ TIỀN (nhầm hai danh sách = tính tiền cho người không đi). Cho "Tôi sẽ đi" nền đặc
   `--action-primary` là biến nó thành đúng cái nút mà mọi màn khác dùng cho hành động chính, kể cả
   luồng điểm danh ⇒ mất luôn tín hiệu mà AC này đòi. Bậc soft giữ được ba việc cùng lúc:
     • cùng HỆ MÀU thương hiệu (court) — thứ người dùng yêu cầu;
     • khác hẳn nút "Có mặt" của màn Điểm danh (nút đó là `.btn-success`, ramp GREEN `#16A34A` nền đặc)
       và khác nút hành động chính `--action-primary` nền đặc — thoả `AC-SESS-03.6`;
     • nói đúng nghĩa "DỰ KIẾN, chưa tính tiền": trọng lượng thị giác nhẹ hơn một hành động đã chốt.
   Tương phản tính theo WCAG, không ước lượng:
     light  court-700 trên court-100 = 7,63:1 · hover court-700 trên court-200 = 6,08:1
     dark   court-300 trên court-900 = 7,57:1 · hover court-300 trên court-700 = 5,63:1 */
.btn-court-soft {
  --bs-btn-bg: var(--court-100);
  --bs-btn-border-color: var(--court-200);
  --bs-btn-color: var(--court-700);
  --bs-btn-hover-bg: var(--court-200);
  --bs-btn-hover-border-color: var(--court-300);
  --bs-btn-hover-color: var(--court-700);
  --bs-btn-active-bg: var(--court-200);
  --bs-btn-active-border-color: var(--court-300);
  --bs-btn-active-color: var(--court-700);
  --bs-btn-disabled-bg: var(--court-100);
  --bs-btn-disabled-border-color: var(--court-200);
  --bs-btn-disabled-color: var(--court-700);
}
[data-bs-theme="dark"] .btn-court-soft {
  --bs-btn-bg: var(--court-900);
  --bs-btn-border-color: var(--court-700);
  --bs-btn-color: var(--court-300);
  --bs-btn-hover-bg: var(--court-700);
  --bs-btn-hover-border-color: var(--court-600);
  --bs-btn-hover-color: var(--court-300);
  --bs-btn-active-bg: var(--court-700);
  --bs-btn-active-border-color: var(--court-600);
  --bs-btn-active-color: var(--court-300);
  --bs-btn-disabled-bg: var(--court-900);
  --bs-btn-disabled-border-color: var(--court-700);
  --bs-btn-disabled-color: var(--court-300);
}
[data-bs-theme="dark"] .btn-outline-danger {
  --bs-btn-color: var(--red-400);
  --bs-btn-border-color: var(--red-400);
  --bs-btn-hover-bg: var(--red-600);
  --bs-btn-hover-border-color: var(--red-600);
  --bs-btn-active-bg: var(--red-600);
  --bs-btn-active-border-color: var(--red-600);
}
/* --nav-group-label (#64789B) là hằng số DÙNG CHUNG 2 theme, định cho nền navy cố định của sidebar
   desktop (--nav-bg) — nhưng cùng class còn được dùng lại trong offcanvas menu mobile, nơi nền theo
   theme (.offcanvas{background:var(--surface)}, dòng ~879). Trên --surface tối, #64789B chỉ ra
   3,28:1. Dark-only: đổi sang --text-muted (đã qua theme, đạt AA trên --surface tối). Light giữ
   nguyên --nav-group-label (không thuộc phạm vi task này). */
[data-bs-theme="dark"] .app-sidebar-group-label {
  color: var(--text-muted);
}
/* Task 2.1 — nút CTA "Xem cách hoạt động" (.auth-cta-landing) là .btn-outline-primary. Bootstrap bake
   CỨNG #0d6efd vào --bs-btn-color/border (không lấy --bs-primary app đã override ở :root — cùng cơ chế
   .btn-outline-secondary/danger ở trên). #0d6efd trên nền vỏ auth --surface-sunken chỉ **4,3:1 (light)**
   / **3,97:1 (dark)** — DƯỚI AA ở CẢ hai theme (đo tĩnh, task 2.1). Đổi sang --action-primary (đã theo
   theme: light --court-500 #0E7A5F ⇒ 5,29:1; dark --court-400 #3EC49E ⇒ 6,68:1 — trước đợt đổi sang
   xanh thảm là --blue-500/--blue-400, cùng mức tương phản nên kết luận AA của task 2.1 không đổi);
   hover nền đặc --action-primary-solid (dark = --court-600) cho chữ trắng đạt. Đây là fix AA THẬT nên áp mọi viewport
   (AA độc lập bề rộng — như ca toast task 1.2), CÓ đổi desktop; scope theo .auth-cta-landing để chỉ
   chạm đúng nút này, KHÔNG lan ra .btn-outline-primary trên nền trắng (ở đó #0d6efd vẫn đạt AA). */
.auth-cta-landing .btn-outline-primary {
  --bs-btn-color: var(--action-primary);
  --bs-btn-border-color: var(--action-primary);
  --bs-btn-hover-bg: var(--action-primary-solid, var(--action-primary));
  --bs-btn-hover-border-color: var(--action-primary-solid, var(--action-primary));
  --bs-btn-hover-color: #fff;
  --bs-btn-active-bg: var(--action-primary-solid, var(--action-primary));
  --bs-btn-active-border-color: var(--action-primary-solid, var(--action-primary));
  --bs-btn-active-color: #fff;
}
/* Đợt 3 task 3.3 — "Thêm người khác"/"Thêm khách vãng lai" (attendance/fragments.html) là
   .btn-outline-primary đứng TRỰC TIẾP trên nền trang (--surface-sunken, không phải trong .app-card
   trắng) — CÙNG hoàn cảnh và CÙNG số đo với .auth-cta-landing ở trên (#0d6efd bake cứng: 4,3:1 light /
   3,97:1 dark, cả hai DƯỚI AA — đã đo ở task 0.5, để dành "đợt sau" xử, nay đúng lúc). Fix giống hệt,
   scope riêng để KHÔNG lan ra .btn-outline-primary ở chỗ khác (trong .app-card trắng #0d6efd vẫn ổn,
   9 template khác dùng class này chưa đo, không tự ý đổi hàng loạt). */
.attendance-quick-add .btn-outline-primary {
  --bs-btn-color: var(--action-primary);
  --bs-btn-border-color: var(--action-primary);
  --bs-btn-hover-bg: var(--action-primary-solid, var(--action-primary));
  --bs-btn-hover-border-color: var(--action-primary-solid, var(--action-primary));
  --bs-btn-hover-color: #fff;
  --bs-btn-active-bg: var(--action-primary-solid, var(--action-primary));
  --bs-btn-active-border-color: var(--action-primary-solid, var(--action-primary));
  --bs-btn-active-color: #fff;
}
/* Task 5.6 — nút "Thêm sân" của fragment dùng chung `court-slots :: editor` cũng là
   .btn-outline-primary, và nó nằm trên `.modal-body` (nền `--surface-sunken` #F8FAFC) ⇒ **cùng con số
   4,3:1** đã đo hai lần trước (2.1 và 3.3). Đây là lần thứ BA của đúng một lỗi, nên fix y hệt và scope
   bằng attribute sẵn có `[data-court-slots]` — không phải thêm class vào template, và một lần sửa ăn
   cả **4 form** dùng fragment này (thêm/sửa Lịch tập · thêm/sửa Buổi tập). Là fix AA THẬT nên áp mọi
   viewport (AA không phụ thuộc bề rộng); `MB-8` của màn Lịch tập vẫn byte-identical vì nút này nằm
   trong modal đang đóng (`display: none`, không vẽ pixel nào ở ảnh desktop). */
[data-court-slots] .btn-outline-primary,
/* Lần thứ NĂM của đúng lỗi #0d6efd-trên-nền-chìm (2.1 · 3.3 · 5.6 · nav-tabs). Nút "Nhắn tin trực
   tiếp qua Messenger" trong modal Góp ý cũng nằm trên `.modal-body` (--surface-sunken #F8FAFC) ⇒
   ĐÚNG 4,30:1 như ba lần trước, đo bằng tools/mobile-audit ở 390×844 (2026-08-20).
   Gộp vào cùng khối thay vì viết luật thứ năm y hệt. 🔴 Ghi chú cho lần sau: `.btn-outline-secondary`
   đã có móc dùng chung `is-on-sunken` sau 7 lần lặp; `.btn-outline-primary` nay cũng tới lần thứ 5 —
   đáng làm móc tương tự trong một task dọn dẹp riêng, thay vì cứ thêm selector theo tên component. */
#modalGopY .btn-outline-primary {
  --bs-btn-color: var(--action-primary);
  --bs-btn-border-color: var(--action-primary);
  --bs-btn-hover-bg: var(--action-primary-solid, var(--action-primary));
  --bs-btn-hover-border-color: var(--action-primary-solid, var(--action-primary));
  --bs-btn-hover-color: #fff;
  --bs-btn-active-bg: var(--action-primary-solid, var(--action-primary));
  --bs-btn-active-border-color: var(--action-primary-solid, var(--action-primary));
  --bs-btn-active-color: #fff;
}
/* Lần thứ TƯ của đúng một lỗi #0d6efd-trên-nền-chìm (2.1 · 3.3 · 5.6 ở trên), lần này qua
   --bs-nav-link-color chứ không phải --bs-btn-color: tab CHƯA chọn của .nav-tabs đo được **4,30:1**
   (#0d6efd trên --surface-sunken #F8FAFC) ở màn Chi tiết buổi tập — dưới AA 4,5:1. Task 3.3 đã thấy
   đúng con số này ("phát hiện phụ, KHÔNG sửa — để lại cho lần sau chạm tới CSS thanh tab"); đo lại khi
   đóng khoảng hở G2 (2026-08-20) xác nhận nó vẫn còn, nên nay xử.
   Scope `.nav-tabs` là hẹp CÓ ĐO: chỉ 2 template dùng class này (`session/buoi.html` +
   `expense/fragments.html`, đều là thanh tab của chính màn buổi tập). KHÔNG lan sang `.nav-pills`
   (`fragments/tabs.html`, màn Cài đặt) — kiểu active của nó khác hẳn (nền đặc) và CHƯA ĐO, đổi mù là
   đúng thứ CLAUDE.md cấm. Tab ĐANG chọn không bị chạm (nó dùng --bs-nav-tabs-link-active-color).
   Là fix AA THẬT ⇒ áp mọi viewport, CÓ đổi desktop — giống ca toast 1.2 / CTA auth 2.1. */
.nav-tabs {
  --bs-nav-link-color: var(--action-primary);
  --bs-nav-link-hover-color: var(--action-primary-hover);
}

/* ============================================================
   BASE
   ============================================================ */
/* Luôn chừa chỗ cho thanh cuộn dọc — thiếu dòng này, trang có nội dung ngắn (không cần cuộn) sẽ
   nhích ngang vài px so với trang có nội dung dài (có thanh cuộn) vì layout mất/còn khác nhau bề
   rộng viewport khả dụng. Rõ nhất khi chuyển qua lại giữa các tab cùng một nhóm (VD: Cài đặt). */
html {
  scrollbar-gutter: stable;
}
/* scrollbar-gutter:stable chỉ áp dụng khi overflow ở trạng thái auto/scroll — Bootstrap Modal khoá
   cuộn nền bằng cách set body.style.overflow = 'hidden' (bootstrap.bundle.js ScrollBarHelper), làm
   "gutter" vừa chừa sẵn ở trên BIẾN MẤT đúng lúc đó, ĐỒNG THỜI Bootstrap còn tự cộng thêm
   padding-right = bề rộng thanh cuộn vào <body> để "bù" cho thanh cuộn biến mất — cộng dồn hai hiệu
   ứng này khiến bề rộng nội dung hụt hẳn một khoảng bằng bề rộng thanh cuộn (~15-17px) trong lúc
   modal mở, nội dung (căn giữa bằng margin auto trong .page-content) nhích ngang, rồi trả lại đúng vị
   trí cũ khi đóng modal — đã đo trực tiếp bằng CDP (BEFORE/AFTER getBoundingClientRect lệch, dừng
   lệch khi bỏ dòng padding-right này). Phần bù của Bootstrap thành thừa vì gutter đã tự lo chuyện
   này rồi — vô hiệu hoá nó bằng !important (thắng inline style Bootstrap tự set qua JS). */
body.modal-open {
  padding-right: 0 !important;
}
body {
  background: var(--surface-sunken);
  color: var(--text);
}
h1, h2, h3, h4, h5, h6 { font-weight: 700; color: var(--text-strong); }
h1 { font-size: 1.5rem; line-height: 2rem; letter-spacing: -.01em; }
h2 { font-size: 1.25rem; line-height: 1.75rem; }
h3 { font-size: 1.125rem; line-height: 1.625rem; font-weight: 600; }
a { color: var(--action-primary); }
a:hover { color: var(--action-primary-hover); }

/* Focus ring thống nhất — không bao giờ mất dấu focus */
a:focus-visible,
button:focus-visible,
.btn:focus-visible,
.form-control:focus-visible,
.form-select:focus-visible,
.stat-card:focus-visible,
.app-sidebar-nav .nav-link:focus-visible,
.app-bottom-nav-item:focus-visible {
  outline: none;
  box-shadow: 0 0 0 3px var(--focus-ring);
}

/* Input ≥16px trên mobile — chống Safari iOS auto-zoom */
.form-control, .form-select { font-size: 1rem; background: var(--surface); color: var(--text); }

/* ============================================================
   TYPOGRAPHY UTILITIES
   ============================================================ */
.text-app-muted { color: var(--text-muted) !important; }
.text-caption {
  font-size: .75rem; line-height: 1rem; font-weight: 600;
  letter-spacing: .02em; text-transform: uppercase; color: var(--text-muted);
}

/* Số tiền — luôn tabular-nums, và không bao giờ bẻ dòng: "1.575.000" rớt chữ "₫"
   xuống dòng dưới đọc như hai con số khác nhau. */
.money { font-variant-numeric: tabular-nums; white-space: nowrap; }
/* white-space cùng lý do với .money ở trên: chuỗi chữ số không có chỗ ngắt, nên chỗ DUY NHẤT bẻ
   được là khoảng trắng trước "₫" — bẻ ở đó làm "1.575.000" và "₫" nằm hai dòng, đọc như hai con số. */
.money-hero {
  font-size: 1.75rem; line-height: 2.25rem; font-weight: 700;
  font-variant-numeric: tabular-nums; white-space: nowrap; color: var(--money-neutral);
}
.money-lg {
  font-size: 1.25rem; line-height: 1.75rem; font-weight: 700;
  font-variant-numeric: tabular-nums; white-space: nowrap; color: var(--money-neutral);
}
.money-pos { color: var(--money-positive) !important; }
.money-neg { color: var(--money-negative) !important; }
.money-neutral { color: var(--money-neutral) !important; }

/* ------------------------------------------------------------
   Dòng "tên sân + khoảng giờ" — court-slot.
   🔴 Cạm bẫy đứng ngay cạnh `.money` ở trên, nên để luật ngay đây: `nowrap` là ĐÚNG cho
   con số, nhưng một giá trị TRỘN chữ tự do với số thì không được thừa hưởng nó. Tên sân
   do người dùng tự đặt, tới 100 ký tự (`CourtSlotForm`); gộp "tên: 19:00–21:00" thành
   một chuỗi rồi gắn `.money` là ép cả cụm nằm lì một dòng.
   Đã lộ thật ở 3 chỗ cùng render dữ liệu này (2026-08-25), đo với tên 100 ký tự:
     • `session/buoi.html` (Thông tin buổi tập) — 1440×900: chữ vọt khỏi cột lưới 800px,
       đẩy CẢ TRANG cuộn ngang 644px. Nặng nhất vì cột `row-cols-md-4` rộng cố định 208px.
     • `fragments/buoi-hero.html` (chip trang chia sẻ) — 390×844: chip vọt khỏi thẻ 236px.
     • `schedule/lich-tap.html` (ô Giờ) — 390×844: ô cắt cụt 201px chữ.
   Cách sửa nằm ở TEMPLATE, không phải ở đây: tách tên và giờ thành hai <span>, `.money`
   chỉ bọc khoảng giờ. Class này lo phần còn lại — cho bẻ giữa từ khi tên không có dấu
   cách nào (`anywhere` chứ không `break-word`, cùng lý do đã ghi ở `can-wrap-note`).
   ------------------------------------------------------------ */
.court-slot { overflow-wrap: anywhere; }
/* Chip mang `nowrap` từ CHÍNH `.badge-soft` (không chỉ từ `.money`) nên phải gỡ riêng, và nó là
   inline-flex ⇒ cần `flex-wrap` thì hai mảnh bên trong mới xuống hàng được thay vì phình ngang. */
.badge-soft.court-slot { white-space: normal; flex-wrap: wrap; max-width: 100%; }

/* ============================================================
   BADGE — soft (thay bg-* đặc mặc định Bootstrap)
   ============================================================ */
.badge-soft {
  display: inline-flex; align-items: center; gap: .25rem;
  padding: .2rem .55rem; border-radius: var(--radius-pill);
  font-size: .75rem; font-weight: 600; line-height: 1.2; white-space: nowrap;
}
/* P5: nền dùng var(--badge-*-bg, rgba(...)) — chỉ định nghĩa ở [data-bs-theme="dark"] (xem chỗ khai
   báo), nên LIGHT vẫn ra đúng rgba() y hệt trước (fallback), không đổi 1 pixel nào. */
.badge-soft.is-green { background: var(--badge-green-bg, rgba(22,163,74,.14));  color: var(--badge-green-fg); }
.badge-soft.is-blue  { background: var(--badge-blue-bg, rgba(37,99,235,.14));  color: var(--badge-blue-fg); }
.badge-soft.is-red   { background: var(--badge-red-bg, rgba(220,38,38,.14));  color: var(--badge-red-fg); }
.badge-soft.is-amber { background: var(--badge-amber-bg, rgba(249,115,22,.16)); color: var(--badge-amber-fg); }
.badge-soft.is-slate { background: var(--badge-slate-bg, rgba(100,116,139,.16)); color: var(--badge-slate-fg); }

/* Bộ đếm của tab Điểm danh ("N người tham gia" / "N đã điểm danh" / "N nam · N nữ"). Trước đây cỡ
   chữ + padding nằm ở `style=` ngay trong template; đưa ra đây vì tầng mobile cần bóp padding lại mà
   inline style thì @media không đè được. Cỡ chữ .95rem là CỐ Ý to hơn badge thường (.75rem) và cố ý
   giữ nguyên ở mobile — đây là con số Admin đọc từ xa khi đang đứng ở sân (CT-6), không phải chip
   phụ; nó đè luật `.badge-soft { font-size: var(--fs-caption) }` của tầng mobile nhờ specificity. */
.attendance-counter .badge-soft { font-size: .95rem; padding: .35rem .7rem; }
/* Khoảng cách của hai hàng đầu tab Điểm danh (bộ đếm và cụm nút "Thêm …"). Trước đây là utility
   `gap-1 gap-sm-2` / `gap-2` ngay trong template; chuyển ra đây vì utility của Bootstrap là
   `!important` nên tầng mobile KHÔNG đè nổi để đặt `column-gap: 0` — cùng cái bẫy đã ghi ở
   `.lich-buoi` và `.lich-header-actions` phía trên. `.5rem` là ĐÚNG giá trị cũ ở mọi bề rộng mà
   luật này còn hiệu lực: `gap-sm-2` (≥576px) và `gap-2`. Bậc 4px của `gap-1` (<576px) không cần
   chép lại — dải đó đã nằm trọn trong tầng ≤767,98px, nơi `column-gap` bị đặt về 0 để `space-evenly`
   chia đều. Nhờ vậy desktop ≥768px không đổi một pixel (`MB-8`). */
.attendance-counter, .attendance-quick-add { gap: .5rem; }

/* Chấm trạng thái bên trong chip (VD "Đang sinh hoạt"). Màu kế thừa currentColor của chip. */
.badge-soft .dot { width: .5rem; height: .5rem; border-radius: 50%; background: currentColor; flex: none; }

/* Chữ màu theo status (VD giờ buổi tập ở màn Lịch) — dùng chung biến màu với .badge-soft cho nhất quán. */
.text-status-green { color: var(--badge-green-fg); }
.text-status-amber { color: var(--badge-amber-fg); }
.text-status-slate { color: var(--badge-slate-fg); }
/* Cố ý KHÔNG dùng .text-danger của Bootstrap cho việc này: nó ghim cứng #dc3545 ở CẢ hai theme
   (chỉ nền mới đổi theo data-bs-theme), nên trên nền tối chữ tụt tương phản. Biến ở đây có sẵn
   bản sáng/tối. */
.text-status-red   { color: var(--badge-red-fg); }

/* Chấm tròn chú thích màu status (VD legend phía trên bảng Lịch). */
.legend-dot { display: inline-block; width: .6rem; height: .6rem; border-radius: 50%; }
.legend-dot.is-green { background: var(--badge-green-fg); }
.legend-dot.is-amber { background: var(--badge-amber-fg); }
.legend-dot.is-slate { background: var(--badge-slate-fg); }

/* Ô ngày bấm-được (Owner/Admin) — bấm vùng trống mở modal tạo buổi đột xuất. Dấu "+" mờ hiện khi
   rê chuột (desktop có hover); màn hình cảm ứng không hiện nhưng cả ô vẫn chạm được. `position:
   relative` để neo dấu "+" tuyệt đối vào góc phải-trên ô. */
.lich-day--clickable { position: relative; cursor: pointer; }
.lich-day--clickable::after {
    content: "\002B"; /* dấu + */
    position: absolute; top: 2px; right: 6px;
    font-size: .95rem; line-height: 1; font-weight: 700;
    color: var(--bs-primary); opacity: 0; pointer-events: none;
    transition: opacity .12s ease-in-out;
}
@media (hover: hover) {
    .lich-day--clickable:hover::after { opacity: .55; }
}

/* Lưới lịch tháng — chuyển từ style inline sang class ở task 5.11, GIỮ NGUYÊN từng giá trị cũ.
   Lý do phải chuyển: tầng mobile cần nới chiều cao ô để mỗi buổi tập đạt vùng chạm 44px (`MB-3`),
   mà style inline thì chỉ `!important` mới đè được — và `!important` trong tầng mobile là thứ luật
   này cấm (nó khoá luôn mọi lần chỉnh sau). `height` trên `<td>` là chiều cao TỐI THIỂU theo cách
   trình duyệt tính bảng; `.lich-day` là hộp cuộn bên trong để một ngày nhiều buổi không đội cả hàng
   tuần cao lên. */
.lich-grid > tbody > tr > td { width: 14.28%; height: 110px; overflow: hidden; }
.lich-grid .lich-day { max-height: 94px; overflow-y: auto; }
/* Nút "một buổi tập" trong ô ngày. Trước đây dùng `.d-block w-100` của Bootstrap, nhưng utility đó là
   `!important` nên tầng mobile KHÔNG đè được — luật `display: flex` viết cho nó ở tầng mobile (task
   5.11) thực ra chưa bao giờ chạy, và chữ giờ nằm dính mép TRÊN của vùng chạm 44px thay vì căn giữa.
   Một class thường (không !important) trả lại quyền đè cho tầng mobile. Desktop: y hệt `.d-block
   w-100` cũ. */
.lich-grid .lich-buoi { display: block; width: 100%; }
/* Hàng nút của header lịch. `flex-wrap`/`gap` khai ở đây thay vì bằng utility `.flex-wrap`/`.gap-2`
   (đúng giá trị cũ: wrap + .5rem) — utility của Bootstrap là `!important`, tầng mobile sẽ không đè
   nổi để ép bốn nút về một dòng. */
.lich-header-actions, .lich-header-actions__nav { flex-wrap: wrap; gap: var(--space-xs, .5rem); }

/* ============================================================
   CARD & COMPONENT
   ============================================================ */
.card {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  box-shadow: var(--elevation-1);
}
.app-card {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  padding: var(--space-lg);
  box-shadow: var(--elevation-1);
}

/* Radius mới theo DESIGN_RULES — card mềm hơn, nút/ô nhập bo 14px */
.card, .app-card, .stat-card, .empty-state, .auth-card { border-radius: var(--radius-card); }
.btn { border-radius: var(--radius-btn); }
.form-control, .form-select { border-radius: var(--radius-btn); padding: .625rem .85rem; }

/* Tom Select — ô tìm kiếm cho mọi select.form-select (CLAUDE.md § 2). tom-select.bootstrap5.css tự
   đọc --bs-body-bg/--bs-border-color, nhưng --bs-body-bg ở app này trỏ --surface-sunken (nền TRANG,
   dòng 196/257) chứ không phải --surface (nền Ô NHẬP mà .form-control/.form-select dùng ở dòng 310)
   — không override thì ô Tom Select lệch màu nền so với mọi input khác trên cùng form. */
.ts-wrapper.form-select .ts-control {
  background: var(--surface);
  color: var(--text);
  border-color: var(--border);
  border-radius: var(--radius-btn);
  padding: .625rem .85rem;
}
/* 🔴 Ô tìm kiếm của Tom Select không được chiếm chỗ trong .ts-control khi danh sách ĐANG ĐÓNG.
   Mặc định thư viện chỉ đưa input ra khỏi luồng (class input-hidden) khi ô MẤT focus — inputState()
   gate bằng `!isFocused`. Nhưng ngay sau khi người dùng chọn một dòng, dropdown đã đóng mà ô VẪN đang
   focus, nên input còn nằm trong luồng flex với `margin: 0 4px !important`
   (.has-items .ts-control > input) và `min-width: 7rem` + `flex-wrap: wrap` (.ts-control) ⇒
     (a) luôn thừa một dấu cách 4px ngay sau chữ,
     (b) nhãn dài thì input WRAP xuống dòng 2, đội ô cao thêm đúng một dòng.
   Đo bằng Chrome headless/CDP ở viewport 390px (2026-08-09): .ts-control cao 44px → 68px ngay sau khi
   chọn "Chị Hằng (Quản trị viên CLB Cầu Giấy)", và cũng 68px khi mở lại ô đã có giá trị. Lòng ô ở
   390px chỉ còn 314px (padding-right 36px do --ts-pr-caret ép !important, cộng padding .85rem ở trên)
   nên nhãn dài hơn ~198px là wrap — nhãn thật trong app vượt ngưỡng này dễ dàng (vd option sổ quỹ
   "dd/MM — mô tả — 500.000 ₫").
   Điều kiện đúng là "danh sách đang đóng" (:not(.dropdown-active)), KHÔNG phải "mất focus".
   ⚠️ KHÔNG sửa bằng plugin 'dropdown_input' của thư viện: nó dời ô tìm kiếm ra ngoài .modal và bị
   _enforceFocus của Bootstrap chặn — xem ghi chú ở fragments/layout.html. */
.ts-wrapper.single.has-items:not(.dropdown-active) .ts-control > input {
  position: absolute;
  left: -10000px;
  opacity: 0;
}
/* Danh sách ĐANG MỞ thì input phải gõ được thật: nhường trọn dòng cho nó bằng cách ẩn nhãn đã chọn.
   Không làm vậy thì nhãn + input(min 7rem) vẫn wrap thành 2 dòng như cũ, còn ép min-width: 0 thì input
   chỉ còn ~6px, không nhìn thấy mình đang gõ gì. Giá trị hiện tại vẫn tra được: nó được tô sáng trong
   danh sách ngay bên dưới. */
.ts-wrapper.single.has-items.dropdown-active .ts-control > .item {
  display: none;
}
/* 🔴 Nguyên nhân THỨ HAI, độc lập với ô tìm kiếm ở trên: nhãn đã chọn tự wrap vì thư viện để
   `white-space: normal`. Đo trong modal ở 390px (2026-08-09): lòng ô chỉ còn 288.4px mà nhãn "Chị Hằng
   (Quản trị viên CLB Cầu Giấy)" cần 300.5px ⇒ 2 line box, .item cao 24px → 48px ⇒ ô 68px. Ở 1280px thì
   lòng ô 414.4px nên vừa 1 dòng, đó là lý do lỗi chỉ lộ trên máy nhỏ.
   Cắt bằng ellipsis thay vì để wrap: ô nhập luôn cao đúng một dòng như mọi .form-control khác trên cùng
   form, giống hành vi của <select> gốc. Giá trị đầy đủ vẫn đọc được khi mở danh sách (dòng đang chọn
   được tô sáng).
   `display: block` là BẮT BUỘC: thư viện đặt `.ts-control .item { display: flex }`, mà text-overflow
   không áp dụng cho chữ trực tiếp trong flex container. `min-width: 0` để item (là flex item của
   .ts-control) co được xuống dưới bề rộng nội dung — thiếu nó thì flex item không chịu co và vẫn tràn. */
.ts-wrapper.single .ts-control > .item {
  display: block;
  min-width: 0;
  max-width: 100%;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.ts-wrapper.form-select.is-invalid .ts-control {
  border-color: var(--bs-danger);
}
.ts-dropdown {
  background: var(--surface);
  color: var(--text);
  border-color: var(--border);
  /* dropdownParent: 'body' (layout.html) append .ts-dropdown làm con trực tiếp của <body> — CSS mặc
     định của tom-select.bootstrap5.css chỉ đặt z-index: 10 cho selector này (bản có class
     ".form-select" đi kèm mới lên 1000, nhưng Tom Select KHÔNG tự copy class đó sang dropdown trừ khi
     bật copyClassesToDropdown, mặc định false). Bootstrap modal ở app này dùng --bs-modal-zindex:
     1055 ⇒ thiếu dòng này, danh sách tìm kiếm bị modal đè lên, thấy list nhưng bấm chọn không ăn
     (bug thật đã gặp: mở modal "Tạo buổi tập đột xuất", không chọn được Sân). +5 để chắc chắn nổi
     trên modal dù giá trị --bs-modal-zindex có đổi. */
  z-index: calc(var(--bs-modal-zindex, 1055) + 5);
}
.ts-dropdown .option.active {
  background: var(--surface-muted);
}

/* ══ Tom Select — chế độ NHIỀU lựa chọn (<select multiple>) ═══════════════════════════════════
   Dùng ở "Thêm người khác" (Điểm danh) và "Đăng ký hộ" (Đăng ký đi tập). Mọi luật dưới đây là để
   ghi đè CSS mặc định của thư viện, không phải trang trí:

   (1) MÀU CHIP BỊ HARDCODE. tom-select.bootstrap5.css đặt thẳng
       `.ts-wrapper.multi .ts-control > div { background: #efefef; color: #343a40 }` và
       `.remove { border-left: 1px solid #dee2e6 }` — số hex cứng, KHÔNG đọc biến theme. Ở theme tối
       chip thành mảng xám sáng chữ đen giữa ô nền tối, và `MB-7` (tương phản AA cả hai theme) hỏng.
       Selector ở đây dùng `.item` (0,4,0) nên thắng `> div` (0,3,1) của thư viện.

   (2) PADDING CỦA Ô. `.ts-wrapper.multi.has-items .ts-control` (0,3,0) của thư viện thắng
       `.ts-wrapper.form-select .ts-control` (0,2,0) ở khối trên ⇒ ô multi khi ĐÃ có chip lại lệch
       padding so với mọi .form-control khác cùng form. Phải khai riêng ở đúng độ đặc hiệu đó.

   (3) `padding-right: 0 !important` trên .item là của plugin remove_button — đừng chống lại nó:
       nút × tự mang padding của mình và nằm sát mép phải chip, đó là bố cục thư viện thiết kế sẵn.

   (4) TRÀN NGANG (`MB-1`). Tên thật dài ("Chị Hằng (Quản trị viên CLB Cầu Giấy)") vượt lòng ô
       288px ở 390px. KHÔNG cắt bằng ellipsis như select đơn: ở đây người dùng cần đọc đủ tên để
       biết mình đã chọn đúng ai. Cho chữ xuống dòng TRONG chip (`white-space: normal` +
       `overflow-wrap: anywhere`) và chặn `max-width: 100%` — chip cao thêm chứ không đẩy ngang.

   (5) CHIỀU CAO Ô. Chọn 10-20 người thì ô dài bằng cả màn hình, nút "Thêm" bị đẩy khỏi tầm nhìn và
       không ai biết là còn nút. Cap chiều cao rồi cho cuộn DỌC bên trong (cuộn dọc có chủ ý, không
       vi phạm MB-1 vốn chỉ nói về chiều ngang). */
.ts-wrapper.multi.form-select .ts-control,
.ts-wrapper.multi.form-select.has-items .ts-control {
  padding: .45rem .5rem;
  gap: .3rem;
  max-height: 11rem;
  overflow-y: auto;
}
.ts-wrapper.multi .ts-control > .item {
  display: inline-flex;
  align-items: center;
  max-width: 100%;
  min-width: 0;
  margin: 0;                       /* khoảng cách do `gap` ở .ts-control lo, tránh cộng dồn hai lớp */
  padding: .2rem 0 .2rem .6rem;    /* padding-right bị plugin ép 0 !important — xem (3) */
  background: var(--surface-muted);
  color: var(--text);
  border: 1px solid var(--border);
  border-radius: var(--radius-btn);
  font-size: .9375rem;             /* 15px — trên ngưỡng 13px của MB-2 */
  line-height: 1.35;
  white-space: normal;
  overflow-wrap: anywhere;
}
.ts-wrapper.multi .ts-control > .item.active {
  background: var(--surface-muted);
  color: var(--text);
  border-color: var(--border);
}
.ts-wrapper.multi .ts-control > .item > .remove {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex: 0 0 auto;
  align-self: stretch;
  min-width: 2rem;
  margin-left: .4rem;
  padding: 0 .35rem;
  border-left: 1px solid var(--border);
  border-radius: 0 var(--radius-btn) var(--radius-btn) 0;
  color: var(--text-muted);
  font-size: 1.1rem;
  line-height: 1;
  text-decoration: none;
}
.ts-wrapper.multi .ts-control > .item > .remove:hover {
  background: var(--surface);
  color: var(--bs-danger);
}
/* Ô tìm kiếm ở chế độ multi phải LUÔN nhìn thấy (khác select đơn — nơi nó bị đẩy ra khỏi luồng khi
   danh sách đóng): nó chính là chỗ gõ để chọn thêm người kế tiếp. */
.ts-wrapper.multi .ts-control > input {
  min-width: 6rem;
  font-size: .9375rem;
}

/* 🔴 KHÔNG có biến thể riêng cho điện thoại — chip ở mọi bề ngang trông GIỐNG HỆT nhau (người dùng
   chốt 2026-08-19). Bản đầu tiên cho mỗi chip chiếm trọn một dòng với nút × 44×44 để qua `MB-3`,
   nhưng tên ngắn ("Chi Lan") để lại một khoảng trống dài tới tận nút × ở mép phải — nhìn rời rạc,
   không còn ra hình cái chip. Đã đảo lại theo yêu cầu.
   ⚠️ Đánh đổi ĐÃ BIẾT, đừng "sửa" lại mà không hỏi: nút × giờ ~32×30px ⇒ `tools/mobile-audit` sẽ
   đếm nó vào `smallTargets` và `MB-3` (vùng chạm ≥ 44×44) BÁO ĐỎ ở màn Điểm danh và Đăng ký đi tập.
   Đây là ngoại lệ có chủ ý, không phải sót. Bỏ nhầm một người thì chọn lại được ngay ở cùng ô nên
   hậu quả nhẹ; muốn quay lại bố cục cũ thì khôi phục khối @media trong lịch sử git của file này. */

/* Statistic Card — cả card là link về nguồn (PP-3) */
.stat-card {
  display: block; height: 100%;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  padding: var(--space-lg);
  box-shadow: var(--elevation-1);
  text-decoration: none; color: inherit;
  transition: box-shadow var(--motion-fast) var(--ease-standard),
              border-color var(--motion-fast) var(--ease-standard),
              transform var(--motion-fast) var(--ease-standard);
}
.stat-card:hover {
  border-color: var(--border-strong);
  box-shadow: var(--elevation-2);
  transform: translateY(-1px);
  color: inherit;
}
.stat-card__label {
  display: flex; align-items: center; gap: .4rem;
  font-size: .75rem; font-weight: 600; letter-spacing: .02em;
  text-transform: uppercase; color: var(--text-muted);
  margin-bottom: var(--space-xs);
}
.stat-card__label i { font-size: 1rem; }
.stat-card__value { margin-bottom: var(--space-2xs); }
.stat-card__meta { font-size: .875rem; color: var(--text-muted); }
.stat-card__meta .arrow { color: var(--action-primary); }

/* Thẻ CLB đang chờ duyệt (BR-MEM-13, Đ8) — không click được, không giống link thường. */
.stat-card--locked {
  opacity: .6;
  cursor: default;
}
.stat-card--locked:hover {
  border-color: var(--border);
  box-shadow: var(--elevation-1);
  transform: none;
}

/* Đầu card có ô icon bo góc màu (kiểu FastClub) */
.stat-card__head { display: flex; align-items: center; gap: .75rem; margin-bottom: var(--space-sm); }
.stat-card__icon {
  width: 2.75rem; height: 2.75rem; border-radius: 12px; flex: 0 0 auto;
  display: inline-flex; align-items: center; justify-content: center; font-size: 1.3rem;
}
/* Biến thể nhỏ cho hàng chỉ số THAM KHẢO (Báo cáo tài chính): giữ nguyên bộ màu is-* bên dưới,
   chỉ hạ kích thước để cụm nhãn không lấn chỗ của số tiền ở khổ máy tính bảng. */
.stat-card__icon--sm { width: 2rem; height: 2rem; border-radius: 9px; font-size: 1rem; }
.stat-card__icon.is-blue   { background: rgba(37,99,235,.12);  color: var(--blue-600); }
.stat-card__icon.is-green  { background: rgba(22,163,74,.12);  color: var(--green-600); }
.stat-card__icon.is-amber  { background: rgba(249,115,22,.14); color: var(--amber-600); }
.stat-card__icon.is-violet { background: rgba(124,58,237,.12); color: var(--violet-500); }
.stat-card__icon.is-red    { background: rgba(220,38,38,.12);  color: var(--red-500); }
[data-bs-theme="dark"] .stat-card__icon.is-blue   { color: #93C5FD; }
[data-bs-theme="dark"] .stat-card__icon.is-green  { color: #4ADE80; }
[data-bs-theme="dark"] .stat-card__icon.is-amber  { color: #FDBA74; }
[data-bs-theme="dark"] .stat-card__icon.is-violet { color: var(--violet-300); }
[data-bs-theme="dark"] .stat-card__icon.is-red    { color: #FCA5A5; }

/* Club pill — bộ chọn CLB góc phải header (kiểu FastClub) */
.page-header__aside { display: flex; align-items: center; gap: .6rem; }
.club-pill {
  display: inline-flex; align-items: center; gap: .55rem;
  padding: .5rem .85rem; border: 1px solid var(--border); border-radius: var(--radius-md);
  background: var(--surface); color: var(--text); text-decoration: none;
  font-weight: 600; font-size: .9rem; box-shadow: var(--elevation-1);
}
.club-pill:hover { border-color: var(--border-strong); color: var(--text); }
.club-pill .lead-ic { color: var(--action-primary); }
.club-pill .chev { color: var(--text-muted); font-size: .8rem; }
.club-pill__logo { width: 1.1rem; height: 1.1rem; border-radius: 50%; object-fit: cover; }
.club-list__logo { width: 1.5rem; height: 1.5rem; border-radius: 50%; object-fit: cover; flex: 0 0 auto; }
/* Biến thể chỉ-hiển-thị: định danh CLB trong header trang, không bấm được (đổi CLB dùng switcher navbar). */
.club-pill--static { cursor: default; }
.club-pill--static:hover { border-color: var(--border); color: var(--text); }

/* Page Header — thay link "← Về…" đặt lệch */
.page-header { margin-bottom: var(--space-xl); }
/* Hàng trên header: breadcrumb (trái) + club-pill định danh CLB (phải) — dùng CHUNG cho MỌI trang
   trong CLB qua fragment `page :: pageHeader`, để tên CLB đang xem luôn hiện, không chỉ Dashboard.
   flex-wrap: pill rớt xuống dòng dưới trên màn hẹp thay vì chèn ép breadcrumb. */
.page-header__top {
  display: flex; align-items: center; justify-content: space-between;
  gap: var(--space-sm); flex-wrap: wrap; margin-bottom: var(--space-2xs);
}
.page-header__top .page-header__crumb { margin-bottom: 0; }
.page-header__top .club-pill { margin-left: auto; }
/* Breadcrumb — "thanh menu" đường dẫn: mọi cấp cha bấm được để quay lại, cấp hiện tại là chữ mờ.
   Dùng flex + wrap để chuỗi dài (VD Buổi tập › Chi tiết buổi › Điểm danh) tự xuống dòng trên mobile. */
.page-header__crumb {
  display: flex; flex-wrap: wrap; align-items: center; gap: .375rem;
  font-size: .8125rem; color: var(--text-muted); margin-bottom: var(--space-2xs);
}
.page-header__crumb a { color: var(--action-primary); text-decoration: none; font-weight: 500; }
.page-header__crumb a:hover { text-decoration: underline; }
.page-header__crumb .sep { color: var(--text-muted); opacity: .55; }
.page-header__crumb .current { color: var(--text-muted); }
.page-header__row {
  display: flex; justify-content: space-between; align-items: flex-start;
  gap: var(--space-md); flex-wrap: wrap; margin-top: var(--space-2xs);
}
/* Tiêu đề trang to & đậm hơn — nhịp thị giác giống mockup FastClub */
.page-header__title { margin: 0; font-size: 1.75rem; line-height: 2.25rem; font-weight: 800; letter-spacing: -.02em; }
.page-header__subtitle { font-size: .9rem; color: var(--text-muted); margin-top: var(--space-2xs); max-width: 60ch; }
/* --reserve: chừa cứng chiều cao 2 dòng cho subtitle. Dùng ở nhóm 4 tab Cài đặt (Chung / Cách chia
   tiền / Sân / Loại cầu): mô tả mỗi tab dài ngắn khác nhau (1 hay 2 dòng) làm page-header cao thấp
   khác nhau, đẩy thanh tab con bên dưới lệch vị trí khi chuyển tab. Ghim cao 2 dòng ⇒ tab luôn cùng
   một chỗ; tab nào mô tả 1 dòng thì dưới nó dư một dòng trắng (chấp nhận được, đổi lấy vị trí ổn định).
   line-height khai rõ để min-height khớp đúng 2 dòng. Mobile ẩn subtitle (display:none ở tầng mobile
   bên dưới) nên min-height này không tác động ở điện thoại. */
.page-header__subtitle--reserve { line-height: 1.4; min-height: calc(1.4em * 2); }

/* ============================================================
   NAV-PILLS — tab dạng segmented (Báo cáo, Sân, Cài đặt…)
   Active = viên xanh đặc (đã map --bs-primary). Track nền chìm.
   ============================================================ */
.nav-pills {
  display: inline-flex; flex-wrap: wrap; gap: .25rem;
  background: var(--surface-muted); padding: .3rem;
  border-radius: var(--radius-md); border: 1px solid var(--border);
}
/* P5: chữ tab chưa active (--text-muted) trên --surface-muted chỉ đạt 4,04:1 ở dark (--surface-muted
   dark #334155 sáng hơn light tương ứng, không đủ tối để bù cho --text-muted). Dark-only: hạ nền pill
   xuống --surface-sunken (đã có, dùng cho nền trang) — đạt ~7:1, không cần đổi --text-muted (dùng ở
   rất nhiều nơi khác, đổi token đó rủi ro lan rộng ngoài phạm vi task này). */
[data-bs-theme="dark"] .nav-pills { background: var(--surface-sunken); }
.nav-pills .nav-link {
  color: var(--text-muted); font-weight: 600; font-size: .9rem;
  padding: .45rem 1rem; border-radius: var(--radius-sm);
}
.nav-pills .nav-link:hover:not(.active) { color: var(--text); background: var(--surface); }
.nav-pills .nav-link.active {
  background: var(--action-primary-solid, var(--action-primary)); color: #fff;
  box-shadow: 0 4px 10px rgba(14,122,95,.28);
}

/* App Alert — soft, kèm hành động bên phải */
.app-alert {
  display: flex; align-items: center; justify-content: space-between;
  gap: var(--space-sm); flex-wrap: wrap;
  padding: var(--space-md); border-radius: var(--radius-md);
  border: 1px solid transparent; font-size: .95rem;
  margin-bottom: var(--space-md);
}
.app-alert__body { display: flex; align-items: center; gap: .5rem; }
/* P5: nền dùng var(--alert-*-bg, rgba(...)) — chỉ định nghĩa ở [data-bs-theme="dark"], LIGHT rơi về
   fallback rgba() gốc, không đổi. */
.app-alert.is-info    { background: var(--alert-info-bg, rgba(37,99,235,.10));  border-color: rgba(37,99,235,.22);  color: var(--badge-blue-fg); }
/* `is-court` — banner của luồng "Đăng ký đi tập" (vòng 2 task 5.12). Cùng lý do với `.btn-court-soft`
   ở khối nút: người dùng chốt cả MÀN đó mang xanh thảm, không còn xanh dương. Chỉ dùng ở màn này —
   `is-info` vẫn là ô màu "thông tin trung tính" dùng chung cho toàn app.
   Tương phản: light chữ --court-700 trên nền đã hoà rgb(231,242,239) = 8,23:1 · dark --court-300 trên
   --alert-court-bg = 8,04:1. Dark khai nền ĐẶC (đúng cơ chế P5 của 4 alert kia) để công cụ audit đọc
   được nền thật thay vì kênh RGB thô của rgba. */
.app-alert.is-court   { background: var(--alert-court-bg, rgba(14,122,95,.10)); border-color: rgba(14,122,95,.22); color: var(--court-700); }
[data-bs-theme="dark"] .app-alert.is-court { color: var(--court-300); }
.app-alert.is-success { background: var(--alert-success-bg, rgba(22,163,74,.10));  border-color: rgba(22,163,74,.22);  color: var(--badge-green-fg); }
.app-alert.is-warning { background: var(--alert-warning-bg, rgba(249,115,22,.12)); border-color: rgba(249,115,22,.26); color: var(--badge-amber-fg); }
.app-alert.is-danger  { background: var(--alert-danger-bg, rgba(220,38,38,.10));  border-color: rgba(220,38,38,.24);  color: var(--badge-red-fg); }

/* Toast thông báo dùng chung (BmhToast, notification.js) — góc trên-phải ở MỌI cỡ màn hình (kể cả
   mobile). z-index trên cả modal vì lỗi có thể xảy ra khi đang mở modal.
   🔴 SỬA COMMENT LỖI THỜI (task 1.2): đoạn trước đây ở đây nói `top` chỉ là giá trị mặc định vì
   notification.js "đo chiều cao THẬT của .app-topbar-mobile/.auth-topbar/.page-header__top rồi ghi đè
   top bằng inline style mỗi lần show()". Cơ chế đó ĐÃ BỊ BỎ (`notification.js` dòng 14–17: toast nay
   ghim cứng ở góc trên vì nền đậm/đặc, đè lên navbar vẫn đọc rõ) — comment cũ khiến người đọc đi tìm
   một hàm không còn tồn tại. `top: var(--space-md)` bây giờ là vị trí THẬT và duy nhất.
   Hệ quả trên mobile, đo được ở 390×844 (ghi lại chứ KHÔNG tự sửa — đây là quyết định thiết kế của
   spec toast 2026-08-17, đảo lại phải hỏi người dùng): toast rộng 350/390px nên nó che gần trọn
   topbar, gồm cả `.app-theme-toggle` — phần tử bấm được DUY NHẤT của topbar mobile — trong 4 giây
   (7 giây với toast `danger`). Trên desktop góc trên-phải là chỗ trống nên không ai mất gì. */
#bmh-toast-container {
  position: fixed; top: var(--space-md); right: var(--space-md);
  z-index: calc(var(--bs-modal-zindex, 1055) + 10);
  display: flex; flex-direction: column; gap: var(--space-sm);
  max-width: min(24rem, calc(100vw - 2rem));
}
#bmh-toast-container .app-alert { margin-bottom: 0; box-shadow: var(--elevation-2); flex-wrap: nowrap; }
/* Nền đậm/đặc thay vì màu nhạt (tint) của .app-alert dùng ở nội dung trang — toast cần nổi bật, tự
   ẩn nhanh nên không cần giữ tông nhẹ nhàng như banner tĩnh. Chữ/icon trắng để đủ tương phản trên nền
   đặc; .btn-close-white (Bootstrap) đổi màu nút đóng cho khớp.
   🔴 Task 1.2 — ĐO ĐƯỢC hai nền TRƯỢT AA với chữ trắng, ở CẢ hai theme và CẢ desktop (probe riêng:
   `audit.js` không bao giờ thấy toast vì toast chỉ tồn tại sau khi JS gọi `BmhToast.show()`):
     • is-success `#16A34A` → **3,30:1** (cần 4,5 cho chữ 15,2px)  ⇒ `--green-500` một bậc quá sáng
     • is-warning `#D97706` → **3,19:1**
   Sửa bằng token có sẵn, không nghĩ mã hex mới:
     • is-success → `--green-600` (#15803D) với chữ trắng: **5,02:1** đo được. Chỉ tối thêm một bậc,
       vẫn đọc ra "thành công".
     • is-warning → GIỮ nền amber, đổi CHỮ sang màu tối (`--slate-900`): **6,37:1**. 🔴 Bản sửa đầu
       tối nền lên `--amber-700` (#7C380B) đạt 8,65 nhưng chụp ảnh ra màu **nâu bùn**, mất luôn nghĩa
       "cảnh báo" và đá nhau với banner amber nhạt phía sau. Bảng màu không có bậc nào giữa
       `--amber-600` (#EA580C — vẫn chỉ 3,56, vẫn trượt) và `--amber-700`, nên đường đúng là đảo
       chiều tương phản chứ không phải tối nền: đây CHÍNH LÀ cách app đã làm cho `.btn-warning`
       (chữ đen trên #ffc107, đo được 12,9:1 ở task 0.6). Kéo theo: phải bỏ `filter` của
       `.btn-close-white` cho riêng toast này, nếu không dấu X trắng biến mất trên nền sáng.
   `is-info`/`is-danger` giữ nguyên GIÁ TRỊ (5,17 và 4,83 — đều đạt), chỉ đổi cách viết từ mã hex sang
   token tương ứng cho khớp luật "mọi màu đến từ token" (`--blue-500` = #2563EB, `--red-500` = #DC2626,
   đo lại xác nhận tương phản không đổi).
   🔴 Đây là thay đổi nhìn thấy được TRÊN CẢ DESKTOP. Khác với ca nút "Chốt sổ ngay" bị hoàn tác ở task
   0.6, ca này KHÔNG phải dọn thẩm mỹ: hai nền trên trượt tiêu chí `MB-7` bằng số, và AA không phụ
   thuộc bề rộng màn hình nên không thể sửa riêng cho mobile. `MB-8` (ảnh 1440×900) vẫn byte-identical
   vì ảnh chụp trang tĩnh không có toast nào. */
#bmh-toast-container .app-alert.is-success { background: var(--green-600); border-color: var(--green-600); color: #fff; }
#bmh-toast-container .app-alert.is-info    { background: var(--blue-500);  border-color: var(--blue-500);  color: #fff; }
#bmh-toast-container .app-alert.is-warning { background: var(--amber-500); border-color: var(--amber-500); color: var(--slate-900); }
#bmh-toast-container .app-alert.is-danger  { background: var(--red-500);   border-color: var(--red-500);   color: #fff; }
/* Dấu X của Bootstrap là ảnh nền; `.btn-close-white` (notification.js gắn cho MỌI toast) đảo nó thành
   trắng — trên nền amber sáng thì gần như mất hẳn. Trả về dấu X tối cho đúng một kiểu toast này.
   Dùng primitive `--slate-900` chứ không phải `--text-strong`: nền toast là màu CỐ ĐỊNH ở cả hai
   theme, nên chữ trên nó cũng phải cố định — `--text-strong` ở theme tối là màu SÁNG, sẽ mất chữ. */
#bmh-toast-container .app-alert.is-warning .btn-close { filter: none; }

/* Empty State — biến màn trống thành điểm khởi đầu */
.empty-state {
  background: var(--surface); border: 1px solid var(--border);
  border-radius: var(--radius-lg); box-shadow: var(--elevation-1);
  padding: var(--space-2xl) var(--space-xl); text-align: center;
}
.empty-state__mark {
  display: flex; align-items: center; justify-content: center;
  width: 3.5rem; height: 3.5rem; margin: 0 auto var(--space-md);
  border-radius: 50%; font-size: 1.6rem; line-height: 1;
  background: rgba(14,122,95,.10); color: var(--action-primary);
}
.empty-state__title { margin-bottom: var(--space-xs); }
.empty-state__desc { color: var(--text-muted); margin-bottom: var(--space-lg); }

/* ============================================================
   Bọc nội dung trang — đẩy phải khi có sidebar (>=992px),
   chừa chỗ bottom-nav (<992px)
   ============================================================ */
.page-content { padding-top: var(--space-lg); }
/* Bề rộng nội dung — QUY ƯỚC: MỌI màn trong CLB dùng content-wide (960) cho đồng nhất bề rộng.
   (Trước đây các màn *sua* dùng content-default(720) nên trông "bé hơn" màn danh sách — theo yêu
    cầu đồng bộ, đã gộp về cùng một size 960.)
     • content-wide  (960): MỌI màn trong CLB — list, bảng tiền, dashboard, VÀ các màn *sua*.
     • content-default(720): còn định nghĩa để dùng lại nếu cần trang một cột hẹp (hiện không màn nào dùng).
     • content-narrow (560): dành cho auth/shell ngoài CLB.
   🔴 Bảng dữ liệu KHÔNG được dùng content-default — nó co bảng lại hẹp hơn các màn khác (bug đã từng gặp ở
      bang-chia-tien). Thêm màn mới ⇒ content-wide. */
.page-content .content-narrow  { max-width: var(--w-narrow); }
.page-content .content-default { max-width: var(--w-content); }
.page-content .content-wide    { max-width: var(--w-wide); }

@media (min-width: 992px) {
  .page-content { margin-left: var(--sidebar-width); padding-top: var(--space-xl); }
}
@media (max-width: 991.98px) {
  .page-content {
    padding-bottom: calc(var(--bottom-nav-height) + env(safe-area-inset-bottom) + var(--space-md));
  }
}

/* ============================================================
   Topbar tối giản (<992px)
   ============================================================ */
.app-topbar-mobile {
  background: var(--surface);
  border-bottom: 1px solid var(--border);
  padding: .5rem 1rem;
  min-height: var(--topbar-height);
}

/* Tên CLB ở topbar: dài tới đâu cũng KHÔNG BAO GIỜ wrap — cắt bằng ellipsis (P3, task 0.4).
   🔴 Vì sao luật này nằm ở ĐÂY chứ không trong khối tầng mobile ở cuối tệp (task 1.1) — và tại sao
   đừng viết lại nguyên văn chuỗi mốc của khối đó trong bất kỳ comment nào: `MobileScaleTest` đếm
   số lần chuỗi mốc xuất hiện để bắt lỗi "luật mobile rải rác nhiều khối", nên một comment nhắc
   đúng chuỗi đó cũng bị tính là mốc thứ hai và ba test của lớp này đỏ cùng lúc (đã xảy ra thật khi
   viết task 1.1). Lý do đặt ở đây: topbar mang
   `d-lg-none` nên nó SỐNG TỚI 991,98px, rộng hơn tầng mobile 767,98px — dải 768–991px (tablet
   dọc) vì thế nhận chrome điện thoại nhưng KHÔNG có luật chống wrap. Đo bằng probe task 1.1:
   ở 768px và 991px, tên CLB 95 ký tự đội topbar từ 56px lên **103,4px** (2 dòng), trong khi ở
   390px cùng tên đó chỉ chiếm 234px (=60vw) và topbar giữ 60,2px. Đặt ngoài mọi @media là an
   toàn cho MB-8 theo đúng lý lẽ của tầng mobile — chỉ khác là bảo vệ bằng CẤU TRÚC COMPONENT
   (>=992px không render topbar này) thay vì bằng breakpoint.
   🔴 --fs-strong/--tap-min chỉ tồn tại TRONG tầng mobile (<768px), nên mọi chỗ dùng token ở
   ngoài đó phải viết kèm giá trị dự phòng CÙNG con số — thiếu nó, 768–991px rơi về giá trị
   thừa hưởng chứ không phải bậc của thang chữ. */
.app-topbar-mobile__club {
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  max-width: 60vw;
  font-size: var(--fs-strong, 1.0625rem); font-weight: 700;
}
.app-topbar-mobile .app-brand { min-width: 0; }
/* Icon topbar về bậc 20px của thang icon (MB-4). `.app-theme-toggle` có `font-size: .85rem` nên
   icon con thừa hưởng 13,6px — đo thật ở 768×1024 (ở <768px tầng mobile đã ép 20px). */
.app-topbar-mobile .bi { font-size: var(--icon-md, 1.25rem); }

/* Vùng chạm topbar (MB-3): logo+tên CLB đo 98×35 và nút đổi theme 35×34 ở 768px. GIỮ kích
   thước nhìn thấy — phóng to chúng đội cao topbar — mở vùng chạm bằng pseudo-element trong
   suốt như P1 làm với nút icon-only. Tầng mobile đã có luật này cho `.app-brand` trần (áp cả
   trang auth/entry ở <768px); cụm dưới đây chỉ mở rộng nó ra dải tablet và CHỈ trong topbar,
   không nới cho `.app-brand` ở nơi khác — chồng nhau ở <768px là vô hại vì cùng kích thước.
   `.app-theme-toggle` phải scope theo topbar: cùng class còn dùng ở footer sidebar desktop,
   nơi một lớp phủ 44px vô hình sẽ giành cú bấm của nút Đăng xuất ngay cạnh. */
.app-topbar-mobile .app-brand,
.app-topbar-mobile .app-theme-toggle { position: relative; }
.app-topbar-mobile .app-brand::after,
.app-topbar-mobile .app-theme-toggle::after {
  content: ''; position: absolute; top: 50%; left: 50%;
  transform: translate(-50%, -50%);
  width: var(--tap-min, 44px); height: var(--tap-min, 44px);
}

/* ============================================================
   Sidebar trái cố định (>=992px)
   ============================================================ */
.app-sidebar {
  position: fixed; top: 0; left: 0; bottom: 0;
  width: var(--sidebar-width);
  background: var(--nav-bg);
  border-right: 1px solid var(--nav-border);
  padding: var(--space-md) 0 0;
  z-index: 1030;
}
.app-sidebar-brand { padding: 0 var(--space-md) var(--space-md); }
.app-brand {
  display: flex; align-items: center; gap: .6rem;
  font-weight: 800; font-size: 1.1rem; color: var(--text-strong);
  text-decoration: none;
}
/* Logo mark: ô bo góc màu xanh — dùng chung sidebar + topbar mobile */
.app-brand .app-brand-mark {
  display: inline-flex; align-items: center; justify-content: center;
  width: 2.2rem; height: 2.2rem; border-radius: 11px;
  background: var(--nav-brand-mark-bg); font-size: 1.15rem; flex: 0 0 auto;
  box-shadow: 0 4px 10px rgba(14,122,95,.35);
}
/* Cỡ mặc định logo SVG (auth/entry — không có ô tile): xanh thương hiệu */
.brand-svg { width: 1.4rem; height: 1.4rem; color: var(--action-primary); flex: 0 0 auto; vertical-align: -.2em; }
/* Trong ô tile sidebar/topbar/entry: trắng, khớp tile */
.app-brand .app-brand-mark .brand-svg { width: 1.35rem; height: 1.35rem; color: #fff; }
/* Biến thể logo CLB (topbar <992px): ảnh lấp KÍN ô tile ⇒ bỏ nền xanh + đổ bóng thương hiệu, nếu
   không ảnh sẽ có viền xanh thừa quanh mép. Giữ nguyên 2,2rem và bo góc 11px của .app-brand-mark ⇒
   chiều cao topbar không đổi (MB-8 an toàn). Ngoài mọi @media: topbar sống tới 991,98px trong khi
   tầng mobile dừng ở 767,98px, và bảo vệ ở đây là bằng CẤU TRÚC (>=992px không render topbar). */
.app-brand .app-brand-mark--logo { background: none; box-shadow: none; overflow: hidden; }
.app-brand .app-brand-mark__logo { width: 100%; height: 100%; object-fit: cover; border-radius: inherit; display: block; }
.app-brand-text { display: flex; flex-direction: column; line-height: 1.12; }
.app-brand-name { font-weight: 800; font-size: 1.05rem; }
.app-brand-sub  { font-size: .7rem; font-weight: 500; }
/* Trong sidebar tối: chữ sáng */
.app-sidebar .app-brand-name { color: var(--nav-text-strong); }
.app-sidebar .app-brand-sub  { color: var(--nav-text-muted); }

/* Club switcher — base SÁNG (dùng trong offcanvas menu mobile) */
.app-club-switch {
  display: flex; align-items: center; justify-content: space-between; gap: .5rem;
  margin: 0 var(--space-sm) var(--space-sm);
  padding: .55rem .7rem;
  border: 1px solid var(--border); border-radius: var(--radius-md);
  background: var(--surface-sunken); color: var(--text);
  text-decoration: none; font-size: .9rem;
}
.app-club-switch:hover { border-color: var(--border-strong); color: var(--text); }
.app-club-switch .label { display: inline-flex; align-items: center; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.app-club-switch i { color: var(--text-muted); }
.app-club-switch__logo { width: 1.1rem; height: 1.1rem; border-radius: 50%; object-fit: cover; margin-right: .4rem; flex: 0 0 auto; }
/* Trong sidebar TỐI: đổi sang nền/chữ sáng cho hợp navy */
.app-sidebar .app-club-switch {
  border-color: var(--nav-border); background: var(--nav-hover-bg); color: var(--nav-text);
}
.app-sidebar .app-club-switch:hover { border-color: var(--border-strong); color: var(--nav-text-strong); background: rgba(148,163,184,.16); }
.app-sidebar .app-club-switch i { color: var(--nav-text-muted); }

.app-sidebar-nav { flex: 1 1 auto; overflow-y: auto; padding: 0 var(--space-xs); }
.app-sidebar-group-label {
  padding: var(--space-md) var(--space-sm) var(--space-2xs);
  font-size: .6875rem; font-weight: 700; letter-spacing: .06em;
  text-transform: uppercase; color: var(--nav-group-label);
}
.app-sidebar-nav .nav-link {
  color: var(--nav-text); padding: .6rem .75rem; margin: 2px 0;
  border-radius: var(--radius-md); display: flex; align-items: center; gap: .7rem;
  font-size: .9375rem; font-weight: 500;
  transition: background var(--motion-fast) var(--ease-standard), color var(--motion-fast) var(--ease-standard);
}
.app-sidebar-nav .nav-link i { font-size: 1.1rem; color: var(--nav-text-muted); width: 1.2rem; text-align: center; }
.app-sidebar-nav .nav-link:hover { background: var(--nav-hover-bg); color: var(--nav-text-strong); }
.app-sidebar-nav .nav-link:hover i { color: var(--nav-text-strong); }
.app-sidebar-nav .nav-link.active {
  background: var(--nav-active-bg);
  color: var(--nav-active-text);
  font-weight: 600;
  box-shadow: 0 6px 14px rgba(14,122,95,.35);
}
.app-sidebar-nav .nav-link.active i { color: var(--nav-active-text); }

.app-sidebar-footer {
  padding: var(--space-sm);
  margin-top: var(--space-xs);
  border-top: 1px solid var(--nav-border);
  display: flex; flex-direction: column; gap: var(--space-xs);
}

/* Hồ sơ người dùng cuối sidebar */
.app-user {
  display: flex; align-items: center; gap: .6rem;
  padding: .5rem; border-radius: var(--radius-md);
  text-decoration: none; color: var(--nav-text);
}
.app-user:hover { background: var(--nav-hover-bg); color: var(--nav-text-strong); }
.app-user__avatar {
  width: 2.2rem; height: 2.2rem; border-radius: 50%; flex: 0 0 auto;
  background: rgba(148,163,184,.18); color: var(--nav-text-strong);
  display: inline-flex; align-items: center; justify-content: center; font-size: 1.05rem;
  overflow: hidden;
}
.app-user__avatar img { width: 100%; height: 100%; object-fit: cover; }
.app-user__meta { display: flex; flex-direction: column; line-height: 1.2; min-width: 0; }
.app-user__name { font-weight: 600; font-size: .85rem; color: var(--nav-text-strong);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 130px; }
.app-user__role { font-size: .72rem; color: var(--nav-text-muted); }
.app-user__chev { margin-left: auto; color: var(--nav-text-muted); font-size: .8rem; }

/* 🔴 Hai nút [Giao diện] [Đăng xuất] phải rộng BẰNG NHAU, nhưng hai flex item lại khác
   loại: một bên là chính <button>, bên kia là <form> bọc nút (form bắt buộc — đăng xuất
   là POST có CSRF). Với flex:1 1 0 Chrome vẫn chia lệch 117.3px/99.3px (đo thật), mà nút
   hẹp hơn lại là nút có chữ dài hơn (cần 102.4px) ⇒ "Đăng xuất" gãy 2 dòng còn "Giao diện"
   1 dòng, hai nút cao bằng nhau nhưng trông lệch hẳn. Grid 2 cột 1fr chia đúng bằng nhau
   bất kể item là thẻ gì; minmax(0,1fr) để cột không bị nội dung đẩy phình ra. */
.app-sidebar-footer-actions {
  display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .3rem;
}
.app-sidebar-footer .app-theme-toggle,
.app-sidebar-footer .btn-logout {
  display: inline-flex; align-items: center; justify-content: center; gap: .3rem;
  background: rgba(148,163,184,.08); border: 1px solid var(--nav-border);
  color: var(--nav-text); border-radius: var(--radius-md);
  padding: .45rem .35rem; font-size: .8rem; width: 100%; cursor: pointer;
  /* 🔴 CHỮ PHẢI NẰM 1 DÒNG. Hai lớp bảo vệ:
     1. nowrap — chặn gãy dòng, kể cả khi ai đó đổi nhãn dài hơn (lúc đó lỗi lộ ra bằng
        cách tràn viền, thấy ngay, chứ không âm thầm làm hai nút cao lệch nhau).
     2. Nới chỗ: gap .4→.3rem, padding ngang .5→.35rem ⇒ nhãn dài nhất ("Đăng xuất" +
        icon) chỉ cần 93.6px trong cột 108.8px — dư 15.2px thay vì 5.9px. Nhờ vậy vẫn
        vừa 1 dòng cả khi cỡ chữ gốc trình duyệt lớn hơn 16px (zoom, hệ điều hành đặt
        font to) — trường hợp trước đây sẽ tràn. */
  white-space: nowrap;
}
.app-sidebar-footer .app-theme-toggle:hover,
.app-sidebar-footer .btn-logout:hover { background: var(--nav-hover-bg); color: var(--nav-text-strong); border-color: var(--border-strong); }

/* ============================================================
   Sidebar collapse (desktop >=992px). Trạng thái ở <html class="sidebar-collapsed">,
   áp trước paint từ localStorage (layout.html) để không nháy.
   ============================================================ */
.app-sidebar-brand { display: flex; align-items: center; justify-content: space-between; gap: .5rem; }
.app-sidebar-collapse-btn {
  flex: none; background: transparent; border: none; color: var(--nav-text-muted);
  padding: .3rem; border-radius: var(--radius-md); cursor: pointer; line-height: 0;
}
.app-sidebar-collapse-btn:hover { background: var(--nav-hover-bg); color: var(--nav-text-strong); }
.collapse-ic { display: inline-flex; }
.collapse-ic--open { display: none; }
html.sidebar-collapsed .collapse-ic--close { display: none; }
html.sidebar-collapsed .collapse-ic--open  { display: inline-flex; }

@media (min-width: 992px) {
  html.sidebar-collapsed { --sidebar-width: 76px; }
  html.sidebar-collapsed .app-sidebar { width: 76px; }
  html.sidebar-collapsed .page-content { margin-left: 76px; }

  /* Ẩn nhãn/khối chữ; chừa nút collapse (để mở lại) + icon. */
  html.sidebar-collapsed .app-brand,
  html.sidebar-collapsed .app-club-switch,
  html.sidebar-collapsed .app-sidebar-group-label,
  html.sidebar-collapsed .app-user__meta,
  html.sidebar-collapsed .app-user__chev,
  html.sidebar-collapsed .app-sidebar-nav .nav-link > span,
  html.sidebar-collapsed .app-theme-toggle > span,
  html.sidebar-collapsed .btn-logout > span { display: none; }

  /* Căn giữa các phần tử còn lại trong rail hẹp. */
  html.sidebar-collapsed .app-sidebar-brand { justify-content: center; }
  html.sidebar-collapsed .app-sidebar-nav .nav-link { justify-content: center; padding: .6rem; }
  html.sidebar-collapsed .app-user { justify-content: center; }
  /* Rail hẹp: xếp dọc — grid 1 cột (trước là flex-direction: column). */
  html.sidebar-collapsed .app-sidebar-footer-actions { grid-template-columns: 1fr; }
}

/* Nút ghost — nền trong suốt, hover nền chìm (dùng cho trigger menu "…") */
.btn-ghost { background: transparent; border: 1px solid transparent; color: var(--text-muted); }
.btn-ghost:hover { background: var(--surface-muted); color: var(--text); }
.min-w-0 { min-width: 0; }

/* Nút đổi theme sáng/tối (mặc định — dùng trên topbar mobile sáng) */
.app-theme-toggle {
  display: inline-flex; align-items: center; justify-content: center; gap: .4rem;
  border: 1px solid var(--border); background: var(--surface);
  color: var(--text-muted); border-radius: var(--radius-md);
  padding: .35rem .6rem; font-size: .85rem; cursor: pointer;
}
.app-theme-toggle:hover { border-color: var(--border-strong); color: var(--text); }

/* ============================================================
   Bottom-nav cố định đáy (<992px)
   ============================================================ */
/* 🔴 `env(safe-area-inset-bottom)` ở đây (và trong `padding-bottom` của `.page-content`) hiện **trơ
   trên máy thật**: `env()` chỉ khác 0 khi thẻ meta viewport có `viewport-fit=cover`, mà
   `fragments/layout.html` cố tình KHÔNG có. Đã kiểm ở task 1.2 và kết luận: **vô hại, đừng "sửa"**.
   Không có `viewport-fit=cover`, trình duyệt tự thu layout viewport về vùng an toàn, nên thanh nav ở
   `bottom: 0` vẫn nằm TRÊN home indicator — đúng kết quả mong muốn, chỉ là do trình duyệt lo thay vì
   do CSS. Thêm `viewport-fit=cover` để tràn viền (edge-to-edge) là một QUYẾT ĐỊNH THIẾT KẾ, không
   phải sửa lỗi, và khi đó phải xử lý cả 4 chiều: đo bằng `Emulation.setSafeAreaInsetsOverride`
   (inset 47/34 của iPhone 14) cho thấy topbar hiện KHÔNG có inset trên — nội dung chỉ cách mép trên
   8px trong khi notch cao 47px ⇒ bật cover mà không thêm `padding-top` cho topbar là đẩy tên CLB
   xuống dưới thanh trạng thái. Giữ `env()` ở đây như một lớp phòng thân: đúng ngay nếu sau này bật. */
.app-bottom-nav {
  position: fixed; left: 0; right: 0; bottom: 0;
  display: flex; background: var(--surface);
  border-top: 1px solid var(--border);
  padding-bottom: env(safe-area-inset-bottom);
  z-index: 1030;
}
/* Nhãn 13px = bậc nhỏ nhất của thang chữ (MB-2); icon 20px = --icon-md (MB-4); mỗi ô đã đủ
   44×44 nhờ `min-height: 56px` + `flex: 1 1 0` (5 ô trên 360px = 72px mỗi ô).
   🔴 Trước task 1.1 nhãn là `.7rem` (11,2px) và chỉ được tầng mobile nâng lên 13px ở <768px —
   nhưng bottom-nav mang `d-lg-none` nên nó sống tới 991,98px: đo ở 768×1024 nhãn "Trang chủ"
   vẫn 11,2px. Đặt bậc chữ ngay tại component (kèm giá trị dự phòng vì token chỉ tồn tại
   trong tầng mobile) thay vì thêm một breakpoint nữa — component không render ở >=992px nên
   desktop không thể bị chạm. Cùng lý lẽ với `.app-topbar-mobile__club` ở trên. */
.app-bottom-nav-item {
  flex: 1 1 0; display: flex; flex-direction: column;
  align-items: center; justify-content: center; gap: .15rem;
  min-height: 56px; padding: .5rem 0;
  color: var(--text-muted); background: none; border: none;
  font-size: var(--fs-caption, .8125rem); text-decoration: none;
}
.app-bottom-nav-item i { font-size: var(--icon-md, 1.25rem); }
.app-bottom-nav-item.active { color: var(--action-primary); }

/* Offcanvas menu (mobile) dùng token */
.offcanvas { background: var(--surface); color: var(--text); }
.offcanvas .offcanvas-header { border-bottom: 1px solid var(--border); }

/* ------------------------------------------------------------
   Vùng chạm + thang chữ trong offcanvas menu (task 1.1, MB-2/MB-3/MB-7).
   🔴 Trạng thái ĐÓNG của offcanvas có `visibility: hidden`, nên `audit.js` (mục 3) bỏ qua toàn
   bộ phần tử bên trong: mọi vùng chạm nhỏ ở đây KHÔNG BAO GIỜ bị đếm nếu chỉ đo trang tĩnh —
   đó là lý do task 1.1 thêm `BMH_OPEN` vào công cụ đo. Đo với menu MỞ (390×844 light /
   360×800 dark / 768×1024 light) trước khi sửa: mục menu 40px · club switcher 41–44px ·
   `.btn-close` 31–32px · "Đăng xuất" 31px (ở 768px, nơi tầng mobile không với tới).
   Toàn bộ cụm này scope theo `.offcanvas` — app chỉ có ĐÚNG MỘT offcanvas (`#menuOffcanvas`,
   `fragments/nav.html`) và nó mang `d-lg-none` ⇒ desktop 1440px không render, MB-8 an toàn về
   mặt cấu trúc.
   ------------------------------------------------------------ */
/* Mục menu: trước task 1.1 là tổ hợp utility viết tay trong template
   (`d-flex align-items-center gap-2 py-2 text-decoration-none` → cao đúng 40px). Đặt tên class
   thật để chiều cao vùng chạm là HỢP ĐỒNG của component, không phải hệ quả tình cờ của một
   chuỗi utility mà lần sửa sau dễ gõ thiếu. */
.app-offcanvas-link {
  display: flex; align-items: center; gap: .5rem;
  min-height: var(--tap-min, 44px);
  margin: 0 -.5rem; padding: 0 .5rem;   /* lùi lề âm: chữ vẫn thẳng cột với nhãn nhóm */
  border-radius: var(--radius-md);
  color: var(--text); text-decoration: none;
}
.app-offcanvas-link:hover,
.app-offcanvas-link:focus-visible { background: var(--surface-muted); color: var(--text-strong); }
.offcanvas .app-club-switch,
.offcanvas .btn { min-height: var(--tap-min, 44px); }
/* `.btn-close` (Bootstrap) 31–32px: giữ kích thước nhìn thấy, mở vùng chạm bằng pseudo như P1. */
.offcanvas .btn-close { position: relative; }
.offcanvas .btn-close::after {
  content: ''; position: absolute; top: 50%; left: 50%;
  transform: translate(-50%, -50%);
  width: var(--tap-min, 44px); height: var(--tap-min, 44px);
}
/* Nhãn nhóm: 11px → 13px (MB-2), và màu. `--nav-group-label` (#64789B) sinh ra cho sidebar
   navy cố định; trên nền `--surface` SÁNG của offcanvas nó chỉ đạt **4,47:1** (cần 4,5 — MB-7,
   đo 390×844 light). Theme tối đã được task 0.6 sửa bằng luật `[data-bs-theme="dark"]` riêng;
   đây là nốt còn lại của theme sáng, lần này scope theo `.offcanvas` để sidebar desktop —
   nơi #64789B ĐÚNG vì nền navy — không bị chạm. */
.offcanvas .app-sidebar-group-label {
  font-size: var(--fs-caption, .8125rem);
  color: var(--text-muted);
}

/* ============================================================
   Bảng — list-card: bảng >=768px, card xếp chồng <768px.
   Cần <td data-label="..."> trong HTML.
   ============================================================ */
/* Bảng dữ liệu — thoáng, hover nhẹ, bỏ viền dọc, header kiểu caption in hoa. */
.table { color: var(--text); margin: 0; }
.table > thead > tr > th {
  font-size: .7rem; font-weight: 700; letter-spacing: .04em; text-transform: uppercase;
  color: var(--text-muted); border-bottom: 1px solid var(--border);
  padding: .85rem 1rem; white-space: nowrap;
}
.table > tbody > tr > td { padding: .85rem 1rem; border-bottom: 1px solid var(--border); vertical-align: middle; }
.table > tbody > tr:last-child > td { border-bottom: 0; }
.table.table-hover > tbody > tr:hover > td,
.app-card .table > tbody > tr:hover > td { background: var(--surface-sunken); }
.table > :not(caption) > * > * { box-shadow: none; } /* bỏ đường kẻ mặc định Bootstrap */

/* Bo góc bảng khớp card mà KHÔNG dùng overflow:hidden — nếu bọc overflow:hidden thì dropdown "…"
   trong hàng bị cắt mất. Chỉ bo 4 ô góc để nền hover không tràn ra ngoài góc bo của card. */
.app-card > .list-card > table > thead > tr > th:first-child { border-top-left-radius: var(--radius-card); }
.app-card > .list-card > table > thead > tr > th:last-child  { border-top-right-radius: var(--radius-card); }
.app-card > .list-card > table > tbody > tr:last-child > td:first-child { border-bottom-left-radius: var(--radius-card); }
.app-card > .list-card > table > tbody > tr:last-child > td:last-child  { border-bottom-right-radius: var(--radius-card); }

/* ------------------------------------------------------------
   Ô số tiền CÓ ghi chú lồng bên trong — can-wrap-note (MỌI bề rộng).
   Ô `amount` mang `.money`, và `.money { white-space: nowrap }` là ĐÚNG cho con số
   ("125.000 ₫" không được bẻ đôi). Nhưng vài ô còn cõng thêm ghi chú VĂN BẢN TỰ DO do
   người dùng gõ — "Ghi đè:" (AC-EXP-04, tối đa 255 ký tự), "Thu cứng riêng:" (BR-MEM-16),
   "Hệ số riêng:" (BR-MEM-11) ở expense/fragments.html. Ghi chú là con của ô nên THỪA HƯỞNG
   `nowrap`: dài bao nhiêu cũng nằm đúng MỘT dòng.
   🔴 Đây là lỗi tầng DESKTOP, không phải mobile: `can-wrap-note` trước chỉ được gỡ nowrap
   bên trong tầng mobile ở cuối tệp (breakpoint 767,98px — ~dòng 2841), nên từ 768px trở lên
   luật nowrap vẫn nguyên. Đo ở tab "Chi phí & chốt sổ", 1440x900, lý do 130 ký tự: bảng rộng
   1182px trong card 928px (**tràn 254px**), chữ vượt mép phải card 168px rồi bị
   `overflow:hidden` của `.app-card` CẮT CỤT, cột "Người" bị bóp từ 271px còn 76px.
   Hai khai báo, thiếu một là chưa hết bệnh:
     • `white-space: normal` — cho ghi chú xuống dòng. Một mình nó KHÔNG đủ: lý do là chuỗi
       255 ký tự không có dấu cách (người dùng dán link, gõ liền) vẫn là MỘT "từ" không bẻ
       được ⇒ đo lại vẫn tràn 1282px. Không phải giả định — đã dựng đúng ca đó để đo.
     • `overflow-wrap: anywhere` — cho bẻ giữa từ khi không còn chỗ. Dùng `anywhere` (không
       phải `break-word`) vì chỉ `anywhere` mới hạ luôn kích thước min-content của ô, tức
       thứ mà thuật toán bảng auto-layout dùng để quyết bề rộng cột.
   `max-width` chặn trần để MỘT lý do dài không nuốt hết bề rộng cột "Người" (không cap: ô
   tiền chiếm 581/926px). 18rem giữ ghi chú ~3 dòng ở 1440 và trả cột "Người" về 271px.
   `margin-left: auto` giữ ghi chú căn phải cùng mép với con số (ô là `text-end`).
   Nhắm vào `> div` (ghi chú) chứ KHÔNG vào chính ô: đặt ở ô thì con số cũng mất nowrap và
   bẻ được thành "30.000 / ₫" — đúng thứ `.money` sinh ra để chặn.
   ------------------------------------------------------------ */
.list-card td[data-role="amount"].can-wrap-note > div {
  white-space: normal;
  overflow-wrap: anywhere;
  max-width: 18rem;
  margin-left: auto;
}

/* ============================================================
   Bảng "khóa 1 dòng" — table-nowrap.
   Ngày giờ, số tiền, tên người là dữ liệu atomic: bẻ dòng giữa chúng bắt mắt
   phải ghép lại mới đọc được, và làm chiều cao các dòng so le trông như lỗi.
   Mặc định khóa cả bảng 1 dòng, rồi mở lại đúng cột chữ tự do bằng .col-wrap;
   .col-grow cho cột đó nuốt hết phần rộng dư (chỉ dùng khi có ĐÚNG MỘT cột chữ
   tự do — nhiều cột cùng grow thì trình duyệt lại chia đều, vô nghĩa).
   Đánh dấu bằng class trên <th>/<td> chứ không nth-child: thêm/bớt cột sau này
   không âm thầm dịch quy tắc sang nhầm cột.
   ============================================================ */
@media (min-width: 768px) {
  .table-nowrap > thead > tr > th, .table-nowrap > tbody > tr > td { white-space: nowrap; }
  .table-nowrap .col-wrap { white-space: normal; }
  .table-nowrap .col-grow { width: 100%; }
}

/* ============================================================
   Bảng nhiều cột hẹp — table-tight-x.
   Chỉ bóp đệm NGANG (1rem → .5rem) để các cột atomic (ngày, badge, số tiền)
   sát nhau hơn, nhường phần rộng dôi ra cho cột .col-grow. Đệm DỌC giữ nguyên
   để chiều cao dòng và vùng chạm không đổi (CT-6). Chỉ áp dụng >=768px vì
   <768px bảng đã chuyển sang dạng card, đệm do .list-card quản.
   ============================================================ */
@media (min-width: 768px) {
  .table-tight-x > thead > tr > th, .table-tight-x > tbody > tr > td { padding-left: .5rem; padding-right: .5rem; }
  .table-tight-x > thead > tr > th:first-child, .table-tight-x > tbody > tr > td:first-child { padding-left: 1rem; }
  .table-tight-x > thead > tr > th:last-child, .table-tight-x > tbody > tr > td:last-child { padding-right: 1rem; }
}

/* ============================================================
   Ô ngày giờ 2 dòng — cell-datetime.
   "07/07/2026 14:30" trên một dòng ép cột Ngày rộng bằng cả chuỗi, ăn mất chỗ
   của cột mô tả. Tách giờ xuống dòng dưới (mờ, nhỏ hơn) để cột chỉ còn rộng
   bằng phần ngày. Ngày và giờ vẫn là dữ liệu atomic — mỗi phần tự nó không bẻ
   dòng, chỉ bẻ ĐÚNG chỗ giữa hai phần.
   <768px bảng thành card: giữ cùng một dòng cho gọn.
   ============================================================ */
.cell-datetime { display: inline; }
.cell-datetime__time { color: var(--text-muted); font-size: .8125rem; margin-left: .35rem; }
@media (min-width: 768px) {
  .cell-datetime__time { display: block; margin-left: 0; }
}

/* ============================================================
   Danh sách Khách gộp trong một dòng công nợ (BR-EXP-06)
   Mỗi khách một dòng, tên trái — tiền phải, để mắt dò thẳng xuống cột tiền.
   ============================================================ */
.guest-list { list-style: none; margin: 0; padding: 0; }
/* `flex-wrap` + tên `flex: 1 1 auto` (thay vì để `space-between` tự chia): từ 2026-08-25 dòng khách
   có BA phần — tên · tiền · badge đã trả/chưa trả. Với `space-between` và ba item không co được,
   phần tiền bị đẩy ra GIỮA khoảng trống, trông như một cột thứ ba lơ lửng. Cho tên nuốt phần rộng dư
   thì tiền và badge dính vào nhau ở mép phải như bố cục hai phần cũ; `min-width: 0` để tên dài tự
   wrap thay vì kéo tràn ô (cùng lỗi đã sửa cho tên sân, xem § bảng khóa 1 dòng). Cột này nằm trong
   `.col-wrap` nên khi hẹp thì badge rơi xuống dòng dưới thay vì ép cả bảng rộng ra. */
.guest-list li {
  display: flex; align-items: baseline; flex-wrap: wrap;
  gap: .25rem .75rem; font-size: .875rem; padding: .1rem 0;
}
.guest-list li + li { border-top: 1px dashed var(--border); padding-top: .25rem; margin-top: .15rem; }
.guest-list__name { color: var(--text-muted); flex: 1 1 auto; min-width: 0; }
.guest-list__amount { white-space: nowrap; }
.guest-list__status { flex: 0 0 auto; }

/* ============================================================
   Vùng chạm điểm danh >=44px trên mobile (OQ-43)
   ============================================================ */
@media (max-width: 991.98px) {
  /* var(--tap-min, 44px): hợp nhất vào token chung (Đợt 3 task 3.3) — --tap-min chỉ tồn tại TRONG
     tầng mobile (<768px, xem ghi chú ở khối định nghĩa token), nên ở dải 768–991,98px token chưa
     định nghĩa và rơi về fallback 44px trong var() — giá trị hiệu dụng KHÔNG đổi ở cả hai dải. */
  .attendance-row .btn { min-height: var(--tap-min, 44px); min-width: 96px; font-size: .95rem; }
  .attendance-row { padding-top: .6rem; padding-bottom: .6rem; }
}

/* ============================================================
   Bảng điểm danh — grid 4 cột: Tên · Giới tính · Trình độ · Trạng thái.
   🔴 Mỗi dòng VẪN là <div> (KHÔNG phải <tr>) để không đụng cơ chế HTMX swap
   từng dòng của nút tick (xem chú thích trong attendance/fragments.html — <tr>
   trong <div> response sẽ bị trình duyệt nuốt mất khi htmx parse). Cột cố định
   px + cột Tên co giãn (1fr) ⇒ các dòng độc lập vẫn thẳng hàng nhau. Bọc trong
   .attendance-scroll (overflow-x) + min-width ⇒ cuộn ngang khi màn hẹp.
   ============================================================ */
.attendance-scroll { overflow-x: auto; }
.attendance-grid {
  display: grid;
  grid-template-columns: minmax(7rem, 1fr) 4.5rem 6.5rem 8.5rem;
  align-items: center;
  gap: .5rem;
  min-width: 28rem;
}
.attendance-grid__name { min-width: 0; }        /* cho phép cột Tên co lại thay vì tràn grid */
/* Tên của dòng KHÁCH là nút mở form sửa (AC-ATT-03.8..10). Không thêm icon vào cột Hành động được: khối
   lưới điện thoại ở cuối file đã đo bản 5 cột 324px (390×844) / 294px (360×800) — vừa đúng hết cuộn
   ngang; một cột nữa 2,25rem + gap đẩy lên ~364/334px là cuộn lại, cắt cụt đúng cột Tên (CT-6).
   Nút mượn diện tích sẵn có của ô Tên ⇒ bảng không rộng thêm pixel nào, và ô Tên cao bằng hàng nên
   vùng chạm đã qua 44px của MB-3. Gạch chân chấm để thấy được là bấm được mà không thành "link xanh"
   giữa một bảng dày chữ. */
.attendance-grid__name .name-edit {
  background: none;
  border: 0;
  padding: 0;
  font: inherit;
  color: inherit;
  white-space: inherit;
  text-align: left;
  cursor: pointer;
  text-decoration: underline;
  text-decoration-style: dotted;
  text-underline-offset: 3px;
}
.attendance-grid__name .name-edit:hover,
.attendance-grid__name .name-edit:focus-visible {
  color: var(--action-primary);
  text-decoration-style: solid;
}
/* Cột thứ 5 "Hành động" (BR-ATT-08 — bỏ người khỏi danh sách). Chỉ bật khi người xem quản lý được
   buổi: Member không có nút nào ở đó, để lưới 5 cột cho họ là mất trắng 2.5rem bề ngang ở đúng chỗ
   chật nhất. Vì thế nó là class RIÊNG chứ không sửa `.attendance-grid` — bản chỉ-đọc giữ nguyên
   từng pixel (MB-8). */
.attendance-grid--actions {
  grid-template-columns: minmax(7rem, 1fr) 4.5rem 6.5rem 8.5rem 2.5rem;
  min-width: 31rem;
}
.attendance-grid__action { justify-self: center; min-width: 0; }
/* Nhãn header hai phiên bản: bản đầy đủ là mặc định ở MỌI bề ngang, bản rút gọn chỉ được bật trong
   khối điện thoại của lưới 5 cột (xem cuối file). Cố ý KHÔNG dùng `d-none`/`d-sm-inline` của
   Bootstrap: hai class đó áp theo breakpoint cho MỌI bảng, sẽ rút nhãn cả ở bản 4 cột chỉ-đọc của
   Member — nơi cột vẫn rộng 72px và không có lý do gì phải cắt chữ (MB-8: không đụng cái đang đúng). */
.attendance-grid__label-short { display: none; }
/* 🔴 Nút trạng thái phải RỘNG BẰNG NHAU ở cả 3 state. Để nút tự co theo chữ thì
   "Chưa điểm danh" (14 ký tự) to gấp ~2,5 lần "Vắng" ⇒ cột Trạng thái nhấp nhô
   theo từng dòng, và mỗi lần tick nút lại nhảy kích thước ngay dưới ngón tay.
   Cho ô chiếm trọn bề rộng cột (8.5rem, đủ chứa nhãn dài nhất) và nút/badge
   width:100% ⇒ mọi state cùng một khung, chỉ đổi màu và chữ. */
.attendance-grid__status { justify-self: stretch; min-width: 0; }
.attendance-grid__status > .btn,
.attendance-grid__status > .badge-soft {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 100%;
  white-space: nowrap;
}

/* Tablet (576–992px): quy tắc vùng chạm ở trên bơm chữ nút lên .95rem ⇒ "Chưa điểm danh"
   rộng 120.3px, KHÔNG vừa phần lòng 118px của cột 8.5rem (đo được: tràn 2.3px). Nới cột
   riêng cho dải này thay vì nới cho cả desktop (desktop chữ 14px, 8.5rem đã dư 7.2px). */
@media (min-width: 576px) and (max-width: 991.98px) {
  .attendance-grid { grid-template-columns: minmax(7rem, 1fr) 4.5rem 6.5rem 9.25rem; }
  .attendance-grid--actions { grid-template-columns: minmax(7rem, 1fr) 4.5rem 6.5rem 9.25rem 2.5rem; }
}

/* 🔴 `.attendance-row .btn { min-width: 96px }` (khối vùng chạm ở trên, ≤991,98px) phóng CẢ nút xóa
   icon-only lên 96px — cột hành động rộng 2.5rem nên nút tràn ra ngoài, đè lên cột Trạng thái. Nhóm
   `is-icon` cố ý giữ kích thước NHÌN THẤY nhỏ và mở vùng chạm 44px bằng pseudo-element (P1 của
   10_Mobile_Spec § "Vùng chạm"), nên trả sàn bề ngang về 0 ở đúng nhóm đó. */
@media (max-width: 991.98px) {
  .attendance-row .btn.is-icon { min-width: 0; }
}

/* Điện thoại: mọi nút trạng thái rộng bằng nhau ⇒ cột phải ôm được NHÃN DÀI NHẤT. Trên
   mobile nhãn PENDING rút còn "Chưa" (xem attendance/fragments.html), nhãn dài nhất trở
   thành "Có mặt" = 47.2px ⇒ cột 4.5rem (72px) là đủ, thay vì 7.75rem như khi giữ nguyên
   "Chưa điểm danh". Nhờ vậy tổng hàng còn ~416px, vừa màn 430px — trước đó 468px nên bảng
   cuộn ngang và tên người bị cắt cụt. Chiều cao chạm 44px (OQ-43) giữ nguyên.
   🔴 min-width: 0 là BẮT BUỘC: min-width 28rem (448px) ở khối gốc sẽ giữ hàng rộng 448px
   dù các cột đã co lại ⇒ vẫn cuộn ngang, thu cột thành vô nghĩa. */
@media (max-width: 575.98px) {
  /* Cột Trình độ 6.5rem → 5.5rem: đủ để hàng vừa màn 375px (iPhone SE/mini) chứ không chỉ
     390px. Trình độ là free text nên giá trị dài sẽ xuống dòng — chấp nhận cao thêm 1 dòng
     để đổi lấy việc KHÔNG cuộn ngang, vì cuộn ngang cắt cụt cột Tên (lỗi đang gặp). */
  .attendance-grid { grid-template-columns: minmax(6rem, 1fr) 4.5rem 5.5rem 4.5rem; min-width: 0; }
  /* 🔴 Lưới 5 cột ở điện thoại — mọi con số dưới đây ĐO THẬT (Chrome headless, 13px = --fs-caption
     của tầng mobile), không ước lượng:
       nhãn header  "GIỚI TÍNH" 63,1 · "TRÌNH ĐỘ" 63,4 · "GIỚI" 27,7 · "TRÌNH" 39,9
       ô dữ liệu    "Nam" 27,8 · "Nữ" 17,5 · "Trung bình" 62,2 · nút xóa 33,2×44
     Bốn cột cũ (bản chỉ-đọc của Member) VỐN ĐÃ tràn: hàng rộng 372px trong hộp 356px ở 390×844 và
     326px ở 360×800 ⇒ cột Trạng thái bị cắt, phải cuộn ngang. Thêm cột thứ 5 mà giữ nguyên các cột
     kia sẽ đẩy lên 412px — tệ gấp đôi. Nên bản 5 cột đi hướng ngược lại: rút nhãn header hai cột
     giữa ("Giới tính"→"Giới", "Trình độ"→"Trình" — cùng thủ pháp đã dùng cho nút trạng thái và bộ
     đếm ngay trong file này) rồi thu chúng về đúng bề rộng nội dung.
     Kết quả đo lại: 324px ở 390×844 và 294px ở 360×800 ⇒ **KHÔNG còn cuộn ngang** ở cả hai, và cột
     Tên còn 104px (390) / 74px (360) — RỘNG HƠN 96px của bản 4 cột hiện tại ở 390.
     "Trung bình" 62,2px không vừa cột 56px ⇒ xuống 2 dòng: đánh đổi CÓ CHỦ Ý, đúng thứ tự ưu tiên đã
     ghi ở khối 4 cột bên trên (thà cao thêm một dòng còn hơn cuộn ngang, vì cuộn ngang cắt cụt cột
     Tên — thứ người dùng thật sự cần đọc khi đang cầm vợt, CT-6). */
  .attendance-grid--actions {
    grid-template-columns: minmax(4.5rem, 1fr) 2.5rem 3.5rem 4.5rem 2.25rem;
    gap: .25rem;
  }
  .attendance-grid--actions .attendance-grid__label-full { display: none; }
  .attendance-grid--actions .attendance-grid__label-short { display: inline; }
  /* "TRẠNG THÁI" rộng ~73px, nhỉnh hơn cột 72px vài px ⇒ mặc định gãy 2 dòng, đội cao hàng
     tiêu đề từ 33px lên 49px. Bỏ letter-spacing .02em (10 ký tự ⇒ tiết kiệm ~2.4px) là vừa
     đúng 1 dòng mà không phải tràn ra ngoài cột. */
  .attendance-grid--head { letter-spacing: normal; }
  .attendance-grid--head > span { white-space: nowrap; }
  .attendance-row .btn { font-size: .8125rem; min-width: 0; padding-left: .35rem; padding-right: .35rem; }
}

/* Nhãn form — đồng nhất mọi màn */
.form-label { font-weight: 600; font-size: .875rem; color: var(--text); margin-bottom: .35rem; }
.form-text { color: var(--text-muted); }

/* ============================================================
   AUTH & ENTRY — vỏ tối giản căn giữa (chưa đăng nhập, KHÔNG sidebar).
   Dùng cho: đăng nhập/đăng ký/quên-đặt lại MK, nhận lời mời (claim).
   ============================================================ */
.auth-shell {
  min-height: 100vh;
  display: flex; flex-direction: column;
  align-items: center; justify-content: center;
  padding: var(--space-2xl) var(--space-md);
  background: var(--surface-sunken);
}
.auth-topbar { position: fixed; top: var(--space-md); right: var(--space-md); z-index: 10; }
.auth-container { width: 100%; max-width: 400px; }
.auth-brand {
  display: flex; align-items: center; justify-content: center; gap: .5rem;
  font-weight: 800; font-size: 1.25rem; color: var(--text-strong);
  text-decoration: none; margin-bottom: var(--space-lg);
}
.auth-brand .app-brand-mark { font-size: 1.4rem; }
.auth-card {
  background: var(--surface); border: 1px solid var(--border);
  border-radius: var(--radius-lg); box-shadow: var(--elevation-2);
  padding: var(--space-xl);
}
.auth-card__title { font-size: 1.25rem; margin-bottom: var(--space-2xs); }
.auth-card__subtitle { font-size: .875rem; color: var(--text-muted); margin-bottom: var(--space-lg); }
.auth-links {
  display: flex; justify-content: space-between; gap: var(--space-sm);
  margin-top: var(--space-md); font-size: .875rem; flex-wrap: wrap;
}
.auth-alt { margin-top: var(--space-md); font-size: .875rem; color: var(--text-muted); text-align: center; }
.auth-footer { margin-top: var(--space-lg); font-size: .8125rem; color: var(--text-muted); text-align: center; }
.auth-cta-landing { margin-bottom: var(--space-lg); text-align: center; }
.auth-cta-landing__hint { margin: var(--space-2xs) 0 0; font-size: .8125rem; color: var(--text-muted); }

/* Entry header — trang SAU đăng nhập nhưng CHƯA chọn CLB (danh sách CLB) — không có sidebar. */
.entry-header {
  display: flex; align-items: center; justify-content: space-between; gap: var(--space-md);
  padding: var(--space-sm) var(--space-md);
  background: var(--surface); border-bottom: 1px solid var(--border);
}
.entry-header .entry-actions { display: flex; align-items: center; gap: var(--space-sm); }
/* Biến thể cho khu quản trị hệ thống: header có THÊM một cụm nav 5 mục ở giữa, nên ba nhóm con
   không đủ chỗ trên một hàng ở màn điện thoại. Không có flex-wrap ⇒ tràn ngang 465px trên viewport
   390px (đo bằng CDP 2026-08-08). Đặt riêng chứ không sửa .entry-header chung, vì các trang entry
   hiện có chỉ có brand + 2 nút và đang vừa khít. */
.entry-header--platform { flex-wrap: wrap; row-gap: var(--space-sm); }
.entry-header--platform > * { min-width: 0; }
.entry-content { max-width: var(--w-wide); margin: 0 auto; padding: var(--space-xl) var(--space-md); }

/* ============================================================
   STEP FLOW — trục hành động buổi tập: Điểm danh → Chi phí → Chốt sổ (M-TSK-1).
   Mảnh, không phô: chỉ để dẫn thứ tự, không cạnh tranh với nội dung.
   ============================================================ */
.step-flow { display: flex; align-items: center; gap: .4rem; flex-wrap: wrap; margin-bottom: var(--space-lg); }
.step-flow__item {
  display: inline-flex; align-items: center; gap: .45rem;
  padding: .35rem .7rem; border-radius: var(--radius-pill);
  font-size: .8125rem; font-weight: 600;
  background: var(--surface-muted); color: var(--text-muted);
  border: 1px solid var(--border);
}
.step-flow__item .num {
  display: inline-flex; align-items: center; justify-content: center;
  width: 1.2rem; height: 1.2rem; border-radius: 50%;
  background: var(--border-strong); color: var(--surface); font-size: .7rem; font-weight: 700;
}
.step-flow__item.is-done { background: rgba(22,163,74,.12); color: var(--badge-green-fg); border-color: rgba(22,163,74,.24); }
.step-flow__item.is-done .num { background: var(--action-primary-solid, var(--action-primary)); color: #fff; }
.step-flow__item.is-current { background: var(--sidebar-active-bg); color: var(--sidebar-active-text); border-color: var(--action-primary); }
.step-flow__item.is-current .num { background: var(--action-primary-solid, var(--action-primary)); color: #fff; }
.step-flow__sep { color: var(--text-disabled); }

/* Hàng nút hành động theo trạng thái buổi */
.action-bar { display: flex; flex-wrap: wrap; gap: var(--space-xs); align-items: center; margin-bottom: var(--space-lg); }
/* Biến thể "action-bar sống TRONG page-header" (task màn Công nợ): P4 cần một actionBar để gom nút
   phụ vào ⋯ trên mobile, nhưng chuyển thanh nút xuống một hàng riêng dưới header — như
   session/buoi.html — là đổi bố cục desktop, phá MB-8. Đặt nó vào đúng slot `actions` cũ thì phải
   gỡ hai thứ chỉ đúng khi nó đứng riêng: `margin-bottom` (page-header đã có lề dưới của chính nó)
   và gap `--space-xs` (khối cũ dùng utility `gap-2` = .5rem — giữ đúng con số đó để desktop không
   đổi một pixel). */
.page-header__row .action-bar { margin-bottom: 0; gap: .5rem; }

/* P4 — thanh hành động. Desktop: hàng ngang, không có nút ⋯. */
.action-bar__primary, .action-bar__rest { display: contents; }
.action-bar__more { display: none; }

/* ============================================================
   Các trang CÔNG KHAI của một buổi tập — /chia-se/{token} (thẻ "vé mời" + bảng chia tiền)
   VÀ /vote/{token}. Markup chung ở templates/fragments/buoi-hero.html.
   Điểm nhấn DUY NHẤT: khối hero WHEN (ngày + giờ). Còn lại giữ yên.

   🔴 Tiền tố `share-` là DẤU VẾT LỊCH SỬ, không phải ranh giới module: nhóm này ra đời khi chỉ
   trang chia sẻ dùng, nay trang vote dùng chung y hệt. Giữ tên vì đổi là 17 chỗ ở đây cộng 3
   template, sót một chỗ là vỡ layout mà không báo lỗi — xem VT-5 trong
   docs/superpowers/specs/2026-07-28-vote-dung-chung-template-chia-se-design.md.
   ============================================================ */
.share-hero {
  padding: var(--space-lg);
  border-radius: var(--radius-md);
  border: 1px solid var(--border);
  background: linear-gradient(135deg, rgba(14,122,95,.12), rgba(22,163,74,.10));
}
.share-hero.is-cancelled { background: var(--surface-muted); }
.share-eyebrow {
  display: flex; align-items: center; gap: .4rem;
  font-weight: 700; color: var(--text-strong);
}
.share-eyebrow .app-brand-mark { font-size: 1.1rem; }
/* Ảnh đại diện CLB thay icon chung khi CLB đã tải logo — cùng dấu chân 1.4rem của .brand-svg icon
   để hàng eyebrow không đổi chiều cao dù hiện nhánh nào. */
.share-eyebrow__logo { width: 1.4rem; height: 1.4rem; border-radius: 50%; object-fit: cover; flex: 0 0 auto; }
/* Ngày + giờ chung một hàng — pill giờ neo bên phải, xuống hàng riêng khi màn quá hẹp cho cả hai. */
.share-hero__when {
  display: flex; align-items: center; justify-content: space-between;
  flex-wrap: wrap; column-gap: .6rem; row-gap: .35rem;
  margin-top: var(--space-sm);
}
.share-hero__date {
  font-size: 1.35rem; line-height: 1.25; font-weight: 800;
  color: var(--text-strong);
}
.share-hero.is-cancelled .share-hero__date { text-decoration: line-through; color: var(--text-muted); }
.share-time {
  display: inline-flex; align-items: center; gap: .4rem; flex: none;
  padding: .3rem .7rem; border-radius: var(--radius-pill);
  background: var(--surface); border: 1px solid var(--border);
  font-weight: 700; font-variant-numeric: tabular-nums;
}
.share-loc { margin-top: var(--space-md); color: var(--text); }
.share-loc__label { display: flex; align-items: flex-start; gap: .5rem; }
.share-loc__label > .bi { color: var(--action-primary); margin-top: .15rem; flex: none; }
.share-slots { margin-top: var(--space-md); }
.share-slots__label { display: flex; align-items: center; gap: .4rem; }
.share-people li { display: flex; align-items: center; gap: .55rem; padding: .35rem 0; }
.share-people li + li { border-top: 1px solid var(--border); }
/* Avatar chữ-cái tái dùng (danh sách thành viên…). 6 tông từ palette, chọn theo hash tên. */
.avatar {
  flex: none; width: 2.5rem; height: 2.5rem; border-radius: 50%;
  display: inline-flex; align-items: center; justify-content: center;
  font-weight: 700; font-size: .85rem; line-height: 1; letter-spacing: .01em;
  user-select: none;
}
/* P5: nền dùng var(--badge-*-bg, rgba(...)) — xem lý do ở nơi khai báo biến trong [data-bs-theme="dark"]. */
.avatar--c0 { background: var(--badge-blue-bg, rgba(37,99,235,.14));  color: var(--badge-blue-fg); }
.avatar--c1 { background: var(--badge-green-bg, rgba(22,163,74,.14));  color: var(--badge-green-fg); }
.avatar--c2 { background: var(--badge-amber-bg, rgba(249,115,22,.16)); color: var(--badge-amber-fg); }
.avatar--c3 { background: var(--badge-violet-bg, rgba(124,58,237,.14)); color: var(--violet-500); }
.avatar--c4 { background: var(--badge-red-bg, rgba(220,38,38,.12));  color: var(--badge-red-fg); }
.avatar--c5 { background: var(--badge-slate-bg, rgba(100,116,139,.16)); color: var(--badge-slate-fg); }
[data-bs-theme="dark"] .avatar--c3 { color: var(--violet-300); }
/* Ảnh thật thay chữ cái. Kích thước vòng tròn KHÔNG đổi — mọi chỗ gọi (bảng thành viên 2.5rem,
   khối công nợ công khai 4.5rem) tự co theo vì ảnh lấp đầy 100% ô cha. object-fit: cover để ảnh
   không bị méo khi tỉ lệ khác 1:1; nền xám giữ chỗ trong lúc ảnh đang tải. */
.avatar--img, .share-avatar.has-img { background: var(--badge-slate-bg, rgba(100,116,139,.16)); overflow: hidden; }
.avatar > img, .share-avatar > img { width: 100%; height: 100%; object-fit: cover; border-radius: 50%; display: block; }

/* Khối thông tin thành viên ở đầu trang công nợ công khai (/chia-se/{token} loại DEBT) — avatar to
   hơn bản .avatar mặc định (2.5rem, dùng ở danh sách bảng), phóng qua wrapper riêng thay vì sửa
   fragment gốc để không ảnh hưởng chỗ khác đang dùng cỡ nhỏ (member/thanh-vien.html). */
.debt-member__avatar { display: flex; justify-content: center; margin-bottom: var(--space-sm); }
.debt-member__avatar .avatar { width: 4.5rem; height: 4.5rem; font-size: 1.5rem; }
.debt-member__name { font-size: 1.5rem; font-weight: 800; color: var(--text-strong); margin: 0 0 var(--space-2xs); }
.debt-member__meta { display: flex; flex-wrap: wrap; justify-content: center; gap: .4rem; margin: var(--space-xs) 0 var(--space-sm); }

/* Avatar ở form "Hồ sơ của tôi" (auth/ho-so.html) — cùng cỡ to đã dùng ở .debt-member__avatar.
   Chữ-cái fallback (.avatar__fallback) chỉ hiện khi #avatarPreview CHƯA có ảnh (còn .d-none) —
   :has() đọc đúng trạng thái DOM hiện tại nên khi file-preview.js gỡ .d-none lúc chọn ảnh mới,
   chữ cái tự ẩn theo mà không cần sửa file-preview.js dùng chung cho cả logo CLB/ảnh QR. */
.profile-avatar { width: 4.5rem; height: 4.5rem; font-size: 1.5rem; }
.profile-avatar:has(> #avatarPreview:not(.d-none)) .avatar__fallback { display: none; }

.share-avatar {
  flex: none; width: 2rem; height: 2rem; border-radius: 50%;
  display: inline-flex; align-items: center; justify-content: center;
  /* 🔴 MB-2 (task 7.2): .8rem = 12,8px, dưới sàn 13px — không lộ ở 7.1 vì danh sách lúc đó rỗng
     (0 người), phát hiện khi 7.2 dựng dữ liệu thật cho .share-split. .8125rem = 13px khớp đúng sàn
     NHỎ NHẤT TUYỆT ĐỐI đã dùng cho --fs-caption (token đó chỉ tồn tại trong tầng mobile, ở đây cần
     áp mọi viewport nên ghi thẳng giá trị). */
  font-weight: 700; font-size: .8125rem;
  background: rgba(22,163,74,.14); color: var(--badge-green-fg);
}
.share-avatar.is-guest { background: rgba(37,99,235,.14); color: var(--badge-blue-fg); }
.share-empty { padding: .35rem 0; }

/* Trang bảng chia tiền công khai — cùng khung "vé mời", nhưng điểm nhấn là TIỀN.
   .share-split dùng lại .share-people (avatar + gạch ngăn) và chỉ thêm cột tiền bên phải. */
.share-split li { align-items: flex-start; padding: .55rem 0; }
.share-split__who { flex: 1 1 auto; min-width: 0; }
.share-split__money { flex: none; text-align: right; }

/* Một dòng "nhãn + giá trị + nút chép" trong popup Mã thanh toán.
   .copy-field__value dùng break-all: số tài khoản/tên chủ TK dài không được đẩy nút chép ra khỏi popup. */
.copy-field {
  display: flex; align-items: center; gap: var(--space-sm);
  padding: var(--space-sm) 0;
}
.copy-field + .copy-field { border-top: 1px solid var(--border); }
.copy-field__text { flex: 1 1 auto; min-width: 0; }
/* 🔴 MB-2 (task 7.2): .8rem = 12,8px, dưới sàn 13px — cùng lớp lỗi với .share-avatar phía trên,
   phát hiện khi mở popup "Mã thanh toán" thật lần đầu (dùng chung ở đây, debt/chi-tiet.html,
   share/cong-khai-cong-no.html). */
.copy-field__label { font-size: .8125rem; color: var(--text-muted); }
/* white-space: normal — gỡ nowrap mà .money áp lên số tài khoản: ở đây break-all mới
   là hành vi đúng, nowrap sẽ đẩy nút chép văng khỏi popup. */
.copy-field__value { font-weight: 700; color: var(--text-strong); word-break: break-all; white-space: normal; }
.copy-btn { flex: none; white-space: nowrap; }
.copy-btn__text { margin-left: .3rem; }

.qr-box {
  padding: var(--space-md);
  border-radius: var(--radius-md);
  border: 1px solid var(--border);
  background: var(--surface-muted);
}
/* Nền trắng cố định: QR ngân hàng là ảnh đen-trên-trắng, để nó trong suốt ở giao diện tối thì
   các ô đen chìm vào nền và app ngân hàng không quét được. */
.qr-box__img {
  display: block; margin: 0 auto var(--space-sm);
  max-width: 260px; width: 100%; height: auto;
  background: #fff; padding: var(--space-xs); border-radius: var(--radius-sm);
}

/* ============================================================
   Thanh phân trang dùng chung (fragments/ui :: pagination)
   ============================================================ */
/* margin-bottom: thanh phân trang là phần tử CUỐI của trang (nằm sau .card) — không có nó thì dòng
   "Hiển thị 1–10 trong tổng số N" dính sát mép dưới viewport, khó đọc. Trên <992px khoảng này cộng
   thêm vào padding-bottom chừa chỗ bottom-nav của .page-content, vẫn cách đều. */
.pagination-bar {
  display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-md);
  justify-content: space-between; margin-top: var(--space-md); margin-bottom: var(--space-lg);
}
.pagination-bar__info { color: var(--text-muted); font-size: .875rem; }
.pagination-bar__pages { display: flex; align-items: center; gap: .25rem; list-style: none; margin: 0; padding: 0; }
.pagination-bar__btn {
  display: inline-flex; align-items: center; justify-content: center;
  min-width: 2.25rem; height: 2.25rem; padding: 0 .5rem;
  border: 1px solid var(--border); border-radius: var(--radius-btn);
  background: var(--surface); color: var(--text); text-decoration: none;
  font-size: .875rem; font-weight: 600; font-variant-numeric: tabular-nums;
}
.pagination-bar__btn:hover { border-color: var(--border-strong); color: var(--text); }
.pagination-bar__btn.is-active {
  background: var(--action-primary-solid, var(--action-primary));
  border-color: var(--action-primary-solid, var(--action-primary));
  color: #fff;
}
.pagination-bar__btn.is-disabled { opacity: .4; pointer-events: none; }
.pagination-bar__ellipsis { padding: 0 .35rem; color: var(--text-muted); }
.pagination-bar__size .btn-ghost { border: 1px solid var(--border); }
@media (max-width: 575.98px) {
  .pagination-bar { justify-content: center; }
  .pagination-bar__info { width: 100%; text-align: center; order: 3; }
}

/* Tôn trọng prefers-reduced-motion */
@media (prefers-reduced-motion: reduce) {
  * { transition: none !important; animation: none !important; }
}

/* Nút quay lại (.page-header__back) chỉ có ý nghĩa khi breadcrumb bị ẩn ở mobile (P3 bên dưới) — ẩn
   lại ở desktop/tablet để không trùng breadcrumb đã hiện. 🔴 Đặt TRƯỚC khối đánh dấu tầng mobile dù
   thuộc cùng primitive P3: đây là luật cho viewport >=768px (desktop), không phải luật mobile —
   MobileScaleTest quét từ mốc đó đến hết tệp và không cho phép luật desktop nằm sau mốc. */
@media (min-width: 768px) {
  .page-header__back { display: none; }
}

/* Cột "Không tham gia" ở màn Quản lý vote (vote/quan-ly.html) — viền DỌC chỉ có nghĩa khi 2 cột
   đứng cạnh nhau (≥768px, `row-cols-md-2`); dưới mốc đó chúng xếp chồng dọc (task 7.7, xem chú
   thích ở template) nên đổi sang viền NGANG — luật đó nằm trong tầng mobile bên dưới. */
@media (min-width: 768px) {
  .vote-result-col { border-left: 1px solid var(--border); }
}

/* ------------------------------------------------------------
   Nút nổi Góp ý / Báo lỗi (fragments/gop-y.html)
   ------------------------------------------------------------
   z-index 1035: TRÊN .app-bottom-nav (1030) để không bị thanh điều hướng che, nhưng DƯỚI backdrop
   của modal (1050) để khi modal mở thì nút mờ đi cùng phần còn lại của trang.

   🔴 Đặt TRƯỚC mốc tầng mobile: MobileScaleTest quét từ mốc đó đến hết tệp và cấm bề rộng px cứng
   (48px của vùng chạm, 1px của honeypot). Đây là luật nền cho MỌI viewport, không phải luật mobile.

   🔴 Dưới 992px .app-bottom-nav (d-lg-none) chiếm đáy màn hình. Để FAB ở `bottom: var(--space-md)`
   là NẰM ĐÈ lên thanh đó, che mất một ô điều hướng. Nâng lên bằng :has() để chỉ trang THẬT SỰ có
   bottom-nav mới bị đẩy (/clb, /toi… không có).
   🔴 Dùng LẠI công thức mà .page-content đã dùng để chừa chỗ cho nav: --bottom-nav-height +
   env(safe-area-inset-bottom) + --space-md. KHÔNG tự viết một con số riêng — bản đầu tin vào
   `min-height: 56px` đọc được ở .app-bottom-nav-item và FAB đè lên nav đúng 1px ở 390×844 (đo bằng
   tools/mobile-audit). Chiều cao THẬT của nav là 69px; token --bottom-nav-height (60px) cộng
   --space-md mới ra khoảng chừa đúng.
   ------------------------------------------------------------ */
.bmh-fab {
  position: fixed;
  right: var(--space-md);
  bottom: var(--space-md);
  z-index: 1035;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--space-xs);
  min-height: 48px;                  /* vùng chạm đủ lớn cho ngón tay (CT-6/MB-3) */
  min-width: 48px;
  padding: 0 var(--space-md);
  border: none;
  border-radius: 999px;
  background: var(--action-primary);
  color: #fff;
  box-shadow: 0 6px 20px rgba(15, 23, 42, .25);
  cursor: pointer;
}

.bmh-fab:hover,
.bmh-fab:focus-visible { filter: brightness(1.08); }

.bmh-fab i { font-size: 1.15rem; }

/* Breakpoint 991,98px KHÔNG thuộc tầng mobile (tầng đó sở hữu 767,98px) — bottom-nav mang
   d-lg-none nên sống tới 991,98px, phải né đúng phạm vi đó. */
@media (max-width: 991.98px) {
  body:has(.app-bottom-nav) .bmh-fab {
    bottom: calc(var(--bottom-nav-height) + env(safe-area-inset-bottom, 0px) + var(--space-md));
  }
}

/* Honeypot chống bot (form Góp ý): đẩy hẳn ra ngoài khung nhìn. KHÔNG dùng display:none — một số
   bot bỏ qua trường display:none nhưng vẫn điền trường "nhìn thấy được" theo DOM. */
.bmh-honeypot {
  position: absolute;
  left: -9999px;
  width: 1px;
  height: 1px;
  overflow: hidden;
}

/* ------------------------------------------------------------
   Preview ảnh đính kèm (form Góp ý) — lưới thumbnail dựng bằng JS trong
   fragments/layout.html, xem #gopY-images-preview.

   Lưới TỰ CO theo bề rộng modal (auto-fill + minmax) chứ không cố định số cột: modal góp ý rộng
   ~500px trên desktop và ~358px ở 390×844, cùng một luật cho ra 5 cột / 3 cột mà không cần
   breakpoint riêng ở tầng mobile.

   `gap` phải ≥ .5rem: vùng chạm của nút X được nới ra ngoài viền ảnh 8px (xem ::after bên dưới),
   gap hẹp hơn thì hai vùng chạm cạnh nhau chồng lên nhau và bấm xóa dễ trúng nhầm ảnh bên cạnh.
   ------------------------------------------------------------ */
.bmh-anh-preview {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(84px, 1fr));
  gap: var(--space-xs);
  margin-top: var(--space-xs);
}

.bmh-anh-preview:empty { display: none; }   /* không chừa khoảng trống khi chưa chọn ảnh nào */

.bmh-anh-thumb {
  position: relative;
  aspect-ratio: 1;
}

.bmh-anh-thumb img {
  width: 100%;
  height: 100%;
  object-fit: cover;                        /* ảnh chụp màn hình rất dài — cắt còn hơn bóp méo */
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  background: var(--surface-muted);         /* nền lúc ảnh chưa decode xong */
  display: block;
}

/* Nút X: vòng tròn NHÌN THẤY 28px cho gọn trên thumbnail ~84px, nhưng vùng CHẠM nới lên 44×44 bằng
   ::after (28 + 8×2) để qua MB-3. Nền tối đặc + chữ trắng thay vì token theme: nút nằm ĐÈ LÊN ẢNH
   của người dùng, ảnh nào cũng có thể sáng hoặc tối nên không có nền theme nào bảo đảm được tương
   phản — chỉ lớp phủ riêng của nút mới bảo đảm. Vì vậy khối này KHÔNG có bản đối ứng theme tối. */
.bmh-anh-thumb__xoa {
  position: absolute;
  top: var(--space-2xs);
  right: var(--space-2xs);
  width: 28px;
  height: 28px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: 0;
  border: none;
  border-radius: 999px;
  background: rgba(15, 23, 42, .78);
  color: #fff;
  cursor: pointer;
  line-height: 1;
}

.bmh-anh-thumb__xoa i { font-size: 16px; }  /* MB-4: cỡ icon chỉ thuộc {16, 20, 24} */

.bmh-anh-thumb__xoa::after {
  content: '';
  position: absolute;
  inset: -8px;
}

.bmh-anh-thumb__xoa:hover,
.bmh-anh-thumb__xoa:focus-visible { background: var(--status-danger); }

/* Thanh tìm kiếm + lọc của màn Thành viên (member/thanh-vien.html).
   🔴 Không dùng utility `align-items-center` như ban đầu: `.form-control` của app có đệm dọc 10px
   còn `.btn` chỉ 6px, nên ô tìm kiếm cao 46px trong khi nút "Lọc" đứng cạnh chỉ 38,6px — đo được ở
   1440px, và lệch cả trục dọc (y 234,8 vs 238,5). Trong `.input-group` nút kính lúp không dính lỗi
   này vì input-group vốn `align-items: stretch`; nút "Lọc" nằm NGOÀI nhóm đó nên không được kéo
   theo. `stretch` ở đây kéo mọi nút bằng đúng chiều cao ô tìm kiếm, không cần đoán con số nào.
   Người dùng báo 2026-08-22.

   `inline-flex` + `align-items: center` là BẮT BUỘC đi kèm: kéo cao một hộp `inline-block` (mặc
   định của `.btn`) chỉ nối dài hộp xuống dưới, chữ vẫn nằm ở dòng đầu và nút trông lệch lên — cùng
   cái bẫy đã ghi ở P1/task 5.11b cho tầng mobile. `gap` cũng bắt buộc: chuyển sang flex thì khoảng
   trắng giữa <i> và nhãn nằm trong anonymous flex item và bị nuốt, icon sẽ dính sát chữ. */
.filter-bar { align-items: stretch; }

.filter-bar > .btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: .35rem;
}

/* ==========================================================================
   Cài đặt riêng khi chia tiền (member/sua.html) — chuyển pane bằng CSS THUẦN
   ==========================================================================
   🔴 Không dùng JS gắn/gỡ class: màn Sửa thành viên tự render lại chính nó (POST → 302 → htmx swap
   #page-content), nên class do JS gắn bị attribute settling của htmx đè — bẫy đã lộ thật ở
   `club/cau-hinh-chia-tien.html` (xem templates/CLAUDE.md). Radio ẩn + `:checked ~` không phụ thuộc
   class nên miễn nhiễm, và trạng thái ban đầu vẫn do server quyết qua `th:checked`.

   Radio ẩn bằng `position:absolute; opacity:0` chứ KHÔNG `display:none`: `display:none` làm input
   rời khỏi cây tiêu điểm ⇒ bàn phím Tab không tới được, người dùng chỉ dùng bàn phím mất hẳn đường
   đổi pane. */
.rieng-switch__radio {
  position: absolute;
  width: 1px;
  height: 1px;
  opacity: 0;
  pointer-events: none;
}

/* Hai nút bằng nhau, xuống dòng gọn ở 390px (CT-6): grid 2 cột thay vì btn-group cố định. */
.rieng-switch__tabs {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: var(--space-xs, .5rem);
}

/* Chiều cao vùng chạm giữ nguyên 44px của `.btn` (MB-3) — không hạ. */
.rieng-switch__tab {
  margin: 0;
  cursor: pointer;
}

.rieng-switch__pane { display: none; }

.rieng-switch__radio#cheDoHeSo:checked ~ .rieng-switch__pane--heso,
.rieng-switch__radio#cheDoThuCung:checked ~ .rieng-switch__pane--thucung {
  display: block;
}

/* Nút đang chọn tô như `.btn-primary` — dùng token của app, không hardcode mã màu. */
.rieng-switch__radio#cheDoHeSo:checked ~ .rieng-switch__tabs .rieng-switch__tab--heso,
.rieng-switch__radio#cheDoThuCung:checked ~ .rieng-switch__tabs .rieng-switch__tab--thucung {
  background-color: var(--action-primary);
  border-color: var(--action-primary);
  color: #FFFFFF;
}

/* Bàn phím: radio vô hình nên vòng tiêu điểm phải vẽ lên NHÃN tương ứng, nếu không người dùng Tab
   tới mà không thấy mình đang ở đâu. */
.rieng-switch__radio#cheDoHeSo:focus-visible ~ .rieng-switch__tabs .rieng-switch__tab--heso,
.rieng-switch__radio#cheDoThuCung:focus-visible ~ .rieng-switch__tabs .rieng-switch__tab--thucung {
  box-shadow: 0 0 0 .25rem var(--focus-ring);
}

/* ============================================================
   === TẦNG MOBILE ===
   🔴 MỌI luật riêng cho điện thoại sống TRONG khối này và chỉ trong khối này.
   Lý do: desktop được bảo vệ VỀ MẶT CẤU TRÚC, không nhờ người sửa cẩn thận.
   Đổi token ở :root để "sửa cho nhanh" là vi phạm MB-8.
   Spec: docs/design/10_Mobile_Spec.md § 3. Test gác: MobileScaleTest.
   ============================================================ */
@media (max-width: 767.98px) {
  :root {
    /* P1 — thang chữ. Không bậc nào dưới 13px: 11px đo được ở nhãn nhóm menu là
       không đọc nổi trên màn 390px khi đang cầm vợt (CT-6). */
    --fs-caption: .8125rem;   /* 13 — nhãn phụ, chip meta. NHỎ NHẤT TUYỆT ĐỐI */
    --fs-body:    .9375rem;   /* 15 — chữ thường, nhãn form, chữ trong nút */
    --fs-strong:  1.0625rem;  /* 17 — danh tính một dòng dữ liệu */
    --fs-title:   1.25rem;    /* 20 — tiêu đề card, H2 */
    --fs-page:    1.375rem;   /* 22 — H1 của trang */
    --fs-money:   1.75rem;    /* 28 — số tiền hero, ĐÚNG MỘT LẦN mỗi màn */

    /* P1 — thang icon. Ba bậc. Đo được 7 cỡ khác nhau trên một trang trước khi sửa. */
    --icon-sm: 1rem;      /* 16 — icon nằm trong dòng chữ */
    --icon-md: 1.25rem;   /* 20 — icon trong nút, bottom-nav */
    --icon-lg: 1.5rem;    /* 24 — icon tile stat-card, icon trạng thái */

    /* P1 — vùng chạm ngón tay (Apple HIG). Đo được 31/34/36/38px trước khi sửa. */
    --tap-min: 44px;
  }

  body { font-size: var(--fs-body); }

  /* Icon: gán theo NGỮ CẢNH, không theo từng chỗ dùng — nếu không lại sinh ra 7 cỡ. */
  .bi { font-size: var(--icon-sm); vertical-align: -.125em; }
  .btn > .bi, .app-bottom-nav-item > .bi, .app-topbar-mobile .bi { font-size: var(--icon-md); }
  .stat-card__icon > .bi, .app-alert__body > .bi { font-size: var(--icon-lg); }

  /* Chữ nhỏ nhất của khung.
     🔴 Task 1.1 đã DỜI hai luật khỏi đây: nhãn bottom-nav (`.app-bottom-nav-item`) và nhãn nhóm
     menu (`.app-sidebar-group-label`, chỉ hiện trong offcanvas ở mobile) — cả hai thuộc vỏ
     `d-lg-none` sống tới 991,98px, nên bậc chữ của chúng nằm ngay tại component (xem
     `.app-bottom-nav-item` và `.offcanvas .app-sidebar-group-label` ở khối "Bottom-nav"/"Offcanvas"
     phía trên tệp). Giữ bản sao ở đây nữa chỉ tạo hai chỗ để quên đồng bộ. */
  .stat-card__label { font-size: var(--fs-caption); }
  .badge-soft, .form-text, .cell-datetime__time { font-size: var(--fs-caption); }
  /* Bộ đếm tab Điểm danh: ba chỉ số PHẢI nằm trên MỘT dòng ở điện thoại (người dùng chốt
     2026-08-23). Đo bằng tools/mobile-audit ở 360×800 (hộp chứa 336px), cùng bộ markup rút gọn
     `d-sm-none` trong attendance/fragments.html: padding .7rem ⇒ cần 347px ⇒ vỡ 2 dòng; .5rem cùng
     `gap-1` ⇒ 281px (309px khi có thêm cụm "chưa rõ") ⇒ một dòng, còn dư ~27px. Chỉ bóp padding,
     KHÔNG hạ cỡ chữ: 15,2px là mức đọc được từ xa khi đứng ở sân (CT-6). */
  .attendance-counter .badge-soft { padding: .35rem .5rem; }

  /* Hai hàng đầu tab Điểm danh — ba badge đếm và cặp nút "Thêm người khác"/"Thêm khách" — TRẢI HẾT
     bề ngang màn ở điện thoại (người dùng chốt 2026-08-24): phần dư dồn vào KHOẢNG CÁCH giữa các
     mục chứ không để trống một mảng bên phải. Đo ở 390×844 (hộp 366px) trước khi sửa: hàng badge
     kết thúc ở 291,3px ⇒ **thừa 74,7px** bên phải; hàng nút kết thúc ở 325,3px ⇒ thừa 40,7px. Cùng
     markup ở 360×800 (hộp 336px) thừa 44,7 và 10,7px.
     `flex-wrap: wrap` giữ nguyên: nếu về sau nội dung dài ra và phải xuống dòng, dòng cuối
     dồn về trái như thường — vẫn đọc được, không vỡ.
     🔴 Chỉ trong tầng ≤767,98px. Từ 768px trở lên hai hàng giữ nguyên bố cục dồn-trái: ở đó nhãn nút
     quay lại dạng đầy đủ ("Thêm khách vãng lai") và badge có lại phần chữ, hộp chứa cũng rộng gấp
     nhiều lần — trải đều ra sẽ đẩy hai nút ra hai đầu một dòng 1000px+, trông như hai nút không liên
     quan đến nhau. Đó cũng là điều kiện `MB-8` (desktop không đổi một pixel).
     KHÔNG dùng `flex: 1` cho các nút để chúng tự giãn: cặp nút này rộng không bằng nhau (176,4 và
     140,9px) nên kéo bằng nhau sẽ đổi cả tương quan thị giác giữa hai hành động, trong khi thứ người
     dùng yêu cầu là giãn KHOẢNG CÁCH. */
  /* 🔴 `space-evenly` chứ KHÔNG phải `space-between` (người dùng chốt lại cùng ngày): `space-between`
     ghim mục đầu vào sát mép trái và mục cuối vào sát mép phải — người dùng báo "sát 2 bên quá".
     `space-evenly` chia phần dư thành các khoảng BẰNG NHAU ở cả hai lề lẫn giữa các mục.
     `column-gap: 0` là BẮT BUỘC đi kèm, nếu không hai loại khoảng cách KHÔNG bằng nhau: `gap-1`/`gap-2`
     bị trừ ra TRƯỚC, rồi `space-evenly` mới chia phần còn lại ⇒ khoảng giữa hai mục = phần chia + gap,
     còn khoảng ở lề chỉ = phần chia (đo ở 390×844: 22,7 so với 18,7px — lệch đúng bằng 4px gap).
     `row-gap` giữ lại: nó chỉ có tác dụng khi hàng phải wrap, và `space-evenly` không lo trục dọc. */
  .attendance-counter, .attendance-quick-add {
    justify-content: space-evenly;
    column-gap: 0;
    row-gap: .5rem;
  }
  /* .text-caption dùng chung toàn app ở 12px (desktop) — đo được ở H2 "Buổi tới" trên dashboard
     mobile (390px). Nâng lên --fs-caption riêng cho tầng mobile, giữ nguyên desktop. */
  .text-caption { font-size: var(--fs-caption); }
  /* Dòng trạng thái của ô tìm nhanh trong tab Điểm danh: nút "Bỏ lọc" là một `.btn`, nên luật chung
     `.btn { font-size: var(--fs-body) }` ngay bên dưới ĐÈ `.text-caption` (cùng độ đặc hiệu 0,1,0 và
     khai sau) ⇒ đo ở 390×844: nhãn "Đang hiện 3/28" 13px đứng cạnh nút 15px, trông như hai bậc chữ
     trong cùng một dòng phụ. Chỉ có ở tầng mobile — desktop không có luật `.btn { font-size }` nào
     nên `.text-caption` của app.css (khai sau Bootstrap) đã thắng sẵn, cả hai cùng 12px.
     Chỉ đổi CỠ CHỮ: vùng chạm 44×44 vẫn do `.btn`/`.btn:not(.is-icon)` lo (đo được 63,8×44). */
  #ket-qua-loc-diem-danh .btn { font-size: var(--fs-caption); }

  /* Vùng chạm: nút thường phóng thẳng lên 44px. */
  .btn { min-height: var(--tap-min); font-size: var(--fs-body); }
  /* 🔴 …nhưng `min-height` trên một hộp `inline-block` (mặc định của `.btn`) chỉ KÉO DÀI hộp xuống
     dưới: chữ vẫn nằm ở dòng đầu, phần dư 12–13px dồn hết xuống đáy ⇒ nút trông "béo" và chữ lệch
     lên trên. Chỉ lộ ở mobile vì chỉ mobile mới có `min-height` (desktop nút cao đúng bằng nội dung,
     không có phần dư nào để lệch). `inline-flex` + `align-items: center` phân đôi phần dư về hai
     phía — cùng cách đã dùng cho `.nav-tabs .nav-link`, `.dropdown-item`, `.list-card a` ở dưới.
     `gap` là BẮT BUỘC đi kèm: khoảng trắng giữa `<i class="bi">` và nhãn nằm trong một anonymous
     flex item nên bị flex layout NUỐT, không có `gap` thì icon dính sát chữ ở mọi nút của app. */
  .btn { display: inline-flex; align-items: center; justify-content: center; gap: .35rem; }
  /* 🔴 Ngoại lệ DUY NHẤT của luật trên: hai nút chọn kênh trong modal Góp ý mang thêm một dòng phụ
     `<span class="d-block">` bên dưới nhãn. Biến nút thành flex container (hàng ngang) sẽ kéo dòng
     phụ đó lên NẰM CÙNG HÀNG với nhãn — vỡ hẳn bố cục hai dòng của chúng. Chúng cũng không cần luật
     trên: `btn-lg` hai dòng nên đã cao hơn 44px sẵn, `min-height` không hề kéo dài chúng ⇒ không có
     phần dư nào để chữ lệch lên. Đã soát toàn bộ `templates/`: đây là hai `.btn` duy nhất của app có
     phần tử block bên trong. Thêm nút kiểu đó ⇒ nhớ lại chỗ này. */
  #bmh-gop-y-chon > .btn { display: block; }
  /* 🔴 …và phóng cả theo CHIỀU NGANG (task 5.6). `min-height` một mình chỉ đóng được một nửa `MB-3`:
     nhãn tiếng Việt có thể rất ngắn, đo được nút "Tắt" ở màn Lịch tập chỉ **43,6px rộng** dù đã cao đủ
     44 — cùng lớp lỗi "hụt vùng chạm theo chiều ngang" mà 5.4 gặp ở link chữ (và đã sửa ở pseudo).
     Nút nào đang hẹp hơn 44px thì tự nó đã vi phạm `MB-3`, nên nới ra là SỬA ở mọi màn chứ không phải
     hồi quy. Trừ đúng nhóm icon-only: `is-icon` cố ý giữ kích thước NHÌN THẤY nhỏ và mở vùng chạm bằng
     pseudo ngay dưới đây — ép `min-width` cho chúng là phá đúng mật độ mà luật kia đang giữ.
     (`.app-theme-toggle`/`.pagination-bar__btn` không mang class `.btn` nên không dính luật này.) */
  .btn:not(.is-icon) { min-width: var(--tap-min); }

  /* 🔴 Nút icon-only GIỮ NGUYÊN kích thước nhìn thấy — phóng to chúng làm vỡ mật độ.
     Mở rộng vùng chạm bằng pseudo-element trong suốt phủ lên trên.
     .app-brand (logo topbar, đo được 182×35) cũng vào nhóm này: .app-topbar-mobile chỉ có
     min-height 56px (không phải chiều cao cố định), phóng .app-brand thẳng lên 44px (44+padding
     16px = 60px) sẽ đội topbar cao thêm — phải giữ nguyên kích thước nhìn thấy như nút icon-only. */
  .btn-sm.is-icon, .app-theme-toggle, .pagination-bar__btn, .app-brand { position: relative; }
  .btn-sm.is-icon::after, .app-theme-toggle::after, .pagination-bar__btn::after, .app-brand::after {
    content: ''; position: absolute;
    top: 50%; left: 50%; transform: translate(-50%, -50%);
    width: var(--tap-min); height: var(--tap-min);
  }

  /* Task 1.8 — trang /clb (danh-sach) tự dựng .entry-header, KHÔNG dùng .app-topbar-mobile, nên tên
     sản phẩm "CLB Cầu Lông" ở đây thiếu luật chống-wrap của .app-topbar-mobile__club (task 0.4).
     Cạnh 3 nút hành động icon-only (Hồ sơ/Đăng xuất ẩn nhãn ở mobile), ở 360px chuỗi này wrap 2 dòng,
     đội header từ 69px lên 77,8px. nowrap giữ 1 dòng (đo: header 360 → 69px, không tràn). Đây là
     chuỗi CỐ ĐỊNH (không phải tên CLB biến thiên) nên nowrap an toàn, không cần thêm ellipsis. */
  .entry-header .app-brand { white-space: nowrap; }

  /* `.entry-actions` của khu quản trị (task 8.7 — nút Menu dropdown) bọc xuống dòng RIÊNG dưới
     `.app-brand` ở điện thoại, và người dùng muốn 2 mục bên trong nó tách về HAI ĐẦU dòng đó
     (theme-toggle sát trái, nút Menu sát phải) — không phải cả cụm dồn về một phía như vòng sửa 1
     (`margin-left: auto`, đã bỏ). `.entry-actions` mặc định `flex: 0 1 auto` (co theo nội dung) nên
     `justify-content: space-between` của chính nó không có khoảng trống nào để giãn; phải cho nó
     `flex: 1 1 auto` để CHIẾM HẾT bề ngang dòng nó đang đứng một mình, rồi `justify-content:
     space-between` mới có tác dụng đẩy 2 con ra 2 mép. Chỉ đặt trong TẦNG MOBILE: ở ≥768px
     `.entry-actions` còn chung dòng với `.app-brand`/`<nav>` (không wrap riêng), cho nó `flex-grow`
     ở đó sẽ nuốt hết khoảng trống `justify-content: space-between` của `.entry-header` đang lo, đẩy
     lệch cả `<nav>` — vi phạm `MB-8`. */
  .entry-header--platform .entry-actions { flex: 1 1 auto; justify-content: space-between; }

  /* Link chữ đứng riêng trong một dòng dữ liệu/breadcrumb cũng phải đủ 44px.
     .data-card là tên khái niệm của primitive P2 trong spec — class thật trong code là
     .list-card (bảng-thành-thẻ), nên chỉ giữ .list-card ở đây.
     .page-header__crumb (breadcrumb "Trang chủ …") đứng riêng một hàng phía trên tiêu đề, không kề
     sát nút nào khác — phóng thẳng lên 44px không đội layout xung quanh. */
  .list-card a:not(.btn), .page-header__crumb a {
    display: inline-flex; align-items: center; min-height: var(--tap-min);
  }
  /* 🔴 …và `min-width` đi CÙNG CẶP với `min-height` (P1, cùng lập luận đã dùng cho `.btn` ở task 5.6
     và cho pseudo của link chữ ở 5.4): `min-height` một mình chỉ đóng một nửa `MB-3`. Tên người
     Việt có thể rất ngắn — đo được ở màn Công nợ (390×844 và 360×800) link tên "Hung" chỉ **43×44**,
     thiếu đúng 1px bề ngang, và `audit.js` đếm nó là `nút<44`. Link nào đang hẹp hơn 44px thì tự nó
     đã vi phạm, nên nới ra là SỬA ở mọi màn chứ không phải hồi quy (đo lại Thành viên · Sổ quỹ ·
     Sân sau khi thêm: không màn nào đổi số). Ngoại lệ đã có sẵn luật riêng bên dưới không bị đụng:
     link trong `list-card__title-block` và trong ô `meta` đã tự trả `min-height: 0` + mở vùng chạm
     bằng pseudo, và pseudo của chúng vốn đã khai đủ cả hai chiều. */
  .list-card a:not(.btn) { min-width: var(--tap-min); }

  /* Tab dạng segmented (.nav-pills, vd "Danh sách"/"Yêu cầu tham gia") là link có chữ, không phải
     icon-only — áp dụng cùng kiểu phóng thẳng như .btn ở trên, không dùng pseudo-element. */
  .nav-pills .nav-link { display: inline-flex; align-items: center; min-height: var(--tap-min); }

  /* ============================================================
     Task 1.4 — thanh tab CUỘN NGANG có chủ ý, KHÔNG wrap/cắt, KHÔNG thu nhỏ chữ.
     Đo được: nav-tabs buổi tập (Điểm danh · Chi phí & chốt sổ · Thanh toán, tổng ~450px) và nav-pills
     Cài đặt (Chung · Cách chia tiền · Sân · Loại cầu) đều WRAP xuống 2 hàng trên 390/360px — thanh tab
     gãy làm đôi trông như lỗi. Cho cuộn ngang + scroll-snap để mọi tab nằm trên MỘT hàng, vuốt ngang
     khi cầm vợt (CT-6). MB-1 tự bỏ qua phần tràn nằm trong vùng overflow-x:auto có chủ ý. */
  .nav-pills, .nav-tabs {
    flex-wrap: nowrap;
    overflow-x: auto;
    scroll-snap-type: x proximity;
    -webkit-overflow-scrolling: touch;
  }
  /* nav-pills là inline-flex (track ôm sát nội dung): kẹp max-width 100% để bộ tab HẸP vẫn ôm gọn,
     chỉ bộ rộng hơn màn mới bị giới hạn rồi cuộn — không kéo giãn track đầy ngang một cách vô cớ. */
  .nav-pills { max-width: 100%; }
  .nav-pills .nav-item, .nav-tabs .nav-item { flex: 0 0 auto; }
  .nav-pills .nav-link, .nav-tabs .nav-link { white-space: nowrap; scroll-snap-align: start; }
  /* nav-tabs buổi tập đo được 42px cao (thiếu 2px so 44) — phóng lên đủ vùng chạm ngón tay (MB-3). */
  .nav-tabs .nav-link { display: inline-flex; align-items: center; min-height: var(--tap-min); }

  /* ============================================================
     Task 1.5 — mục dropdown (component dùng chung). Bootstrap để .dropdown-item cao ~32px, dưới ngưỡng
     ngón tay. Vùng mù: dropdown ĐÓNG mặc định nên audit tĩnh không đếm — chỉ lộ khi đo bằng BMH_OPEN
     (mở dropdown chọn "size / trang" của pagination ra: nút<44 6→8, hai mục "10/25/50 / trang" đều
     158×32). Là component dùng chung (dropdown size phân trang · menu avatar topbar Hồ sơ/Đăng xuất ·
     club switcher…) ⇒ phóng đủ 44px một chỗ, mọi dropdown hưởng. Menu ⋯ của actionBar KHÔNG dùng
     .dropdown-item (dùng .action-bar__menu .btn, đã 44px từ P4) nên không đụng. */
  .dropdown-item { display: flex; align-items: center; min-height: var(--tap-min); }

  /* Nút đóng toast (task 1.2). Đo được **18,4×22,8px** ở 390px và **17,6×22,8px** ở 360px — nhỏ hơn
     mọi nút icon-only khác trong app, mà toast lại là thứ hay bị đóng bằng ngón tay nhất khi nó che
     mất chỗ đang cần xem (nó ghim ở góc trên-phải, đè lên topbar — xem ghi chú ở khối
     #bmh-toast-container). `audit.js` không đếm được nó vì toast chỉ tồn tại sau khi JS gọi show();
     số trên đo bằng probe riêng của task 1.2.
     Giữ kích thước nhìn thấy + mở vùng chạm bằng pseudo, đúng luật P1 cho nút icon-only. Đặt TRONG
     tầng mobile (khác `.offcanvas .btn-close` của task 1.1 — offcanvas mang `d-lg-none` nên chỉ tồn
     tại ở mobile, còn toast sống ở MỌI bề rộng): 44px là ngưỡng ngón tay, không phải ngưỡng chuột. */
  #bmh-toast-container .btn-close { position: relative; }
  #bmh-toast-container .btn-close::after {
    content: ''; position: absolute; top: 50%; left: 50%;
    transform: translate(-50%, -50%);
    width: var(--tap-min); height: var(--tap-min);
  }

  /* Nút X của MỌI modal (task 5.2). Bootstrap để `.btn-close` **31×31** — đo được ở modal "Cho rời
     CLB" (`member/sua.html`), nhưng đây là component DÙNG CHUNG: app có ~20 modal, tất cả cùng một
     nút này. Vùng mù giống hệt offcanvas ở task 1.1: modal đóng thì `audit.js` bỏ qua vì
     `visibility: hidden`, phải đo bằng `BMH_OPEN` mới thấy. Cùng cách xử lý đã dùng cho offcanvas
     (1.1) và toast (1.2): giữ kích thước NHÌN THẤY, mở vùng CHẠM bằng pseudo-element. */
  .modal .btn-close { position: relative; }
  .modal .btn-close::after {
    content: ''; position: absolute; top: 50%; left: 50%;
    transform: translate(-50%, -50%);
    width: var(--tap-min); height: var(--tap-min);
  }

  /* Checkbox/radio: hộp 15×15 của Bootstrap quá nhỏ cho ngón tay, nhưng phóng to chính ô vuông sẽ phá
     mật độ form (và trông như lỗi). Vùng chạm THẬT là cả nhãn — bấm `<label for>` toggle đúng ô đó,
     hành vi HTML gốc chứ không phải mẹo CSS; `audit.js` từ task 5.2 đã tính nhãn vào vùng chạm
     (`via: "label"`). Đo ở modal "Cho rời CLB": nhãn 340×**39** ⇒ chỉ thiếu 5px. Nâng nhãn cho đủ
     ngưỡng ngón tay. 🔴 Dùng `display: block` chứ KHÔNG `flex`: nhãn ở đây có `<strong>` lồng giữa câu
     ("… — **giữ nguyên** khoản nợ …"), biến nhãn thành flex container sẽ tách câu thành nhiều flex
     item không wrap được, chữ tràn ngang. */
  .form-check-label { display: block; min-height: var(--tap-min); }
  /* 🔴 …và cũng phải đủ 44px theo CHIỀU NGANG (task 5.6, cùng lớp lỗi với `.btn` ở trên và với link
     chữ ở 5.4). Nhãn tiếng Việt có thể cực ngắn: đo ở modal "Tạo Lịch tập", nhóm 7 checkbox thứ trong
     tuần nằm trong `d-flex flex-wrap` nên mỗi `.form-check` co về bề rộng nội dung ⇒ nhãn "Tư" **41px**
     và "Ba" **42px**, hụt vùng chạm dù đã cao đủ 44. Nhãn dài (câu trong modal "Cho rời CLB" ở 5.2)
     không bị chạm vì `min-width` chỉ nâng SÀN. */
  .form-check-label { min-width: var(--tap-min); }

  /* Hàng hành động nguy hiểm ở cuối màn sửa (task 5.5 — `court/sua.html`; task 5.9 gắn thêm cho
     `shuttlecock/sua.html`, cùng lỗi y hệt, không phải đổi một luật nào ở đây). Hàng này có HAI hình
     thái tùy trạng thái: sân đang dùng ⇒
     `[Ẩn sân] [Xóa sân]`, sân đã ẩn ⇒ câu trạng thái `Sân này đã bị ẩn.` + `[Xóa sân]`. Hình thái thứ
     hai mới là vấn đề: `<p>` là một flex item bình thường nên nó đứng SÁT nút, và ở 360px hai thứ ăn
     trọn bề ngang thẻ với đúng 8px `gap` ⇒ đọc thành một cụm "nhãn + nút" (như thể nút là hành động
     CỦA câu đó), thay vì "một câu trạng thái" và "một hành động" tách bạch. Không tiêu chí nào bắt
     được — `MB-1..MB-8` đều sạch ở cả 4 tổ hợp; chỉ thấy bằng ảnh (lần thứ năm của lớp lỗi này).
     `flex-basis: 100%` đẩy câu xuống hẳn dòng riêng, nút giữ nguyên bề rộng tự nhiên và vẫn căn giữa
     ở dòng dưới — cố ý KHÔNG cho nút full-width: `P4` § 3 nói hành động phá hủy phải KÉM nổi bật, kéo
     nó ra trọn bề ngang là đi ngược lại. Desktop không đọc class này nên `MB-8` không đụng. */
  .danger-actions > p { flex-basis: 100%; text-align: center; }

  /* Ảnh XEM TRƯỚC trong modal (task 6.1 — modal "Thông tin chuyển khoản" ở `club/cai-dat.html`).
     🔴 Lỗi chỉ thấy bằng ẢNH, mọi tiêu chí số đều sạch: `MB-1` không đo được vì modal không tràn
     NGANG, `MB-5` đo `scrollHeight` của TRANG chứ không của lớp phủ. Đo tay ở 390×844:
     `#qrPreview` cao **379px** (ảnh QR người dùng tải lên là ẢNH CHỤP MÀN HÌNH điện thoại — dọc,
     tỉ lệ ~1:2; template chỉ kẹp cạnh NGANG bằng style inline nên chiều cao thả nổi) ⇒
     `.modal-dialog` cao **970px** trên viewport **844px**, và `.modal-footer` bắt đầu ở **y=900** —
     tức hai nút Hủy/Lưu nằm HOÀN TOÀN dưới nếp gấp: mở modal ra không thấy nút lưu ở đâu.
     Kẹp chiều cao ảnh xem trước; cạnh ngang do style inline giữ nguyên nên trình duyệt co theo cạnh
     chặt hơn và tự giữ tỉ lệ (đo sau: 88×180, dialog 772px, nút Lưu ở y=719). Đây là ảnh XEM TRƯỚC (để biết mình vừa chọn đúng tệp), không phải chỗ để quét mã
     — bản đầy đủ vẫn hiển thị nguyên cỡ cho thành viên ở màn công nợ, nên không vi phạm § 7 "không
     ẩn bớt thông tin". Desktop không đọc luật này (nằm trong tầng mobile) ⇒ `MB-8` an toàn. */
  .modal-body .img-thumbnail { max-height: 180px; }

  /* ------------------------------------------------------------
     Lưới lịch tháng (task 5.11 — `session/lich.html`). Đây là màn DUY NHẤT của app có một bảng thật
     KHÔNG chuyển sang thẻ ở mobile: 7 cột của một tuần là ngữ nghĩa của chính màn hình, xếp chồng là
     mất luôn thứ mà lịch tồn tại để làm. Đã đo trước khi quyết (plan gốc dặn đúng chỗ này): ép lưới
     vừa 360px cho ô rộng trong chỉ **44px**, trong khi "17:30–19:30" cần **78,1px** và ngay cả nửa
     đầu "17:30–" đã là **43px** ⇒ mọi buổi đều bị cắt chữ. Nên giữ bề rộng tối thiểu 640px (khai ở
     style inline của chính `<table>`, NGOÀI tầng này) + cuộn ngang có chủ đích — đúng ngoại lệ mà
     `MB-1` chừa sẵn — và sửa cho việc cuộn đó tử tế.
     🔴 Đừng viết lại con số đó thành một khai báo `min-width` ở đây: `MobileScaleTest` quét NGUYÊN
     VĂN tầng này (kể cả trong comment) và cấm mọi bề rộng px cứng khác 44px — đúng luật, vì bề rộng
     px cứng trong tầng mobile là cách nhanh nhất làm vỡ màn 360px.
     ------------------------------------------------------------ */
  /* (1) `MB-2`: `.table > thead > tr > th` toàn cục là `.7rem` = **11,2px**. Mọi màn khác thoát vì
     `.list-card table thead { display: none }` giấu hẳn hàng tiêu đề ở mobile — lịch thì không phải
     `.list-card` nên hàng `T2…CN` hiện ra và là chữ dưới sàn 13px DUY NHẤT còn lại của Đợt 5. Nâng
     đúng bảng này ở đúng tầng mobile; `letter-spacing` giảm theo để 7 nhãn không phình ngang. */
  .lich-grid > thead > tr > th { font-size: var(--fs-caption); letter-spacing: .02em; }
  /* (2) Chữ giờ bị CẮT ở 7/20 buổi — lỗi đang chạy thật, không phải hệ quả của task này. Ô rộng
     trong 90px, chuỗi giờ 78,1px: thừa chỗ. Nhưng ngày có từ 3 buổi thì hộp `.lich-day` bật thanh
     cuộn dọc, nuốt ~15px ⇒ còn 75px < 78,1px, và `.money { white-space: nowrap }` (đúng cho SỐ TIỀN,
     sai cho khoảng giờ) không cho xuống dòng nên chữ bị xén. Cho phép bẻ dòng: dấu `–` (en dash) là
     điểm ngắt hợp lệ nên chuỗi tự thành "17:30–" / "19:30", không mất chữ nào. */
  .lich-grid .money { white-space: normal; }
  /* (3) `MB-3`: nút buổi tập cao **20px**. Đây là vùng chạm THẬT trên màn hình dùng tại sân, không
     phải nút phụ. Không dùng mẹo pseudo của `P1` ở đây — các nút xếp CHỒNG cách nhau 20px nên vùng
     chạm ảo 44px sẽ đè lên nhau và bấm trúng nhầm buổi, tệ hơn cả nút nhỏ. Nới chiều cao thật, và nới
     luôn ô chứa để hai buổi cùng ngày vẫn nằm gọn (18px số ngày + 2×48 = 114). Ngày từ 3 buổi trở lên
     vẫn cuộn trong ô như thiết kế cũ. */
  .lich-grid tbody button { min-height: var(--tap-min); display: flex; align-items: center; }
  /* (3a) Chiều cao ô: `130px` của lần trước là chiều cao CỐ ĐỊNH tính cho "hai buổi cùng một ngày",
     nên ngày TRỐNG — đại đa số ô của một tháng — cũng cao 130px và cả lịch thành một cột ô rỗng dài
     ngoằng. Từ khi bỏ cuộn lồng ở (3b), `height` chỉ còn là chiều cao TỐI THIỂU: ô nhiều buổi tự nở
     đúng phần nó cần. Nên hạ sàn xuống vừa MỘT buổi — 19,5px số ngày + 4px `mt-1` + 44px vùng chạm +
     8px `p-1` của `<td>` = 75,5px — và để những ngày đông buổi tự lo phần còn lại. */
  .lich-grid > tbody > tr > td { height: 5rem; }
  /* (3b) Bỏ hẳn `max-height` của hộp ngày ở mobile — tức bỏ luôn việc CUỘN LỒNG bên trong ô. Nút cao
     44px làm ngày 3 buổi cần 164px, quá hộp 114px ⇒ ô đó sinh một vùng cuộn dọc rộng ~90px nằm trong
     một trang cũng đang cuộn dọc: trên màn cảm ứng gần như không điều khiển được, và buổi thứ ba bị
     giấu sau một thao tác mà người dùng không có cách nào biết là có. Cho hàng tuần đó tự cao lên
     thay vì cuộn. Đo được: **3 ô cuộn lồng → 0**, đổi lại `cuộn` +0,10 màn (2 hàng nở 130 → 173px).
     Đánh đổi có chủ ý, cùng loại với 4.3/5.7. Desktop giữ nguyên `max-height: 94px`: ở đó ô rộng
     90px, nút vẫn 20px, ba buổi vừa đúng hộp — không có gì phải sửa. */
  .lich-grid .lich-day { max-height: none; overflow-y: visible; }
  /* (4) Hàng nút ở page-header. Lần trước cho nút chính chiếm trọn một dòng và đẩy ba nút điều hướng
     xuống dòng dưới: đúng ở 360px, nhưng ở 430px thì thừa chỗ mà vẫn ăn hai dòng, và một nút primary
     kéo hết bề ngang trông nặng hơn hẳn thứ nó làm. Nay ép CẢ BỐN nút về một dòng ở mọi bề rộng
     điện thoại: hạ cỡ chữ của riêng hàng này xuống `--fs-caption` (13px — đúng bậc thấp nhất của
     thang, không phá `MB-2`) để ngân sách bề ngang vừa cả 360px. Ba nút điều hướng gom trong
     `__nav` và dính vào mép phải; nút chính neo mép trái. `flex-wrap: nowrap` là phần cưỡng chế:
     không có nó, chỉ cần một nhãn dài hơn dự tính là hàng lại vỡ đôi như cũ.
     Ba nút điều hướng KHÔNG được co (`flex-shrink: 0`) — chúng đã ở đúng sàn vùng chạm 44px; chỗ
     thiếu (nếu có) phải lấy từ nút chính, nơi còn chữ để cắt bớt.
     Ngân sách đo ở 360px (màn hẹp nhất, `container` = 336px): nút chính 154 + `‹` 44 + `Hôm nay` 70,3
     + `›` 44 + 3 khoảng cách 6px = **330,3px**. Dư 5,7px — chật, nên mỗi con số dưới đây đều là một
     phần của ngân sách đó, đừng nới lại lẻ một cái. Cỡ chữ 15 → 13px trả về ~24px, `padding` ngang
     .5 → .4rem trả thêm ~6px, `gap` 8 → 6px trả 6px. */
  .lich-header-actions { flex-wrap: nowrap; width: 100%; }
  .lich-header-actions, .lich-header-actions__nav { gap: .375rem; }
  .lich-header-actions .btn { font-size: var(--fs-caption); padding-left: .4rem; padding-right: .4rem; }
  .lich-header-actions__nav { margin-left: auto; flex: 0 0 auto; }
  .lich-header-actions > .btn-primary { min-width: 0; }

  /* ------------------------------------------------------------
     Bảng "Đã đăng ký" (task 5.12 — `session/dang-ky.html`). Màn thứ HAI giữ nguyên dạng bảng ở
     mobile, nhưng vì lý do khác hẳn màn Lịch: bảng này chỉ có 2 cột (tên+dòng phụ · nút Hủy) nên nó
     VỐN ĐÃ là một thẻ 2 dòng — chuyển sang `.list-card` chỉ làm nó to ra. Đo thật ở 390×844 với 11
     người (bật `data-role` + `list-card` bằng `BMH_EVAL` ngay trên trang đang chạy): mỗi hàng
     **72,2 → 96,3px**, cả trang **1573 → 1882px**, `cuộn` **1,86 → 2,23** màn. Và `.list-card table
     thead { display: none }` sẽ nuốt luôn hàng `Đã đăng ký (11)` — hàng đó KHÔNG phải nhãn cột mà là
     tiêu đề khối mang SỐ ĐẾM, ẩn đi là mất thông tin thật (§ 7 của spec cấm), còn đưa nó ra ngoài
     bảng thì đổi markup desktop ⇒ phá `MB-8`. Nên giữ bảng, và chỉ vá đúng chỗ hàng tiêu đề đó lộ ra
     luật toàn cục `.table > thead > tr > th` (cỡ chữ `.7rem` = **11,2px**) — cùng một lỗ hổng `MB-2`
     mà màn Lịch đã gặp ở (1) phía trên, vì cùng một lý do "không phải `.list-card`".
     🔴 Bài học 5.11 lặp lại NGUYÊN XI ở chính đoạn này: câu trên thoạt đầu viết con số dưới dạng
     `thuộc-tính: giá-trị` và `MobileScaleTest` bắt ngay — nó quét NGUYÊN VĂN cả comment. Muốn nhắc
     một khai báo trong tầng này thì tách tên thuộc tính ra khỏi giá trị, như câu hiện tại.
     ------------------------------------------------------------ */
  .dang-ky-list > thead > tr > th { font-size: var(--fs-caption); }

  /* ============================================================
     P2 — .data-card: bảng thành THẺ CÓ TIÊU ĐỀ, không phải danh sách nhãn/giá trị.
     Cơ chế cũ (td[data-label]::before) nở mỗi ô thành một dòng "Nhãn ··· Giá trị":
     hàng 7 cột ⇒ thẻ 7 dòng, tên người bị đẩy sang phải sau nhãn, ô rỗng vẫn ăn
     một dòng. Đo được: 10 giao dịch quỹ = 4,19 màn; 4 thành viên = 2,11 màn.

     Hợp đồng với template: đánh dấu vai trò từng ô bằng data-role trên <td>.
     Bảng desktop KHÔNG đổi gì — data-role không có tác dụng ở >=768px.
     ============================================================ */
  .list-card table thead { display: none; }
  /* 🔴 Bug phát hiện sau task 3.4 (báo lại 2026-08-18): thiếu `tfoot` trong danh sách reset display
     này. `<tfoot>` không bị chạm nên vẫn giữ `display: table-footer-group` mặc định trong khi `table`
     cha đã bị ép `display: block` — trình duyệt coi `tfoot` mồ côi như một "bảng" con riêng, tính bề
     rộng kiểu shrink-to-fit thay vì kế thừa 100% khối cha, khiến dòng "Tổng chi phí"/"Chênh lệch làm
     tròn" co hẹp lại và chữ vỡ dòng giữa từ. Thêm `tfoot` vào cùng danh sách để nó cũng thành khối
     block 100% như `table`/`tbody`/`tr`/`td`. */
  .list-card table, .list-card tbody, .list-card tfoot, .list-card tr, .list-card td { display: block; width: 100%; }
  /* 🔴 Bug phát hiện sau task 3.4 (báo lại 2026-08-18): Bootstrap `.table` khai `--bs-table-bg:
     var(--bs-body-bg)`, mà app này override `--bs-body-bg` toàn cục thành `--surface-sunken` (xám
     nhạt, dùng cho NỀN TRANG — xem comment ở khối Tom Select ~dòng 513). Selector gốc của Bootstrap
     `.table > :not(caption) > * > *` tự sơn nền đó lên TỪNG `<td>` — vô hình trên desktop vì các ô kề
     sát nhau tạo thành một mảng liền, nhưng ở mobile mỗi `<td>` giờ là một "dòng" riêng trong thẻ card
     TRẮNG (`.list-card tbody tr { background: var(--surface) }`), nên mảng xám của riêng nó lộ thành
     một khối xám xấu tách rời — rõ nhất ở ô `meta` nhiều chữ (chiếm cả bề ngang, nhiều dòng). Trả nền
     `<td>` về trong suốt để chỉ còn đúng nền trắng của `tr` cha. */
  .list-card td { background-color: transparent; }

  /* 🔴 C1/C2 (review cuối đợt 0, phát hiện bằng render Chrome headless thật — không ai render P2
     lần nào suốt 9 review từng-task trước đó): dòng ngay trên ép `width: 100%` cho MỌI td, nhưng
     các luật data-role bên dưới không khai lại `width` ⇒ mỗi ô vẫn rộng bằng cả hàng (flex-basis kế
     thừa 100%), xếp DỌC từng ô một dòng thay vì cùng dòng theo `order`; ô amount/status/actions còn
     `flex: 0 0 auto` (flex-shrink: 0) nên tràn khỏi thẻ thay vì co lại. Tương tự, `.table > tbody >
     tr > td` (dòng ~957) đặt `padding: .85rem 1rem` + `border-bottom` cho MỌI td — nhánh fallback
     (~ cuối khối này) đã tự reset hai thứ đó, nhánh data-role thì chưa, khiến thẻ cao dư và mỗi chip
     meta có một gạch chân lạ. Đo được trước khi sửa (hàng giao dịch quỹ 7 ô): mọi ô rộng 417–419px,
     thẻ cao 175px, `+108.000 ₫` bị cắt ở mép phải; sau khi thêm `width: auto` + reset padding/border:
     thẻ còn ~68px, đúng 2 dòng, không tràn. */
  .list-card td[data-role] { width: auto; padding: 0; border: 0; }

  /* 🔴 flex-wrap + order, KHÔNG dùng grid-template-areas. Với grid, hai <td> cùng
     data-role="meta" rơi vào cùng một ô và CHỒNG LÊN NHAU — grid area không cho
     nhiều phần tử chảy nối tiếp. Flex + order thì số ô meta bao nhiêu cũng được.
     Ép xuống dòng giữa hàng 1 và hàng 2 bằng ::after (pseudo-element của flex
     container LÀ một flex item, nên nhận được order và flex-basis). */
  .list-card tbody tr {
    display: flex; flex-wrap: wrap; align-items: center;
    column-gap: .5rem; row-gap: .15rem;
    border: 1px solid var(--border);
    border-radius: var(--radius-md);
    margin-bottom: .5rem;
    padding: .625rem .75rem;
    background: var(--surface);
  }
  .list-card tbody tr::after {
    content: ''; order: 4; flex-basis: 100%; height: 0;   /* dấu ngắt dòng */
  }

  /* Hàng 1 — order 1..3 */
  .list-card td[data-role="title"] {
    order: 1; flex: 1 1 auto; min-width: 0;
    display: flex; align-items: center; gap: .4rem;
    font-size: var(--fs-strong); font-weight: 600; color: var(--text-strong);
  }
  /* ------------------------------------------------------------
     `list-card__title-block` (task 5.1) — cho ô title mang cả một KHỐI DANH TÍNH nhiều dòng
     (avatar + tên + badge "Chưa kích hoạt" + SĐT trong CÙNG một <td>, vì tách ra ô khác là đổi cột
     desktop ⇒ phá MB-8). Bốn luật dưới đây đều đến từ số đo, không phải thẩm mỹ; scope bằng class vì
     4 template khác cũng dùng `title` (debt/cong-no · expense/fragments · expense/bang-chia-tien ·
     settlement/fragments) mà chưa được đo lại — ứng viên nâng lên primitive khi tới lượt chúng.
     ------------------------------------------------------------ */
  /* (1) Ô title "nặng" chiếm trọn hàng 1 ⇒ `status` (vai trò) bị đẩy xuống một hàng RIÊNG, lơ lửng
     giữa thẻ và tốn ~25px mỗi dòng. Cho status nhập luôn vào hàng meta: "Thành viên · Nữ · Đang sinh
     hoạt" đọc liền mạch và thẻ về đúng 2 khối như hình mẫu spec § 3.2. Đã thử 4 biến thể khác (ép
     `flex-basis: 0` cho title, kéo actions lên hàng 1…) và đo cả 4: đây là biến thể duy nhất vừa
     không có lỗi thị giác vừa đạt `MB-5` ở 360px — xem bảng biến thể ở docs/design/11_Mobile_Progress.md
     § "Chi tiết 5.1". */
  .list-card td.list-card__title-block ~ td[data-role="status"] { order: 5; margin-left: 0; }
  /* (2) `.list-card a:not(.btn) { min-height: var(--tap-min) }` (task 0.2) phóng tên người lên 44px
     CAO THẬT. Ở bản fallback cũ mỗi ô là một dòng riêng nên 44px đó không đội gì — đúng như 0.2 ghi.
     Trong khối danh tính nhiều dòng thì khác hẳn: 44px của dòng tên CỘNG THÊM vào dòng badge/SĐT bên
     dưới (ô title cao 70px thay vì ~52px, 10 dòng dôi ~188px = 0,23 màn ở 360px). Trả chiều cao NHÌN
     THẤY về tự nhiên và mở vùng CHẠM bằng pseudo-element phủ lên — đúng nguyên tắc P1 vẫn dùng cho nút
     icon-only; `MB-3` vẫn đạt (đo lại: smallTargets = 0). */
  .list-card td.list-card__title-block a:not(.btn) { min-height: 0; position: relative; }
  /* 🔴 `min-width` KHÔNG phải đề phòng — đo được ở task 5.4 (màn Sân): tên dữ liệu có thể NGẮN. Ba
     link "Sân 3"/"Sân 4"/"Sân 7" rộng 41–43px và link phụ "Xem bản đồ" rộng 40px ⇒ `width: 100%`
     của pseudo cũng chỉ ra 41–43px, vùng chạm vẫn dưới 44 theo CHIỀU NGANG (MB-3 tính cả hai
     chiều). Màn Thành viên (5.1) không lộ vì tên người luôn dài hơn 44px. Vùng chạm dôi ra chỉ nằm
     trong ô title vốn còn chỗ trống bên phải nên không đè lên phần tử bấm được nào. */
  .list-card td.list-card__title-block a:not(.btn)::after {
    content: ''; position: absolute;
    top: 50%; left: 0; transform: translateY(-50%);
    width: 100%; min-width: var(--tap-min); height: var(--tap-min);
  }
  /* (2b) Task 5.4 — ô title mang TÊN + một link phụ đứng ngay sau nó trong CÙNG <td> ("Xem bản đồ"
     ở màn Sân; trên desktop link này nằm dưới tên vì nó được bọc trong một <div> block). Không đổi
     `.list-card__title-block` trần vì màn Thành viên (5.1) đã đo xong với bố cục hiện tại — scope
     bằng class riêng, đúng cách đã dùng cho `list-card__action-inline` / `list-card__meta-fill`.
     Hai luật đến từ ảnh chụp 360px, không phải thẩm mỹ: thiếu `flex-wrap` thì <div> chứa link là
     một flex item bị CO lại tới min-content ⇒ "Xem bản đồ" vỡ thành 2–3 dòng dựng đứng cạnh tên;
     `nowrap` + `flex: 0 0 auto` giữ nó nguyên cụm rồi cho cả cụm rơi xuống dòng dưới khi tên dài. */
  /* (2d) Ô title là một CHUỖI CHỮ thuần, không phải khối danh tính nhiều phần tử (task 5.6 — cột
     "Các thứ" ở màn Lịch tập: `Hai, Tư, Sáu`, mỗi thứ là một `<span>` riêng do vòng lặp Thymeleaf).
     `display: flex` của luật gốc biến MỖI span thành flex item, và flexbox **bỏ hẳn** khoảng trắng
     giữa các item rồi thay bằng `gap: .4rem` ⇒ dấu phẩy trôi ra giữa khoảng trống, đọc thành
     "Hai ,  Tư ,  Sáu". Trả về `block` thì chuỗi chảy như chữ bình thường, khoảng trắng trong nguồn
     giữ nguyên và kết quả khớp đúng bản desktop. Cùng lớp lập luận với `meta` ở task 4.4 (flex sai
     khi nội dung là CÂU/chuỗi chữ, không phải các chip rời). */
  .list-card td[data-role="title"].list-card__title-plain { display: block; }
  .list-card td.list-card__title-linked { flex-wrap: wrap; }
  .list-card td.list-card__title-linked > div { flex: 0 0 auto; white-space: nowrap; }
  /* (2c) 🔴 Người dùng báo sau 5.4 (2026-08-19): ở 390px, số tiền của sân TÊN DÀI rơi xuống một dòng
     RIÊNG, lơ lửng giữa thẻ (`margin-left: auto` đẩy nó khỏi mép trái, nút `⋯` chiếm mép phải) — đo
     được 3/10 thẻ và mỗi thẻ đó cao thêm 27,9px. Nguyên nhân KHÔNG phải thiếu chỗ mà là cách flexbox
     ngắt dòng: nó xếp item vào dòng theo kích thước GIẢ ĐỊNH, tức `flex-basis` — `title` thừa kế
     `flex: 1 1 auto` nên basis = max-content = BỀ RỘNG TÊN + "Xem bản đồ" TRÊN CÙNG MỘT DÒNG (338px
     cho "Sân 1 — Nhà thi đấu Cầu Giấy"), vượt chỗ trống ⇒ amount bị đẩy sang dòng sau, rồi title mới
     co lại và tự wrap. `min-width: 0` ở luật gốc không cứu được: nó chỉ hạ SÀN co, không đổi kích
     thước giả định dùng để ngắt dòng. Basis 0 thì tổng giả định của dòng 1 luôn vừa, title nở ra
     chiếm phần còn lại sau amount + `⋯` và chữ tự wrap BÊN TRONG nó — cùng một lập luận (và cùng một
     cách sửa) đã dùng cho `list-card__meta-fill` (4.4) và `list-card__meta-grow` (5.4).
     Đặc hiệu (0,3,1) để thắng CHẮC luật gốc `td[data-role="title"]` (0,2,1) chứ không dựa vào thứ tự
     nguồn — bài học C3 của đợt 0. Scope bằng class: `list-card__title-linked` chỉ dùng ở màn Sân và
     màn Loại cầu (task 5.8 — cùng khuôn bảng, ô title là một LINK đứng cùng hàng với amount) nên 4
     màn khác đang dùng `data-role="title"` không phải đo lại. */
  .list-card td[data-role="title"].list-card__title-linked { flex-basis: 0; }
  /* (2e) `list-card__title-fill` — CÙNG một lỗi và cùng một cách sửa như (2c), nhưng cho ô title là
     một CHUỖI CHỮ THUẦN (tên người ở bảng "CLB đang nợ tiền ứng" — fund/quy.html) chứ không phải một
     link như `title-linked`. Tách class riêng vì `title-linked` còn kéo theo `flex-wrap` + `> div
     nowrap` dành cho link phụ đứng cạnh tên, ở đây không có gì để wrap. Đo ở 360×800, hàng "Nguyễn
     Thị Thu Phương" + "1.250.000 ₫": thiếu luật này thì basis = max-content = bề rộng TÊN trên MỘT
     dòng (190px) + amount 120px = 310px > 300px khả dụng ⇒ flexbox ngắt dòng, số tiền rơi xuống một
     dòng RIÊNG lơ lửng giữa thẻ và thẻ cao 140,4px; có luật này thì tên tự wrap BÊN TRONG ô và số
     tiền ở lại hàng 1, thẻ còn 121,8px. */
  /* 2026-08-24 — ô này không còn là chuỗi chữ THUẦN: bảng tiền ứng gom theo người nên tên có thể
     kèm badge "n khoản". `display: flex` của luật gốc biến tên và badge thành HAI flex item, và
     flex-shrink bóp cái tên cho badge vừa chỗ thay vì đẩy badge xuống dòng — đo ở 390×844 với
     "Nguyễn Thị Thu Phương": tên vỡ 3 dòng, badge lơ lửng giữa khối chữ, thẻ cao 147px.
     `flex-wrap: wrap` KHÔNG cứu được (đã thử, vẫn 147px): item co được thì flexbox co chứ không
     ngắt dòng. Trả ô về `block` để tên + badge chảy như CHỮ bình thường và tự wrap — cùng cách sửa
     với `list-card__title-plain` ở (2d), thẻ còn 122px. `flex-basis: 0` vẫn giữ vì đó là thuộc
     tính của ô với tư cách ITEM của hàng, không liên quan tới display của chính nó. */
  .list-card td[data-role="title"].list-card__title-fill {
    flex-basis: 0; display: block;
  }
  /* (2f) `list-card__title-debtor` — LẦN THỨ BA của cùng một lỗi flex-basis (sau 2c màn Sân và 2e
     bảng tiền ứng), lần này ở màn Công nợ. Khác hai lần trước ở chỗ ô title mang MỘT CỤM nhiều
     mảnh: link tên + badge "Khách"/"Chưa kích hoạt"/"Chờ xác nhận" + dòng "↳ dẫn bởi …" của khách
     vãng lai, tất cả trong CÙNG một `<td>` (tách ra ô khác là đổi cột desktop ⇒ phá `MB-8`).
     Đo trước khi sửa ở 390×844 (5 người, có tên dài): ô title chiếm trọn 338px bề ngang thẻ ⇒
     `amount` bị đẩy xuống một dòng RIÊNG lơ lửng giữa thẻ, mỗi thẻ **114,8px** (thẻ tên dài 121,8).
     Ba luật, không phải một:
       · `flex-basis: 0` — đúng cách sửa của 2c/2e: tổng kích thước giả định của dòng 1 luôn vừa nên
         `amount` ở lại hàng 1, cụm danh tính tự wrap BÊN TRONG ô.
       · `flex-wrap: wrap` — bắt buộc đi kèm ở đây (2e không cần vì ô đó là chuỗi chữ thuần): basis 0
         mà không cho wrap thì badge và dòng "dẫn bởi" bị NÉN theo chiều ngang, đúng cảnh chữ vỡ dọc
         từng ký tự đã thấy ở ảnh audit bản cũ.
       · `> div { flex: 1 0 100% }` — dòng "dẫn bởi …" là một `<div>` (khối), phải chiếm trọn dòng
         riêng thay vì chen vào hàng badge. Cố ý KHÔNG dùng `white-space: nowrap` như `title-linked`:
         tên người dẫn dài thì câu này phải wrap được, không thì tràn ngang. */
  .list-card td[data-role="title"].list-card__title-debtor {
    flex-basis: 0; flex-wrap: wrap; row-gap: .2rem;
  }
  .list-card td.list-card__title-debtor > div { flex: 1 0 100%; }
  .list-card td.list-card__title-debtor .small { font-size: var(--fs-caption); }
  /* (3) Khối danh tính vốn là 2 tầng cứng (tên ở trên, badge+SĐT ở dưới). Cho nó chảy thành MỘT hàng
     tự wrap: màn rộng thì "Tên · Chưa kích hoạt · 0966442200" nằm gọn một dòng, màn hẹp mới xuống
     dòng — thay vì luôn tốn 2 dòng. `flex-wrap` ở hàng con là bắt buộc chứ không phải đề phòng: thiếu
     nó, SĐT tràn khỏi hộp title và ĐÈ LÊN badge vai trò bên phải (thấy bằng ảnh 360×800, `audit.js`
     không báo vì phần tràn vẫn nằm trong màn hình — `MB-1` chỉ đo mép phải viewport). */
  .list-card td.list-card__title-block .min-w-0 {
    display: flex; flex-wrap: wrap; align-items: center; column-gap: .4rem;
  }
  .list-card td.list-card__title-block .min-w-0 .d-flex { flex-wrap: wrap; margin-top: 0 !important; }
  /* (4) `.small` (Bootstrap, .875em) trong ô title tính trên NỀN 17px của title ⇒ ~14,9px: to hơn cả
     chữ `meta` 13px ngay bên dưới, dù đây là thông tin phụ ("(bạn)", SĐT). Ghim về đúng bậc caption
     của thang P1 — cùng lớp sửa với `meta .small` ở task 3.4, chỉ khác nền tính. */
  .list-card td.list-card__title-block .small { font-size: var(--fs-caption); }
  .list-card td[data-role="amount"] {
    order: 2; flex: 0 0 auto; margin-left: auto;
    font-size: var(--fs-strong); font-weight: 700;
    font-variant-numeric: tabular-nums; white-space: nowrap; text-align: right;
  }
  /* Một số amount mang theo ghi chú phụ lồng bên trong (lý do ghi đè AC-EXP-04, "Thu cố định"
     BR-EXP-20 — expense/fragments.html) thay vì là amount thuần một dòng. `flex: 0 0 auto` +
     `white-space: nowrap` ở trên đúng cho số tiền ngắn, nhưng ép ghi chú dài thành MỘT dòng không
     xuống được ⇒ tràn ngang thật đo được (override reason ~90 ký tự tràn quá mép phải ~140px ở
     390px, xem docs/design/11_Mobile_Progress.md § Chi tiết 3.4). Scope bằng class riêng — không
     đổi hành vi `amount` một dòng mặc định ở nơi khác chưa có ghi chú lồng bên trong.
     🔴 Khối này CHỈ lo phần flex của thẻ mobile. Việc gỡ `nowrap` cho chính ghi chú nằm ở
     luật NỀN `.can-wrap-note > div` (mục "Bảng — list-card", ~dòng 1626) và áp cho mọi bề
     rộng — vì bệnh tràn ngang có ở CẢ desktop, không riêng mobile (2026-08-25). Đừng gom
     ngược luật nền vào đây. */
  .list-card td[data-role="amount"].can-wrap-note {
    flex-shrink: 1; min-width: 0; white-space: normal;
  }
  /* Nhãn cho ô `amount` — cùng lý do đã ghi cho `meta.is-labeled` ở dưới (spec § 3.2: trường MƠ HỒ
     vẫn phải giữ nhãn trên mobile), chỉ khác chỗ đặt: `thead` bị giấu ở mobile nên số tiền ở hàng 1
     mất hết ngữ cảnh cột. Lộ ra ở bảng "CLB đang nợ tiền ứng" (fund/quy.html): thẻ đọc thành "Chị
     Hằng 47.000 ₫ / Đã ứng 270.000 ₫" — SỐ ÍT quan trọng hơn thì có nhãn, số phải HÀNH ĐỘNG thì
     không, dễ hiểu ngược hẳn hai con số.
     🔴 `display: block` chứ KHÔNG inline (đo, không phải thẩm mỹ): nhãn inline nối vào số làm cụm
     amount rộng 205px ở 360×800, ăn hết chỗ của tên ⇒ tên dài vỡ 3 dòng, thẻ 147,3px (so với 124,2px
     khi chưa có nhãn). Nhãn xuống dòng riêng thì ô rộng đúng `max(nhãn, số)` ≈ 120px, thẻ 112,5px cho
     hàng thường và 121,8px cho hàng tên dài — đều đặn, không có hàng nào bị đội đột biến. Giá phải
     trả: +16px mỗi thẻ; đổi lại không còn con số nào không rõ nghĩa. Nhãn đọc từ `data-label` nên
     template chỉ thêm class, desktop không đổi một pixel (MB-8). */
  .list-card td[data-role="amount"].is-labeled::before {
    content: attr(data-label); display: block; line-height: 1.25;
    font-size: var(--fs-caption); font-weight: 400; color: var(--text-muted);
  }
  .list-card td[data-role="status"] { order: 3; flex: 0 0 auto; margin-left: auto; }
  /* Có cả amount lẫn status ⇒ status đã hết chỗ ở hàng 1, để nó theo amount sát phải.
     🔴 C3b (review cuối đợt 0): `~` so khớp theo THỨ TỰ TRONG DOM, không theo `order` hiển thị —
     bảng sổ quỹ map cột thật theo thứ tự "Loại chi"(status) ĐỨNG TRƯỚC "Số tiền"(amount), nên chỉ
     viết một chiều `amount ~ status` là chưa đủ: khi status đứng trước amount trong markup, cả hai
     ô cùng còn `margin-left: auto` ⇒ mỗi ô tự đẩy sát phải, tràn ngang ~24px. Viết cả hai chiều. */
  .list-card td[data-role="amount"] ~ td[data-role="status"] { margin-left: 0; }
  .list-card td[data-role="status"] ~ td[data-role="amount"] { margin-left: 0; }

  /* Hàng 2 — order 5..6 */
  /* 🔴 `display: block`, KHÔNG phải `flex` (đổi ở task 4.4 — đo được, không phải thẩm mỹ): một ô meta
     chứa CÂU (mô tả giao dịch quỹ, ghi chú xóa nợ…) chứ không chỉ chip ngắn. Trong flex container,
     đoạn chữ trần là MỘT anonymous flex item và flexbox ngắt dòng theo kích thước max-content của cả
     item ⇒ câu dài luôn bị đẩy nguyên khối xuống dòng mới, bỏ lại `::before` (dấu `·` / nhãn) đứng
     TRƠ MỘT MÌNH ở cuối dòng trên — vừa xấu vừa tốn nguyên một dòng ~19,5px. Với `block`, `::before`
     là inline nằm ngay đầu câu và câu tự wrap giữa chừng như chữ bình thường. Đo ở sổ quỹ (10 dòng,
     mô tả dài do hệ thống tự sinh): `cuộn` 3,09 → 2,49 ở 390px. Con của meta vốn đều inline
     (span/a/badge) nên vẫn nằm ngang; `gap` mất đi được thay bằng khoảng trắng HTML sẵn có. */
  .list-card td[data-role="meta"] {
    order: 5; flex: 0 1 auto; min-width: 0;
    display: block;
    font-size: var(--fs-caption); color: var(--text-muted);
  }
  /* Link nằm TRONG ô meta (vd link buổi tập ở cột "Liên quan" của sổ quỹ): luật 0.2 phóng mọi
     `.list-card a` lên 44px CAO THẬT — đúng khi link là cả một dòng dữ liệu, nhưng ở đây nó là chú
     thích 13px nằm giữa câu, 44px đó cộng thẳng vào chiều cao thẻ (đo: 6/10 dòng có link ⇒ ~120px).
     Cùng cách xử đã dùng cho tên người ở `list-card__title-block` (5.1) và nút icon-only (P1): trả
     chiều cao NHÌN THẤY về tự nhiên, mở vùng CHẠM bằng pseudo-element phủ 44px. `MB-3` vẫn đạt
     (`smallTargets = 0` sau khi đo lại). */
  .list-card td[data-role="meta"] a:not(.btn) {
    display: inline; min-height: 0; position: relative;
  }
  .list-card td[data-role="meta"] a:not(.btn)::after {
    content: ''; position: absolute;
    top: 50%; left: 0; transform: translateY(-50%);
    width: 100%; height: var(--tap-min);
  }
  /* Nhiều ô meta ⇒ ngăn cách bằng dấu chấm giữa thay vì xuống dòng.
     🔴 `:not(.is-blank)` KHÔNG phải trang trí (thêm ở task 5.1): tổ hợp anh-em `~` so khớp theo DOM,
     không theo những gì đang hiển thị — ô `is-blank` tuy `display: none` vẫn là anh-em đứng trước, nên
     bản cũ gắn dấu `·` MỞ ĐẦU cho ô meta hiện đầu tiên mỗi khi mọi ô meta trước nó đều rỗng (màn Thành
     viên có tới 4 ô meta, 2 trong đó thường rỗng ⇒ lộ ngay). Yêu cầu ô đứng trước phải là meta CÓ nội
     dung thì dấu chấm mới đúng nghĩa "ngăn cách hai giá trị". */
  .list-card td[data-role="meta"]:not(.is-blank) ~ td[data-role="meta"]::before {
    content: '·'; margin-right: .35rem; opacity: .5;
  }
  /* Trường MƠ HỒ vẫn phải giữ nhãn trên mobile (spec § 3.2 liệt kê: Hệ số riêng · Loại chi · Liên
     quan) — "0.50" trần giữa "Nam · Đang sinh hoạt" không tự giải thích được. Nhãn đọc từ chính
     `data-label` mà template đã có sẵn cho nhánh fallback, nên template chỉ cần thêm class: không
     thêm markup ⇒ desktop không đổi một pixel (MB-8).
     🔴 Hai luật vì `::before` là DUY NHẤT một cái cho mỗi phần tử — ô vừa cần nhãn vừa cần dấu ngăn
     cách phải viết gộp cả hai vào một `content`. Đặc hiệu tự xếp đúng thứ tự: luật dưới (0,5,3) thắng
     luật ngăn cách (0,4,3), luật ngăn cách lại thắng luật nhãn trần (0,3,2) — nên ô is-labeled đứng
     đầu lấy nhãn trần, đứng sau một meta có nội dung thì lấy "· Nhãn". */
  .list-card td[data-role="meta"].is-labeled::before {
    content: attr(data-label) ' '; opacity: .8;
  }
  .list-card td[data-role="meta"]:not(.is-blank) ~ td[data-role="meta"].is-labeled::before {
    content: '· ' attr(data-label) ' '; opacity: .8;   /* .8 chứ không .5 của dấu ngăn cách: đây là CHỮ phải đọc được */
  }
  /* ------------------------------------------------------------
     Hàng meta 2 DÒNG (task 4.4, người dùng chốt bố cục) — dùng ở sổ quỹ: dòng trên "Danh mục · Mô
     tả", dòng dưới "Liên quan" bên trái và "Người ghi" sát phải. Ba luật đi cùng nhau, thiếu một cái
     là hỏng:
     (1) Ngắt dòng thứ hai. `tr::after` (order 4) đã là dấu ngắt giữa hàng 1 và hàng meta; `::before`
         của chính `tr` còn trống nên dùng làm dấu ngắt thứ hai ở order 6. 🔴 PHẢI scope bằng class
         trên `<tr>`: `actions` mặc định cũng là order 6, gắn dấu ngắt này cho MỌI hàng sẽ đẩy nút
         thao tác của các màn khác xuống một dòng riêng.
     (2) Ô "Liên quan" `flex: 1 1 0` — flex-basis 0 chứ không phải `auto`. Flexbox quyết định ngắt
         dòng theo kích thước GIẢ ĐỊNH (max-content khi basis là auto): để auto thì "Liên quan" +
         "Người ghi" cộng lại thường vượt bề ngang thẻ ⇒ mỗi ô lại rơi một dòng, đúng thứ ta muốn
         tránh. Basis 0 thì tổng giả định luôn vừa, ô này nở ra chiếm phần còn lại và chữ tự wrap
         BÊN TRONG nó.
     (3) Ô "Người ghi" giữ bề rộng tự nhiên (`flex: 0 0 auto`) + `margin-left: auto` + canh phải.
     Đo được: mỗi hàng bớt một dòng ~19,5px. ------------------------------------------------------ */
  .list-card tbody tr.list-card__meta-2line::before {
    content: ''; order: 6; flex-basis: 100%; height: 0;
  }
  .list-card td[data-role="meta"].list-card__meta-fill { order: 7; flex: 1 1 0; }
  .list-card td[data-role="meta"].list-card__meta-end {
    order: 7; flex: 0 0 auto; margin-left: auto; text-align: right;
  }
  /* Hai ô này KHÔNG lấy dấu `·` mở đầu nữa: dấu đó có nghĩa "ngăn cách hai giá trị cùng dòng", mà ô
     fill luôn MỞ ĐẦU một dòng còn ô end đứng tách hẳn sang mép phải — dấu chấm ở đó chỉ tốn chỗ và
     đọc như dấu đầu dòng. Đặc hiệu (0,5,3) để thắng chắc luật "· + nhãn" (0,5,2) chứ không dựa vào
     thứ tự nguồn — đúng bài học C3 của đợt 0. */
  .list-card tbody tr.list-card__meta-2line td[data-role="meta"].list-card__meta-fill.is-labeled::before,
  .list-card tbody tr.list-card__meta-2line td[data-role="meta"].list-card__meta-end.is-labeled::before {
    content: attr(data-label) ' '; opacity: .8;
  }
  /* Link buổi tập nằm trong ô fill vốn hẹp: để nó tự wrap thì "12/08/2026 19:00–21:00" gãy làm đôi
     giữa giờ, tốn thêm một dòng ở đa số hàng. Nó là một mốc thời gian — đọc theo cụm hoặc không đọc
     được, nên cấm xuống dòng. */
  .list-card td[data-role="meta"].list-card__meta-fill a:not(.btn) { white-space: nowrap; }
  /* ------------------------------------------------------------
     `list-card__meta-grow` (task 5.4) — ô meta chứa một CÂU DÀI (địa chỉ sân) đứng cùng dòng với
     badge trạng thái. Mặc định `meta` là `flex: 0 1 auto`, tức flex-basis = max-content: một địa chỉ
     40–70 ký tự luôn "xin" trọn bề ngang thẻ ⇒ badge (và nút ⋯ ở màn khác) bị đẩy xuống một dòng
     RIÊNG. Basis 0 thì ô này nở ra chiếm phần còn lại sau badge và câu tự wrap BÊN TRONG nó — cùng
     lập luận đã dùng cho `list-card__meta-fill` ở 4.4, chỉ khác là giữ nguyên order 5 (màn này không
     có hàng meta thứ hai). Đo ở màn Sân (10 sân, 360×800): `cuộn` 2,12 → 2,06 và 3/10 thẻ bớt đúng
     một dòng.
     Luật thứ hai trả lại `margin-left: auto` mà (1) của `list-card__title-block` đã tắt: ở màn Thành
     viên status nối ĐUÔI câu meta nên phải sát trái, còn ở đây thẻ có một "rãnh phải" thẳng hàng
     (số tiền · nút ⋯) nên badge phải rơi vào đúng rãnh đó. Không có luật này, sân KHÔNG khai địa chỉ
     (ô meta `is-blank`) sẽ hiện badge lạc sang mép trái. Khi có địa chỉ thì ô meta đã nuốt hết chỗ
     trống nên `auto` không đổi gì. ------------------------------------------------------------ */
  .list-card td[data-role="meta"].list-card__meta-grow { flex: 1 1 0; }
  .list-card td.list-card__meta-grow ~ td[data-role="status"] { margin-left: auto; }
  /* `.small` (Bootstrap, .875em) lồng trong một `meta` td (vd chi tiết cầu ở expense/fragments.html)
     trước đây tính trên nền 15px (fallback `--fs-body`) ⇒ ~13,1px, đạt `MB-2`. Nay nền đã là 13px
     (`--fs-caption` của chính `meta`) ⇒ .875 × 13 ≈ 11,4px, DƯỚI ngưỡng. `meta` đã tự thay việc của
     `.small` (nhỏ hơn + màu muted) nên bên trong nó `.small` không cần thu thêm — ép về đúng 13px
     bằng `inherit`. Desktop không đổi: luật này chỉ sống trong TẦNG MOBILE, `.small` gốc (87,5% của
     ngữ cảnh cha ở bảng thật) không bị chạm. */
  .list-card td[data-role="meta"] .small { font-size: inherit; }
  /* Cùng họ bug trên nhưng KHÔNG cần `.list-card`/`meta` để lộ ra (task 8.3): `<code>` (Bootstrap,
     .875em) lồng trong `.small` (13,125px = 87,5% của --fs-body 15px, đã tự đạt MB-2) tự thu thêm
     87,5% nữa ⇒ 11,48px — dưới ngưỡng, dù bản thân `.small` không sao. Đo ở mã CLB
     (`platform/clb.html`, thẻ mobile `.stat-card` liệt kê "Mã · Chủ CLB · …"). Ép `inherit` như cách
     đã dùng cho `.small` lồng trong `meta` — cùng lý do: phần tử con không cần thu thêm khi ngữ cảnh
     cha đã đủ nhỏ. Không giới hạn `.list-card` vì đây là `<code>` bất kỳ nằm trong `.small` bất kỳ. */
  .small code { font-size: inherit; }
  /* `platform/clb.html` (task 8.3), hàng 1 của thẻ mobile: tên CLB + cụm [badge, nút "⋯"] trong một
     `justify-content-between flex-wrap`. Tên CLB không khai `flex`, mặc định `flex: 0 1 auto` ⇒
     flex-basis = 'auto' = BỀ RỘNG NỘI DUNG (vd 125px), TÍNH TRƯỚC khi co — bộ đóng dòng của flexbox
     quyết định ngắt dòng dựa trên tổng flex-basis (không phải kích thước sau co), nên dù `shrink:1`
     lẽ ra đủ chỗ sau khi co, 125+8(gap)+168(cụm) = 301px vẫn vượt 278px khả dụng ⇒ cụm bị đẩy XUỐNG
     DÒNG DƯỚI dù thừa chỗ (đo: 360×800, tên dài 15 ký tự "CLB Test Nologo", row1 75px thay vì ~30px).
     Cùng lỗi/cùng cách sửa đã dùng cho `list-card__title-linked` (5.4b) và `list-card__meta-grow`
     (5.4): ép flex-basis 0 để bộ đóng gói dòng dùng đúng kích thước co được. Card này không dùng
     `.list-card` (bảng riêng `d-lg-none` tự dựng) nên đặt tên class riêng. */
  .stat-card__title-grow { flex: 1 1 0; min-width: 0; }
  .list-card td[data-role="actions"] {
    order: 6; flex: 0 0 auto; margin-left: auto;
    display: flex; align-items: center; gap: .25rem;
  }
  /* `.btn { min-height: var(--tap-min) }` (P1) phóng cả nút "⋯" icon-only lên 44px CAO THẬT, mà nó lại
     là phần tử cao nhất của hàng 2 ⇒ mỗi thẻ cao thêm ~13px chỉ vì một nút 16px icon (đo ở task 5.1:
     10 dòng = 2,63 màn ở 360px, trượt MB-5). Với nút đã mang `is-icon`, vùng chạm 44×44 do
     pseudo-element lo — đúng nguyên tắc P1 "giữ nguyên kích thước NHÌN THẤY, mở rộng vùng CHẠM" — nên
     ở đây trả chiều cao nhìn thấy về tự nhiên. Scope trong ô `actions` của thẻ dữ liệu: không đụng nút
     icon-only ở topbar/entry-header (task 1.8 đã đo với chiều cao hiện tại). */
  .list-card td[data-role="actions"] .btn-sm.is-icon { min-height: 0; }
  /* 🔴 Bug phát hiện sau task 3.4 (báo lại 2026-08-18): bảng "Xem trước bảng chia tiền"
     (expense/fragments.html) không có ô `meta` — nút "Ghi đè" (`actions`, order 6) mất bạn đồng hành
     nên rơi một mình xuống hàng 2, canh phải giữa khoảng trắng trống trơn (hàng 1 chỉ có
     title+amount, hàng 3-4 là Hệ số/Tiền thô dạng fallback riêng dòng) — trông lạc lõng và bị phóng
     đại kích thước so với xung quanh. Gắn thêm class này CHỈ ở bảng đó để kéo nút lên cùng hàng 1,
     ngay sau amount (order bằng amount ⇒ giữ đúng thứ tự DOM amount-rồi-đến-actions). KHÔNG đổi
     `order` mặc định (6) — bảng "Khoản chi" vẫn dùng dropdown "⋯" cạnh `meta` ở hàng 2 như cũ, đã ổn
     định, không phải sửa. */
  .list-card td[data-role="actions"].list-card__action-inline { order: 2; }
  /* `list-card__action-row1-end` (task 5.10 — màn Danh sách buổi tập). Cũng kéo `actions` lên hàng 1
     như `action-inline`, nhưng đặt nó SAU `status` thay vì trước. Vì sao cần biến thể thứ hai thay vì
     dùng lại `action-inline`: ở màn này ô `actions` là một nút CHỮ ("Xem"), và `order: 2` nhét nó vào
     GIỮA ngày và badge trạng thái ⇒ cắt ngang mạch đọc "ngày → trạng thái" và lệch hẳn thứ tự cột của
     bảng desktop (Trạng thái đứng trước cột nút). `order: 3` cho nó BẰNG `status`, và flexbox xếp các
     item cùng `order` theo THỨ TỰ DOM — mà trong markup `<td>` Trạng thái đứng trước `<td>` nút — nên
     badge tự ra trước, nút theo sau, không cần đụng tới markup desktop. `margin-left: 0` là bắt buộc:
     luật gốc cho `actions` là `margin-left: auto`, để nguyên thì CẢ HAI ô cùng tự đẩy sát phải và tách
     rời nhau ra hai đầu (cùng cái bẫy hai chiều `amount`/`status` đã ghi ở C3b) — nay chỉ `status` giữ
     `auto`, kéo nguyên cụm "badge + nút" về sát phải như một khối.
     Chọn bằng số, không phải thẩm mỹ: 4 biến thể đo ở 10 buổi tập thật, xem
     docs/design/11_Mobile_Progress.md § "Chi tiết 5.10". */
  .list-card td[data-role="actions"].list-card__action-row1-end { order: 3; margin-left: 0; }

  /* Ô rỗng biến mất hoàn toàn. CSS không so khớp được nội dung chữ, nên template PHẢI tự đánh dấu:
     th:classappend="${x == null} ? 'is-blank'".
     🔴 C3 (review cuối đợt 0): luật này TỪNG đứng ngay sau `.list-card tbody tr::after` — TRƯỚC cụm
     `td[data-role="title"|"meta"|"actions"] { display: flex }` ở trên. Cả hai phía cùng độ đặc hiệu
     (0,2,1) ⇒ luật đứng SAU (cụm data-role) thắng theo thứ tự nguồn, `display: none` bị đè lại thành
     `display: flex`, ô rỗng vẫn hiện (đã đo thấy `data-role="meta" class="is-blank"` vẫn in `—`). Di
     chuyển luật xuống ĐÂY (sau toàn bộ cụm data-role, kể cả actions) để phần cascade quyết định theo
     thứ tự nguồn, không cần tăng đặc hiệu hay `!important`. */
  .list-card td.is-blank { display: none; }

  /* Fallback: ô CHƯA đánh data-role giữ mẫu nhãn/giá trị cũ, xếp dưới cùng.
     Cho phép chuyển từng màn một thay vì phải sửa hết 50 template trong một commit.
     🔴 Bug phát hiện sau task 3.4 (báo lại 2026-08-18): scope PHẢI là `tbody`, không phải mọi `td`
     trong `.list-card`. `<tfoot>` cũng chứa `<td>` không mang `data-role` (nhãn "Tổng chi phí"/"Chênh
     lệch làm tròn"), nên khớp CẢ selector này — và vì `:not([data-role])` tính đặc hiệu như một class,
     cụm này (0,2,1) đặc hiệu HƠN `.list-card tfoot td` (0,1,2) nên LUÔN thắng bất kể thứ tự nguồn,
     đè `flex-basis: 100%` (dành cho ô xếp riêng một dòng trong tbody) lên đúng ô label của tfoot ⇒ ô
     đó bị ép co hẹp lại (chia đôi 100%+100% trong một `tr` không có `flex-wrap`), chữ vỡ dòng giữa từ
     ("Chênh lệch làm" / "tròn2.000 ₫" dính liền không có khoảng trắng). Thêm `tbody` vào selector để
     loại trừ tfoot — đúng phạm vi ban đầu của luật (dữ liệu tbody chưa migrate), không đổi hành vi tbody. */
  .list-card tbody td:not([data-role]) {
    order: 7; flex-basis: 100%;
    display: flex; justify-content: space-between; align-items: center;
    gap: .5rem; border: none !important; padding: .3rem 0 !important;
    text-align: right !important; font-size: var(--fs-body);
  }
  .list-card tbody td:not([data-role])[data-label]::before {
    content: attr(data-label);
    font-weight: 600; color: var(--text-muted); text-align: left;
  }
  /* 🔴 `text-align: left` đi CÙNG CẶP với `justify-content` ở đây, không phải trang trí (2026-08-22):
     luật cha đặt `text-align: right !important` cho cặp "nhãn — giá trị", nhưng ô KHÔNG có
     `data-label` chỉ chứa một câu trần (dòng rỗng "Chưa có … nào."). `justify-content: flex-start`
     kéo được cả cụm chữ về trái, còn `text-align` vẫn là right ⇒ câu dài WRAP thì dòng thứ hai bị
     đẩy sang mép phải, đọc thành hai khối lệch nhau. Chỉ lộ khi câu đủ dài để xuống dòng, nên
     "Chưa có giao dịch nào." (sổ cái) không bao giờ thấy còn "Chưa có khoản ứng nào CLB còn nợ."
     (tab Tiền ứng, 360×800) thì thấy ngay. Sửa ở primitive vì 5 màn khác cũng có dòng rỗng dài y hệt
     (Sân · Lịch tập · Buổi tập · Công nợ · Loại cầu) và đang cùng lỗi này. */
  .list-card tbody td:not([data-role]):not([data-label]) {
    justify-content: flex-start; text-align: left !important;
  }

  .list-card tfoot tr {
    display: flex; justify-content: space-between; align-items: center;
    background: var(--sidebar-active-bg);
    border-radius: var(--radius-md); padding: .5rem .75rem; font-weight: 700;
  }
  .list-card tfoot td { display: block; width: auto; border: none !important; padding: 0 !important; }
  .list-card tfoot td:empty { display: none; }
  .list-card tfoot td.money { margin-left: auto; }

  /* Trong chế độ thẻ, danh sách khách gộp phải co lại thành cột phải. */
  .list-card .guest-list { min-width: 60%; }

  /* ============================================================
     P3 — header trang nén. Đo được: trên 844px, nội dung thật của màn buổi tập
     bắt đầu ở ~420px vì topbar + breadcrumb + H1 40px + thanh nút 3 hàng.
     ============================================================ */
  .page-header { margin-bottom: var(--space-md); }
  .page-header__crumb { display: none; }           /* breadcrumb thay bằng nút "‹" (nay ở cùng dòng tiêu đề) */
  /* Hàng trên header (breadcrumb + club-pill) rỗng hoàn toàn trên mobile sau khi nút "‹" chuyển
     xuống page-header__row (task 1.3) — ẩn cả hàng để bỏ luôn 4px margin rỗng đẩy nội dung xuống. */
  .page-header__top { display: none; }
  .page-header__subtitle { display: none; }        /* nội dung phụ, không đáng một dòng trên 390px */
  .page-header__top .club-pill { display: none; }  /* tên CLB đã lên topbar */
  .page-header__title { font-size: var(--fs-page); line-height: 1.75rem; }

  /* Nút "‹" nằm CÙNG DÒNG tiêu đề (task 1.3): row xếp từ trái, actions (con duy nhất không phải
     title/back) đẩy về phải bằng margin-left:auto — giữ đúng hành vi cũ (vừa thì nằm phải, dài thì
     rớt xuống dòng). align-items:center để chevron thẳng hàng dọc với dòng tiêu đề (trường hợp phổ
     biến: tiêu đề 1 dòng). */
  .page-header__row { justify-content: flex-start; align-items: center; column-gap: .5rem; }
  .page-header__row > :not(.page-header__title):not(.page-header__back) { margin-left: auto; }

  /* Nhóm 4 tab Cài đặt (Chung / Cách chia tiền / Sân / Loại cầu): trên mobile subtitle bị ẩn nên
     chiều cao header do HÀNG TIÊU ĐỀ quyết định. Tab có nút hành động (Thêm sân / Thêm loại cầu /
     Mã CLB) cao 44px vì `.btn` bị phóng lên var(--tap-min); tab "Cách chia tiền" KHÔNG có nút nên
     hàng chỉ cao ~1 dòng tiêu đề (~28px) ⇒ header thấp hơn ~16px ⇒ thanh tab con bên dưới lệch vị
     trí khi chuyển tab. Ghim min-height hàng tiêu đề = tap-min để cả 4 tab cao bằng nhau (tab thiếu
     nút chỉ dư một khoảng trắng nhỏ). Scope qua marker `--reserve` (chỉ có ở 4 trang này) bằng
     :has — codebase đã dùng :has (xem .action-bar phía dưới), không phải cú pháp mới. */
  .page-header:has(.page-header__subtitle--reserve) .page-header__row { min-height: var(--tap-min); }

  .page-header__back {
    display: inline-flex; align-items: center; justify-content: center;
    flex: 0 0 auto;                                 /* trong row: không co, luôn giữ 44px */
    width: var(--tap-min); height: var(--tap-min);
    margin-left: -.5rem;                            /* chevron hơi lấn trái, căn quang học với lề nội dung */
    color: var(--text-muted); text-decoration: none;
  }
  .page-header__back .bi { font-size: var(--icon-md); }

  /* 🔴 Luật chống wrap tên CLB ở topbar (P3) ĐÃ DỜI ra khối "Topbar tối giản (<992px)" ở phía trên
     tệp — task 1.1. Lý do: topbar mang `d-lg-none` nên sống tới 991,98px, còn khối này chỉ với tới
     767,98px ⇒ dải 768–991px không có bảo vệ (đo được: tên 95 ký tự đội topbar 56px → 103,4px ở
     768px và 991px). Không để lại bản sao ở đây. */

  /* ============================================================
     P4 — thanh hành động phân cấp. Đo được ở màn buổi tập: 5 nút cùng kiểu
     outline, cùng trọng lượng, wrap 3 hàng lởm chởm trên 390px.
     ============================================================ */
  .action-bar { display: flex; gap: var(--space-xs); align-items: stretch; }
  .action-bar__primary { display: block; flex: 1 1 auto; min-width: 0; }
  .action-bar__primary > .btn { width: 100%; }
  /* "Thăng cấp" nút primary thành nút NỀN ĐẶC chỉ ở mobile — markup giữ NGUYÊN class gốc trước
     task 0.5 (`.btn-outline-info`, xem session/buoi.html) để desktop không đổi một pixel nào
     (MB-8, fix round 1 2026-08-15). 🔴 KHÔNG đổi sang `.btn-outline-primary` dù nghe "đúng ngữ
     nghĩa" hơn — đo bằng CDP mới phát hiện `--bs-info`/`--bs-primary` mà app override ở :root
     KHÔNG áp dụng vào `.btn-outline-*` (Bootstrap dùng giá trị hex gốc `#0dcaf0`/`#0d6efd`, hai màu
     THẬT SỰ khác nhau ở app này — ratio tương phản đo được 1.87 vs 4.3), nên đổi class sẽ đổi màu
     desktop thật, không phải chỉ lý thuyết suông.
     🔴 Vì vậy nút "thăng cấp" phải ép màu trực tiếp qua CSS (không nhờ vào class variant nào), và
     phải ép ĐỦ CẢ BA trạng thái (thường/hover/focus/active) bằng chính selector này (2 class +
     pseudo ⇒ độ đặc hiệu cao hơn `.btn-outline-*:hover` gốc của Bootstrap ở mọi thứ tự nạp CSS) —
     nếu chỉ đè `background-color` ở trạng thái thường, kiểu viền/nền trong suốt gốc của Bootstrap
     sẽ lộ lại khi chạm/focus.
     🔴 C4 (review cuối đợt 0): `--action-primary` ở dark = `--court-400` (#3EC49E, trước đợt đổi
     màu là `--blue-400` #60A5FA) — chọn để làm CHỮ/ICON nổi trên nền tối, KHÔNG phải để làm nền đặc
     cho chữ trắng (trắng/court-400 = 2,19:1 — dưới ngưỡng AA 4,5:1, y như blue-400 (2,54:1) trước đây). Đổi
     sang `--action-primary-solid` (dark = `--court-600`, 7,11:1) — token đã có sẵn, dựng đúng cho
     vai trò "nền đặc + chữ trắng" (xem khai
     báo ở trên, dòng ~272), trước đó bị cố ý bỏ qua vì "ngoài phạm vi task 0.6". LIGHT không định
     nghĩa `--action-primary-solid` ⇒ fallback về `--action-primary`/`--action-primary-hover` y hệt
     trước ⇒ `MB-8` (1440px) không đổi. */
  .action-bar__primary > .btn {
    background-color: var(--action-primary-solid, var(--action-primary));
    border-color: var(--action-primary-solid, var(--action-primary));
    color: #fff;
  }
  .action-bar__primary > .btn:hover,
  .action-bar__primary > .btn:focus,
  .action-bar__primary > .btn:focus-visible,
  .action-bar__primary > .btn:active {
    background-color: var(--action-primary-solid, var(--action-primary-hover));
    border-color: var(--action-primary-solid, var(--action-primary-hover));
    color: #fff;
  }
  .action-bar__rest { display: none; }          /* bản hàng ngang — chỉ desktop */
  .action-bar__more { display: block; flex: 0 0 auto; }
  /* Nút trong dropdown trông như mục menu, không như nút. */
  .action-bar__menu .btn {
    display: flex; align-items: center; gap: .5rem;
    width: 100%; min-height: var(--tap-min);
    border: 0; border-radius: 0; background: none;
    justify-content: flex-start; text-align: left;
    padding: 0 1rem; color: var(--text);
  }
  .action-bar__menu .btn-outline-danger, .action-bar__menu .btn-danger { color: var(--status-danger); }
  /* Không có primary ⇒ nút ⋯ tự chiếm trọn bề ngang thay vì đứng lẻ bên phải.
     🔴 KHÔNG dùng :empty — slot vắng (~{}) để lại các node khoảng trắng bên trong
     <div>, nên :empty không bao giờ khớp. :has() kiểm "có phần tử con nào không"
     và đúng trong cả hai trường hợp. */
  .action-bar:not(:has(.action-bar__primary > *)) .action-bar__primary { display: none; }
  .action-bar:not(:has(.action-bar__primary > *)) .action-bar__more { flex: 1 1 auto; }
  .action-bar:not(:has(.action-bar__primary > *)) .action-bar__more > .btn { width: 100%; }

  /* ============================================================
     Đợt 2 — vỏ auth (dang-nhap/dang-ky/quen-mat-khau/da-dat-lai/dat-lai-that-bai/doi-mk-bat-buoc +
     member/nhan-loi-moi). Cả 7 màn dùng chung .auth-shell/.auth-container/.auth-card ⇒ sửa ở ĐÂY ăn
     cả cụm. Số đo tĩnh (Chrome headless + bootstrap.min.css thật) ghi kèm từng luật.
     ============================================================ */
  /* Ô nhập cao ≥ 48px cho dễ gõ một tay trên điện thoại (việc riêng Đợt 2). Đo trước: 46px. */
  .auth-card .form-control { min-height: 48px; }
  /* Nút chính auth khớp chiều cao ô nhập (đo trước: 44px) — CTA/submit đầu trang cần bấm chắc tay. */
  .auth-card .btn, .auth-cta-landing .btn { min-height: 48px; }
  /* Link chữ đứng riêng trong vỏ auth (Tạo tài khoản · Quên mật khẩu? · ← Quay lại · footer) phải đủ
     44px vùng chạm. Đo trước: 17–21px cao. */
  .auth-links a, .auth-alt a, .auth-footer a {
    display: inline-flex; align-items: center; min-height: var(--tap-min);
  }
  /* Logo thương hiệu đầu vỏ auth mang class .auth-brand (KHÔNG phải .app-brand) nên luật pseudo 44px
     của P1 không chạm tới. Đo trước: cao 33,6px. Căn giữa nội dung trong 44px, giữ kích thước nhìn thấy. */
  .auth-brand { min-height: var(--tap-min); }

  /* Nút nổi Góp ý: nhãn "Góp ý" chỉ hiện từ 768px (d-md-inline), nên dưới mốc đó bỏ padding ngang
     để nút tròn đều. Bề rộng/chiều cao vẫn do min-width/min-height ở luật nền lo — không khai lại
     px ở đây (MobileScaleTest cấm bề rộng px cứng trong tầng mobile). */
  .bmh-fab { padding: 0; }

  /* ============================================================
     Đợt 7 — hero buổi tập của trang công khai (share/cong-khai.html · share/cong-khai-bang-chia-
     tien.html · vote/cong-khai.html), người dùng báo lỗi 2026-08-21.
     ============================================================ */
  /* 🔴 "Thứ Năm"/"Chủ Nhật" (2 tên thứ dài nhất) + "dd/mm/yyyy" ở cỡ chữ nền 1.35rem RỘNG HƠN bề
     rộng còn lại của .share-hero ở 390/360px (đo: 251,7px cần vs 242/212px có) ⇒ trình duyệt ngắt
     dòng ngay giữa "Thứ Năm," và "21/08/2026" — 5/7 thứ vẫn vừa nên lỗi chỉ lộ 2/7 lần, dễ bị bỏ
     sót nếu chỉ đo một ngày. Hạ về `--fs-strong` (17px, token "danh tính một dòng dữ liệu" đã có
     sẵn) ⇒ cả 7 thứ đều vừa một dòng ở 360px (đo lại "Chủ Nhật, 21/08/2026": 205,1px < 212px, còn
     dư ~7px). Hệ quả phụ: kèm pill giờ trên cùng hàng giờ KHÔNG còn thứ nào vừa nữa (trước đây 5/7
     vừa, 2/7 vỡ) — `.share-hero__when` vốn đã `flex-wrap: wrap` nên pill tự xuống hàng riêng, y hệt
     hành vi 5/7 thứ cũ, giờ ÁP DỤNG ĐỀU cho cả 7 — nhất quán hơn, không phải hồi quy. Desktop giữ
     1.35rem (container 640px rộng, không đụng ngưỡng này). */
  .share-hero__date { font-size: var(--fs-strong, 1.0625rem); }

  /* Cột "Không tham gia" ở màn Quản lý vote (vote/quan-ly.html, task 7.7) — 2 cột `row-cols-2` xếp
     CẠNH NHAU ở MỌI viewport khiến tên dài wrap 2 dòng sát nút "Xóa" trong ~158px/cột ở 390px; đổi
     sang `row-cols-1 row-cols-md-2` (xếp chồng dưới 768px) nên viền dọc `.border-start` cũ không
     còn hợp lý — thay bằng viền NGANG + khoảng cách phía trên khi đứng riêng một hàng. Bản đối ứng
     viền dọc cho ≥768px nằm ở khối desktop phía trên (trước mốc tầng mobile). */
  .vote-result-col { border-top: 1px solid var(--border); padding-top: var(--space-sm); margin-top: var(--space-sm); }

  /* Nút "Xem bản đồ" (fragments/buoi-hero :: sanBox, khối .share-loc) sống lồng trong .auth-card
     (vỏ dùng chung của 3 trang công khai — auth-shell không có bản riêng cho "trang công khai không
     phải form đăng nhập") nên vô tình ăn `.auth-card .btn { min-height: 48px }` — cỡ dành cho nút
     SUBMIT CHÍNH của form đăng nhập/đăng ký, quá to so với một hành động PHỤ nằm trong khối hero dày
     đặc (badge/chip xung quanh chỉ 13-16px). Người dùng báo 2026-08-21. Trả về đúng sàn --tap-min
     chung (44px) — vẫn đủ P1, không còn thừa 4px "nặng tay" so với phần còn lại của hero. */
  .share-hero .btn { min-height: var(--tap-min); }

  /* ============================================================
     Đợt 8 — `.modal-sheet`: modal dạng BOTTOM SHEET trên điện thoại.
     Dùng đầu tiên ở modal "Lọc thành viên" (member/thanh-vien.html), 2026-08-22.
     ============================================================ */
  /* 🔴 Đây là lời đáp cho cái bẫy đã ghi ở task 6.1 phía trên: hộp thoại căn giữa cao hơn viewport
     thì `.modal-footer` rơi HOÀN TOÀN dưới nếp gấp — mở modal ra không thấy nút lưu ở đâu, và
     KHÔNG tiêu chí số nào bắt được (MB-1 không đo được vì modal không tràn ngang). Sheet đóng hẳn
     cả lớp lỗi đó: chiều cao bị kẹp ở 85vh, phần vượt đi vào vùng cuộn của `.modal-body`, còn
     `.modal-footer` là anh em ruột nằm ngoài vùng cuộn nên luôn thấy được.
     Lợi ích thứ hai, đo bằng ngón tay chứ không bằng pixel: nút hành động tụt xuống sát đáy màn —
     vùng ngón cái với tới dễ nhất khi cầm một tay (CT-6). */
  .modal-sheet .modal-dialog {
    position: fixed;
    inset: auto 0 0 0;
    margin: 0;
    max-width: none;
  }

  /* Bo góc CHỈ ở trên: cạnh dưới dính mép màn hình, bo nốt sẽ hở hai khe tam giác ở đáy. */
  .modal-sheet .modal-content {
    max-height: 85vh;
    border-radius: var(--radius-card) var(--radius-card) 0 0;
  }

  /* Vùng cuộn DUY NHẤT của sheet. Không có dòng này thì `.modal-content` tự cắt cụt nội dung
     (overflow mặc định) và các nhóm lọc cuối biến mất không dấu vết. */
  .modal-sheet .modal-body { overflow-y: auto; }

  /* Tay nắm — thuần trang trí nên làm bằng pseudo-element, KHÔNG thêm thẻ vào từng modal dùng
     class này. Nó là tín hiệu "khối này trượt lên từ đáy", thứ người dùng điện thoại đọc được ngay
     mà không cần chữ. Kích thước bằng rem: tầng mobile cấm hardcode bề rộng px (MobileScaleTest). */
  .modal-sheet .modal-header {
    position: relative;
    padding-top: var(--space-lg);
  }

  .modal-sheet .modal-header::before {
    content: '';
    position: absolute;
    top: .375rem;
    left: 50%;
    transform: translateX(-50%);
    width: 2.25rem;
    height: .25rem;
    border-radius: var(--radius-pill);
    background: var(--border-strong);
  }

  /* Trượt lên TỪ ĐÁY thay vì rơi từ trên xuống (Bootstrap mặc định translate(0, -50px)) — hướng
     chuyển động phải khớp với chỗ khối này thật sự nằm, nếu không mắt sẽ đọc sai nó là hộp thoại. */
  .modal-sheet.fade .modal-dialog { transform: translateY(100%); }
  .modal-sheet.show .modal-dialog { transform: none; }

  /* Chip lọc (btn-check) trong sheet — thu theo CHIỀU NGANG, người dùng báo pill quá to (2026-08-22).
     🔴 Chỉ hạ cỡ chữ và đệm ngang; chiều cao vẫn là 44px của `.btn { min-height: var(--tap-min) }`
     phía trên. Hạ chiều cao mới là thứ phá MB-3 — đúng loại lỗi mà đợt 0-7 vừa đi dẹp (31/34/36/38px
     về 44px), nên không đánh đổi. Hẹp lại vẫn giải quyết được vấn đề thật: nhóm "Trạng thái" 5 chip
     rơi từ 3 hàng xuống 2, mỗi nhóm bớt một hàng ⇒ sheet thấp hẳn.
     `--fs-caption` (13px, sàn thấp nhất của thang P1) thay vì `--fs-body`: đây là CHIP lọc đọc lướt
     một cụm từ, không phải nút hành động — cùng vai trò với chip meta mà token này vốn dành cho. */
  .filter-pills .btn {
    font-size: var(--fs-caption);
    padding-left: .5rem;
    padding-right: .5rem;
  }
}
