/* ============================================================================
   motion.css — lớp chuyển động dùng chung, theo bộ luật + token của Animate UI
   (skill .claude/skills/animate-ui, nhánh C: "markup thuần", mức SUBTLE).

   App không dùng React nên không cài @animate-ui/react: các giá trị dưới đây là
   chính token của nó, in ra bằng `node .claude/skills/animate-ui/scripts/css-timing.mjs
   <token>` (spring ⇒ đường cong linear() mà Motion sẽ chạy). Đổi một giá trị thì in
   lại bằng script đó, đừng gõ tay.

   Nạp SAU app.css (fragments/layout :: head và landing/gioi-thieu.html — trang `/`
   tự dựng <head> riêng, LP-7) để đè đúng các luật transition cũ của Bootstrap/app.

   🔴 Chỉ animate transform · opacity · filter (+ height của collapse). Không `all`.
   🔴 Hover chỉ trong @media (hover: hover) — cảm ứng giữ :hover sau khi nhấc tay.
   🔴 "Giảm chuyển động" của hệ điều hành: app.css đã tắt MỌI transition/animation
   (`* { transition: none !important; animation: none !important }`), nên ở đây chỉ
   còn một khối nhắc lại điểm xuất phát tĩnh cho phòng khi luật đó được nới ra.
   ============================================================================ */

:root {
  /* Đường cong (tokens.md § Curves). */
  --aui-ease-out: cubic-bezier(0.23, 1, 0.32, 1);       /* vào/ra */
  --aui-ease-soft: cubic-bezier(0.215, 0.61, 0.355, 1); /* bề mặt mắt nhìn thấy mở ra */
  --aui-ease-away: cubic-bezier(0.33, 0.17, 0.67, 0.5); /* bị đóng: đi ngay, nhanh dần */
  --aui-ease-color: cubic-bezier(0.25, 0.1, 0.25, 1);   /* đổi màu */

  /* Spring (stiffness/damping của Animate UI, đã đổi sang linear()). */
  /* spring.press 550/30 — tới nơi ~120ms */
  --aui-spring-press: 400ms linear(0, 0.0261, 0.0935, 0.188, 0.2977, 0.4132, 0.5271, 0.6343, 0.7313, 0.8159, 0.8872, 0.9452, 0.9906, 1.0244, 1.048, 1.063, 1.0709, 1.0732, 1.0713, 1.0663, 1.0594, 1.0514, 1.0429, 1.0346, 1.0267, 1.0196, 1.0134, 1.0082, 1.004, 1.0007, 0.9982, 0.9965, 1, 0.9948, 0.9946, 0.9948, 1, 1, 1, 1);
  /* spring.highlight 500/40 — nhanh, không vượt */
  --aui-spring-highlight: 300ms linear(0, 0.0233, 0.0813, 0.1598, 0.2483, 0.3396, 0.4288, 0.5126, 0.5894, 0.658, 0.7183, 0.7704, 0.8149, 0.8524, 0.8837, 0.9094, 0.9303, 0.9472, 0.9607, 0.9713, 0.9796, 0.986, 0.9908, 0.9944, 0.997, 0.9988, 1.0001, 1.001, 1.0015, 1);
  /* spring.dialog 150/25 — điềm tĩnh, không vượt */
  --aui-spring-dialog: 750ms linear(0, 0.0071, 0.0261, 0.0541, 0.0886, 0.1279, 0.1702, 0.2143, 0.2592, 0.3041, 0.3485, 0.3918, 0.4336, 0.4738, 0.5121, 0.5485, 0.5829, 0.6153, 0.6456, 0.674, 0.7004, 0.725, 0.7479, 0.769, 0.7886, 0.8066, 0.8233, 0.8386, 0.8527, 0.8656, 0.8775, 0.8884, 0.8983, 0.9074, 0.9158, 0.9234, 0.9303, 0.9367, 0.9424, 0.9477, 0.9525, 0.9569, 0.9609, 0.9645, 0.9678, 0.9708, 0.9735, 0.976, 0.9782, 0.9803, 0.9821, 0.9838, 0.9853, 0.9867, 0.9879, 0.9891, 0.9901, 0.9911, 0.9919, 0.9927, 0.9934, 0.994, 0.9946, 0.9951, 0.9955, 0.996, 0.9964, 0.9967, 0.997, 0.9973, 0.9976, 0.9978, 1, 1, 1);
  /* spring.toast 280/24 — "đáp xuống", vượt ~4% */
  --aui-spring-toast: 550ms linear(0, 0.0134, 0.0492, 0.1017, 0.1659, 0.2377, 0.3134, 0.3904, 0.4664, 0.5396, 0.6088, 0.673, 0.7317, 0.7846, 0.8315, 0.8726, 0.9081, 0.9382, 0.9634, 0.984, 1.0006, 1.0136, 1.0234, 1.0305, 1.0353, 1.0381, 1.0393, 1.0393, 1.0382, 1.0364, 1.0341, 1.0314, 1.0284, 1.0254, 1.0224, 1.0194, 1.0166, 1.014, 1.0116, 1.0094, 1.0075, 1.0057, 1.0042, 1.0029, 1.0019, 1.001, 1.0003, 0.9997, 0.9992, 0.9989, 1, 1, 1, 1, 1);

  /* spring.selection 300/30 — trượt êm giữa các mục (vạch tab) */
  --aui-spring-selection: 500ms linear(0, 0.0141, 0.0509, 0.1035, 0.1663, 0.2349, 0.306, 0.3769, 0.4459, 0.5115, 0.573, 0.6297, 0.6814, 0.728, 0.7696, 0.8064, 0.8387, 0.8668, 0.8911, 0.9119, 0.9296, 0.9445, 0.957, 0.9673, 0.9758, 0.9827, 0.9882, 0.9926, 0.9961, 0.9987, 1.0007, 1.0021, 1.0031, 1.0038, 1.0041, 1, 1, 1, 1.004, 1.0038, 1.0035, 1.0032, 1.0029, 1.0026, 1.0023, 1.002, 1.0017, 1.0015, 1, 1);
  /* spring.thumb 300/25 — núm công tắc, vượt ~4% */
  --aui-spring-thumb: 500ms linear(0, 0.0143, 0.0525, 0.1082, 0.1759, 0.2509, 0.3297, 0.4092, 0.487, 0.5614, 0.6311, 0.6953, 0.7534, 0.8052, 0.8507, 0.89, 0.9235, 0.9516, 0.9747, 0.9933, 1.0079, 1.0189, 1.027, 1.0325, 1.0359, 1.0375, 1.0377, 1.0369, 1.0352, 1.0329, 1.0303, 1.0274, 1.0244, 1.0214, 1.0185, 1.0157, 1.0131, 1.0108, 1.0087, 1.0068, 1.0051, 1.0037, 1.0025, 1.0015, 1.0007, 1.0001, 0.9996, 0.9992, 0.9989, 1);

  /* Tween (tokens.md § Tweens). */
  --aui-t-swap: 150ms var(--aui-ease-out);         /* transition.swap */
  --aui-t-pop-out: 150ms var(--aui-ease-out);      /* transition.popOut */
  --aui-t-color: 200ms var(--aui-ease-color);      /* transition.color */
  --aui-t-menu: 220ms var(--aui-ease-soft);        /* transition.menu */
  --aui-t-fade: 200ms var(--aui-ease-out);         /* transition.fade */
  --aui-t-reveal: 300ms var(--aui-ease-out);       /* transition.reveal (css-timing.mjs, Task 20) */
  --aui-t-dialog-fade: 400ms var(--aui-ease-soft); /* transition.dialogFade */
  --aui-t-overlay: 400ms var(--aui-ease-soft);     /* transition.overlay */
  --aui-t-sheet: 400ms var(--aui-ease-soft);       /* transition.sheet */
  --aui-t-disclosure: 350ms var(--aui-ease-soft);  /* transition.disclosure */
  --aui-t-dismiss: 200ms var(--aui-ease-away);     /* transition.dismiss */

  /* Điểm xuất phát (tokens.md § Scales/Blur). */
  --aui-scale-press: .97;
  --aui-scale-choice: .985;
  --aui-scale-menu: .9;
  --aui-scale-toast: .96;
  --aui-dialog-from: perspective(500px) rotateX(-20deg) scale(.8);
  --aui-blur-surface: blur(4px);
  --aui-blur-icon: blur(2px);
}

