@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,430 @@
1
+ # Bố cục form và xác thực
2
+
3
+ Không có wireframe thì dựng bố cục mặc định (hoặc cái mà điều kiện trong đề
4
+ chọn), rồi báo một dòng lúc giao. Xem câu 4 trong `../../SKILL.md`.
5
+
6
+ ---
7
+
8
+ ## Đăng nhập, đăng ký
9
+
10
+ **A. Một cột giữa màn** (mặc định, hợp mọi trường hợp)
11
+
12
+ ```
13
+ ┌─────────────────┐
14
+ │ logo │
15
+ │ Tiêu đề │
16
+ │ (câu dẫn) │
17
+ │ │
18
+ │ nhãn │
19
+ │ [ô nhập ] │
20
+ │ nhãn quên mk? │
21
+ │ [ô nhập ] │
22
+ │ (chỗ câu lỗi) │
23
+ │ [ NÚT ] │
24
+ │ ─── hoặc ─── │
25
+ │ [ nút Google ] │
26
+ │ chưa có tk? Tạo │
27
+ └─────────────────┘
28
+ ```
29
+
30
+ Khối rộng `max-w-md`. **Không viền** — đây là card đứng một mình, xem `M29`.
31
+ Không căn giữa chữ trong form, chỉ căn giữa cả khối.
32
+
33
+ - **Logo sản phẩm luôn có**, đặt trên tiêu đề, dùng đúng dấu hiệu đang nằm ở sidebar
34
+ (dự án có component logo thì import, chưa có thì dựng theo `../components/logo.md`).
35
+ Màn xác thực là chỗ duy nhất người dùng chưa vào app; thiếu logo thì trang đọc như
36
+ form quản trị không biết của ai, và người dùng không chắc mình đang đăng nhập đúng
37
+ chỗ. Logo ở đây là cấu trúc, không phải "thêm logo" mà `S3` cấm.
38
+ - **Câu dẫn chỉ khi có điều để nói** (vd "Dùng thử 14 ngày, không cần thẻ" khi đề cho
39
+ biết). Không bịa câu chào kiểu "Chào mừng bạn quay lại!" cho đủ khuôn.
40
+
41
+ **B. Hai cột, form trái ảnh phải** (hợp khi muốn chèn lời chứng thực hoặc ảnh sản phẩm)
42
+
43
+ ```
44
+ ┌──────────────┬──────────────┐
45
+ │ form như A │ ảnh / trích │
46
+ │ │ dẫn khách │
47
+ └──────────────┴──────────────┘
48
+ ```
49
+
50
+ ---
51
+
52
+ ## Mặc định cho màn xác thực — dựng luôn, báo một dòng
53
+
54
+ **Đừng hỏi bốn câu.** Người dùng gõ "dựng màn đăng nhập" là muốn thấy màn đăng
55
+ nhập, không muốn làm một bài khảo sát. Dựng theo mặc định dưới đây, rồi **nói
56
+ một dòng** cho họ biết mình đã chọn gì.
57
+
58
+ | Thứ | Mặc định | Vì sao |
59
+ | --- | --- | --- |
60
+ | **"Quên mật khẩu?"** | **Có** | Thiếu nó thì người quên mật khẩu không còn đường nào vào. Đây là phần tử duy nhất mà thiếu là hỏng chức năng, không phải hỏng thẩm mỹ |
61
+ | **Ghi nhớ đăng nhập** | **Không** | Nó đổi thời hạn phiên ở **backend**, không phải chỉ là cái checkbox. Vẽ ra mà backend không làm gì là lừa người dùng — sai nhiều hơn là thiếu |
62
+ | **Đăng nhập mạng xã hội** | **Có, một nút Google** ("Đăng nhập bằng Google" / "Đăng ký bằng Google") | Gần như mọi app có màn đăng nhập đều có nút này, người dùng tìm nó trước cả ô email. Thiếu là trang trông như bản dựng dở. Handler để rỗng, báo lúc giao là cần nối nhà cung cấp. Sản phẩm cho lập trình viên thì thêm GitHub. Cả đăng nhập lẫn đăng ký đều có, và cùng bộ nhà cung cấp |
63
+ | **Placeholder** | **Có**, ngoại lệ của `T25` | Màn đứng một mình, cả trang chỉ có vài ô: ô trống trơn trông như chưa dựng xong. Chữ ở mục dưới |
64
+ | **Con trỏ lúc mở màn** | **Nằm sẵn ở ô đầu** (`autoFocus`) | Cả màn chỉ có một việc là gõ. Áp cho **mọi** màn trong luồng: đăng nhập, đăng ký, từng bước quên mật khẩu, OTP. Bước này có bước kia không thì sang bước mới người dùng phải chạm lại vào ô |
65
+ | **"Nhập lại mật khẩu"** | **Không** | Ô mật khẩu đã có nút mắt để xem lại. Hầu hết app đã bỏ ô gõ lại; thêm nó là thêm một ô cho mỗi người đăng ký để phòng lỗi mà nút mắt đã phòng rồi |
66
+ | **Chiều cao ô và nút** | **`h-12`** | Ngoại lệ duy nhất của `h-11 md:h-10` (`budgets.md`): form đứng một mình giữa trang, cả màn chỉ có nó, ô to hơn một bậc là hợp. Form trong app, modal, cài đặt thì không |
67
+
68
+ **Dòng báo, đặt chung với dòng báo bố cục lúc giao:**
69
+
70
+ > Mình dựng kèm "Quên mật khẩu?" và nút Google (nút chưa nối, cần cấu hình đăng nhập
71
+ > Google ở backend); chưa có ghi nhớ đăng nhập vì nó cần backend đổi thời hạn phiên.
72
+ > Muốn thêm nhà cung cấp khác hay bỏ Google thì nói.
73
+
74
+ ### Placeholder trên màn xác thực
75
+
76
+ Câu hướng dẫn ngắn, không dùng email mẫu (`ten@congty.com` bị đọc nhầm thành chữ đã
77
+ điền sẵn, `T25`), không dùng `••••••` (`T26`):
78
+
79
+ | Ô | Placeholder |
80
+ | --- | --- |
81
+ | Họ và tên | `Nhập họ và tên` |
82
+ | Email | `Nhập email` |
83
+ | Mật khẩu (đăng nhập) | `Nhập mật khẩu` |
84
+ | Mật khẩu (đăng ký) | `Tạo mật khẩu`, dòng gợi ý "Ít nhất 8 ký tự" vẫn nằm **dưới ô** vì nó phải còn đó lúc đang gõ |
85
+
86
+ Câu lỗi vẫn không được trùng placeholder: "Chưa nhập email", không phải "Nhập email".
87
+ Ngoại lệ này chỉ cho màn xác thực đứng một mình. Form trong app, modal, cài đặt vẫn
88
+ theo `T25`.
89
+
90
+ Một dòng, không phải một bảng câu hỏi. Người dùng **không nhắc gì** thì coi như
91
+ đồng ý, dựng theo mặc định, đi tiếp.
92
+
93
+ **Chưa nối backend thì gửi hợp lệ vẫn phải đi tiếp.** Bấm "Đăng ký" với form đúng mà
94
+ không có gì xảy ra thì người duyệt tưởng nút hỏng. Giả lập: nút
95
+ quay spinner ~1 giây, rồi sang bước kế tiếp nếu đã dựng (đăng nhập → trang đầu của app, đăng ký → nhập OTP → trang đầu của app, quên mật
96
+ khẩu → nhập mã, mật khẩu mới → màn xong). Hàm gọi API vẫn để trống, có comment chỗ nối.
97
+
98
+ **Bước sau hiện đúng thứ người dùng vừa gõ.** Gõ `an@congty.vn` ở trang đăng ký thì trang
99
+ OTP ghi "Mã … vừa được gửi tới an@congty.vn", không phải email mẫu của trang OTP (người duyệt
100
+ tưởng mã gửi nhầm địa chỉ). Chuyền qua state của router; email mẫu chỉ dùng khi mở thẳng trang OTP bằng link, và
101
+ ở trang `/states`. Cùng luật `S6`: dữ liệu giả của một luồng phải khớp nhau giữa các bước.
102
+
103
+ **Họ nói muốn thêm thì thêm ngay, đừng hỏi lại.** "Thêm remember me" là đủ rõ —
104
+ dựng luôn, không hỏi "bro muốn tích sẵn không, đặt ở đâu". Mặc định phần dưới
105
+ đã trả lời hết mấy câu đó rồi.
106
+
107
+ ### Ghi nhớ đăng nhập — khi họ yêu cầu
108
+
109
+ - **Không tích sẵn.** Tích sẵn là quyết định hộ người dùng về bảo mật.
110
+ - Nhãn **"Ghi nhớ đăng nhập"**, đừng dịch thẳng "Nhớ tôi" — tiếng Việt đọc ra như máy dịch (`T24`).
111
+ - Đặt cùng hàng với "Quên mật khẩu?": checkbox trái, link phải.
112
+ - Nói một câu lúc giao: cái này cần backend đổi thời hạn phiên, không chỉ là checkbox.
113
+ - App có dữ liệu nhạy cảm hoặc hay dùng trên máy chung — ngân hàng, hồ sơ sức khoẻ, quản trị nội bộ — thì **nói một câu khuyên bỏ**, rồi vẫn làm theo ý họ.
114
+
115
+ ---
116
+
117
+ ## "Quên mật khẩu?"
118
+
119
+ **Nằm cùng hàng với nhãn "Mật khẩu", căn phải.** Không nằm dưới ô nhập, không
120
+ nằm dưới nút submit.
121
+
122
+ ```html
123
+ <div class="relative flex flex-col gap-2">
124
+ <label for="password" class="w-fit cursor-pointer text-sm font-medium">Mật khẩu</label>
125
+ <div class="relative"><!-- ô mật khẩu + nút mắt --></div>
126
+ <!-- Đứng SAU ô trong DOM, absolute lên hàng nhãn -->
127
+ <a href="/quen-mat-khau" class="absolute top-0 right-0 text-sm text-foreground outline-hidden hover:underline">Quên mật khẩu?</a>
128
+ </div>
129
+ ```
130
+
131
+ **Thứ tự Tab: email → mật khẩu → nút mắt → "Quên mật khẩu?" → Đăng nhập.** Link đặt
132
+ trong hàng nhãn bằng `flex justify-between` thì nó đứng trước ô mật khẩu trong DOM: gõ
133
+ email xong bấm Tab là rơi vào link, bấm Enter theo thói quen là rời trang đăng nhập. Nên link nằm sau ô trong DOM, `absolute top-0 right-0` để vẫn hiện ở
134
+ hàng nhãn; vị trí trên màn không đổi một pixel. Đừng chữa bằng `tabindex="-1"`: người
135
+ dùng bàn phím mất luôn lối vào link.
136
+
137
+ **Vì sao không để dưới ô nhập** — dòng đó đã có chủ: gợi ý lúc thường, câu lỗi
138
+ khi sai. Và "gõ sai mật khẩu" chính là lúc link này cần rõ nhất, nên hai thứ đạt
139
+ đỉnh cùng lúc ở cùng một chỗ. Hàng nhãn thì luôn chỉ có một dòng, không bao giờ
140
+ đụng.
141
+
142
+ **Vì sao không để dưới nút submit** — ở đó nó lẫn vào khu "hoặc / đăng nhập bằng
143
+ Google / chưa có tài khoản?", thành một link tình cờ nằm giữa một đống link.
144
+
145
+ Ba thông số:
146
+
147
+ | | Lấy gì | Vì sao |
148
+ | --- | --- | --- |
149
+ | Cỡ | `text-sm`, **bằng nhãn** | Cùng hàng mà lệch cỡ thì trông như canh hụt. Nhỏ hơn cũng làm vùng chạm mobile hẹp lại |
150
+ | Màu | `--foreground`, **không làm mờ** | Chữ nhạt đọc ra là đã bị khoá (`I8`). Đây là lối thoát duy nhất của người không vào được tài khoản — làm nó trông disabled là chặn đúng người đang cần |
151
+ | Độ đậm | `font-normal` (nhãn là `font-medium`) | Phân cấp bằng **một** thứ thôi. `M13`: thứ bậc đến từ cỡ chữ, độ đậm, màu chữ — dùng cả ba cùng lúc là thừa |
152
+
153
+ **Là link, không phải nút.** Trong form mà thành nút thì nó cạnh tranh với nút đăng
154
+ nhập nằm ngay dưới. Có `cursor-pointer`, gạch chân khi hover. Khác "Xem tất cả" của
155
+ dashboard (`I7`, cũng là link) ở màu: ở đây `--foreground` không làm mờ, vì là lối
156
+ thoát duy nhất của người không vào được tài khoản.
157
+
158
+ ---
159
+
160
+ ## Luồng quên mật khẩu
161
+
162
+ Ba bước và một màn xong, cùng một card, cùng khuôn với màn đăng nhập: logo, ô `h-12`,
163
+ placeholder. Gửi mã 6 số hay gửi link trong email là chuyện backend; mặc định dựng mã
164
+ vì cả luồng nằm trong một tab, báo một dòng lúc giao.
165
+
166
+ 1. **Nhập email.** Tiêu đề "Quên mật khẩu", câu dẫn nói sẽ nhận được gì ("Nhập email
167
+ đã đăng ký, mã đặt lại mật khẩu sẽ được gửi tới đó"). Đến từ trang đăng nhập mà ô
168
+ email đã gõ thì điền sẵn. Nút "Gửi mã". Dưới card là link **"Quay lại đăng nhập"**,
169
+ cách hầu hết app gọi lối ra này.
170
+ 2. **Nhập mã**, theo `components/otp-input.md`. Câu dẫn **không xác nhận email có tài
171
+ khoản hay không**: "Nếu email này đã đăng ký, mã gồm 6 số đã được gửi tới …". Báo
172
+ "Email chưa đăng ký" là cho người lạ dò xem ai có tài khoản.
173
+ 3. **Mật khẩu mới.** Câu dẫn nói đang đổi cho tài khoản nào: "Cho tài khoản
174
+ **an@congty.vn**" (email in bằng `EmailText`). Một ô "Mật khẩu mới" có nút mắt, gợi ý
175
+ "Ít nhất 8 ký tự" dưới ô, **không có ô nhập lại** (cùng lý do với trang đăng ký, bảng
176
+ mặc định ở trên). Nút "Lưu mật khẩu". Form có ô `username` ẩn mang email đó để trình
177
+ quản lý mật khẩu lưu đúng tài khoản (`I28`).
178
+ - **Phiên đặt lại hết hạn thì thay cả form** bằng câu báo và một nút `primary`
179
+ "Gửi mã mới" (`I3`: lối ra duy nhất) (gửi lại tới email cũ, sang bước 2). Câu báo nói luôn điều người
180
+ dùng lo: "Mật khẩu cũ chưa bị đổi". Đừng để ô mật khẩu và nút "Lưu mật khẩu" nằm
181
+ dưới khối lỗi: bấm Lưu lần nữa vẫn hỏng, và khối lỗi có nút riêng thì màn có hai
182
+ nút tranh nhau làm việc chính (`N5`).
183
+ 4. **Xong.** "Đã đổi mật khẩu", nút "Đăng nhập" về trang đăng nhập với email điền sẵn.
184
+ Backend cho đăng nhập luôn sau khi đổi thì bỏ màn này, vào thẳng app; báo lúc giao.
185
+
186
+ ---
187
+
188
+ ## Nút đăng nhập mạng xã hội
189
+
190
+ Số lượng quyết định bố cục:
191
+
192
+ | Số nút | Bố cục |
193
+ | --- | --- |
194
+ | 1–2 | Xếp dọc, full width, có chữ: `Đăng nhập bằng Google`. Nút viền (`variant="outline"`), logo gốc trái chữ, cao bằng ô nhập |
195
+ | **3 trở lên** | **A** bên dưới. Báo một dòng lúc giao: muốn xếp dọc đủ chữ thì nói |
196
+
197
+ **Đường chia "hoặc"** giữa nút chính và nút mạng xã hội:
198
+
199
+ ```html
200
+ <div class="my-6 flex items-center gap-3">
201
+ <span class="h-px flex-1 bg-border-strong"></span>
202
+ <span class="text-xs text-muted">hoặc</span>
203
+ <span class="h-px flex-1 bg-border-strong"></span>
204
+ </div>
205
+ ```
206
+
207
+ Kẻ bằng **`--border-strong`**, không `--border`. `--border` (`#f7f7f8`) là đường tóc giữa các
208
+ hàng danh sách, nơi có chữ hai bên đỡ mắt; đứng một mình trên card trắng thì nó chỉ
209
+ 1.06 : 1, hai vạch biến mất, còn trơ chữ "hoặc" lơ lửng giữa hai nút.
210
+
211
+ Xếp dọc 3–4 nút full width thì phần mạng xã hội **dài hơn cả form thật**, và
212
+ người dùng phải cuộn qua một dãy nút giống hệt nhau mới thấy ô email. Thứ chính
213
+ của màn bị đẩy lên trên thành thứ phụ.
214
+
215
+ **A. Chia cột, chỉ icon** (mặc định khi có từ 3 nút)
216
+
217
+ ```
218
+ │ [ Đăng nhập ] │
219
+ │ ─── hoặc ─── │
220
+ │ [ G ] [ GH ] [ X ] │
221
+ └────────────────────┘
222
+ ```
223
+
224
+ - Nút vuông, cao bằng ô nhập, chia đều `grid-cols-3`.
225
+ - **Bắt buộc có `aria-label`** — không có chữ thì trình đọc màn hình chỉ thấy một cái nút trống.
226
+ - Logo giữ màu gốc theo `F16`, đừng tô xám cho "đồng bộ".
227
+ - Quá 4 nút thì không xếp một hàng nữa: giữ 2–3 cái dùng nhiều nhất, phần còn lại bỏ hẳn.
228
+
229
+ **B. Vẫn xếp dọc, đủ chữ** (chỉ khi người dùng yêu cầu)
230
+
231
+ Chữ đầy đủ đọc rõ hơn icon trần, đổi lại tốn chiều dọc. Chọn B thì nói rõ
232
+ đánh đổi đó lúc giao.
233
+
234
+ **Nhớ đâu là cái chính.** Ô email và nút đăng nhập là nhân vật chính của màn,
235
+ khối mạng xã hội là đường tắt. Khối tắt mà chiếm nhiều chỗ hơn đường chính thì
236
+ bố cục đã sai, dù từng nút đều đẹp.
237
+
238
+ ---
239
+
240
+ ## Đánh dấu trường bắt buộc
241
+
242
+ Người dùng phải biết trường nào bắt buộc **trước khi** bấm gửi, không phải sau
243
+ khi bị báo lỗi. Chọn một trong hai cách theo tỉ lệ, đừng dùng cả hai:
244
+
245
+ | Tình huống | Cách đánh dấu |
246
+ | --- | --- |
247
+ | Đa số trường bắt buộc | Ghi `Không bắt buộc` bên cạnh nhãn của số ít trường tuỳ chọn |
248
+ | Đa số trường tuỳ chọn | Ghi dấu `*` sau nhãn của trường bắt buộc, kèm một dòng chú thích ở đầu form |
249
+
250
+ Dấu `*` **tô đỏ**, cùng sắc với câu lỗi (`text-red-600 dark:text-red-400`), ngoại lệ có tên ở `M9`,
251
+ `aria-hidden="true"`; ô vẫn mang `required` để trình đọc màn hình đọc "bắt buộc".
252
+ Đây là quy ước số đông: người dùng đã quen "* đỏ = phải điền", để xám thì họ phải
253
+ dừng lại tự hỏi dấu đó nghĩa là gì.
254
+
255
+ Dấu `*` trong dòng chú thích ("Mục có dấu * là bắt buộc") cũng đỏ y như ở nhãn: chú thích giải nghĩa một ký hiệu thì phải in đúng ký hiệu đó, xám ở đây đỏ ở kia là hai dấu khác nhau.
256
+
257
+ ```html
258
+ <label for="full-name" class="text-sm font-medium">
259
+ Họ và tên <span aria-hidden="true" class="text-red-600 dark:text-red-400">*</span>
260
+ </label>
261
+ ```
262
+
263
+ ---
264
+
265
+ ## Form nhiều trường
266
+
267
+ **A. Một cột dọc** (mặc định, dưới 8 trường)
268
+
269
+ Nhãn nằm trên ô nhập, không nằm cạnh. Trường liên quan nhau thì gom nhóm, cách
270
+ nhóm khác bằng khoảng trắng lớn hơn, không bằng đường kẻ.
271
+
272
+ **B. Chia mục có tiêu đề** (từ 8 trường trở lên)
273
+
274
+ ```
275
+ Thông tin cá nhân
276
+ [ô] [ô]
277
+ [ô ]
278
+
279
+ Địa chỉ
280
+ [ô ]
281
+ [ô] [ô] [ô]
282
+
283
+ [Huỷ] [LƯU]
284
+ ```
285
+
286
+ Nút hành động nằm cuối, căn phải, primary bên phải cùng.
287
+
288
+ **Ô tuỳ chọn ít người đụng tới** (mã, tuỳ chọn kỹ thuật, đều có mặc định): gom vào một
289
+ khu thu gọn "Cài đặt nâng cao" ở cuối các ô, dựng là dòng chữ có chevron, không khung, ô
290
+ bên trong thẳng cột với ô ngoài. Mẫu ở "Khu thu gọn trong form" trong
291
+ `../components/accordion.md`.
292
+
293
+ **C. Nhiều bước** (khi **các bước phụ thuộc nhau** hoặc là luồng làm một lần: onboarding, thanh toán, đăng ký hồ sơ. Form dài mà các phần độc lập thì dùng kiểu B có mục, như trang cài đặt — số trường nhiều không phải lý do chia bước)
294
+
295
+ Thanh bước ở trên, mỗi bước một màn, nút "Quay lại" và "Tiếp" ở đáy. Không dùng
296
+ nhiều bước cho form ngắn, nó chỉ làm chậm.
297
+
298
+ **Thanh các bước:**
299
+
300
+ ```
301
+ (✓)━━━━━━━━━━━━━━━━(2)─────────────────(3)
302
+ Thông tin công ty **Người liên hệ** Xác nhận
303
+ ```
304
+
305
+ | Trạng thái | Vòng `size-8 rounded-full` | Nhãn | Đường nối phía sau |
306
+ | --- | --- | --- | --- |
307
+ | Đã xong | nền `primary`, icon `check` `size-4` `primary-foreground` | `text-foreground` | `h-0.5 bg-primary` |
308
+ | Đang làm | `border-2 border-foreground bg-surface`, số `font-semibold` | `font-semibold text-foreground` | `h-0.5 bg-secondary` |
309
+ | Chưa tới | nền `bg-secondary`, số `text-muted` | `text-muted` | `h-0.5 bg-secondary` |
310
+ | Có lỗi | nền `bg-red-600`, chữ `!` `text-sm font-bold text-white` (ký tự, không icon) | `text-red-700`, câu lỗi `text-sm text-red-600` dưới nhãn | như trạng thái của nó |
311
+
312
+ - **Phần "chưa tới" dùng `--secondary`, không `--background`, không `--border`.** Thanh các bước thường đứng trên nền trang, ngoài card. Vòng `bg-background` trùng đúng màu nền trang nên tan mất, chỉ còn con số "3" lơ lửng cuối đường nối; `--border` (#f7f7f8) còn **sáng hơn** nền trang (#f4f4f6), đường nối và đoạn thanh mảnh chưa tới gần như vô hình. `--secondary` (#e7e8ec) đọc được cả trên nền trang lẫn trên card trắng.
313
+ - **Vòng lỗi là vòng đặc đỏ với dấu `!`, không phải vòng viền đỏ bọc icon `circle-alert`.** Icon đó tự có một vòng tròn, đặt vào vòng viền thành hai vòng lồng nhau, nhìn rối và nhỏ xíu. Vòng đặc cùng khuôn với bước đã xong (đặc + ký hiệu), chỉ đổi màu và ký hiệu.
314
+ - Dựng bằng `<ol>`, bước đang làm có `aria-current="step"`. Các bước `flex-1`, **riêng bước cuối `flex-none`** (không đường nối, không `pr`): hàng trải đúng từ mép trái tới mép phải của card bên dưới. Để bước cuối `flex-1` thì cột cuối chỉ có vòng và một chữ ngắn, hở một mảng bên phải, cả thanh lệch trái so với card.
315
+ - **Thanh ngang chỉ có nhãn, không mô tả dưới nhãn**, khi mỗi bước đã có tiêu đề và câu dẫn trong card (mặc định, xem "Khung một bước" dưới). Mô tả ở thanh cộng câu dẫn trong card là hai câu cho một ý, cách nhau 100px (`N3`). Thanh ghi "Có thể để trống, mời sau", câu dẫn ghi "Chưa cần thì để trống, mời sau ở trang Thành viên", nhãn ô ghi "Không bắt buộc": ba lần "không bắt buộc" trên một màn. Mô tả dưới nhãn chỉ dùng khi bên cạnh không có tiêu đề bước riêng (thường là thanh dọc bên trái form dài); khi đó `text-sm text-muted text-pretty`, cho xuống dòng, không `truncate`. Thiếu `text-pretty` là trơ một chữ ở dòng cuối.
316
+ - **Bước đã xong bấm được để quay lại** (vòng + nhãn là một nút, `cursor-pointer`, hover nhãn gạch chân). Bước chưa tới không bấm được. Có cho nhảy cóc tới bước chưa tới hay không là logic, người dùng quyết. **Đang gửi ở bước cuối thì các bước đã xong `disabled`** như nút "Quay lại": trông bấm được (rê vào gạch chân) mà bấm không đi đâu là nói sai.
317
+ - **Màn hẹp dưới `sm` thu gọn**, không cố nhét ba cột: một dòng "Bước 2 / 3" `text-sm font-medium tabular-nums` + một thanh mảnh `h-1` chia đoạn theo số bước. **Tên bước không ghi ở dòng này khi card ngay dưới đã có tiêu đề bước**: "Bước 1 / 3 · Thông tin workspace" rồi tiêu đề card "Thông tin workspace" cách 50px là lặp, cùng lý do đường dẫn không ghi trang đang đứng (`layouts/app.md`). Đoạn bước đã xong `bg-primary`, **đoạn bước đang làm `bg-primary/30`** (nửa đậm), chưa tới `bg-secondary`. Ba tầng như ba kiểu vòng ở màn rộng. Không tô đậm đủ đoạn đang làm: đứng ở bước cuối sẽ trông y như đã xong hết. Cũng không để đoạn đang làm xám như chưa tới: chữ ghi "Bước 2 / 3" mà thanh chỉ sáng một đoạn, đọc như thanh bị thiếu. **Ca có ghi tên bước** ("Bước 2 / 3 · Người liên hệ", khi bên dưới không có tiêu đề bước riêng): **tên được xuống dòng**, `text-pretty`, không `truncate`: tên bước là thứ người dùng cần đọc (`N8`), và đổi bước là đổi cả màn nên dòng chữ cao thêm một dòng không tính là nhảy (`N1`). Cụm "Bước 2 / 3 ·" `whitespace-nowrap` để không bị bẻ đôi. Mô tả ẩn. Không để các cột bước wrap thành hai hàng (`R6`). Có bước lỗi thì đoạn của bước đó đỏ, và **thêm một dòng `text-xs text-red-600` dưới thanh nói bước nào sai** ("Bước 1 còn thiếu mã số thuế"), bấm được để quay lại. Chỉ có đoạn đỏ mà không có chữ thì màn hẹp không biết sai ở đâu.
318
+ - Không quá 5 bước. Hơn nữa là form đang cần gộp bước lại.
319
+
320
+ **Khung một bước:**
321
+
322
+ ```
323
+ Tạo workspace mới <- tên trang, một lần
324
+ (1)━━━━━━━━━━(2)──────────(3) <- thanh các bước, chỉ nhãn
325
+ ┌──────────────────────────────────┐
326
+ │ Thông tin workspace │ <- h2 = nhãn bước, text-base font-semibold
327
+ │ Một câu dẫn nói điều chưa ai nói │ <- text-sm text-muted
328
+ │ [ô] [ô] │
329
+ │ ──────────────────────────────── │
330
+ │ [Huỷ | Quay lại] [Tiếp] │
331
+ └──────────────────────────────────┘
332
+ ```
333
+
334
+ - **Mỗi thông tin nói một lần.** Tên bước: thanh các bước (bản đồ cả luồng, như mục đang chọn ở sidebar) và tiêu đề card. Câu dẫn chỉ nói thứ chưa nằm ở chỗ khác: vì sao cần, dùng vào đâu, mời sau ở đâu. **"Không bắt buộc" chỉ ở cạnh nhãn ô** (mục "Đánh dấu trường bắt buộc"), không nhắc lại ở câu dẫn hay ở thanh.
335
+ - **Tiêu điểm**: mở trang thì con trỏ nằm sẵn ở ô đầu của bước 1 (`autoFocus`, cùng lý do màn đăng nhập ở trên). Sang bước khác thì đưa tiêu điểm lên `h2` (`tabIndex={-1}`, `outline-hidden`): trình đọc màn hình đọc tên bước mới, và ở màn hẹp trang về đầu bước thay vì đứng ở hàng nút dưới đáy.
336
+ - **Hàng nút đáy**: lối lùi bên trái, nút chính bên phải, `border-t border-border pt-5 mt-8`, cao bằng ô (`h-11 md:h-10`). Bước 1 lối lùi là "Huỷ" (rời luồng), các bước sau "Quay lại". Nút chính "Tiếp", bước cuối là động từ của cả luồng ("Tạo workspace", không "Hoàn tất"). Bấm "Tiếp" chỉ kiểm bước đang đứng; sai thì đứng lại, focus ô sai đầu tiên. Lùi về không mất gì đã điền.
337
+ - **Chặn bấm đúp ở nút chính**: nút cuối hiện đúng chỗ nút "Tiếp", bấm đúp "Tiếp" ở bước áp chót thì cú thứ hai gửi luôn khi người dùng chưa kịp đọc bước xác nhận. `onClick` bỏ qua `event.detail > 1` (Enter có `detail = 0`, không bị chặn).
338
+ - **Bước xác nhận chia nhóm theo bước, mỗi nhóm một link "Sửa"** ở mép phải tiêu đề nhóm, bấm về đúng bước đó. Đây là cách trang thanh toán nào cũng làm. Ở màn hẹp thanh các bước chỉ là dòng chữ và thanh mảnh, không bấm được, nên thiếu "Sửa" thì muốn sửa tên từ bước 3 phải bấm "Quay lại" hai lần qua bước 2. Nhóm là khối nhãn–giá trị (`../components/description-list.md`), tiêu đề nhóm **`text-sm font-semibold`**, link "Sửa" là link chữ `text-sm text-foreground/70` như `I7`, rê vào `text-foreground` + gạch chân, không nút viền. **Tiêu đề nhóm phải khác kiểu chữ giá trị**: giá trị trong khối nhãn–giá trị đã là `text-sm font-medium text-foreground`, tiêu đề nhóm cũng `font-medium` thì trùng hệt, ở màn hẹp (nhãn trên, giá trị dưới) "Thông tin workspace" đọc như một giá trị nữa. Không đổi sang chữ nhỏ xám: tiêu đề nhóm nhạt hơn nhãn con bên dưới là ngược thứ bậc. Không thêm đường kẻ giữa nhóm, khoảng trắng `gap-6` đủ tách. Sửa xong bấm "Tiếp" đi lại tuần tự như thường, không nhảy thẳng về bước xác nhận (nhảy thẳng là logic, người dùng quyết).
339
+
340
+ ---
341
+
342
+ ## Trạng thái lỗi
343
+
344
+ Lỗi hiện **dưới ô nhập**, không hiện trong placeholder, không hiện ở tooltip.
345
+
346
+ ```
347
+ nhãn
348
+ [ô nhập ] <- viền đỏ đặc; quầng đỏ mờ chỉ khi đang focus
349
+ Email này đã có người dùng <- chữ đỏ, text-xs, ngay dưới ô
350
+ ```
351
+
352
+ - Viền `red-500` đặc, ring `red-500/10` rất mờ. Không tô nền đỏ cả ô.
353
+ - Câu lỗi nói **cách sửa**, không nói "không hợp lệ". "Email này đã có người dùng" chứ không phải "Email không hợp lệ".
354
+ - **Câu lỗi không được trùng chữ với placeholder hay nhãn.** Trùng là dấu hiệu nó không mang thêm thông tin nào — xem mục dưới.
355
+ - **Ô tự điền theo ô khác** (đường dẫn theo tên, mã theo tên sản phẩm): ô nguồn trống thì **chỉ ô nguồn báo lỗi**, ô phụ thuộc để yên, vì gõ ô nguồn là ô kia tự có. Ô phụ thuộc chỉ báo lỗi khi người dùng đã tự sửa nó, hoặc khi ô nguồn có chữ mà giá trị sinh ra vẫn sai (quá ngắn, trùng). Báo cả hai thì bấm Tiếp khi trống ra hai dòng đỏ "Chưa nhập tên workspace" và "Chưa nhập đường dẫn", mà chỉ cần gõ tên là hết cả hai (`N3`).
356
+ - Gợi ý thời điểm (người dùng quyết): hiện lỗi sau khi rời ô hoặc bấm gửi, đừng hiện ngay ký tự đầu tiên. Skill chỉ lo lỗi **trông ra sao**, dựng nó như một trạng thái tĩnh của ô.
357
+ - **Sửa xong một ô thì câu lỗi mất, nhưng chỗ của nó ở lại** tới lần bấm gửi sau (`min-h-4` trên dòng dưới ô, bằng một dòng `text-xs`). Rút câu lỗi đi ngay thì mọi ô bên dưới nhảy lên 16px đúng lúc người dùng đang đưa chuột xuống ô kế tiếp (`N1`). Ô nào có sẵn dòng gợi ý thì không cần: gợi ý quay về đúng chỗ câu lỗi vừa rời.
358
+ - **Mặc định: chỉ lỗi tại chỗ, không banner tóm tắt.** Bấm gửi mà có lỗi thì cuộn tới và **focus ô lỗi đầu tiên**; mỗi ô sai viền đỏ + một câu dưới ô. Form trong sản phẩm (SaaS, công cụ làm việc) hầu hết làm vậy; hệ thiết kế cho dịch vụ công thì thêm hộp tóm tắt lỗi đầu trang có link tới từng ô, vì form của họ dài và người điền lần đầu, skill chỉ giữ ca đó cho form rất dài (ca 2 bên dưới). Banner liệt kê lỗi trên một form thường chỉ đọc lại đúng mấy câu đã nằm dưới từng ô: hai tín hiệu cho một ý (`N3`), và cả màn đỏ rực (chủ dự án: "thực tế có ai làm mục đỏ ở trên đâu"). Luật cũ "form dài hơn một màn thì có banner" sai, vì ở 375px form nào cũng dài hơn một màn.
359
+ - **Banner chỉ dùng cho hai ca:**
360
+ 1. **Lỗi không gắn với ô nào**: mất mạng, hết phiên, không có quyền, trùng dữ liệu phía máy chủ. Banner một dòng nói chuyện gì và làm gì tiếp, không liệt kê.
361
+ 2. **Form rất dài, chia nhiều mục có tiêu đề** (từ khoảng 12 trường, hoặc phải cuộn qua nhiều mục): lỗi ở mục cuối không thể thấy khi đang đứng ở đầu. Lúc đó banner liệt kê từng lỗi kèm link nhảy tới đúng ô, và **vẫn giữ** lỗi tại chỗ.
362
+
363
+ ```html
364
+ <div role="alert" class="mb-6 rounded-xl border border-red-200 bg-red-50 p-4">
365
+ <p class="text-sm font-medium text-red-700">Chưa gửi được, còn 3 chỗ cần sửa</p>
366
+ <ul class="mt-2 space-y-1 text-sm text-red-600">
367
+ <li><a href="#tieu-de" class="underline underline-offset-2 outline-hidden">Tiêu đề</a>, chưa điền</li>
368
+ </ul>
369
+ </div>
370
+ ```
371
+
372
+ Đây là chỗ duy nhất được tô nền đỏ. Ô nhập thì không bao giờ.
373
+
374
+ ---
375
+
376
+ ## Gợi ý và câu lỗi là hai thứ khác nhau
377
+
378
+ Một field có **ba** chỗ chứa chữ, mỗi chỗ một việc. Lẫn lộn chúng là lỗi hay gặp
379
+ nhất ở form, và nhìn ảnh chụp rất khó nhận ra vì "trông vẫn đủ chữ".
380
+
381
+ | Chỗ | Việc | Màu | Khi nào hiện |
382
+ | --- | --- | --- | --- |
383
+ | **Nhãn** | Ô này là gì | `--foreground` | Luôn |
384
+ | **Gợi ý** | Thứ người dùng chưa biết trước khi gõ | `--muted`, `text-xs` | Luôn |
385
+ | **Câu lỗi** | Vừa gõ sai cái gì, sửa thế nào | đỏ, `text-xs` | Chỉ khi sai |
386
+
387
+ **Dưới ô chỉ có MỘT dòng.** Có lỗi thì câu lỗi **thay chỗ** gợi ý, không đẩy gợi
388
+ ý xuống thành hai dòng chồng nhau.
389
+
390
+ ### Ô có giới hạn ký tự
391
+
392
+ Gợi ý đã ghi "Tối đa 120 ký tự" thì ô phải cho thấy mình đang ở đâu so với mức đó.
393
+ Chỉ ghi mà không đếm thì người gõ câu 132 ký tự không biết mình vượt, tới lúc gửi mới
394
+ bị chặn.
395
+ Cách của các hệ thiết kế lớn:
396
+
397
+ - **Bộ đếm cùng dòng với gợi ý, căn phải**: `flex justify-between gap-3`, gợi ý trái, bộ
398
+ đếm `shrink-0 text-xs text-muted tabular-nums` phải, ghi "98/120". Vẫn một dòng dưới ô.
399
+ - **Chỉ hiện khi đã gõ tới khoảng 80% giới hạn.** Dưới đó chỉ có gợi ý; đếm từ ký tự đầu
400
+ là một con số nhảy liên tục ngay cạnh chỗ người ta đang nghĩ câu chữ.
401
+ - **Vượt thì bộ đếm đỏ** ("132/120", chữ đỏ như câu lỗi), gợi ý giữ nguyên. Bấm gửi mà
402
+ vẫn vượt thì câu lỗi thay gợi ý và nói cách sửa: "Dài hơn 120 ký tự, bớt 12 ký tự".
403
+ - **Không `maxlength`.** Chặn cứng thì dán một câu dài bị cắt giữa chữ mà không ai báo,
404
+ người dùng mất phần đuôi. Cho gõ và dán quá, rồi báo.
405
+ - Bộ đếm nằm trong `aria-describedby` của ô, cập nhật trong vùng `aria-live="polite"`.
406
+
407
+ ### Ba câu hỏi trước khi viết một dòng chữ đỏ
408
+
409
+ 1. **Câu này có trùng chữ với placeholder hoặc nhãn không?** Trùng thì bỏ. Placeholder ghi "Nhập mật khẩu của bạn" mà chữ đỏ dưới ô cũng ghi "Nhập mật khẩu của bạn" thì người dùng đọc hai lần cùng một câu, và vẫn không biết mình sai ở đâu.
410
+ 2. **Nó nói người dùng LÀM GÌ tiếp, hay chỉ nói ô đang trống?** Mắt đã thấy ô trống rồi.
411
+ 3. **Nó có phải lỗi không, hay là gợi ý bị tô nhầm màu đỏ?** "Nhập email bạn dùng để đăng nhập" là gợi ý — nó đúng cả khi người dùng chưa làm gì sai. Gợi ý thì xám và hiện sẵn, đừng đợi có lỗi mới đỏ lên.
412
+
413
+ ### Ô trống thì viết gì
414
+
415
+ | Ô | Sai | Đúng |
416
+ | --- | --- | --- |
417
+ | Email | `Nhập email của bạn` *(trùng placeholder)* | `Chưa nhập email` |
418
+ | Mật khẩu | `Nhập mật khẩu của bạn` *(trùng placeholder)* | `Chưa nhập mật khẩu` |
419
+ | Email sai định dạng | `Email không hợp lệ` | `Email phải có dấu @` |
420
+ | Mật khẩu ngắn | `Mật khẩu không hợp lệ` | `Mật khẩu cần ít nhất 8 ký tự` |
421
+ | Sai thông tin đăng nhập | `Đăng nhập thất bại` | `Email hoặc mật khẩu chưa đúng` |
422
+
423
+ Dòng cuối là ca riêng: nói rõ sai cái nào **là lỗ hổng bảo mật** — người ngoài dò
424
+ được email nào có tài khoản. Nên ở đúng ca này thì mơ hồ là cố ý, và câu lỗi đặt
425
+ ở chỗ câu lỗi trên nút Đăng nhập (xem khung wireframe đăng nhập ở đầu file) chứ không dưới một ô cụ thể.
426
+ Sau lỗi này: **giữ email, xoá ô mật khẩu và đưa con trỏ vào đó**, khối lỗi `role="alert"`
427
+ để trình đọc màn hình đọc ngay. Người dùng gần như luôn gõ lại mật khẩu chứ không sửa
428
+ từng ký tự, và ô còn chấm tròn cũ thì họ phải bôi đen xoá trước (cách Google, GitHub,
429
+ Microsoft đều làm). Không tô đỏ ô nào, vì không biết ô nào sai. Gõ lại vào ô thì khối
430
+ lỗi vẫn đứng yên tới lần gửi sau, không biến mất giữa lúc gõ làm nút nhảy lên (`N1`).