@haposoft/cafekit 0.16.7 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) hide show
  1. package/README.md +14 -22
  2. package/bin/__tests__/codex-hooks-ownership.test.js +2 -2
  3. package/bin/__tests__/codex-hooks.test.js +0 -36
  4. package/bin/__tests__/codex-native.test.js +46 -88
  5. package/bin/__tests__/develop-contract.test.js +26 -57
  6. package/bin/__tests__/docs-sync-on-demand.test.js +93 -0
  7. package/bin/__tests__/grok-envelope.test.js +0 -56
  8. package/bin/__tests__/hook-payload.test.js +4 -6
  9. package/bin/__tests__/legacy-authority-decoupled.test.js +159 -0
  10. package/bin/__tests__/legacy-hooks-retired.test.js +221 -0
  11. package/bin/__tests__/omp-bridge.test.js +20 -17
  12. package/bin/__tests__/omp-hooks.test.js +0 -19
  13. package/bin/__tests__/optional-skill-inventory.test.js +112 -4
  14. package/bin/__tests__/orca-session.test.js +0 -2
  15. package/bin/__tests__/package-inventory.test.js +100 -541
  16. package/bin/__tests__/runtime-dir.test.js +0 -3
  17. package/bin/__tests__/runtime-schema.test.js +3 -2
  18. package/bin/__tests__/skill-routing-source.test.js +27 -15
  19. package/bin/__tests__/spec-narrowing.test.js +0 -27
  20. package/bin/__tests__/specs-v2-execution-closeout.test.js +4 -69
  21. package/bin/__tests__/ui-ux-skill.test.js +101 -0
  22. package/bin/__tests__/usage-hook-retired.test.js +114 -0
  23. package/bin/__tests__/validator-grounding.test.js +9 -28
  24. package/bin/phases/codex-runtime.js +24 -0
  25. package/bin/phases/omp-runtime.js +22 -0
  26. package/package.json +1 -1
  27. package/src/claude/CLAUDE.md +3 -10
  28. package/src/claude/agents/brainstormer.md +7 -3
  29. package/src/claude/agents/code-auditor.md +35 -50
  30. package/src/claude/agents/debugger.md +5 -79
  31. package/src/claude/agents/deployer.md +3 -5
  32. package/src/claude/agents/docs-keeper.md +2 -7
  33. package/src/claude/agents/implementer.md +6 -9
  34. package/src/claude/agents/inspector.md +2 -3
  35. package/src/claude/agents/project-manager.md +7 -15
  36. package/src/claude/agents/researcher.md +9 -3
  37. package/src/claude/agents/spec-maker.md +11 -11
  38. package/src/claude/hooks/__tests__/fixtures/statusline-default.golden +1 -1
  39. package/src/claude/hooks/__tests__/spec-gate.test.js +74 -3
  40. package/src/claude/hooks/__tests__/spec-state-touched.test.js +134 -0
  41. package/src/claude/hooks/__tests__/state.test.js +68 -0
  42. package/src/claude/hooks/__tests__/statusline.test.js +140 -11
  43. package/src/claude/hooks/agent.cjs +2 -1
  44. package/src/claude/hooks/lib/context.cjs +0 -89
  45. package/src/claude/hooks/lib/hook-payload.cjs +2 -2
  46. package/src/claude/hooks/rules.cjs +2 -1
  47. package/src/claude/hooks/session.cjs +0 -1
  48. package/src/claude/hooks/spec-gate.cjs +17 -28
  49. package/src/claude/hooks/spec-state.cjs +95 -1
  50. package/src/claude/hooks/state.cjs +21 -2
  51. package/src/claude/migration-manifest.json +30 -15
  52. package/src/claude/rules/ai-dev-rules.md +1 -1
  53. package/src/claude/rules/hook-protocols.md +8 -15
  54. package/src/claude/rules/manage-docs.md +9 -23
  55. package/src/claude/rules/orchestrator.md +3 -4
  56. package/src/claude/rules/review-audit-self-decision.md +3 -3
  57. package/src/claude/rules/skill-domain-routing.md +1 -3
  58. package/src/claude/rules/skill-workflow-routing.md +4 -5
  59. package/src/claude/rules/state-sync.md +3 -12
  60. package/src/claude/rules/workflow.md +10 -18
  61. package/src/claude/runtime.schema.json +5 -3
  62. package/src/claude/scripts/generate-skill-catalog.cjs +7 -6
  63. package/src/claude/scripts/provenance.cjs +23 -5
  64. package/src/claude/scripts/spec-receipt.cjs +24 -7
  65. package/src/claude/scripts/spec-resolver.cjs +1 -2
  66. package/src/claude/scripts/validate-spec-output.cjs +5 -32
  67. package/src/claude/settings/settings.json +1 -32
  68. package/src/claude/skills/{question → ask}/SKILL.md +9 -75
  69. package/src/claude/skills/brainstorm/SKILL.md +43 -131
  70. package/src/claude/skills/code-review/SKILL.md +48 -17
  71. package/src/claude/skills/code-review/references/spec-compliance-review.md +2 -3
  72. package/src/claude/skills/code-review/references/verification-gate.md +22 -27
  73. package/src/claude/skills/debug/SKILL.md +29 -149
  74. package/src/claude/skills/develop/SKILL.md +27 -36
  75. package/src/claude/skills/develop/references/parallel-waves.md +0 -8
  76. package/src/claude/skills/develop/references/quality-gate.md +15 -13
  77. package/src/claude/skills/docs/SKILL.md +2 -2
  78. package/src/claude/skills/docs/references/update-workflow.md +1 -1
  79. package/src/claude/skills/fix/SKILL.md +153 -0
  80. package/src/claude/skills/{hotfix → fix}/references/diagnosis-protocol.md +2 -2
  81. package/src/claude/skills/{hotfix → fix}/references/parallel-patterns.md +2 -2
  82. package/src/claude/skills/{hotfix → fix}/references/prevention-gate.md +3 -3
  83. package/src/claude/skills/{hotfix → fix}/references/review-cycle.md +1 -1
  84. package/src/claude/skills/git/SKILL.md +48 -9
  85. package/src/claude/skills/git/references/commit-protocols.md +35 -16
  86. package/src/claude/skills/git/references/worktree-blueprint.md +42 -6
  87. package/src/claude/skills/orca/SKILL.md +9 -8
  88. package/src/claude/skills/research/SKILL.md +2 -0
  89. package/src/claude/skills/scout/SKILL.md +61 -0
  90. package/src/claude/skills/{inspect → scout}/references/internal-inspection.md +26 -10
  91. package/src/claude/skills/specs/SKILL.md +22 -29
  92. package/src/claude/skills/specs/references/review.md +8 -6
  93. package/src/claude/skills/specs/references/templates.md +34 -41
  94. package/src/claude/skills/sync/SKILL.md +8 -15
  95. package/src/claude/skills/sync/references/rebind-and-audit.md +59 -0
  96. package/src/claude/skills/sync/references/sync-protocols.md +4 -13
  97. package/src/claude/skills/test/SKILL.md +22 -3
  98. package/src/claude/skills/test/references/execution-strategy.md +83 -0
  99. package/src/claude/skills/test/references/failure-triage.md +1 -1
  100. package/src/claude/skills/ui-ux/LICENSE +21 -0
  101. package/src/claude/skills/ui-ux/SKILL.md +497 -0
  102. package/src/claude/skills/ui-ux/references/brand-tokens.md +90 -0
  103. package/src/claude/skills/ui-ux/references/budgets.md +113 -0
  104. package/src/claude/skills/ui-ux/references/checklist.md +271 -0
  105. package/src/claude/skills/ui-ux/references/components/accordion.md +246 -0
  106. package/src/claude/skills/ui-ux/references/components/avatar.md +153 -0
  107. package/src/claude/skills/ui-ux/references/components/banner.md +43 -0
  108. package/src/claude/skills/ui-ux/references/components/breadcrumb.md +201 -0
  109. package/src/claude/skills/ui-ux/references/components/button.md +142 -0
  110. package/src/claude/skills/ui-ux/references/components/card.md +113 -0
  111. package/src/claude/skills/ui-ux/references/components/charts.md +376 -0
  112. package/src/claude/skills/ui-ux/references/components/chat.md +132 -0
  113. package/src/claude/skills/ui-ux/references/components/choice-controls.md +369 -0
  114. package/src/claude/skills/ui-ux/references/components/comment-thread.md +17 -0
  115. package/src/claude/skills/ui-ux/references/components/description-list.md +116 -0
  116. package/src/claude/skills/ui-ux/references/components/empty-state.md +98 -0
  117. package/src/claude/skills/ui-ux/references/components/file-upload.md +107 -0
  118. package/src/claude/skills/ui-ux/references/components/inline-edit.md +128 -0
  119. package/src/claude/skills/ui-ux/references/components/input.md +161 -0
  120. package/src/claude/skills/ui-ux/references/components/list-row.md +56 -0
  121. package/src/claude/skills/ui-ux/references/components/loading.md +198 -0
  122. package/src/claude/skills/ui-ux/references/components/logo.md +147 -0
  123. package/src/claude/skills/ui-ux/references/components/otp-input.md +43 -0
  124. package/src/claude/skills/ui-ux/references/components/quantity-input.md +100 -0
  125. package/src/claude/skills/ui-ux/references/components/range-slider.md +16 -0
  126. package/src/claude/skills/ui-ux/references/components/small-controls.md +340 -0
  127. package/src/claude/skills/ui-ux/references/components/sortable-header.md +86 -0
  128. package/src/claude/skills/ui-ux/references/components/tag-input.md +14 -0
  129. package/src/claude/skills/ui-ux/references/components/timeline.md +38 -0
  130. package/src/claude/skills/ui-ux/references/components/tree.md +30 -0
  131. package/src/claude/skills/ui-ux/references/design-process.md +650 -0
  132. package/src/claude/skills/ui-ux/references/layouts/app-kanban.html +303 -0
  133. package/src/claude/skills/ui-ux/references/layouts/app.md +1369 -0
  134. package/src/claude/skills/ui-ux/references/layouts/form.md +430 -0
  135. package/src/claude/skills/ui-ux/references/layouts/overlay.md +552 -0
  136. package/src/claude/skills/ui-ux/references/layouts/pricing.md +204 -0
  137. package/src/claude/skills/ui-ux/references/locked-rules.md +35 -0
  138. package/src/claude/skills/ui-ux/references/principles.md +408 -0
  139. package/src/claude/skills/ui-ux/references/refactor.md +245 -0
  140. package/src/claude/skills/ui-ux/references/responsive.md +195 -0
  141. package/src/claude/skills/ui-ux/references/review.md +528 -0
  142. package/src/claude/skills/ui-ux/references/rules-color.md +623 -0
  143. package/src/claude/skills/ui-ux/references/rules-form.md +235 -0
  144. package/src/claude/skills/ui-ux/references/rules-state.md +680 -0
  145. package/src/claude/skills/ui-ux/references/rules-type.md +369 -0
  146. package/src/claude/skills/ui-ux/references/styles.md +311 -0
  147. package/src/claude/skills/ui-ux/references/system.md +219 -0
  148. package/src/claude/skills/ui-ux/references/tailwind-v4-traps.md +237 -0
  149. package/src/claude/skills/ui-ux/references/tokens.css +362 -0
  150. package/src/claude/skills/ui-ux/scripts/lint-skill.mjs +216 -0
  151. package/src/claude/skills/ui-ux/scripts/package.json +9 -0
  152. package/src/claude/skills/ui-ux/scripts/probe.mjs +4447 -0
  153. package/src/claude/skills/ui-ux-pro-max/SKILL.md +1 -1
  154. package/src/claude/status.cjs +149 -144
  155. package/src/codex/AGENTS.md +8 -13
  156. package/src/codex/hooks/agent.cjs +2 -1
  157. package/src/codex/hooks/lib/spec-receipt.cjs +2 -2
  158. package/src/codex/hooks/lib/spec-utils.cjs +0 -7
  159. package/src/codex/hooks/rules.cjs +2 -1
  160. package/src/codex/hooks/session.cjs +0 -2
  161. package/src/codex/hooks/spec-gate.cjs +7 -18
  162. package/src/codex/hooks.json +0 -20
  163. package/src/codex/rules/state-sync.md +3 -12
  164. package/src/common/AGENTS.md +2 -2
  165. package/src/omp/extensions/cafekit-bridge.mjs +6 -6
  166. package/src/claude/hooks/__tests__/completion-authority.test.js +0 -990
  167. package/src/claude/hooks/__tests__/semantic-review-authority.test.js +0 -259
  168. package/src/claude/hooks/completion-authority-check.cjs +0 -56
  169. package/src/claude/hooks/completion-authority-state.cjs +0 -527
  170. package/src/claude/hooks/completion-authority.cjs +0 -163
  171. package/src/claude/hooks/semantic-review-authority.cjs +0 -154
  172. package/src/claude/hooks/task-scaffold-guard.cjs +0 -114
  173. package/src/claude/hooks/usage.cjs +0 -197
  174. package/src/claude/skills/delegate/SKILL.md +0 -124
  175. package/src/claude/skills/delegate/references/codex-delegation.md +0 -71
  176. package/src/claude/skills/delegate/references/grok-delegation.md +0 -84
  177. package/src/claude/skills/hotfix/SKILL.md +0 -289
  178. package/src/claude/skills/inspect/SKILL.md +0 -142
  179. package/src/claude/skills/loop/SKILL.md +0 -114
  180. package/src/claude/skills/loop/references/bounded-loop-protocol.md +0 -104
  181. package/src/claude/skills/loop/references/metric-and-guard-contract.md +0 -76
  182. package/src/claude/skills/route/SKILL.md +0 -94
  183. package/src/claude/skills/route/references/agent-timing.md +0 -46
  184. package/src/claude/skills/route/references/chaining-patterns.md +0 -53
  185. package/src/claude/skills/route/references/task-taxonomy.md +0 -48
  186. package/src/codex/hooks/completion-authority-check.cjs +0 -67
  187. package/src/codex/hooks/completion-authority-state.cjs +0 -527
  188. package/src/codex/hooks/completion-authority.cjs +0 -109
  189. package/src/codex/hooks/semantic-review-authority.cjs +0 -130
  190. package/src/codex/hooks/task-scaffold-guard.cjs +0 -63
  191. /package/src/claude/skills/{question → ask}/templates/question.md +0 -0
  192. /package/src/claude/skills/{hotfix → fix}/references/escalation-tactics.md +0 -0
  193. /package/src/claude/skills/{hotfix → fix}/references/workflow-specialized.md +0 -0