/* ---------------------------------------------------------------------------
   1. NHẤN (recipes/press.md) — lún 0.97 trên spring.press, nhả ra bật về cùng
   spring. app.css đã có cú lún ở `.btn:active`; ở đây chỉ thay nhịp: trước là
   100ms ease-out (đi thẳng rồi dừng cứng), nay là spring nên nhả tay có một nhịp
   bật rất nhẹ. Bốn thuộc tính màu khai lại vì `transition` không cộng dồn.
   --------------------------------------------------------------------------- */
.btn {
  transition: color var(--aui-t-color),
              background-color var(--aui-t-color),
              border-color var(--aui-t-color),
              box-shadow var(--aui-t-color),
              transform var(--aui-spring-press);
}

/* Thẻ số liệu bấm được (recipes/card.md): bóng đổ đổi trên transition.color, nhấc
   và lún trên spring.highlight để rời chuột là quay về ngay, không trôi. */
.stat-card {
  transition: box-shadow var(--aui-t-color),
              border-color var(--aui-t-color),
              transform var(--aui-spring-highlight);
}
@media (hover: hover) and (pointer: fine) {
  a.stat-card:hover { transform: translateY(-2px); }
}
a.stat-card:active { transform: scale(var(--aui-scale-choice)); }

/* ---------------------------------------------------------------------------
   2. MENU (recipes/menu.md) — dropdown Bootstrap và danh sách Tom Select mọc ra
   từ phía nút đã mở nó: mờ → rõ, 0.9 → 1, transition.menu (220ms ease.soft).
   🔴 Dùng thuộc tính `scale` riêng, KHÔNG `transform`: Popper định vị
   .dropdown-menu bằng `transform: translate(...)` inline — animate `transform` là
   giành chỗ với nó và menu bay về góc trang trong lúc chạy.
   Chỉ có lối VÀO: Bootstrap và Tom Select tắt menu bằng display:none ngay lập
   tức. Menu mở cả ngày nên đóng tức thì vẫn đúng tinh thần "ngắn và chính xác".
   --------------------------------------------------------------------------- */
@keyframes aui-menu-in {
  from { opacity: 0; scale: var(--aui-scale-menu); }
}
.dropdown-menu { transform-origin: top left; }
.dropdown-menu-end,
.dropdown-menu[data-popper-placement$="-end"] { transform-origin: top right; }
.dropdown-menu[data-popper-placement^="top"] { transform-origin: bottom left; }
.dropdown-menu[data-popper-placement="top-end"] { transform-origin: bottom right; }
.dropdown-menu.show { animation: aui-menu-in var(--aui-t-menu); }

/* Tom Select bật/tắt danh sách bằng style.display — animation chạy lại mỗi lần
   danh sách từ none chuyển sang hiện. Lật lên trên (bmhDatViTriDropdown ở
   fragments/layout.html gắn .bmh-dropdown-lat-len) thì mọc từ mép dưới. */
.ts-dropdown {
  transform-origin: top center;
  animation: aui-menu-in var(--aui-t-menu);
}
.ts-dropdown.bmh-dropdown-lat-len { transform-origin: bottom center; }

/* ---------------------------------------------------------------------------
   3. HỘP THOẠI (recipes/modal.md, rules B7) — nội dung hiện dần ngay
   (dialogFade), hết nhoè và "ngả" về phía trước từ perspective/rotateX(-20deg)/
   scale(.8) trên spring.dialog. Bị đóng: quay lại đúng đường đã tới, trên
   transition.dismiss (200ms, đi ngay). Vẫn căn giữa.
   🔴 Bootstrap chờ transition để biết khi nào xong:
     • mở  → chờ .modal-dialog, đọc thời lượng của thuộc tính ĐẦU TIÊN và nhận
       transitionend ĐẦU TIÊN ⇒ `filter` (400ms) đứng đầu, nên focus vào modal sau
       ~400ms chứ không phải đợi hết đuôi 750ms của spring.
     • đóng → chờ chính .modal (opacity) ⇒ opacity đóng phải dài bằng dialog
       (200ms), ngắn hơn là modal bị display:none giữa chừng.
   --------------------------------------------------------------------------- */
.modal.fade { transition: opacity var(--aui-t-dismiss); }
.modal.fade.show { transition: opacity var(--aui-t-dialog-fade); }

.modal.fade .modal-dialog {
  transform: var(--aui-dialog-from);
  filter: var(--aui-blur-surface);
  transition: filter var(--aui-t-dismiss), transform var(--aui-t-dismiss);
}
/* Lúc nghỉ PHẢI là none/none: transform hay filter còn sót lại biến hộp thoại
   thành containing block của con position:fixed bên trong nó. */
.modal.show .modal-dialog {
  transform: none;
  filter: none;
  transition: filter var(--aui-t-dialog-fade), transform var(--aui-spring-dialog);
}
/* Cú "nảy" của Bootstrap khi bấm ra ngoài modal static — giữ nguyên, chỉ thay nhịp. */
.modal.modal-static .modal-dialog { transform: scale(1.02); }

/* Toàn màn hình: không ngả, không nhoè (filter trên cả màn hình là đắt nhất). */
.modal.fade .modal-fullscreen { transform: none; filter: none; }

/* Lớp nền tối: Bootstrap mở nền TRƯỚC rồi mới mở hộp thoại (chờ transition của
   nền xong), nên kéo dài nền lên 400ms là bắt người dùng chờ thêm 250ms mới thấy
   nội dung. Giữ thời lượng 150ms của Bootstrap, chỉ thay đường cong linear. */
.modal-backdrop.fade { transition: opacity 150ms var(--aui-ease-soft); }

/* Điện thoại. Hai biến thể không ngả:
   • .modal-sheet (sheet dưới đáy, app.css "Đợt 8") — trượt lên từ mép bằng chính
     chiều cao của nó, không nảy (sát mép màn hình), transition.sheet vào /
     transition.dismiss ra (rules B7 "Sheet"). sheet-keo.js vẫn tự đặt
     transitionDuration inline khi kéo-thả, và nó thắng luật ở đây.
   • hộp thoại nổi còn lại — modal trên điện thoại thường cao gần kín màn, ngả
     rotateX(-20deg) với phối cảnh 500px làm méo hẳn mép trên/dưới. Nên chỉ mọc từ
     0.9 + nhoè (bước "subtler" của feel.md). */
@media (max-width: 767.98px) {
  .modal.fade:not(.modal-sheet) .modal-dialog { transform: scale(var(--aui-scale-menu)); }
  .modal.show:not(.modal-sheet) .modal-dialog { transform: none; }

  .modal.modal-sheet.fade .modal-dialog {
    transform: translateY(100%);
    filter: none;
    transition: transform var(--aui-t-dismiss);
  }
  .modal.modal-sheet.show .modal-dialog {
    transform: none;
    transition: transform var(--aui-t-sheet);
  }
}

/* ---------------------------------------------------------------------------
   4. OFFCANVAS (menu điện thoại #menuOffcanvas) — sheet trượt từ mép:
   transition.sheet vào, transition.dismiss ra; nền mờ vào/ra cùng
   transition.overlay. Offcanvas không chờ nền nên nền dài 400ms không làm ai chờ.
   --------------------------------------------------------------------------- */
.offcanvas { --bs-offcanvas-transition: transform var(--aui-t-sheet); }
.offcanvas.hiding { --bs-offcanvas-transition: transform var(--aui-t-dismiss); }
.offcanvas-backdrop.fade { transition: opacity var(--aui-t-overlay); }

/* ---------------------------------------------------------------------------
   5. MỞ/GẬP (recipes/disclosure.md) — collapse của Bootstrap: 350ms ease.soft
   thay cho .35s ease, để thấy nó MỞ RA chứ không giật mở.
   --------------------------------------------------------------------------- */