@@ -0,0 +1,680 @@
1
+ # Trạng thái và tương tác — luật I
2
+
3
+ Nguồn duy nhất cho nút, hover, focus, danh sách, modal. Con số ở `budgets.md`.
4
+
5
+ ---
6
+
7
+ ## Nút
8
+
9
+ > **⚠️ Đảo luật.** Bản cũ của skill này viết "ba variant `primary` / `ghost` /
10
+ > `danger`, không outline" và "nút mặc định không icon". **Cả hai đã bỏ.** Chủ dự
11
+ > án chốt ngược lại. Đừng hồi sinh luật cũ.
12
+
13
+ **I1. Nút mặc định là nút viền, không phải nền màu nhấn. Icon trái chỉ khi nó nói đúng hành động.**
14
+
15
+ Dựng nút mới → nút viền. Gắn icon lucide **bên trái** chữ khi có một glyph gọi đúng tên
16
+ hành động: thêm (`plus`), lọc, tải xuống, xuất, sao chép, chia sẻ. Nút form và nút
17
+ trong modal (Lưu, Huỷ, Gửi, Tạo công việc) **chỉ có chữ**: tiêu đề đã nói việc gì, icon
18
+ chỉ lặp lại.
19
+
20
+ *Vì sao:* nguyên lời chủ dự án — *"không nên để brand bị nhiều màu quá trong dự
21
+ án"*. Nút nền nhấn rải khắp nơi thì màu thương hiệu loang ra, tới lúc có một nút
22
+ **thật sự** cần nổi thì nó không nổi được nữa. Cùng tinh thần với `M2`.
23
+
24
+ | Loại nút | Dùng |
25
+ | --- | --- |
26
+ | Mặc định | viền + chữ; thêm icon trái khi glyph gọi đúng tên hành động (thêm, lọc, tải xuống, xuất) |
27
+ | Nút gửi form, nút ở footer modal và panel | chỉ chữ: động từ + đối tượng ("Tạo công việc", "Xác nhận đơn", "In hoá đơn"). Cả footer một kiểu: một nút có icon một nút không là lệch |
28
+ | Nút đổi trạng thái trên dòng hay ô (tiếp đón, duyệt, giao) | động từ nói việc sẽ làm ("Tiếp đón", "Bắt đầu khám", "Hoàn tất"), **không** dùng tên trạng thái đích ("Đã đến", "Xong"): đứng cạnh badge "Đã xác nhận", nút "Đã đến" đọc thành badge thứ hai, người xem không biết khách đã đến hay chưa |
29
+ | Hành động chính **duy nhất** của một khu, thật cần nổi | nền màu nhấn |
30
+ | Nút phụ cần rõ hơn ghost: nút rộng hết card, nút cạnh `primary` | nền `--secondary`, không viền |
31
+ | Chỉ icon | nút cỡ icon, có `aria-label`, cao bằng nút chữ cạnh nó |
32
+ | Nút trong hộp xác nhận | chỉ chữ, không icon: icon đã đứng ở đầu hộp (`layouts/overlay.md`) |
33
+ | Xoá và việc nguy hiểm khác (nút đứng riêng, `I4`) | nền `rose-500/10` + chữ `rose-700` lúc nào cũng hiện, rê vào nền đậm lên `/15` |
34
+
35
+ **I2. Chọn nền màu nhấn thì nói một câu lý do.** Nộp bài, thanh toán, tham gia —
36
+ những chỗ đó hợp lệ. Nhưng phải là một lựa chọn, không phải mặc định.
37
+
38
+ **I3. Trong một nhóm lựa chọn chỉ một nút được là nút chính.** Ba nút đặc màu như
39
+ nhau là chưa quyết định hộ người dùng.
40
+
41
+ **I4. Hành động nguy hiểm không đỏ đặc.**
42
+
43
+ **Việc nào là nguy hiểm: xét ba câu hỏi, không dò theo danh sách.** Việc đó có
44
+ 1. làm **mất dữ liệu** không (xoá dự án, workspace, tài khoản)?
45
+ 2. **kết thúc một thứ đang chạy** không (gói trả phí, phiên đăng nhập, lời mời, khoá API)?
46
+ 3. **cắt quyền hay cắt truy cập** của ai đó không (rời nhóm, gỡ thành viên, đăng xuất)?
47
+
48
+ Chỉ cần một câu "có" là nguy hiểm, tô theo bảng ba dạng bên dưới. **"Lấy lại được" không
49
+ làm việc đó hết nguy hiểm**: huỷ gói vẫn dùng tới hết kỳ, đăng xuất rồi đăng nhập lại được,
50
+ rời nhóm rồi xin mời lại được, cả ba vẫn đỏ.
51
+
52
+ **Lý lẽ đã bị bác, đừng dùng lại** (danh sách đủ ở `locked-rules.md`):
53
+ - "Không mất dữ liệu nên để trung tính": chủ dự án đã thử bản đăng xuất trung tính
54
+ ("đăng xuất mất danger"), chốt lại cả cho đăng xuất hàng loạt.
55
+ - "Vẫn dùng được tới hết kỳ nên không đỏ": đã dính, huỷ gói ra nút viền xám, hộp xác
56
+ nhận icon xám, nút đen.
57
+ - "Nhiều app để nó trung tính": quy ước số đông không lật luật chủ dự án đã chốt.
58
+
59
+ | Việc | Nguy hiểm? | Câu nào "có" |
60
+ | --- | --- | --- |
61
+ | Xoá dự án, workspace, tài khoản, khoá API đã hết hạn | Có | 1 |
62
+ | Huỷ gói trả phí | Có | 2 |
63
+ | Thu hồi khoá API, thu hồi lời mời | Có | 2 |
64
+ | Đăng xuất, đăng xuất thiết bị khác, đăng xuất hàng loạt | Có | 3 (chủ dự án chốt) |
65
+ | Rời nhóm, gỡ thành viên | Có | 3 |
66
+ | Xoá ảnh đại diện (có Hoàn tác trong toast) | Không, nút viền trung tính | Không câu nào: ảnh cũ quay lại ngay khi bấm Hoàn tác (`app.md`, "Trang hồ sơ cá nhân") |
67
+ | Gửi lại lời mời hàng loạt, gửi email cho 240 khách | Không | Không câu nào. Vẫn hỏi lại vì đụng nhiều thứ một lúc, dùng hộp trung tính (`overlay.md`) |
68
+
69
+ Gặp việc chưa có trong bảng thì trả lời ba câu, ghi thêm một dòng vào bảng, và ghi mã
70
+ `I4` ngay cạnh câu nói màu nút trong spec (`scripts/lint-skill.mjs` bắt câu thiếu mã).
71
+
72
+ Đăng xuất trong menu thì chỉ đỏ lúc rê; nút "Đăng xuất N thiết bị khác" là nút đứng riêng
73
+ nên đỏ sẵn. Có ba dạng tuỳ chỗ đứng:
74
+
75
+ | Chỗ | Lúc thường | Rê vào / Tab tới |
76
+ | --- | --- | --- |
77
+ | **Nút đứng riêng** — hàng nút, hộp xác nhận, khu nguy hiểm | nền `rose-500/10`, chữ + icon `rose-700` | nền `rose-500/15` |
78
+ | **Mục trong menu** — dropdown, sidebar | trung tính như mục khác | chữ + icon đỏ, nền `rose-500/10` |
79
+ | **Nút lặp lại trên từng dòng** — "Đăng xuất" mỗi thiết bị, "Gỡ" mỗi thành viên hiện thẳng | nút viền trung tính như nút dòng khác | viền trong suốt, nền `rose-500/10`, chữ `rose-700` |
80
+
81
+ Nút thì nền mờ đỏ luôn hiện (chủ dự án chốt), code ở
82
+ `components/button.md`. Không bao giờ `bg-rose-500 text-white`, không viền đỏ.
83
+
84
+ Nút lặp trên từng dòng thì đỏ lúc rê như mục menu, không đỏ sẵn: bốn dòng bốn nút nền
85
+ đỏ là cả khung đỏ, và nút hàng loạt ở chân khung ("Đăng xuất 4 thiết bị khác", nút đứng
86
+ riêng, đỏ sẵn) không còn nổi lên được. Class: `hover:border-transparent hover:bg-rose-500/10
87
+ hover:text-rose-700`, kèm cùng bộ đó cho `focus-visible:`.
88
+
89
+ Phần dưới là cho **mục trong menu**. Khi rê vào thì đổi **cả hai**: chữ (kèm icon) sang đỏ, nền sang đỏ rất mờ.
90
+
91
+ ```html
92
+ <button class="group text-foreground hover:bg-rose-500/10 hover:text-rose-700 dark:hover:text-rose-400">
93
+ <i data-lucide="trash-2" class="size-4 text-muted group-hover:text-rose-700 dark:group-hover:text-rose-400"></i>
94
+ Xoá dự án
95
+ </button>
96
+ ```
97
+
98
+ "Trung tính" nghĩa là **trông y như các mục khác cùng chỗ** — trong menu thì chữ
99
+ `--foreground` như mọi mục, trong hàng nút thì là nút phụ. Không có nghĩa là
100
+ `text-muted`: mục xoá mà nhạt hơn các mục khác thì đọc ra là đã bị khoá
101
+ (`I8`).
102
+
103
+ - **Nền đỏ ~10%**, không hơn (nút đứng riêng được `/15` lúc rê vào vì nó đã sẵn `/10`). Đậm hơn thì nó thành một dải màu cảnh báo, không còn là trạng thái rê chuột.
104
+ - **Chữ `rose-700`, không `rose-500`** — mục menu cũng vậy, không riêng gì nút. Chữ `rose-500` trên nền `rose-500/10` chỉ 3.2:1, trượt mức 4.5:1 của chữ 14px; mắt thấy hồng tươi, đẹp, nhưng khó đọc. Nền tối thì `rose-400`.
105
+ - **Không dùng Tailwind** thì cùng công thức bằng token: rê vào nền `var(--danger-bg)`, chữ và icon `var(--danger)` (`tokens.css`, nền tối tự đổi).
106
+ - **Phím mũi tên cũng phải đỏ lên.** Dùng shadcn / Radix thì mục sáng lên bằng `data-[highlighted]`, không phải `hover:` — viết mỗi `hover:` thì đi bằng phím, mục xoá vẫn xám. Thay cả ba chỗ: `data-[highlighted]:bg-rose-500/10 data-[highlighted]:text-rose-700` ở hàng, `group-data-[highlighted]:text-rose-700` ở icon (`I13`).
107
+ - **Icon đổi màu cùng chữ.** Icon lúc thường là `text-muted`, nên phải có **`group` ở hàng** và `group-hover:text-rose-700` ở icon. Thiếu `group` thì `group-hover` im lặng không chạy — chữ đỏ mà icon còn xám, và không có lỗi nào báo.
108
+ - Chỉ mục nguy hiểm được đỏ. Các mục khác trong cùng menu vẫn hover về nền xám như `I10`.
109
+ - Mục nguy hiểm trong menu thì **tách xuống cuối**, cách bằng một đường chia.
110
+
111
+ **I5. Nút màu nhấn đang có thì KHÔNG đi quét đổi hàng loạt.** Chỉ đổi sang viền +
112
+ icon khi đang được yêu cầu sửa UI/UX ở đúng khu đó, và ghi vào nhật ký. Quét hàng
113
+ loạt là một PR không ai duyệt nổi.
114
+
115
+ **I6. Không đẻ ma trận variant nhân size.** Cần nút khác cỡ thì truyền
116
+ `className`. Một bản `Button` ở project cũ của bro đã phình lên 8 variant, 3 size
117
+ và một variant `glow` dùng ba lớp radial gradient — đó là ví dụ ngược.
118
+
119
+ **I7. "Xem tất cả", "Đọc thêm" là link chữ, không phải nút.**
120
+
121
+ Nó dẫn sang màn khác và là hành động phụ của khối: phải trông bấm được, nhưng không
122
+ được nặng ngang tiêu đề. Các app lớn đều để nó nhẹ ở góc header, và phần lớn dựng
123
+ nó là link chữ.
124
+
125
+ - **Dẫn sang màn khác thì là link, nhìn cũng là link**: `<Link>` `inline-flex h-8 items-center text-sm font-medium text-foreground/70`, **không padding ngang, không nền**; rê vào thì `text-foreground underline underline-offset-4`. Không vòng focus (`I13`). `h-8` là vùng bấm dọc. Không icon mũi tên, không tô màu nhấn.
126
+ - **Vì sao không phải nút `ghost`**: (1) rê vào mà hiện nền xám bo góc là ngôn ngữ của nút làm một việc tại chỗ, trong khi cùng card các tên việc, tên dự án là link rê vào gạch chân: hai kiểu cho cùng một việc "sang trang khác" (`N5`); (2) `px-3` của nút đẩy chữ lệch vào trong 12px so với mép phải nội dung (số % của hàng bên dưới); bỏ padding thì chữ thẳng mép.
127
+ - **Nạp thêm tại chỗ thì vẫn là nút `ghost`**: "Xem hoạt động cũ hơn" nối thêm hàng ngay bên dưới, không đổi trang (`components/timeline.md`). Phân theo việc nó làm, không theo chữ trên nó.
128
+ - Đừng dùng `secondary` nền xám cao 40px: card nào cũng có một khối xám kéo mắt.
129
+ - **Căn phải.** Khối có header thì đặt ở header bên phải, cùng hàng với tiêu đề. Danh sách phải đọc hết mới bấm thì đặt cuối khối, vẫn căn phải, vẫn **trong khung** (xem `F3`).
130
+
131
+ ```tsx
132
+ <Link
133
+ to={href}
134
+ className="inline-flex h-8 items-center text-sm font-medium whitespace-nowrap text-foreground/70 underline-offset-4 outline-hidden transition-colors hover:text-foreground hover:underline"
135
+ >
136
+ Xem tất cả
137
+ </Link>
138
+ ```
139
+
140
+ **I8. Nút phụ không được trông như đã bị khoá.** Chữ nhạt trên nền nhạt thì người
141
+ dùng đọc ra là nút disabled. Chênh lệch nền của nút phụ với nền cha phải **thấy
142
+ được khi liếc**.
143
+
144
+ ---
145
+
146
+ ## Hover và focus
147
+
148
+ **I9. Mọi phần tử bấm được phải CÓ hover, và hover đó phải nhìn thấy được.**
149
+
150
+ Hai vế, hay sót vế đầu. Đổi màu xong tự hỏi: chênh lệch này có nhận ra khi liếc
151
+ không.
152
+
153
+ ⚠️ **Nút `primary` là chỗ bị quên nhiều nhất.** Nó đã nổi sẵn nên nhìn tĩnh thấy
154
+ ổn, và người dựng bỏ qua. Nhưng nút chính của cả màn mà rê vào không phản hồi gì
155
+ thì nó là thứ duy nhất trong trang trông như ảnh chụp. Luôn có
156
+ `hover:bg-primary-hover` — token đã có sẵn trong `tokens.css`, không phải tự chế
157
+ màu.
158
+
159
+ **Vế ngược: thứ không bấm được thì không có nền rê.** Nền rê là lời hứa "bấm vào đây";
160
+ dòng chỉ để đọc mà rê vào tô nền thì người dùng bấm thử, không ra gì. Dòng chứa một nút
161
+ (như "Điểm danh") vẫn không phải dòng bấm được: hover nằm ở nút. Hai cách: bỏ hover, hoặc
162
+ cho cả dòng là link tới chi tiết. Ngoại lệ duy nhất: rê vào dòng để hiện nút ẩn
163
+ (`components/list-row.md`), nền rê báo nút đã hiện. Dòng không link mà vẫn rê ra nền, đặt
164
+ cạnh card có dòng là link, thì hai danh sách trông giống nhau mà một bên bấm được.
165
+ Probe báo "Nền rê trên khối không bấm được".
166
+
167
+ **I10. Hover của một dòng là một lớp nền nhẹ, không tô đậm lên, không phóng to.
168
+ Nền hover không bao giờ trùng màu nền trang, cũng không trùng nền của khung ngay phía sau.**
169
+
170
+ Ngoại lệ: nút mở/đóng của accordion không tô nền hover (`I30`, `components/accordion.md`); tiêu đề cột bảng sắp xếp được cũng vậy, chỉ chữ đậm lên (`components/sortable-header.md`).
171
+
172
+ Chọn token theo **nền hover có chạm hai mép khung hay không**:
173
+
174
+ | Dòng | Hover | Vì sao |
175
+ | --- | --- | --- |
176
+ | **Thụt vào**, có bo góc, cách mép khung một khe: mục menu, link sidebar, dòng danh sách trong widget | `hover:bg-item-hover` | Nền xám nằm gọn trong khung trắng, mắt đọc ra một viên được ấn xuống |
177
+ | **Tràn hết bề ngang**, chạm hai mép khung trắng: dòng bảng, danh sách chia `divide-y` sát mép | `hover:bg-surface-hover` | Tô `--background` thì dòng đó cùng màu với nền trang bên ngoài khung, trông như khung bị khoét thủng một dải |
178
+
179
+ `--item-hover` ở nền sáng bằng đúng `--background`, ở nền tối là trắng phủ 5%. Đừng viết
180
+ `hover:bg-background` cho dòng thụt vào: ở nền tối nền trang tối hơn card, rê vào là chìm
181
+ xuống 1.07:1, gần như không thấy (`M21`).
182
+
183
+ Dòng **đang chọn** (tick checkbox) của bảng dùng **cùng nền mờ với hover**, `--surface-hover`.
184
+ Dấu hiệu của "đã chọn" là **checkbox đã tick**, không phải nền. Rê vào dòng đã chọn thì
185
+ giữ nguyên. Nút ⋯ trong dòng rê vào có nền trùng tông hover cũng được, không cần tách.
186
+
187
+ Chủ dự án chốt sau khi thử hết các cách tách nền, và cả ba đều bỏ:
188
+ - **`--secondary`**: rõ, nhưng tick cả trang thành mười dải xám đậm, trái gu mờ.
189
+ - **`--background`**: đúng bằng màu nền trang bên ngoài khung, dòng đã chọn trông như
190
+ khung bị khoét một dải — y hệt lỗi hover tràn mép ở trên, chỉ là đổi sang dòng đã chọn.
191
+ - **Vạch dọc đậm ở mép trái**: chọn tất cả thì mười vạch nối thành một cột đen (`N3`).
192
+
193
+ Muốn hai trạng thái tách nhau thì tách bằng checkbox, không bằng thêm một bậc xám. Vạch
194
+ trái để dành cho **một** mục đang mở trong cột điều hướng (sidebar, cây thư mục).
195
+
196
+ **Mục đang mở không có checkbox thì nền của nó khác nền rê.** Danh sách bên trái của bố cục
197
+ danh sách + chi tiết, hộp thư, cây thư mục: không có dấu nào khác ngoài nền, nên rê ra đúng
198
+ nền đó là rê qua mục nào cũng trông như vừa chọn nó. Luật
199
+ "cùng nền mờ" ở trên chỉ cho dòng bảng tick checkbox.
200
+
201
+ - **Danh sách chọn một mục nằm trong card thì dòng thụt vào**, không tràn mép: khung `p-1`,
202
+ dòng `rounded-xl` (card 16 = 12 + 4, `M19`), rê `hover:bg-item-hover`, đang mở một bậc đậm hơn `bg-secondary` (màu nhấn
203
+ có sắc thì nền nhạt của màu nhấn, như `bg-primary/8`). Dòng tràn mép phải dùng `surface-hover`
204
+ (`#f8f8fa`), trên card trắng chỉ chênh 7 mức, gần như không thấy, còn đang mở và đang rê thì
205
+ không còn bậc nào để tách.
206
+ - **Không vạch trái ở dòng nằm trong khung bo góc `overflow-hidden`**: dòng đầu và dòng cuối,
207
+ bo góc khung cắt vạch thành một mảnh cong. Vạch trái chỉ cho cột điều hướng không bo góc
208
+ (sidebar, cây thư mục).
209
+
210
+ **Nút và ô bấm được nằm trong dòng có nền rê thì nền rê của nó là `bg-foreground/8`**,
211
+ không `/5` như nút đứng ngoài (`components/button.md`). Chuột đang ở trên nút thì cũng
212
+ đang ở trên dòng, nên nút luôn chồng lên nền dòng `#f8f8fa`: `/5` ra `#ededef`, chỉ hơn nền
213
+ dòng 11 mức, mắt đọc thành cùng một mảng xám. `/8` ra
214
+ `#e6e6e8`, tách rõ mà vẫn nhạt. Áp cho ô sửa tại chỗ, nút ⋯, icon button trong dòng, cả
215
+ lúc mở (`aria-expanded:bg-foreground/8`). Chữ phụ trong ô đó (`—` của ô trống, ngày
216
+ nhạt) rê vào thì lên `hover:text-foreground`, như nút ghost: `--muted` trên nền `/8` chỉ
217
+ 4.05 : 1 (`styles.md`).
218
+
219
+ **I11. Hành động trên dòng: ít thì hiện thẳng, nhiều thì gom vào nút ba chấm.**
220
+
221
+ | Số hành động của một dòng | Cách hiện |
222
+ | --- | --- |
223
+ | **1–2**, không có hành động nguy hiểm | Icon button `h-8` nằm thẳng trong dòng, cột cuối căn phải. Danh sách thì mờ lúc thường, hiện khi rê vào dòng. **Bảng thì luôn hiện**, chữ `text-muted`: bảng dài người ta dò theo cột, nút lúc có lúc không làm cột cuối nhảy |
224
+ | **Từ 3 trở lên**, hoặc có xoá | **Một** nút `MoreHorizontal` luôn hiện ở cột cuối, bấm ra dropdown. Hành động hay dùng nhất (thường là sửa) được phép nằm ngoài thêm một nút, cạnh dấu ba chấm |
225
+
226
+ Trong dropdown: mục thường ở trên, **xoá tách xuống cuối** sau một đường chia,
227
+ hover đỏ `rose` (`I4`). Nút ba chấm có `aria-label="Thao tác"`, và **chặn nổi bọt**
228
+ (`event.stopPropagation()`) khi cả dòng cũng bấm được để mở chi tiết — không thì
229
+ bấm ba chấm là nhảy luôn sang trang chi tiết.
230
+
231
+ Thiết bị không có chuột thì không có hover: nút ẩn-hiện-khi-rê phải kèm
232
+ `[@media(hover:none)]:opacity-100`, không thì trên điện thoại không bao giờ thấy.
233
+
234
+ **I12. Hover, focus, chọn thì chỉ đổi màu.** Ngoại lệ: card hover được
235
+ `transition-all`. Khối nổi **mở và đóng** (modal, dropdown, panel, toast) thì có chuyển
236
+ động vào ra riêng, số ở mục "Chuyển động" cuối `layouts/overlay.md`.
237
+
238
+ **I13. Không vẽ vòng focus.** Chủ dự án chốt.
239
+
240
+ Nút (mọi dạng), link, link sidebar, tab, chip, checkbox, radio, công tắc, card chọn, dòng
241
+ danh sách, tay cầm thanh trượt: chỉ `outline-hidden`, **không** `focus-visible:ring-*`,
242
+ `focus-visible:outline-*`, `ring-offset-*`. Tab hay Shift+Tab tới thì không có vòng bao
243
+ ngoài. Đánh đổi đã biết: người dùng bàn phím không thấy mình đang đứng ở nút nào. **Đừng
244
+ thêm lại khi thấy Tab tới không có dấu gì**, và đừng báo nó là lỗi lúc soi (`V1`). Dự án
245
+ cần đạt chuẩn tiếp cận thì xem `I14`.
246
+
247
+ **Ngoại lệ lúc soi** (chủ dự án chốt): dự án **tự vẽ** vòng focus ở các control
248
+ khác mà một chỗ Tab tới không thấy gì, thì đó là chỗ bị đè mất trong hệ của họ, không phải
249
+ gu. Báo Lệch hệ, sửa bằng đúng vòng của họ (token `--focus-ring`, class focus của component
250
+ dùng chung). Dự án không vẽ vòng ở đâu cả thì vẫn theo luật trên: không báo. Ca hay sót:
251
+ tab đang chọn đặt `box-shadow` viền trong, đè mất vòng focus của `Button`, trong khi mọi
252
+ nút khác có vòng.
253
+
254
+ Lý do chốt: vòng xám 2px vẽ chồng lên dấu đang chọn, vạch trái, gạch chân thành ba bốn dấu
255
+ trên một dòng; bấm phím (Shift, phím tắt) trong lúc đang đứng trên phần tử cũng làm nó hiện,
256
+ nên người dùng chuột vẫn gặp.
257
+
258
+ Vẫn giữ, vì không phải vòng bao ngoài:
259
+
260
+ | Phần tử | Lúc focus |
261
+ | --- | --- |
262
+ | Mục trong menu, dropdown, listbox, lệnh trong command palette | **tô nền như hover** (`data-[highlighted]:bg-item-hover`): phím mũi tên dời đúng một chỗ sáng, thiếu nó là menu không đi bằng phím được |
263
+ | Ô nhập, textarea | viền + ring mờ: `focus:border-focus focus:ring-2 focus:ring-focus`, xem dưới bảng |
264
+ | Nút mở select, ô chọn ngày, ô chọn giờ (là `<button>`, không gõ được) | viền `focus-visible:border-focus`; viền + ring mờ chỉ khi đang mở (`aria-expanded:`), không `focus:` |
265
+ | Card chọn (radio dạng card) | chỉ dấu **đang chọn** (viền + ring mờ); Tab tới không thêm gì |
266
+
267
+ **Ô nhập dùng `focus`**, vì người dùng cần thấy mình đang gõ vào ô nào, dù vào bằng chuột
268
+ hay bàn phím.
269
+
270
+ **Nút mở select trông như ô nhập nhưng không phải ô nhập**: chọn xong một mục, focus
271
+ trả về nút (đúng, cho bàn phím), và nếu nút dùng `focus:` thì nó giữ viền đậm + ring y
272
+ như đang mở, dù danh sách đã đóng. Trên Safari bấm nút khác không lấy focus, nên viền đó
273
+ bám mãi tới khi bấm ra chỗ trống: hai ô cùng loại đứng cạnh nhau mà một ô viền đen đậm,
274
+ trông như đang mở.
275
+ Dùng `focus-visible:` (bàn phím mới sáng; trình duyệt tự biết lần focus trả về sau cú bấm
276
+ chuột không phải bàn phím) và `aria-expanded:` (đang mở).
277
+
278
+ **Vì sao ô điền được ring mà nút thì không.** Chủ dự án chốt, đảo bản
279
+ "ô chỉ đổi viền": viền đổi màu một mình thì trong form nhiều ô khó thấy ô nào
280
+ đang gõ, nhất là select đang mở. Ring ở đây là `--ring-focus` (màu nhấn 10%) dày
281
+ 2px (`ring-2`, không `ring-4`: `F20`), **mờ tới mức đọc ra là vầng sáng quanh ô**, không phải vòng viền thứ hai. Ô lỗi lúc thường chỉ viền đỏ `border-red-500` + câu lỗi, **không quầng**; quầng đỏ
282
+ `ring-2 ring-red-500/10` chỉ hiện khi ô lỗi đang focus (xem `input.md`).
283
+
284
+ **`outline-hidden` (Tailwind v4) hay `outline-none` (v3)**, đừng dùng `outline: none`
285
+ thuần hay `outline-none` của v4. Hai class kia làm outline **trong suốt** chứ không
286
+ xoá hẳn, nên nó vẫn hiện ra ở chế độ tương phản cao của Windows. Đó là chỗ duy
287
+ nhất người dùng thật sự cần nó mà không có nền hover để thay.
288
+
289
+ **Menu: chỉ MỘT mục sáng tại một thời điểm.** Hover với focus mà là hai trạng
290
+ thái riêng thì rê chuột vào mục này trong khi Tab đang đứng ở mục kia, hai mục
291
+ cùng sáng, người dùng không biết bấm Enter sẽ mở cái nào. Dùng Radix / shadcn thì
292
+ dùng **`data-[highlighted]`** thay cho cả `hover:` lẫn `focus:`:
293
+
294
+ ```tsx
295
+ <DropdownMenuItem className="outline-hidden data-[highlighted]:bg-item-hover">
296
+ ```
297
+
298
+ `data-[highlighted]` đi theo cả chuột lẫn phím mũi tên, nên luôn chỉ có một mục
299
+ sáng. Không dùng Radix thì khi chuột vào mục nào, gọi `.focus()` cho mục đó.
300
+
301
+ **I14. Trả lại vòng focus chỉ khi chủ dự án yêu cầu.**
302
+
303
+ Đề nói "cần accessibility", "đạt WCAG", dự án nhà nước, ngân hàng, giáo dục có yêu cầu
304
+ tiếp cận thì trả vòng lại, **ở đúng một chỗ** (class gốc của `Button`, của chip, của tab),
305
+ để muốn bỏ thì sửa một dòng:
306
+
307
+ `outline-hidden focus-visible:ring-2 focus-visible:ring-foreground/50 focus-visible:ring-offset-2 focus-visible:ring-offset-surface`
308
+
309
+ - `ring-foreground/50` là mức thấp nhất đạt 3:1 trên nền trắng (WCAG 1.4.11); nền tối `ring-white/50`.
310
+ - Dòng trong khung `overflow-hidden` (accordion, tiêu đề cột bảng) thì `ring-inset`, không thì bị cắt.
311
+ - Vòng không chồng lên dấu đang chọn: viền "đang chọn", "đang bật" vẽ bằng `inset-ring-*` hoặc `border`, không `ring-*`, vì `ring-*` chỉ có một lớp bóng và vòng focus sẽ thay mất viền chọn (`W8`).
312
+
313
+ **I15. Sidebar: mục đang chọn tô nền xám, không tô màu nhấn, không viền.** Mục
314
+ chưa chọn thì không nền. Rê vào `hover:bg-item-hover`; đang chọn **đậm hơn một bậc**
315
+ `bg-secondary` + `font-medium`. Cây thư mục cùng công thức. Đừng cho rê và đang chọn
316
+ cùng nền `--background`: mục đang chọn không có dấu nào khác ngoài nền nên phải khác nền
317
+ rê (`I10`), probe xếp Hỏng. Luật "cùng
318
+ nền mờ" chỉ còn cho dòng bảng tick checkbox. Hover hay đang chọn thì **icon và chữ cùng
319
+ lên `--foreground`**; lúc thường cả hai `foreground/70`, không mờ tới `--muted`.
320
+ Xem `layouts/app.md`.
321
+
322
+ Ngoại lệ đã dính: khi mục đang chọn là **ảnh** (avatar ở thanh dưới mobile), tô
323
+ màu đè lên ảnh thì không đọc ra là "đang chọn" — dùng vòng `box-shadow` quanh ảnh.
324
+
325
+ ---
326
+
327
+ ## Danh sách
328
+
329
+ **I16. Dữ liệu nhiều thì phân trang, đừng đổ hết ra.**
330
+
331
+ Danh sách hay bảng quá khoảng 25 dòng thì thêm phân trang, hoặc nút tải thêm.
332
+ Nguồn dữ liệu trả về tổng thì hiện **tổng số** và **đang xem tới đâu**: "51 tới 75
333
+ trong 312 dòng". Không có tổng (API phân trang bằng con trỏ) thì
334
+ chỉ "‹ Trước / Sau ›" hoặc "Tải thêm", **không bịa tổng** (`N7`, `N10`).
335
+
336
+ **I17. Ngưỡng giấu nội dung sau một cú bấm:** chỉ dùng accordion hay tab khi danh
337
+ sách dài hơn 6 mục, hoặc mỗi phần trả lời dài quá 3 dòng. Dưới ngưỡng đó thì
338
+ hiện hết.
339
+ Ngoại lệ: FAQ ở trang bảng giá luôn accordion (`layouts/pricing.md`): nó là chỗ tra,
340
+ không phải thứ người ta đến để đọc.
341
+ Khu "Cài đặt nâng cao" trong form cũng không tính theo ngưỡng này: nó giấu các ô ít
342
+ người đụng tới để đường chính ngắn (`components/accordion.md`, "Khu thu gọn trong form").
343
+
344
+ **I18. Thanh cuộn tự ẩn: đứng yên thì không thấy, rê vào hoặc đang cuộn thì hiện.**
345
+
346
+ Công thức đã chạy ở dự án thật, CSS
347
+ nằm sẵn trong `tokens.css`, áp cho cả app:
348
+
349
+ - Thanh **4px**, rãnh trong suốt, thumb bo tròn hẳn.
350
+ - **Đứng yên: thumb trong suốt.** Rê chuột vào vùng cuộn: hiện mờ (16%). Đang cuộn: đậm hơn (28%). Rê thẳng vào thumb: 40%.
351
+ - **Ẩn bằng màu trong suốt, không bằng `scrollbar-width: none`.** Bề rộng vẫn giữ chỗ, lúc thanh hiện ra nội dung không bị đẩy ngang 4px. Dùng `none` là mỗi lần cuộn cả khối giật một cái.
352
+ - **Màu thumb đi qua biến `--scrollbar-thumb` đặt trên khung cuộn**, không viết `*:hover::-webkit-scrollbar-thumb`. Chrome không vẽ lại thumb theo selector đó: rê vào không hiện gì, phải cuộn mới hiện, nên thanh "tự ẩn" thành "ẩn tới lúc đã cuộn". `*:hover { --scrollbar-thumb: … }` thì Chrome vẽ lại ngay. Dự án đang dùng bản cũ thì thay cả khối bằng khối trong `tokens.css`.
353
+ - **Khối Firefox phải bọc `@supports not selector(::-webkit-scrollbar)`.** Từ Chrome 121, có `scrollbar-width` là Chrome bỏ hết `::-webkit-scrollbar` và vẽ thanh gốc to, chiếm chỗ.
354
+ - Trạng thái "đang cuộn" cần một component nhỏ gắn `.is-scrolling` vào **đúng phần tử đang cuộn**, gỡ ra sau 700ms. Nghe `scroll` ở pha **capture** để bắt được cả vùng cuộn lồng nhau (sidebar, danh sách trong modal). Mount một lần ở gốc app:
355
+
356
+ ```tsx
357
+ import { useEffect } from "react";
358
+
359
+ // Gắn .is-scrolling vào đúng phần tử đang cuộn, gỡ ra 700ms sau khi dừng.
360
+ // CSS trong tokens.css đọc class này để hiện thanh cuộn (luật I18).
361
+ export default function ScrollbarAutohide() {
362
+ useEffect(() => {
363
+ const hideTimers = new WeakMap<Element, number>();
364
+
365
+ function handleScroll(event: Event) {
366
+ // Cuộn cả trang thì target là document, lấy phần tử gốc.
367
+ const scrollingElement =
368
+ event.target instanceof Element ? event.target : document.scrollingElement;
369
+ if (!scrollingElement) return;
370
+
371
+ scrollingElement.classList.add("is-scrolling");
372
+ window.clearTimeout(hideTimers.get(scrollingElement));
373
+ hideTimers.set(
374
+ scrollingElement,
375
+ window.setTimeout(() => scrollingElement.classList.remove("is-scrolling"), 700),
376
+ );
377
+ }
378
+
379
+ window.addEventListener("scroll", handleScroll, { capture: true, passive: true });
380
+
381
+ return () => window.removeEventListener("scroll", handleScroll, { capture: true });
382
+ }, []);
383
+
384
+ return null;
385
+ }
386
+ ```
387
+
388
+ `.scrollbar-clean` (ẩn hẳn, không bao giờ hiện) chỉ còn cho **hàng chip / tab
389
+ cuộn ngang**. Vùng cuộn dọc thì để thanh tự ẩn lo, đừng gắn `scrollbar-clean`:
390
+ ẩn hẳn thì người dùng chuột không có gì để kéo, cũng không biết còn bao nhiêu.
391
+
392
+ **Thanh ẩn thì mép cắt phải báo "còn nữa".** Thanh tự ẩn chỉ hiện khi chuột đã
393
+ nằm trong vùng cuộn; người vừa mở command palette bằng ⌘K, tay còn trên bàn
394
+ phím, không thấy gì cả. Tín hiệu lúc đứng yên là **mục cuối bị mép dưới cắt
395
+ ngang, lộ khoảng một nửa**. macOS, iOS, Android mặc định ẩn thanh cuộn lúc đứng
396
+ yên, và người dùng hay bỏ sót cả thanh cuộn đang hiện; nội dung bị cắt ngang thì mắt
397
+ muốn cuộn tiếp để xem nốt:
398
+
399
+ - **Chọn `max-h` sao cho mép dưới cắt giữa một mục, không cắt sát ranh giới hai mục.** Cắt còn thiếu vài px thì trông như danh sách hết ở đó (mục cuối lộ gần trọn thì không ai biết còn mục bên dưới). Công thức cho khung `p-1`, mục `h-10`: `max-h` = 40 × số mục trọn + 4 + 20 → **`max-h-76`** (304px, lộ 7 mục rưỡi) cho select, dropdown dài. Danh sách có nhãn nhóm thì đo ở trạng thái mặc định rồi xê `max-h` từng bậc 4px tới khi mục cuối lộ giữa 1/3 và 2/3.
400
+ - **Mục cao thấp khác nhau** (thông báo, bình luận, kết quả tìm có mô tả) thì không chốt được một con số `max-h`. Tính bằng JS lúc mở: trong giới hạn cao tối đa, tìm mục thấp nhất mà **điểm giữa** của nó còn lọt, rồi hạ chiều cao danh sách xuống đúng điểm giữa đó. Dữ liệu dài ngắn hay màn cao thấp thế nào cũng cắt giữa một mục:
401
+
402
+ ```ts
403
+ // Chiều cao để mục cuối lộ đúng một nửa (luật I18). Mỗi mục gắn data-peek-item.
404
+ export function getPeekListHeight(listElement: HTMLElement, maxHeight: number): number {
405
+ if (listElement.scrollHeight <= maxHeight) return listElement.scrollHeight;
406
+
407
+ const listTop = listElement.getBoundingClientRect().top - listElement.scrollTop;
408
+ let peekHeight = maxHeight;
409
+
410
+ for (const item of listElement.querySelectorAll<HTMLElement>("[data-peek-item]")) {
411
+ const itemRect = item.getBoundingClientRect();
412
+ const itemMiddle = itemRect.top - listTop + itemRect.height / 2;
413
+ if (itemMiddle > maxHeight) break;
414
+ peekHeight = itemMiddle;
415
+ }
416
+
417
+ return peekHeight;
418
+ }
419
+ ```
420
+ - **Lớp nổi có danh sách cuộn (command palette, select, dropdown dài) chớp thanh cuộn một lần lúc mở**, như macOS: nếu `scrollHeight > clientHeight` thì gắn `.is-scrolling` vào vùng danh sách, gỡ ra sau ~1 giây. Chỉ lớp nổi; sidebar và trang thì không chớp.
421
+ - **Vùng cuộn nằm trong khung bo góc (lớp nổi, card) thì rãnh lùi theo đầu nó chạm**: đầu nào chạm góc bo thì lùi **bằng bán kính góc bo** (khung `rounded-2xl` → `mb-4`, cả hai đầu chạm thì `my-4`), vì đầu tròn của thanh 4px sát mép chỉ nằm trọn trong góc khi cách mép từ R − 2px; đầu nào nằm dưới đường kẻ thẳng thì `mt-2`. Viết bằng `[&::-webkit-scrollbar-track]:mb-4`. Khe phải trừ đi 4px của thanh (`pr-1` thay cho `p-2`, kèm `[scrollbar-gutter:stable]`). Lùi thiếu thì cuộn tới cuối, đuôi thanh bị góc bo cắt vát (`my-2` vẫn thiếu 2px ở khung bo 16px); không trừ khe thì khe phải rộng hơn khe trái 4px. Xem mẫu ở mục Command palette trong `layouts/overlay.md`.
422
+ - **Không phủ dải mờ ở đáy** để báo còn nữa: thêm một lớp gradient là thêm tín hiệu cho việc mục bị cắt nửa đã nói (`N3`), và dải mờ đè lên chữ của mục cuối.
423
+
424
+ ```ts
425
+ // Chớp thanh cuộn một lần khi lớp nổi mở (luật I18). Gọi trong effect lúc open.
426
+ export function flashScrollbar(scrollElement: HTMLElement | null) {
427
+ if (!scrollElement || scrollElement.scrollHeight <= scrollElement.clientHeight) return;
428
+
429
+ scrollElement.classList.add("is-scrolling");
430
+ window.setTimeout(() => scrollElement.classList.remove("is-scrolling"), 1000);
431
+ }
432
+ ```
433
+
434
+ **I19. Mọi trang có dữ liệu đều cần đủ ba trạng thái: đang tải, rỗng, lỗi.**
435
+
436
+ Khung chờ phải **đúng hình** của nội dung sẽ hiện ra, không phải một vòng xoay
437
+ giữa màn. Khung chờ sai hình thì trang nhảy một cái lúc dữ liệu về, và đó là thứ
438
+ người dùng cảm nhận được dù không gọi tên được. Đã có dữ liệu trên màn (đổi lọc, đổi
439
+ trang, đổi tab) thì giữ dữ liệu cũ, không vẽ lại khung chờ.
440
+
441
+ Đang tải: `components/loading.md`. Rỗng, lỗi: `components/empty-state.md`.
442
+
443
+ ---
444
+
445
+ ## Modal
446
+
447
+ **I20. Modal có ô nhập thì bấm ra ngoài KHÔNG được đóng.**
448
+
449
+ Popup nào chứa `input` / `textarea` / `select` / rich-text / upload ảnh thì
450
+ backdrop **không** mang handler đóng. Đang điền dở mà con chuột lỡ click một cái
451
+ ra ngoài là mất sạch — không có nháp, không undo. Đóng bằng nút ✕ / Huỷ / Esc,
452
+ tức là phải cố ý.
453
+
454
+ - `e.target === e.currentTarget` **không phải cách vá**: nó chỉ chặn click bị bubble từ bên trong, còn click thẳng vào backdrop — đúng cái tay lỡ bấm — vẫn đóng. Xoá cả prop `onClick` đi.
455
+ - Gỡ dismiss thì **phải chắc còn đường đóng khác**. Dính thật ở một dự án: hai bottom sheet lấy backdrop làm lối ra DUY NHẤT, gỡ xong là khoá luôn người dùng trong sheet.
456
+
457
+ **I21. Vẫn giữ dismiss cho thứ chỉ để đọc hoặc chọn.** Lightbox ảnh, xem chi
458
+ tiết đơn, roster, dropdown, menu, panel thông báo, drawer mobile. Đóng nhầm mấy
459
+ cái đó không mất gì.
460
+
461
+ **I22. Panel và dropdown phải portal ra `document.body`.**
462
+
463
+ Popup lồng trong sidebar hay thanh dưới sẽ bị clip bởi `overflow` hoặc bị nhốt
464
+ trong stacking context của cha. Portal thoát mọi thứ đó.
465
+
466
+ **I23. Dựng modal mới thì dùng khung dùng chung, đừng tự dựng backdrop bằng
467
+ `position: fixed`.** Khung dùng chung cho sẵn khoá tiêu điểm, trả tiêu điểm về
468
+ nút đã mở, khoá cuộn nền, và aria đúng chuẩn — tự dựng thì mất hết.
469
+
470
+ ---
471
+
472
+ ## Điều hướng
473
+
474
+ **I24. Panel thông báo mở tại chỗ, không điều hướng sang trang khác.** Điều hướng
475
+ đi mất luôn ngữ cảnh chỉ để liếc một cái thông báo.
476
+
477
+ **I25. Đã bỏ.** Luật cũ nói "đánh dấu đã đọc" chỉ áp cho phạm vi
478
+ đang lọc. Đó là logic dữ liệu, không phải giao diện, người dùng tự quyết (xem
479
+ phạm vi ở `../SKILL.md`). Giữ số để các chỗ dẫn `I26` trở đi không lệch.
480
+
481
+ ---
482
+
483
+ ## Ô nhập
484
+
485
+ **I26. Nhãn phải gắn vào ô, có `cursor-pointer`, và chỉ rộng bằng chữ.**
486
+
487
+ Ba thứ đi liền nhau, thiếu một cái là lỗi:
488
+
489
+ ```html
490
+ <label for="email" class="w-fit cursor-pointer text-sm font-medium">Email</label>
491
+ <input id="email" />
492
+ ```
493
+
494
+ - **`for` / `htmlFor` khớp `id`** — bấm vào chữ là ô nhận tiêu điểm. Không có thì nhãn chỉ là chữ trang trí, và trình đọc màn hình cũng không biết ô này tên gì.
495
+ - **`cursor-pointer`** — nhãn bấm được mà con trỏ vẫn là que gõ chữ thì không ai biết để mà bấm.
496
+ - **`w-fit`** — chỗ sót nhiều nhất. `<label>` là block, không có `w-fit` thì nó chiếm trọn chiều ngang. Bấm vào khoảng trắng trống bên phải chữ, cách chữ 300px, ô vẫn sáng lên. Người dùng bấm hụt ra ngoài mà thấy ô phản hồi thì tưởng mình bấm trúng cái gì đó.
497
+
498
+ **I27. Ô mật khẩu phải có nút hiện/ẩn.** Không có thì người dùng gõ sai một ký tự
499
+ là phải xoá hết gõ lại, và đó là lý do rời form phổ biến nhất ở màn đăng nhập.
500
+
501
+ - Nút **chỉ có icon**, `absolute` trong ô, căn phải. Icon `Eye` / `EyeOff` theo `F15`.
502
+ - **Nút `size-10`, `right-1` căn giữa dọc** (`inset-y-0 my-auto`, không `-translate-y-1/2`: `N11`), icon `size-4` giữa nút. Icon đứng đúng chỗ cũ, chỉ vùng bấm to ra; vừa khít `pr-11` (4 + 40 = 44px). Nút ôm sát icon (`p-1`, 24px) thì trên điện thoại bấm trượt vào ô, bàn phím bật lên thay vì hiện mật khẩu. Không vòng focus (`I13`).
503
+ - **`type="button"`.** Quên thì nó mặc định là `submit` — bấm xem mật khẩu hoá ra gửi form.
504
+ - `aria-label` đổi theo trạng thái: "Hiện mật khẩu" / "Ẩn mật khẩu". Không phải một nhãn cố định.
505
+ - Chừa chỗ cho nút bằng padding phải trên chính ô (`pr-11`), đừng để chữ gõ dài chui xuống dưới icon.
506
+ - Mặc định là **ẩn**. Mở sẵn thì mật khẩu phơi ra trước mặt người đứng sau lưng.
507
+
508
+ **I28. KHÔNG tắt gợi ý điền sẵn của trình duyệt. Khai báo cho nó đúng.**
509
+
510
+ Cái khung đen Chrome bật lên khi chạm vào ô email là **trình quản lý mật khẩu**,
511
+ không phải lỗi giao diện. Người dùng bấm một cái là điền xong cả form. Tắt nó đi
512
+ là ép người ta gõ tay mật khẩu 20 ký tự, và đẩy họ sang chỗ đặt mật khẩu dễ nhớ.
513
+
514
+ `autocomplete="off"` ở form đăng nhập còn bị Chrome, Safari, Firefox **cố tình
515
+ bỏ qua** — tắt không được, chỉ làm hỏng phần gợi ý chứ không tắt hẳn.
516
+
517
+ Việc phải làm là ngược lại: khai báo đủ để nó đoán đúng.
518
+
519
+ | Ô | `autocomplete` |
520
+ | --- | --- |
521
+ | Email / tên đăng nhập | `username` (hoặc `email`) |
522
+ | Mật khẩu, màn **đăng nhập** | `current-password` |
523
+ | Mật khẩu, màn **đăng ký** hoặc đổi mật khẩu | `new-password` |
524
+ | Mã OTP | `one-time-code` |
525
+
526
+ Mỗi ô cũng phải có `name`. Thiếu `name` thì trình duyệt không có gì để lưu, và
527
+ lần sau không gợi ý được.
528
+
529
+ **Form đặt mật khẩu mới không có ô email** (bước cuối luồng quên mật khẩu, đổi mật khẩu
530
+ qua link) thì thêm một ô ẩn mang tên tài khoản, ngay đầu form:
531
+
532
+ ```html
533
+ <input type="email" name="username" autocomplete="username" value="an@congty.vn" hidden readonly />
534
+ ```
535
+
536
+ Không có ô này thì trình quản lý mật khẩu lưu mật khẩu mới mà không biết của tài khoản
537
+ nào, hoặc lưu thành một mục mới bên cạnh mục cũ. Lần đăng nhập sau nó vẫn gợi ý mật khẩu
538
+ cũ, người dùng tưởng đổi chưa được.
539
+
540
+ Khung gợi ý **che mất ô ngay dưới** — đó là hành vi bình thường của trình duyệt,
541
+ nó tự đóng khi gõ hoặc khi rời ô. Đừng đẩy khoảng cách các trường ra xa để
542
+ "tránh" nó.
543
+
544
+ ---
545
+
546
+ ## Vùng bấm
547
+
548
+ **I29. Nền hover, vùng bấm và `cursor-pointer` phải nằm trên CÙNG MỘT phần tử,
549
+ và phần tử đó rộng hết hàng.**
550
+
551
+ Lỗi hay gặp nhất ở menu, sidebar và danh sách bấm được. Nền hover nằm ở phần tử
552
+ bọc ngoài, rộng cả hàng, còn phần tử bấm được (`<a>`, `<button>`) lại là inline,
553
+ chỉ ôm vừa khít chữ.
554
+
555
+ Hệ quả: rê chuột ngang qua hàng thì con trỏ **nhấp nháy**, qua chữ là bàn tay, qua
556
+ khoảng trống là mũi tên. Nền vẫn sáng cả hàng, nên người dùng tưởng bấm đâu cũng
557
+ được. Bấm vào khoảng trống thì **không có gì xảy ra**, và họ nghĩ app bị đơ.
558
+
559
+ ```html
560
+ <!-- Sai: hover ở <li>, vùng bấm chỉ bằng chữ -->
561
+ <li class="flex h-10 items-center rounded-xl px-3 hover:bg-item-hover">
562
+ <a href="/ho-so">Hồ sơ của bạn</a>
563
+ </li>
564
+
565
+ <!-- Đúng: <li> trơn, mọi thứ dồn vào <a> -->
566
+ <li>
567
+ <a href="/ho-so" class="flex h-10 w-full cursor-pointer items-center gap-2.5 rounded-xl px-3 hover:bg-item-hover">
568
+ <i data-lucide="user" class="size-4 text-muted"></i>
569
+ Hồ sơ của bạn
570
+ </a>
571
+ </li>
572
+ ```
573
+
574
+ **React với shadcn / Radix: bẫy `asChild`.** Đây là chỗ dính nhiều nhất trong
575
+ dự án Next:
576
+
577
+ ```tsx
578
+ // Sai: Item có hover và rộng cả hàng, nhưng điều hướng nằm ở <Link> inline bên trong.
579
+ // Bấm vào khoảng trống của hàng thì menu đóng lại mà không đi đâu cả.
580
+ <DropdownMenuItem>
581
+ <Link href="/ho-so">Hồ sơ của bạn</Link>
582
+ </DropdownMenuItem>
583
+
584
+ // Đúng: asChild để <Link> TRỞ THÀNH chính Item, thừa hưởng hover lẫn vùng bấm
585
+ <DropdownMenuItem asChild>
586
+ <Link href="/ho-so" className="flex w-full cursor-pointer items-center gap-2.5">
587
+ Hồ sơ của bạn
588
+ </Link>
589
+ </DropdownMenuItem>
590
+ ```
591
+
592
+ **Cách kiểm, mất năm giây:** rê chuột từ mép trái sang mép phải của hàng, thật
593
+ chậm. Con trỏ phải là bàn tay **suốt từ đầu tới cuối**. Đổi dù một lần là lỗi.
594
+
595
+ Áp cho: mục menu, link sidebar, dòng danh sách bấm được, tab, và card mà cả khối
596
+ bấm được. Padding của hàng đặt trên **phần tử bấm**, không đặt trên phần tử bọc,
597
+ vì padding cũng là vùng bấm.
598
+
599
+ `cursor-pointer` phải ghi tường minh trên `<button>` ở Tailwind v4 — xem `W7`.
600
+
601
+ **I30. Không dùng `<details>` / `<summary>` cho bất cứ thứ gì mở/đóng.** Dựng bằng
602
+ `<button aria-expanded>` + khối trượt `grid-rows`.
603
+
604
+ `<details>` mở và đóng tức thì, không trình duyệt nào animate sẵn: bấm là nội dung
605
+ giật ra, bấm lần nữa là biến mất, cả trang bên dưới nhảy theo (`N1`). Cách vá bằng
606
+ `::details-content` + `interpolate-size` chỉ chạy trên Chrome; Safari và Firefox vẫn
607
+ giật.
608
+
609
+ Áp cho mọi thứ mở/đóng: accordion, FAQ, nhóm sidebar thu gọn, mục cây, "xem chi
610
+ tiết" trong dòng, khối bước công cụ trong chat. Cùng lý do, **không render có điều
611
+ kiện** (`{isOpen && …}`) hay `hidden` cho phần nội dung đó: cũng giật y như vậy.
612
+
613
+ Mẫu đầy đủ cho accordion (padding, hover, khung, cách kiểm) ở `components/accordion.md`.
614
+ Công thức lõi, giống nhóm sidebar (`layouts/app.md`):
615
+
616
+ ```tsx
617
+ <button type="button" aria-expanded={isOpen} aria-controls={panelId} onClick={() => setIsOpen(!isOpen)}>
618
+ {label}
619
+ <ChevronDown className={cn("size-4 shrink-0 transition-transform duration-200 motion-reduce:transition-none", isOpen && "rotate-180")} aria-hidden />
620
+ </button>
621
+
622
+ <div
623
+ id={panelId}
624
+ inert={!isOpen}
625
+ className={cn(
626
+ "grid transition-[grid-template-rows] duration-200 ease-out motion-reduce:transition-none",
627
+ isOpen && "grid-rows-[1fr]",
628
+ !isOpen && "grid-rows-[0fr]",
629
+ )}
630
+ >
631
+ <div className="min-h-0 overflow-hidden">…</div>
632
+ </div>
633
+ ```
634
+
635
+ - **`duration-200 ease-out`**, chevron xoay cùng nhịp. Không đo chiều cao bằng JS.
636
+ - **Khối đóng gắn `inert`**: Tab không lọt vào nội dung đã ẩn, trình đọc màn hình không đọc.
637
+ - **Có `motion-reduce:transition-none`.**
638
+ - **Nút mở/đóng tràn hết bề ngang khung trắng (accordion, FAQ) thì không tô nền hover**,
639
+ chevron đậm lên thay nền. Ngoại lệ của `I10`, lý do và các cách đã thử ở
640
+ `components/accordion.md`.
641
+ - **Nút và nội dung giữ padding cố định, không đổi theo trạng thái.** Nút `py-*` như nhau
642
+ lúc mở và đóng; nội dung cùng `px` với nút, chỉ `pb`, nối tiếp padding dưới của nút.
643
+ Không bớt `pb` của nút khi mở, không margin âm (`N11`): tô nền nút là chữ lệch hẳn về
644
+ một mép.
645
+ - Đổi lại mất Ctrl+F tự mở khối chứa chữ (chỉ `<details>` có). Chấp nhận, như
646
+ accordion của mọi thư viện component.
647
+
648
+ Grep một lượt khi dựng xong: `<details` và `<summary` phải ra 0.
649
+
650
+
651
+ ---
652
+
653
+ ## Gỡ phần tử đang có focus
654
+
655
+ **I31. Bấm một nút làm chính dòng của nó biến mất thì tự chuyển focus.** Gỡ dòng
656
+ (đăng xuất thiết bị, xoá dòng không qua hộp xác nhận, thu hồi lời mời) làm nút đang có
657
+ focus rời DOM, và focus rơi về `<body>`: trình đọc màn hình đọc lại từ đầu trang, người
658
+ dùng bàn phím mất chỗ.
659
+
660
+ - Còn dòng sau: focus vào nút cùng loại ở dòng sau. Hết dòng sau thì dòng trước.
661
+ - Hết dòng có nút (hoặc gỡ hàng loạt): focus lên tiêu đề của khối (`tabIndex={-1}`,
662
+ `outline-hidden`), hay ô trống / nút chính của trạng thái rỗng.
663
+ - Gỡ qua hộp xác nhận: hộp đóng thì trả focus theo đúng luật trên, không trả về nút mở
664
+ hộp vì nút đó có thể đã mất (vd nút "Đăng xuất 4 thiết bị khác" ẩn khi không còn thiết
665
+ bị khác).
666
+ - Đổi focus sau khi React đã gỡ dòng: `flushSync` rồi `focus()`, hoặc giữ id đích trong
667
+ ref và focus trong `useEffect` khi danh sách đổi.
668
+
669
+ **I32. Nền rê của một khối không được trùng nền của khối con bên trong nó.** ⚑
670
+
671
+ Dòng danh sách, ô danh mục, card bấm được hay có một ô icon (hoặc badge) nền xám nhạt.
672
+ Rê vào mà khối chuyển đúng sang màu đó thì ô icon **biến mất**, chỉ còn icon trơ trọi,
673
+ như vừa bị gỡ khỏi dòng. Mẫu `list-row.md` rê `hover:bg-item-hover`, ô icon trong nhiều
674
+ file cũng `bg-background`: ghép hai cái là dính.
675
+
676
+ - Khối rê được có ô con nền `bg-background` thì ô con **đảo sang nền card lúc rê**:
677
+ khối có `group`, ô con thêm `group-hover:bg-surface`. Mảng rê xám, ô icon thành trắng,
678
+ vẫn đọc ra là một ô.
679
+ - Hoặc cho ô con một viền `border-border` để nó không dựa vào nền.
680
+ - Kiểm khi rê: ô con có còn nhìn ra là một ô không. Probe báo "Khối con biến mất lúc rê".