.collapsing { transition: height var(--aui-t-disclosure); }
.collapsing.collapse-horizontal { transition: width var(--aui-t-disclosure); }

/* ---------------------------------------------------------------------------
   6. TOAST (recipes/toast.md) — BmhToast ở góc trên: hạ xuống bằng chính chiều
   cao của nó, mọc từ 0.96, hết nhoè 4px, trên spring.toast. Bị đóng / hết giờ:
   mờ đi, thu về 0.96, nhoè lại trên transition.dismiss.
   notification.js tạo Toast với `animation: false` và tự chạy lối ra (gắn
   .is-leaving rồi gỡ khỏi DOM khi animation xong) — hiệu ứng .fade/.showing của
   Bootstrap chỉ biết opacity và đụng nhau với animation ở đây.
   Blur nằm ở animation mờ (tween), KHÔNG ở animation di chuyển: spring vượt quá
   1 sẽ đẩy blur về số âm.
   --------------------------------------------------------------------------- */
@keyframes aui-toast-in-move {
  from { transform: translateY(-100%) scale(var(--aui-scale-toast)); }
}
@keyframes aui-toast-in-fade {
  from { opacity: 0; filter: var(--aui-blur-surface); }
}
@keyframes aui-toast-out {
  to { opacity: 0; transform: scale(var(--aui-scale-toast)); filter: var(--aui-blur-surface); }
}
#bmh-toast-container .toast { transform-origin: top center; }
#bmh-toast-container .toast.show {
  animation: aui-toast-in-move var(--aui-spring-toast), aui-toast-in-fade var(--aui-t-fade);
}
#bmh-toast-container .toast.is-leaving {
  pointer-events: none;
  animation: aui-toast-out var(--aui-t-dismiss) forwards;
}
/* Có topbar mobile (≤991.98px): toast nằm dưới topbar (app.css) và chỉ hạ xuống 12px thay vì cả
   chiều cao — hạ từ -100% sẽ chạm mép trên màn hình và iOS 26 tô thanh trạng thái thành màu toast
   rồi giữ nguyên tới khi tắt app (người dùng báo 02/11/2026). */
@keyframes aui-toast-in-move-ngan {
  from { transform: translateY(-12px) scale(var(--aui-scale-toast)); }
}
@media (max-width: 991.98px) {
  #bmh-toast-container .toast.show {
    animation: aui-toast-in-move-ngan var(--aui-spring-toast), aui-toast-in-fade var(--aui-t-fade);
  }
}

/* ===========================================================================
   PRIMITIVE DÙNG LẠI (kế hoạch 2026-10-29, Task 0). Các màn CHỈ gắn thuộc tính
   `data-aui` / class, không viết lại hiệu ứng. Cả bốn primitive htmx dựa trên
   class do CHÍNH htmx quản (`htmx-added` · `htmx-swapping` · `htmx-request`) —
   class do JS của ta gắn vào phần tử vừa swap sẽ bị bước settle gỡ mất
   (templates/CLAUDE.md, bẫy attribute settling).
   =========================================================================== */

/* ---------------------------------------------------------------------------
   7. VÀO — `data-aui="vao"` (interactions/list.md, mức subtle): phần tử htmx vừa
   chèn hiện dần và nhô lên 8px. htmx gắn `htmx-added` cho phần tử GỐC của nội
   dung vừa swap rồi gỡ sau ~20ms (settleDelay) ⇒ transition chạy từ trạng thái ẩn.
   Hai dạng bộ chọn:
     • chính phần tử được swap mang `data-aui="vao"` (hx-swap outerHTML một dòng);
     • cả khối được swap lại, server chỉ gắn `data-aui="vao"` lên DÒNG VỪA THÊM —
       chỉ dòng đó chuyển động, phần còn lại của danh sách đứng yên.
   🔴 Chỉ ẩn khi có .htmx-added: lúc tải trang không có class đó ⇒ không chạy, và
   khi animation bị tắt thì phần tử vẫn hiện sau 20ms (MotionCssTest gác).
   `translate` (thuộc tính riêng) chứ không `transform`: không giành chỗ với
   transform sẵn có của phần tử.
   --------------------------------------------------------------------------- */
[data-aui="vao"] {
  transition: opacity var(--aui-t-fade), translate var(--aui-spring-highlight);
}
.htmx-added[data-aui="vao"],
.htmx-added [data-aui="vao"] {
  opacity: 0;
  translate: 0 8px;
}

/* ---------------------------------------------------------------------------
   8. RA — `data-aui="ra"` trên phần tử sắp bị thay/xoá, kèm
   `hx-swap="outerHTML swap:150ms"`: htmx gắn `htmx-swapping` lên đích trong lúc
   trễ ⇒ mờ đi và co nhẹ trên transition.popOut (150ms = đúng độ trễ swap).
   🔴 KHÔNG dùng ở tick điểm danh: 150ms trễ nhân 28 người là trừ thẳng vào ngân
   sách chốt sổ (M-TSK-1). Chỉ cho thao tác xoá/huỷ hiếm.
   --------------------------------------------------------------------------- */
[data-aui="ra"] {
  transition: opacity var(--aui-t-pop-out), scale var(--aui-t-pop-out);
}
[data-aui="ra"].htmx-swapping {
  opacity: 0;
  scale: .98;
}

/* ---------------------------------------------------------------------------
   9. SỐ — `data-aui="so"` trên giá trị đổi tại chỗ (tiền, bộ đếm) khi nó tới qua
   swap (kể cả hx-swap-oob): mờ qua blur 2px trên transition.swap. Chữ KHÔNG phóng
   to/thu nhỏ (rules B9). Giữ bề rộng ổn định bằng `tabular-nums` để dãy số cạnh
   bên không xô lệch.
   --------------------------------------------------------------------------- */
[data-aui="so"] {
  font-variant-numeric: tabular-nums;
  transition: opacity var(--aui-t-swap), filter var(--aui-t-swap);
}
/* 🔴 Dạng con cháu CHỈ khi khối vừa swap tự đăng ký bằng `data-aui-so-con` (người dùng bắt
   29/10/2026): bản đầu là `.htmx-added [data-aui="so"]` trơn, khớp MỌI số đánh dấu nằm trong BẤT KỲ
   khối nào vừa swap — chuyển sang tab Điểm danh là cả nội dung tab nạp lại, số "có mặt" tắt rồi hiện
   lại dù không đổi. Khối được swap riêng ĐỂ ĐỔI SỐ (OOB bộ đếm sau một cú tick) mới gắn
   `data-aui-so-con`; lần nạp cả tab render khối đó không có thuộc tính này ⇒ số đứng yên. */
.htmx-added[data-aui="so"],
.htmx-added[data-aui-so-con] [data-aui="so"] {
  opacity: 0;
  filter: var(--aui-blur-icon);
}

/* ---------------------------------------------------------------------------
   10. NÚT ĐANG GỬI — `data-aui="nut"` trên nút gửi request htmx. Khi request
   đang chạy, htmx gắn `htmx-request` lên phần tử phát request: chính nút (hx-post
   trên nút) hoặc <form> chứa nó (form boosted). Nhãn mờ đi, vòng quay hiện ở
   giữa, nút không bấm thêm được. Bề rộng nút giữ nguyên (nhãn chỉ mờ, không bị gỡ).
   🔴 `pointer-events: none` chỉ chặn CON TRỎ (đo: nhấp đúp chuột thật = 1 request).
   Enter lặp trên bàn phím vẫn gửi lại — nút mà gửi hai lần là sai nghiệp vụ (Chốt
   sổ, ghi nhận đã thu) thì màn đó thêm `hx-sync="this:drop"`; server vẫn phải tự
   chống trùng như cũ.
   Nút bận vì việc chạy TRÊN MÁY (không request htmx — vd tạo ảnh vinh danh) thì JS
   của nút gắn `aria-busy="true"` và tự gỡ khi xong/lỗi; cùng một vòng quay (Task 15).
   --------------------------------------------------------------------------- */
@keyframes aui-quay { to { rotate: 360deg; } }
@keyframes aui-nhip { 50% { opacity: .35; } }
[data-aui="nut"] { position: relative; }
[data-aui="nut"].htmx-request,
form.htmx-request [data-aui="nut"],
[data-aui="nut"][aria-busy="true"] {
  pointer-events: none;
  color: transparent !important; /* !important: thắng màu chữ của từng biến thể .btn-* */
}
[data-aui="nut"].htmx-request::after,
form.htmx-request [data-aui="nut"]::after,
[data-aui="nut"][aria-busy="true"]::after {
  content: '';
  position: absolute;
  inset: 0;
  margin: auto;
  width: 1.1em;
  height: 1.1em;
  border: 2px solid currentColor;
  border-color: var(--bs-btn-color, currentColor);
  border-right-color: transparent;
  border-radius: 50%;
  animation: aui-quay .7s linear infinite;
}

/* ---------------------------------------------------------------------------
   11. KHUNG CHỜ — `.aui-khung-cho` (interactions/skeleton.md): thay chữ "Đang
   tải…" của khối nạp lười. Một vệt sáng quét ngang bằng `transform` trên ::after
   (compositor). Trình duyệt tự dừng animation khi tab ẩn; giảm chuyển động thì
   vệt đứng yên (khối dưới).
   Dùng: `<div class="aui-khung-cho" style="height: 3rem"></div>` — chiều cao do
   chỗ dùng quyết định, để khung chờ có đúng hình của thứ sắp tới.
   --------------------------------------------------------------------------- */
@keyframes aui-quet { from { translate: -100% 0; } to { translate: 100% 0; } }
.aui-khung-cho {
  position: relative;
  overflow: hidden;
  min-height: 1em;
  border-radius: var(--radius-btn, .5rem);
  background: var(--surface-sunken, #f1f5f9);
}
.aui-khung-cho::after {
  content: '';
  position: absolute;
  inset: 0;
  background: linear-gradient(90deg, transparent, rgba(255, 255, 255, .55), transparent);
  animation: aui-quet 1.4s linear infinite;
}
[data-bs-theme="dark"] .aui-khung-cho::after {
  background: linear-gradient(90deg, transparent, rgba(255, 255, 255, .07), transparent);
}
/* Danh sách chờ dựng sẵn — `fragments/ui :: khungCho(soDong)`: mỗi dòng cao bằng vùng chạm 44px. */
.khung-cho-ds { display: grid; gap: var(--space-xs, .5rem); }
.khung-cho-ds .aui-khung-cho { height: var(--tap-min, 2.75rem); }

/* ---------------------------------------------------------------------------
   12. VẠCH TAB — `data-aui-tab` trên `.nav` (recipes/indicator.md): một vạch 2px
   dưới tab đang chọn, trượt sang tab mới trên spring.selection. motion.js đo tab
   `.active` rồi ghi `--aui-tab-x`/`--aui-tab-w` lên chính `.nav`; vạch là ::after
   nên không chèn thêm phần tử nào vào DOM (morph/settle không đụng tới).
   Chuyển động chỉ bật sau lần đo đầu (`data-aui-tab-san-sang`) — không thì vạch
   trượt từ mép trái mỗi lần tải trang.
   Vạch có `width` chuyển động: ngoại lệ có chủ đích của rules A8 cho indicator
   (scaleX sẽ kéo méo bo góc).
   --------------------------------------------------------------------------- */
/* :where() ⇒ độ ưu tiên 0: thanh tab nào đã tự là sticky/absolute (`.buoi-tabs` dính đầu màn trên
   điện thoại) giữ nguyên — phần tử định vị nào cũng làm khối chứa cho ::after được. */
:where(.nav[data-aui-tab]) { position: relative; }
.nav[data-aui-tab]::after {
  content: '';
  position: absolute;
  left: 0;
  bottom: 0;
  height: 2px;
  width: var(--aui-tab-w, 0);
  translate: var(--aui-tab-x, 0) 0;
  border-radius: 2px;
  background: var(--action-primary, var(--bs-primary));
  pointer-events: none;
}
.nav[data-aui-tab][data-aui-tab-san-sang]::after {
  transition: translate var(--aui-spring-selection), width var(--aui-spring-selection);
}

/* ---------------------------------------------------------------------------
   13. LỖI FIELD (interactions/field-error.md, subtle) — dòng lỗi của Bootstrap
   hiện dần và hạ 4px khi nó từ display:none chuyển sang hiện. Không rung: lỗi
   validate là thông tin, không phải lời trách.
   --------------------------------------------------------------------------- */
@keyframes aui-loi-vao { from { opacity: 0; translate: 0 -4px; } }
.is-invalid ~ .invalid-feedback,
.was-validated :invalid ~ .invalid-feedback {
  animation: aui-loi-vao var(--aui-t-fade);
}

/* ---------------------------------------------------------------------------
   14. CÔNG TẮC (recipes/press.md § Switch) — núm trượt sang bên trên spring.thumb,
   màu nền đổi ngay trên transition.color. Bootstrap vẽ núm bằng
   background-position nên đó là thuộc tính duy nhất có thể chuyển động ở đây.
   --------------------------------------------------------------------------- */
.form-switch .form-check-input {
  transition: background-position var(--aui-spring-thumb),
              background-color var(--aui-t-color),
              border-color var(--aui-t-color),
              box-shadow var(--aui-t-color);
}

/* ===========================================================================
   MÀN: buoi-khung — chi tiết buổi tập (kế hoạch 2026-10-29, Task 1)
   =========================================================================== */
/* Đổi tab: nội dung tab mới hiện dần trên transition.fade (200ms ease.out) thay cho
   `.15s linear` của Bootstrap.
   🔴 `visibility 0s` ĐỨNG ĐẦU danh sách là có chủ đích: Bootstrap đọc thời lượng của
   thuộc tính ĐẦU TIÊN để biết phải chờ bao lâu rồi mới gắn `.show` cho tab mới. Để
   opacity đứng đầu thì nó chờ trọn 200ms với một ô TRỐNG rồi mới bắt đầu mờ vào ⇒ đổi
   tab thành 400ms. Với 0s, tab mới bắt đầu hiện ngay khung hình kế. (visibility không
   đổi giá trị nên không sinh transition thật nào.) */
.buoi-chi-tiet .tab-content > .tab-pane.fade {
  transition: visibility 0s, opacity var(--aui-t-fade);
}
/* 🔴 Bốn hộp thoại NẰM TRONG luồng chốt sổ (M-TSK-1) mở bằng dạng NGẮN NHẤT (Task 22, 30/10/2026):
   mờ + mọc từ 0.97 trên transition.fade (200ms), không ngả, không nhoè. Đo A/B ở 390×844, CPU ×4: hộp
   thoại mặc định (mục 3, spring.dialog 750ms) cần ~1,03 giây từ lúc hiện tới lúc nút trong hộp đứng yên
   để bấm ⇒ cả luồng chậm thêm 1,3–3,9 giây so với trước Đợt 0 (hộp confirm() của trình duyệt). Luồng
   này chạy MỖI buổi tập, tại sân — "ngắn nhất của gate" là luật chung của kế hoạch. Thêm một hộp thoại
   vào luồng chốt sổ thì thêm id của nó vào đây. Lối ra giữ transition.dismiss (200ms) của mục 3. */
:is(#xn-mo-diem-danh, #xn-mo-chi-phi, #xn-chot-so, #modalThemKhoanChi).modal.fade .modal-dialog {
  transform: scale(var(--aui-scale-press));
  filter: none;
}
:is(#xn-mo-diem-danh, #xn-mo-chi-phi, #xn-chot-so, #modalThemKhoanChi).modal.fade.show {
  transition: opacity var(--aui-t-fade);
}
:is(#xn-mo-diem-danh, #xn-mo-chi-phi, #xn-chot-so, #modalThemKhoanChi).modal.show .modal-dialog {
  transform: none;
  transition: transform var(--aui-t-fade);
}

/* Màn: chi-phi-chot-so — mờ nội dung khoản đang xóa; không làm mờ modal trong ô actions. */
.chi-phi-row > td:not([data-role="actions"]) { transition: opacity var(--aui-t-swap); }
.chi-phi-row:has([data-chi-phi-delete] form.htmx-request) > td:not([data-role="actions"]) {
  opacity: .45;
}

/* Màn: thanh-toan — dòng đang thu/hoàn mờ trong lúc chờ (cả khi request đi từ modal chọn hình thức:
   nút nguồn nằm trong dòng). Ô thao tác giữ rõ để thấy vòng quay `data-aui="nut"`. Không có class
   nào do JS gắn: htmx tự gỡ `.htmx-request` khi xong, kể cả lỗi. */
.tt-row > td:not([data-role="actions"]) { transition: opacity var(--aui-t-swap); }
.tt-row:has(.htmx-request) > td:not([data-role="actions"]) { opacity: .45; }

/* Màn: buoi-danh-sach — thẻ/hàng buổi bấm được (recipes/card.md, mức subtle). Opt-in bằng chính
   link `.list-card__ca-the` của hàng (màn Buổi tập + Lịch chế độ Ngày).
   • Điện thoại (<768px, hàng là thẻ): nhấn ⇒ cả thẻ co về scale.choice (.985) trên spring.press, nền
     đổi (luật nền ở app.css) trên transition.color. Thẻ không phóng to khi hover.
   • ≥768px (hàng bảng thật, bấm cả hàng nhờ /js/hang-bam-duoc.js): nền nhạt khi rê chuột, đậm hơn khi
     nhấn — đổi `background-color` của các Ô (bóng inset `--bs-table-bg-state` của Bootstrap đã bị
     app.css tắt cho bảng này, đo 29/10: `box-shadow: none`), không scale <tr>.
   🔴 Hàng của buổi đã hủy chứa modal xác nhận (position: fixed); `scale` trên <tr> chỉ tồn tại lúc
   link của hàng đang :active, tức không bao giờ trùng lúc modal mở. */
.list-card tbody tr:has(.list-card__ca-the) {
  transition: background-color var(--aui-t-color), scale var(--aui-spring-press);
}
.list-card tbody tr:has(.list-card__ca-the) > td { transition: background-color var(--aui-t-color); }
@media (max-width: 767.98px) {
  .list-card tbody tr:has(.list-card__ca-the:active) { scale: var(--aui-scale-choice); }
}
@media (min-width: 768px) {
  .list-card tbody tr:has(.list-card__ca-the):active > td {
    background-color: color-mix(in srgb, var(--surface-muted) 60%, var(--border-strong));
  }
}
/* Đo 29/10: nền gốc của ô bảng đã là slate-50 (= --surface-sunken), nên rê chuột phải là muted
   (slate-100) mới thấy, và nhấn phải ĐẬM hơn nữa (trộn với --border-strong) — nhấn nhạt hơn rê thì
   cú nhấn trông như nhả ra. */
@media (min-width: 768px) and (hover: hover) and (pointer: fine) {
  .list-card tbody tr:has(.list-card__ca-the) { cursor: pointer; }
  .list-card tbody tr:has(.list-card__ca-the):hover:not(:active) > td { background-color: var(--surface-muted); }
}

/* Chip buổi trên lưới Tháng/Tuần (fragments/lich-buoi.html) — là <button> mở modal xem nhanh: nhấn ⇒
   scale.press (.97); rê chuột ⇒ nền nhạt, CHỈ thiết bị có chuột (trước 29/10 luật hover của chip tuần
   nằm trơn ở app.css ⇒ trên cảm ứng nền sẽ "dính" sau khi chạm). */
.lich-buoi { transition: background-color var(--aui-t-color), scale var(--aui-spring-press); }
.lich-buoi:active { scale: var(--aui-scale-press); }
@media (hover: hover) and (pointer: fine) {
  .lich-buoi:hover { background-color: var(--surface-muted); }
}

/* Màn: dang-ky-ho — chỉ dòng gửi đối chiếu phản hồi trong lúc chờ, không cả danh sách. */
.doi-chieu-hang {
  transition: opacity var(--aui-t-swap);
}
.doi-chieu-hang:has(.htmx-request) {
  opacity: .45;
}

/* Màn: trang-chu — dòng Buổi tới chỉ đổi nền, không nhấc hay co cả hàng. */
.dashboard-buoi { transition: background-color var(--aui-t-color); }

/* Màn: vinh-danh — dòng + thẻ bục đổi nền khi rê/nhấn (luật nền ở app.css).
   "+N người cùng hạng": danh sách bung ra hiện dần + nhô 4px CHỈ lúc mở (đóng tức thì), mũi tên xoay
   trên transition.disclosure. Keyframes chạy mỗi khi [open] được gắn, kể cả lúc htmx phục hồi trang từ
   lịch sử với <details> đang mở — vô hại: 200ms hiện dần, không có trạng thái ẩn nào để kẹt.
   Trang Thành tích ("khoảnh khắc hiếm", người dùng chọn 30/10/2026): thanh tiến độ danh hiệu chạy từ 0
   tới đúng mức mỗi lần trang mở, bằng scaleX (compositor) chứ không width; giảm chuyển động ⇒ luật
   toàn cục của app.css tắt animation, thanh đứng yên đúng mức. */
.honor-row,
.honor-podium__card,
.honor-more__nut { transition: background-color var(--aui-t-color); }
.honor-more__mui { transition: transform var(--aui-t-disclosure); }
@keyframes aui-honor-bung { from { opacity: 0; translate: 0 -4px; } }
.honor-more__ds[open] > .honor-list { animation: aui-honor-bung var(--aui-t-fade); }
@keyframes aui-honor-thanh { from { transform: scaleX(0); } }
.honor-progress .progress-bar {
  transform-origin: left center;
  animation: aui-honor-thanh var(--aui-spring-selection);
}

/* Màn: thong-bao — dòng thông báo đổi nền khi rê/nhấn (luật nền ở app.css). Bấm rồi thì dòng mờ và
   không nhận cú bấm thứ hai trong lúc chờ trang đích: dòng là form POST điều hướng THẬT
   (hx-boost="false"), không có `.htmx-request` để bám ⇒ /js/thong-bao.js gắn `data-tb-dang-mo` lên
   form và tự gỡ ở `pageshow` (Back từ bfcache), trước khi htmx chụp lịch sử, và sau 8 giây chốt chặn. */
.bmh-tb-link { transition: background-color var(--aui-t-color), opacity var(--aui-t-swap); }
form[data-tb-dang-mo] .bmh-tb-link {
  opacity: .55;
  pointer-events: none;
}

/* Màn: clb-ho-so — Nâng cấp: chip thời hạn đổi màu trên transition.color và lún như nút khi nhấn
   (spring.press; giảm chuyển động ⇒ --aui-scale-press = 1). Giá đổi theo chip mờ qua bằng Web
   Animations ở /js/thanh-toan.js. Thanh toán thành công ("khoảnh khắc hiếm", người dùng chọn
   30/10/2026): dấu tích bật vào một lần trên spring.toast (vượt ~4% rồi đáp). Không fill-mode ⇒ luật
   giảm chuyển động của app.css tắt animation là dấu tích đứng yên, hiện rõ ngay. */
.bmh-plan__period span {
  transition: color var(--aui-t-color),
              background-color var(--aui-t-color),
              border-color var(--aui-t-color),
              transform var(--aui-spring-press);
}
.bmh-plan__period:active span { transform: scale(var(--aui-scale-press)); }
@keyframes aui-pay-done { from { opacity: 0; scale: .9; } }
.bmh-pay-done__icon { animation: aui-pay-done var(--aui-spring-toast); }

/* Màn: xac-thuc-loi — tour "Hướng dẫn CLB" (onboarding/tour.html) là hộp thoại tự dựng, không phải
   .modal Bootstrap, nên trước đây bật/tắt tức thì. Cùng nhịp với mục 3 (hộp thoại): nền mờ trên
   transition.overlay; panel hết nhoè + "ngả" về trước trên spring.dialog (điện thoại: mọc từ 0.9);
   bị đóng quay lại đúng đường trên transition.dismiss. /js/tour-cai-dat.js gắn/gỡ .is-open và gỡ
   khối sau transitionend hoặc 300ms (giảm chuyển động: app.css tắt transition ⇒ hiện/ẩn ngay).
   Lúc nghỉ (.is-open) transform/filter = none: không biến panel thành containing block. */
.tour-cai-dat__backdrop { opacity: 0; transition: opacity var(--aui-t-dismiss); }
.tour-cai-dat__panel {
  opacity: 0;
  transform: var(--aui-dialog-from);
  filter: var(--aui-blur-surface);
  transition: opacity var(--aui-t-dismiss), filter var(--aui-t-dismiss), transform var(--aui-t-dismiss);
}
.tour-cai-dat.is-open .tour-cai-dat__backdrop { opacity: 1; transition: opacity var(--aui-t-overlay); }
.tour-cai-dat.is-open .tour-cai-dat__panel {
  opacity: 1;
  transform: none;
  filter: none;
  transition: opacity var(--aui-t-dialog-fade), filter var(--aui-t-dialog-fade), transform var(--aui-spring-dialog);
}
@media (max-width: 767.98px) {
  .tour-cai-dat:not(.is-open) .tour-cai-dat__panel { transform: scale(var(--aui-scale-menu)); }
}
.tour-cai-dat__progress span { transition: background-color var(--aui-t-color); }

/* ---------------------------------------------------------------------------
   Giảm chuyển động — mọi thứ vẫn hiện/ẩn, không gì di chuyển, phóng to hay ngả.
   Luật toàn cục của app.css (`* { transition/animation: none !important }`) phủ
   phần tử thật, nhưng `*` KHÔNG khớp pseudo-element ⇒ ::after của vòng quay, khung
   chờ và vạch tab phải tự tắt ở đây. Vòng quay đổi sang nhịp mờ chứ không đứng
   hình (rules A4: chỉ báo đang tải vẫn phải nói "đang chạy").
   --------------------------------------------------------------------------- */
@media (prefers-reduced-motion: reduce) {
  :root {
    --aui-scale-press: 1;
    --aui-scale-choice: 1;
    --aui-scale-menu: 1;
    --aui-scale-toast: 1;
    --aui-dialog-from: none;
  }
  .btn:active,
  a.stat-card:active,
  a.stat-card:hover { transform: none; }
  .htmx-added[data-aui="vao"],
  .htmx-added [data-aui="vao"] { translate: none; }
  [data-aui="ra"].htmx-swapping { scale: none; }
  [data-aui="nut"].htmx-request::after,
  form.htmx-request [data-aui="nut"]::after,
  [data-aui="nut"][aria-busy="true"]::after { animation: aui-nhip 1.2s ease-in-out infinite; }
  .aui-khung-cho::after { animation: none; }
  .nav[data-aui-tab][data-aui-tab-san-sang]::after,
  .nav[data-aui-tab]::after { transition: none; }
}
