@haposoft/cafekit 0.16.7 → 0.18.1

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 (231) 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 +169 -272
  4. package/bin/__tests__/codex-native.test.js +100 -202
  5. package/bin/__tests__/develop-contract.test.js +122 -1413
  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 +154 -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 +90 -925
  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 +30 -161
  20. package/bin/__tests__/specs-v2-execution-closeout.test.js +0 -437
  21. package/bin/__tests__/test-runtime-dependency-closure.cjs +1 -2
  22. package/bin/__tests__/ui-ux-skill.test.js +101 -0
  23. package/bin/__tests__/usage-hook-retired.test.js +114 -0
  24. package/bin/__tests__/validator-grounding.test.js +18 -2949
  25. package/bin/lib/managed-writer.js +2 -3
  26. package/bin/phases/codex-runtime.js +27 -0
  27. package/bin/phases/copy-payload.js +13 -28
  28. package/bin/phases/omp-runtime.js +25 -0
  29. package/package.json +2 -3
  30. package/src/claude/CLAUDE.md +3 -10
  31. package/src/claude/agents/brainstormer.md +7 -3
  32. package/src/claude/agents/code-auditor.md +44 -56
  33. package/src/claude/agents/debugger.md +8 -88
  34. package/src/claude/agents/deployer.md +3 -5
  35. package/src/claude/agents/docs-keeper.md +6 -14
  36. package/src/claude/agents/git-ops.md +2 -4
  37. package/src/claude/agents/implementer.md +12 -16
  38. package/src/claude/agents/inspector.md +4 -3
  39. package/src/claude/agents/project-manager.md +9 -17
  40. package/src/claude/agents/researcher.md +9 -3
  41. package/src/claude/agents/spec-maker.md +15 -15
  42. package/src/claude/agents/test-runner.md +8 -11
  43. package/src/claude/agents/ui-ux-designer.md +0 -1
  44. package/src/claude/hooks/__tests__/fixtures/statusline-default.golden +1 -1
  45. package/src/claude/hooks/__tests__/spec-gate.test.js +244 -333
  46. package/src/claude/hooks/__tests__/spec-state-touched.test.js +160 -0
  47. package/src/claude/hooks/__tests__/state.test.js +68 -0
  48. package/src/claude/hooks/__tests__/statusline.test.js +140 -11
  49. package/src/claude/hooks/agent.cjs +2 -1
  50. package/src/claude/hooks/lib/hook-payload.cjs +2 -2
  51. package/src/claude/hooks/rules.cjs +2 -1
  52. package/src/claude/hooks/session.cjs +0 -1
  53. package/src/claude/hooks/spec-gate.cjs +17 -83
  54. package/src/claude/hooks/spec-state.cjs +142 -39
  55. package/src/claude/hooks/state.cjs +21 -2
  56. package/src/claude/migration-manifest.json +42 -22
  57. package/src/claude/output-styles/coding-level-0-eli5.md +9 -9
  58. package/src/claude/output-styles/coding-level-1-junior.md +3 -3
  59. package/src/claude/output-styles/coding-level-2-mid.md +3 -3
  60. package/src/claude/output-styles/coding-level-3-senior.md +2 -2
  61. package/src/claude/output-styles/coding-level-4-lead.md +5 -5
  62. package/src/claude/output-styles/coding-level-5-god.md +6 -6
  63. package/src/claude/rules/ai-dev-rules.md +2 -3
  64. package/src/claude/rules/hook-protocols.md +8 -15
  65. package/src/claude/rules/manage-docs.md +9 -23
  66. package/src/claude/rules/orchestrator.md +3 -4
  67. package/src/claude/rules/review-audit-self-decision.md +3 -3
  68. package/src/claude/rules/skill-domain-routing.md +1 -3
  69. package/src/claude/rules/skill-workflow-routing.md +4 -5
  70. package/src/claude/rules/state-sync.md +3 -12
  71. package/src/claude/rules/workflow.md +11 -19
  72. package/src/claude/runtime.schema.json +5 -3
  73. package/src/claude/scripts/generate-skill-catalog.cjs +7 -6
  74. package/src/claude/scripts/provenance.cjs +23 -5
  75. package/src/claude/scripts/spec-receipt.cjs +24 -121
  76. package/src/claude/scripts/spec-resolver.cjs +41 -458
  77. package/src/claude/scripts/workflow-policy.cjs +3 -1801
  78. package/src/claude/settings/settings.json +1 -32
  79. package/src/claude/skills/agent-browser/SKILL.md +1 -1
  80. package/src/claude/skills/{question → ask}/SKILL.md +9 -75
  81. package/src/claude/skills/brainstorm/SKILL.md +43 -131
  82. package/src/claude/skills/chrome-devtools/SKILL.md +14 -31
  83. package/src/claude/skills/code-review/SKILL.md +29 -81
  84. package/src/claude/skills/code-review/references/spec-compliance-review.md +4 -8
  85. package/src/claude/skills/debug/SKILL.md +29 -149
  86. package/src/claude/skills/develop/SKILL.md +26 -83
  87. package/src/claude/skills/develop/references/parallel-waves.md +0 -8
  88. package/src/claude/skills/develop/references/quality-gate.md +15 -13
  89. package/src/claude/skills/develop/references/subagent-patterns.md +0 -6
  90. package/src/claude/skills/docs/SKILL.md +2 -2
  91. package/src/claude/skills/docs/references/update-workflow.md +1 -1
  92. package/src/claude/skills/fix/SKILL.md +153 -0
  93. package/src/claude/skills/{hotfix → fix}/references/diagnosis-protocol.md +2 -2
  94. package/src/claude/skills/{hotfix → fix}/references/parallel-patterns.md +2 -2
  95. package/src/claude/skills/{hotfix → fix}/references/prevention-gate.md +3 -3
  96. package/src/claude/skills/{hotfix → fix}/references/review-cycle.md +1 -1
  97. package/src/claude/skills/git/SKILL.md +48 -9
  98. package/src/claude/skills/git/references/commit-protocols.md +35 -16
  99. package/src/claude/skills/git/references/worktree-blueprint.md +42 -6
  100. package/src/claude/skills/orca/SKILL.md +9 -8
  101. package/src/claude/skills/research/SKILL.md +17 -46
  102. package/src/claude/skills/scout/SKILL.md +61 -0
  103. package/src/claude/skills/{inspect → scout}/references/internal-inspection.md +26 -10
  104. package/src/claude/skills/specs/SKILL.md +22 -29
  105. package/src/claude/skills/specs/references/review.md +8 -6
  106. package/src/claude/skills/specs/references/templates.md +34 -41
  107. package/src/claude/skills/sync/SKILL.md +16 -45
  108. package/src/claude/skills/sync/references/rebind-and-audit.md +59 -0
  109. package/src/claude/skills/sync/references/sync-protocols.md +4 -13
  110. package/src/claude/skills/test/SKILL.md +39 -104
  111. package/src/claude/skills/test/references/execution-strategy.md +93 -21
  112. package/src/claude/skills/test/references/failure-triage.md +1 -1
  113. package/src/claude/skills/ui-ux/LICENSE +21 -0
  114. package/src/claude/skills/ui-ux/SKILL.md +497 -0
  115. package/src/claude/skills/ui-ux/references/brand-tokens.md +90 -0
  116. package/src/claude/skills/ui-ux/references/budgets.md +113 -0
  117. package/src/claude/skills/ui-ux/references/checklist.md +271 -0
  118. package/src/claude/skills/ui-ux/references/components/accordion.md +246 -0
  119. package/src/claude/skills/ui-ux/references/components/avatar.md +153 -0
  120. package/src/claude/skills/ui-ux/references/components/banner.md +43 -0
  121. package/src/claude/skills/ui-ux/references/components/breadcrumb.md +201 -0
  122. package/src/claude/skills/ui-ux/references/components/button.md +142 -0
  123. package/src/claude/skills/ui-ux/references/components/card.md +113 -0
  124. package/src/claude/skills/ui-ux/references/components/charts.md +376 -0
  125. package/src/claude/skills/ui-ux/references/components/chat.md +132 -0
  126. package/src/claude/skills/ui-ux/references/components/choice-controls.md +369 -0
  127. package/src/claude/skills/ui-ux/references/components/comment-thread.md +17 -0
  128. package/src/claude/skills/ui-ux/references/components/description-list.md +116 -0
  129. package/src/claude/skills/ui-ux/references/components/empty-state.md +98 -0
  130. package/src/claude/skills/ui-ux/references/components/file-upload.md +107 -0
  131. package/src/claude/skills/ui-ux/references/components/inline-edit.md +128 -0
  132. package/src/claude/skills/ui-ux/references/components/input.md +161 -0
  133. package/src/claude/skills/ui-ux/references/components/list-row.md +56 -0
  134. package/src/claude/skills/ui-ux/references/components/loading.md +198 -0
  135. package/src/claude/skills/ui-ux/references/components/logo.md +147 -0
  136. package/src/claude/skills/ui-ux/references/components/otp-input.md +43 -0
  137. package/src/claude/skills/ui-ux/references/components/quantity-input.md +100 -0
  138. package/src/claude/skills/ui-ux/references/components/range-slider.md +16 -0
  139. package/src/claude/skills/ui-ux/references/components/small-controls.md +340 -0
  140. package/src/claude/skills/ui-ux/references/components/sortable-header.md +86 -0
  141. package/src/claude/skills/ui-ux/references/components/tag-input.md +14 -0
  142. package/src/claude/skills/ui-ux/references/components/timeline.md +38 -0
  143. package/src/claude/skills/ui-ux/references/components/tree.md +30 -0
  144. package/src/claude/skills/ui-ux/references/design-process.md +650 -0
  145. package/src/claude/skills/ui-ux/references/layouts/app-kanban.html +303 -0
  146. package/src/claude/skills/ui-ux/references/layouts/app.md +1369 -0
  147. package/src/claude/skills/ui-ux/references/layouts/form.md +430 -0
  148. package/src/claude/skills/ui-ux/references/layouts/overlay.md +552 -0
  149. package/src/claude/skills/ui-ux/references/layouts/pricing.md +204 -0
  150. package/src/claude/skills/ui-ux/references/locked-rules.md +35 -0
  151. package/src/claude/skills/ui-ux/references/principles.md +408 -0
  152. package/src/claude/skills/ui-ux/references/refactor.md +245 -0
  153. package/src/claude/skills/ui-ux/references/responsive.md +195 -0
  154. package/src/claude/skills/ui-ux/references/review.md +528 -0
  155. package/src/claude/skills/ui-ux/references/rules-color.md +623 -0
  156. package/src/claude/skills/ui-ux/references/rules-form.md +235 -0
  157. package/src/claude/skills/ui-ux/references/rules-state.md +680 -0
  158. package/src/claude/skills/ui-ux/references/rules-type.md +369 -0
  159. package/src/claude/skills/ui-ux/references/styles.md +311 -0
  160. package/src/claude/skills/ui-ux/references/system.md +219 -0
  161. package/src/claude/skills/ui-ux/references/tailwind-v4-traps.md +237 -0
  162. package/src/claude/skills/ui-ux/references/tokens.css +362 -0
  163. package/src/claude/skills/ui-ux/scripts/lint-skill.mjs +216 -0
  164. package/src/claude/skills/ui-ux/scripts/package.json +9 -0
  165. package/src/claude/skills/ui-ux/scripts/probe.mjs +4447 -0
  166. package/src/claude/skills/ui-ux-pro-max/SKILL.md +25 -80
  167. package/src/claude/skills/web-testing/SKILL.md +2 -20
  168. package/src/claude/status.cjs +149 -144
  169. package/src/codex/AGENTS.md +8 -13
  170. package/src/codex/hooks/agent.cjs +2 -1
  171. package/src/codex/hooks/lib/spec-receipt.cjs +4 -32
  172. package/src/codex/hooks/lib/spec-utils.cjs +6 -52
  173. package/src/codex/hooks/rules.cjs +2 -1
  174. package/src/codex/hooks/session.cjs +0 -2
  175. package/src/codex/hooks/spec-gate.cjs +13 -81
  176. package/src/codex/hooks/spec-state.cjs +37 -41
  177. package/src/codex/hooks.json +0 -20
  178. package/src/codex/rules/state-sync.md +3 -12
  179. package/src/common/AGENTS.md +6 -12
  180. package/src/omp/extensions/cafekit-bridge.mjs +6 -6
  181. package/bin/__tests__/change-firewall.semantic-firewall.test.js +0 -2133
  182. package/bin/__tests__/finalizer-pass-gate.semantic-firewall.test.js +0 -342
  183. package/bin/__tests__/release-preflight.semantic-firewall.test.js +0 -586
  184. package/bin/__tests__/review-receipt-c2.semantic-firewall.test.js +0 -378
  185. package/bin/__tests__/specs-v2-policy-and-scaffold.test.js +0 -665
  186. package/bin/__tests__/specs-v2-validator-grounder-contract.test.js +0 -1132
  187. package/src/claude/hooks/__tests__/completion-authority.test.js +0 -990
  188. package/src/claude/hooks/__tests__/semantic-review-authority.test.js +0 -259
  189. package/src/claude/hooks/completion-authority-check.cjs +0 -56
  190. package/src/claude/hooks/completion-authority-state.cjs +0 -527
  191. package/src/claude/hooks/completion-authority.cjs +0 -163
  192. package/src/claude/hooks/lib/context.cjs +0 -616
  193. package/src/claude/hooks/lib/detect.cjs +0 -474
  194. package/src/claude/hooks/semantic-review-authority.cjs +0 -154
  195. package/src/claude/hooks/task-scaffold-guard.cjs +0 -114
  196. package/src/claude/hooks/usage.cjs +0 -197
  197. package/src/claude/scripts/change-firewall.cjs +0 -1748
  198. package/src/claude/scripts/spec-authoring-digest.cjs +0 -151
  199. package/src/claude/scripts/spec-authoring-validation.cjs +0 -126
  200. package/src/claude/scripts/spec-final-state.cjs +0 -290
  201. package/src/claude/scripts/spec-ground.cjs +0 -650
  202. package/src/claude/scripts/spec-readiness.cjs +0 -425
  203. package/src/claude/scripts/spec-scaffold.cjs +0 -2317
  204. package/src/claude/scripts/spec-semantic-model.cjs +0 -396
  205. package/src/claude/scripts/validate-spec-output.cjs +0 -3941
  206. package/src/claude/skills/code-review/references/verification-gate.md +0 -46
  207. package/src/claude/skills/delegate/SKILL.md +0 -124
  208. package/src/claude/skills/delegate/references/codex-delegation.md +0 -71
  209. package/src/claude/skills/delegate/references/grok-delegation.md +0 -84
  210. package/src/claude/skills/hotfix/SKILL.md +0 -289
  211. package/src/claude/skills/inspect/SKILL.md +0 -142
  212. package/src/claude/skills/loop/SKILL.md +0 -114
  213. package/src/claude/skills/loop/references/bounded-loop-protocol.md +0 -104
  214. package/src/claude/skills/loop/references/metric-and-guard-contract.md +0 -76
  215. package/src/claude/skills/route/SKILL.md +0 -94
  216. package/src/claude/skills/route/references/agent-timing.md +0 -46
  217. package/src/claude/skills/route/references/chaining-patterns.md +0 -53
  218. package/src/claude/skills/route/references/task-taxonomy.md +0 -48
  219. package/src/claude/skills/specs/templates/design.md +0 -82
  220. package/src/claude/skills/specs/templates/requirements-init.md +0 -8
  221. package/src/claude/skills/specs/templates/requirements.md +0 -42
  222. package/src/claude/skills/specs/templates/spec-state.json +0 -56
  223. package/src/claude/skills/specs/templates/task.md +0 -47
  224. package/src/codex/hooks/completion-authority-check.cjs +0 -67
  225. package/src/codex/hooks/completion-authority-state.cjs +0 -527
  226. package/src/codex/hooks/completion-authority.cjs +0 -109
  227. package/src/codex/hooks/semantic-review-authority.cjs +0 -130
  228. package/src/codex/hooks/task-scaffold-guard.cjs +0 -63
  229. /package/src/claude/skills/{question → ask}/templates/question.md +0 -0
  230. /package/src/claude/skills/{hotfix → fix}/references/escalation-tactics.md +0 -0
  231. /package/src/claude/skills/{hotfix → fix}/references/workflow-specialized.md +0 -0
@@ -0,0 +1,198 @@
1
+ # Đang tải (loading)
2
+
3
+ Câu cần hỏi trước khi vẽ: **người dùng đang chờ cái gì, và trên màn đã có gì rồi.**
4
+ Mỗi ca một hình. Lỗi hay gặp nhất của AI là dùng một hình cho mọi ca: cứ chờ là
5
+ skeleton, cứ bấm là spinner.
6
+
7
+ | Ca | Hình |
8
+ | --- | --- |
9
+ | Tải lần đầu, dữ liệu qua mạng | Khung chờ đúng hình (mục dưới) |
10
+ | Tải lần đầu, dữ liệu đọc từ máy (localStorage, state sẵn có) | Không khung chờ, không spinner. Một dòng chữ mờ như `empty-state.md`, chỉ khác chữ |
11
+ | **Tải lần hai**: đổi lọc, đổi trang, đổi tab, gõ tìm | **Giữ dữ liệu cũ** + thanh mảnh trên đầu khung |
12
+ | **Đổi ngay trên màn**: toggle, tick, kéo thẻ, đổi thứ tự | Đổi luôn, không spinner. Lỗi thì trả lại như cũ + toast |
13
+ | Bấm nút gửi, lưu, tạo | Spinner thế chỗ icon trong nút (`button.md`) |
14
+ | Tự lưu (cài đặt, trình soạn thảo) | Dấu "Đang lưu… / Đã lưu" cạnh chỗ vừa đổi |
15
+ | "Xem thêm" cuối danh sách | Spinner đè giữa nút, nút giữ bề rộng (`timeline.md`) |
16
+ | **Việc chạy lâu**: xuất tệp, nhập dữ liệu | Có tiến độ, chạy nền, xong báo toast |
17
+ | Tải tệp lên | `file-upload.md` |
18
+ | AI đang trả lời | `chat.md` |
19
+
20
+ Không bao giờ một vòng xoay giữa màn thay cho cả trang (`I19`). Chuyển trang thì
21
+ header, sidebar đứng yên, chỉ vùng nội dung thành khung chờ.
22
+
23
+ ---
24
+
25
+ ## Khung chờ (dữ liệu qua mạng, lần đầu)
26
+
27
+ Theo `I19`: khung chờ **đúng hình** dòng thật, để lúc dữ liệu về trang không nhảy.
28
+
29
+ ```html
30
+ <ul aria-busy="true" class="divide-y divide-border">
31
+ <li class="flex items-center gap-3 px-4 py-3"> <!-- cùng padding, cùng divide với dòng thật -->
32
+ <div class="size-8 shrink-0 animate-pulse rounded-full bg-foreground/5 motion-reduce:animate-none"></div>
33
+ <div class="min-w-0 flex-1 space-y-2">
34
+ <div class="h-3 w-2/5 animate-pulse rounded-full bg-foreground/5 motion-reduce:animate-none"></div>
35
+ <div class="h-3 w-1/4 animate-pulse rounded-full bg-foreground/5 motion-reduce:animate-none"></div>
36
+ </div>
37
+ <div class="h-3 w-16 animate-pulse rounded-full bg-foreground/5 motion-reduce:animate-none"></div>
38
+ </li>
39
+ </ul>
40
+ <span class="sr-only" role="status">Đang tải danh sách khách hàng</span>
41
+ ```
42
+
43
+ - **Vệt chờ là lớp phủ `bg-foreground/5`, không `bg-background`** (`M21`). Bản sáng hai cách ra cùng một màu
44
+ (`#f4f4f4` / `#f4f4f6` trên card trắng); bản tối `bg-background` tối hơn card, cả khung chờ thành dãy lỗ đen
45
+ khoét xuống, còn lớp phủ thì sáng lên mờ như mọi vùng tô khác.
46
+ - **Mượn nguyên khuôn dòng thật**: cùng cỡ avatar, cùng padding, cùng đường chia `divide-y`. Dòng thật có đường chia mà khung chờ không có thì lúc dữ liệu về vẫn thấy cả khối đổi hình.
47
+ - **Thanh chữ cao `h-3`, chiều dài lệch nhau** giữa các dòng (`w-2/5`, `w-1/2`, `w-1/3`…). Dài bằng nhau thì trông như sọc kẻ, không giống chữ.
48
+ - Số dòng bằng số dòng mỗi trang, hoặc đủ lấp khung, không bịa 3 dòng cho một khung 10 dòng.
49
+ - **Bảng nhóm thì khung chờ bắt đầu bằng một hàng nhóm**, không thẳng vào dòng dữ liệu, kẻo lúc dữ liệu về cả bảng tụt một hàng (`../layouts/app.md`, mục Bảng nhóm theo trạng thái).
50
+ - `animate-pulse` luôn đi kèm `motion-reduce:animate-none`. Trình đọc màn hình không thấy khung, nên phải có `aria-busy` và một câu `sr-only`.
51
+ - **Không bao giờ hiện câu rỗng trong lúc chờ.** "Chưa có khách hàng nào" chớp lên rồi danh sách hiện ra là người dùng vừa được báo sai một lần. Chưa biết rỗng hay không thì là đang tải.
52
+
53
+ ---
54
+
55
+ ## Tải lần hai: giữ dữ liệu cũ
56
+
57
+ Đổi lọc, đổi trang, đổi tab, gõ tìm, kéo lại: trên màn **đã có dữ liệu**. Thay cả bảng
58
+ bằng khung chờ lần nữa là xoá mất thứ người dùng đang nhìn, rồi vẽ lại; mỗi phím gõ
59
+ trong ô tìm là bảng chớp một lần. Giữ nguyên dữ liệu cũ tới khi dữ liệu mới về, chỉ
60
+ báo "đang chạy" bằng một thanh mảnh chạy ở mép trên khung.
61
+
62
+ ```html
63
+ <div aria-busy="true" class="relative overflow-hidden rounded-2xl border border-border">
64
+ <!-- Thanh chạy: chỉ có khi đang tải lại -->
65
+ <div class="absolute inset-x-0 top-0 h-0.5 overflow-hidden">
66
+ <div class="h-full w-1/3 animate-progress-slide bg-primary motion-reduce:animate-pulse"></div>
67
+ </div>
68
+ <table>…dữ liệu cũ, giữ nguyên…</table>
69
+ </div>
70
+ <span class="sr-only" role="status">Đang tải kết quả</span>
71
+ ```
72
+
73
+ ```css
74
+ @theme {
75
+ --animate-progress-slide: progress-slide 1.2s ease-in-out infinite;
76
+ @keyframes progress-slide {
77
+ from { translate: -100% 0; }
78
+ to { translate: 300% 0; }
79
+ }
80
+ }
81
+ ```
82
+
83
+ - **Dữ liệu cũ không mờ, không khoá.** Mờ đi là chữ tụt dưới ngưỡng tương phản và đọc ra là "bị khoá"; người dùng vẫn đọc, vẫn chọn được dòng cũ trong lúc chờ.
84
+ - **Thanh nằm trong khung đang tải**, không ở đỉnh trang: đổi lọc của bảng thì thanh ở mép bảng, mắt đang nhìn đúng chỗ đó. Thanh đè lên viền trên, không chèn thêm chỗ (`N1`).
85
+ - **Thứ vừa bấm đổi ngay**: chip lọc đã chọn, tab đã chuyển, số trang đã tô. Chỉ phần dữ liệu là chờ.
86
+ - Kết quả về là 0 dòng thì sang câu rỗng (`empty-state.md`). **Tải lần hai hỏng thì giữ dữ liệu cũ, báo bằng toast lỗi có Thử lại** (`../layouts/overlay.md`); khối "Không tải được…" của `empty-state.md` chỉ dành cho khi chưa có gì trên màn.
87
+ - **Hỏng thì tab, chip, số trang trả về giá trị khớp dữ liệu đang hiện**, như ca đổi ngay trên màn. Để tab "Đang giao dịch 19" sáng trên một bảng toàn khách của "Tất cả", chân bảng ghi 32, là màn tự nói sai; câu giải thích "bảng đang hiện kết quả trước đó" là vá chỗ sai đó bằng chữ. Toast nói cái chưa tải được ("Chưa tải được khách hàng Đang giao dịch"), Thử lại là bấm lại đúng lựa chọn đó. **Riêng ô tìm giữ nguyên chữ đã gõ**, không xoá thứ người dùng gõ; toast "Chưa tìm được “ng”" + Thử lại.
88
+ - Gõ tìm thì kết quả về sau mới thắng: phản hồi của "ng" về sau phản hồi của "nguyen" không được đè lên. Huỷ hay bỏ phản hồi cũ là logic người dùng (`N10`), skill chỉ nhắc.
89
+ - Dự án có React Query, SWR thì đây là `placeholderData: keepPreviousData` / `keepPreviousData`, thanh mảnh hiện theo `isFetching`; React thuần thì `useTransition`, thanh theo `isPending`.
90
+
91
+ ---
92
+
93
+ ## Thời điểm hiện: chờ 300ms, đã hiện thì giữ 500ms
94
+
95
+ Dữ liệu về trong 200ms mà vẫn vẽ khung chờ là màn **chớp** một cái: khung hiện rồi
96
+ biến ngay, nhìn như lỗi. Ngược lại, khung vừa hiện được 50ms đã biến cũng chớp.
97
+
98
+ - **Chưa tới 300ms thì chưa vẽ gì**: khung giữ đúng chiều cao, trống. Không câu rỗng, không khung chờ.
99
+ - **Đã hiện thì giữ tối thiểu 500ms**, kể cả dữ liệu về ngay sau đó.
100
+ - Áp cho khung chờ, thanh mảnh tải lần hai, và chữ "Đang lưu…". **Không áp cho spinner trong nút**: spinner thế chỗ icon nên không xô gì, và người vừa bấm cần thấy nút đã nhận.
101
+
102
+ ```ts
103
+ // Hiện cờ đang tải sau delayMs; đã hiện thì giữ ít nhất minVisibleMs.
104
+ export function useDelayedLoading(isLoading: boolean, delayMs = 300, minVisibleMs = 500) {
105
+ const [isVisible, setIsVisible] = useState(false);
106
+ const shownAtRef = useRef(0);
107
+
108
+ useEffect(() => {
109
+ if (isLoading) {
110
+ if (isVisible) return;
111
+
112
+ const showTimer = window.setTimeout(() => {
113
+ shownAtRef.current = Date.now();
114
+ setIsVisible(true);
115
+ }, delayMs);
116
+ return () => window.clearTimeout(showTimer);
117
+ }
118
+
119
+ if (!isVisible) return;
120
+
121
+ const remainingMs = Math.max(0, minVisibleMs - (Date.now() - shownAtRef.current));
122
+ const hideTimer = window.setTimeout(() => setIsVisible(false), remainingMs);
123
+ return () => window.clearTimeout(hideTimer);
124
+ }, [isLoading, isVisible, delayMs, minVisibleMs]);
125
+
126
+ return isVisible;
127
+ }
128
+ ```
129
+
130
+ Lúc giữ 500ms mà dữ liệu đã về thì vẫn hiện dữ liệu ngay; chỉ thanh mảnh hay chữ
131
+ "Đang lưu…" ở lại cho đủ giờ. Khung chờ lần đầu thì đợi đủ giờ rồi mới thay bằng dữ liệu:
132
+
133
+ ```tsx
134
+ const isSkeletonVisible = useDelayedLoading(isLoading);
135
+
136
+ {isSkeletonVisible ? <CustomerListSkeleton /> : isLoading ? <div className="min-h-96" /> : <CustomerList />}
137
+ ```
138
+
139
+ ---
140
+
141
+ ## Đổi ngay trên màn
142
+
143
+ Toggle, tick việc xong, kéo thẻ sang cột khác, đổi thứ tự, gắn nhãn, ghim: **đổi
144
+ luôn trên màn lúc bấm**, không spinner, không khoá điều khiển trong lúc chờ máy chủ.
145
+ Đây là việc nhỏ, gần như luôn thành công, và người dùng thường bấm liền mấy cái.
146
+
147
+ - **Lỗi thì trả lại như cũ** (công tắc gạt về, thẻ về cột cũ, đúng vị trí cũ) **kèm toast lỗi** nói cái gì chưa lưu được và có Thử lại: "Chưa chuyển được 'Sửa trang thanh toán' sang Đang làm". Trả lại im lặng thì người dùng tưởng mình bấm trượt. Tên việc dài thì cắt ~30 ký tự + `…` (`../layouts/overlay.md`, Toast), dòng mô tả nói thẻ đang nằm đâu ("Thẻ đã về lại cột Cần làm").
148
+ - Toast lỗi theo `../layouts/overlay.md`: không tự tắt, `role="alert"`. Chỗ đã có dấu tự lưu (hàng cài đặt) thì câu lỗi nằm ở dấu đó, không thêm toast (mục dưới).
149
+ - **Không dùng cho việc không đổi lại được hoặc máy chủ phải quyết**: thanh toán, gửi lời mời, xoá vĩnh viễn, tạo bản ghi mà màn kế tiếp cần mã của nó. Mấy việc đó là nút đang xử lý (`button.md`).
150
+ - Gạt thì đổi tại chỗ, có xảy ra hay không là logic (`N10`); skill chỉ dựng đủ hai hình: đã đổi, và đã trả lại + toast.
151
+
152
+ ---
153
+
154
+ ## Tự lưu: "Đang lưu… / Đã lưu"
155
+
156
+ Trang cài đặt không nút Lưu tổng (`../layouts/app.md`) và trình soạn thảo tự lưu cần
157
+ một dấu nhỏ nói đã lưu chưa, đặt **cạnh chỗ vừa đổi**: cạnh nhãn của hàng cài đặt,
158
+ cạnh tiêu đề tài liệu ở header trình soạn thảo.
159
+
160
+ ```html
161
+ <span role="status" class="inline-flex items-center gap-1 text-xs text-muted">
162
+ <!-- Đang lưu: chỉ chữ -->
163
+ Đang lưu…
164
+ <!-- Đã lưu: <i data-lucide="check" class="size-3.5"></i> Đã lưu -->
165
+ </span>
166
+ ```
167
+
168
+ | Trạng thái | Hình |
169
+ | --- | --- |
170
+ | Đang lưu | "Đang lưu…" `text-xs text-muted`, không spinner. Hiện theo luật 300ms |
171
+ | Đã lưu | icon `check` `size-3.5` + "Đã lưu", cùng màu `text-muted`, không xanh lá. Cài đặt: tắt sau ~2 giây. Trình soạn thảo: ở lại, thành "Đã lưu lúc 14:32" |
172
+ | Không lưu được | **Điều khiển trả về giá trị đã lưu** (công tắc gạt về, select về lựa chọn cũ), cạnh nhãn "Chưa lưu được" `text-red-600` + link chữ "Thử lại" (áp lại đúng thay đổi vừa hỏng). Ở lại tới khi lưu được. Không thêm toast: lỗi đã nói tại chỗ (`N3`) |
173
+
174
+ - **Hỏng mà công tắc vẫn nằm ở vị trí mới thì màn đang nói sai**: công tắc tắt cạnh "Chưa lưu được", người đọc không biết máy chủ đang bật hay tắt. Trả về giá trị đã lưu, như ca đổi ngay trên màn. **Riêng ô chữ giữ nguyên chữ đã gõ**, không xoá thứ người dùng gõ.
175
+ - **Không xanh lá cho "Đã lưu"**: lưu là việc thường xuyên, như dấu `Check` xám của bước công cụ trong `chat.md`. Xanh lá để dành cho trạng thái "đang ổn" (`rules-color.md`).
176
+ - Dấu nằm trong cùng dòng với nhãn, `flex-wrap`, đổi chữ thì chỉ nó dài ra, không đẩy điều khiển (`N1`).
177
+ - Công tắc mở luồng (quét mã, nhập mật khẩu) thì không có dấu này, xem `choice-controls.md`.
178
+
179
+ ---
180
+
181
+ ## Việc chạy lâu
182
+
183
+ Xuất tệp, nhập dữ liệu, tạo báo cáo, chạy hàng loạt: vài giây tới vài phút. Ba điều:
184
+
185
+ 1. **Có tiến độ.** Biết tổng thì thanh có số: "Đã nhập 340 / 1.200 dòng", thanh `h-2` `role="progressbar"` (`charts.md`, "Thanh tiến độ đứng riêng"). Không biết tổng thì chữ nói đang làm bước nào ("Đang gom đơn tháng 9…"), không thanh chạy vô tận.
186
+ 2. **Chạy nền.** Không khoá cả màn bằng modal chờ. Bấm xong thì toast tiến độ ở lại tới khi xong: tiêu đề ngắn "Đang xuất 1.240 đơn", dòng dưới "340 / 1.240 · xong sẽ báo bạn". Tiêu đề dài kiểu "Đang xuất 1.240 đơn, xong sẽ báo bạn" ở màn hẹp rớt một chữ xuống dòng hai (`T10`). Người dùng làm việc khác. Màn nhập dữ liệu có modal thì đóng modal được, việc vẫn chạy.
187
+ 3. **Xong báo toast, kèm hành động**: "Đã xuất 1.240 đơn" + nút "Tải xuống"; "Đã nhập 1.180 dòng, 20 dòng lỗi" + "Xem dòng lỗi". Hỏng thì toast lỗi có Thử lại.
188
+
189
+ - Dưới ~3 giây thì không cần chạy nền: nút đang xử lý là đủ (`button.md`).
190
+ - **Nút đã bấm không quay spinner khi toast tiến độ đã hiện** (`N3`): một việc, một chỗ báo đang chạy. Spinner trong nút cộng thanh trong toast là hai tín hiệu cho một việc, mắt không biết nhìn chỗ nào. Nút giữ icon và chữ, không mờ, `aria-disabled` tới khi xong để không ai bấm xuất hai lần (`button.md`, đang xử lý, bỏ phần spinner).
191
+
192
+ ---
193
+
194
+ ## Spinner
195
+
196
+ - Một hình cho cả app: `LoaderCircle` lucide, `animate-spin motion-reduce:animate-none`, cỡ bằng icon nó thế chỗ (`size-4` trong nút).
197
+ - **Một việc đang chạy, một spinner** (`N3`). Spinner trong nút thì không thêm thanh mảnh, không thêm chữ "Đang gửi…" ở chỗ khác.
198
+ - Spinner chỉ thế chỗ một icon hoặc đè giữa một nút. Không đứng một mình giữa khối thay cho dữ liệu: chỗ đó là khung chờ.
@@ -0,0 +1,147 @@
1
+ # Logo sản phẩm ⚑
2
+
3
+ **Dự án đã có logo thì dùng của họ**, không vẽ lại, không chỉnh (logo là nhận diện, `V1` trong
4
+ `review.md`). File này chỉ dùng khi dự án mới chưa có logo mà màn có chỗ cho logo: đầu sidebar,
5
+ màn xác thực (`layouts/form.md`), trang lỗi đứng riêng (`layouts/app.md`). Việc nhỏ hơn một màn
6
+ mà màn đó không có chỗ cho logo thì không vẽ (`S3`).
7
+
8
+ Tìm trước khi vẽ:
9
+
10
+ ```bash
11
+ find . -path ./node_modules -prune -o \( -iname "*logo*" -o -iname "*brand*" -o -iname "icon.svg" -o -iname "favicon.svg" \) -print 2>/dev/null | head
12
+ ```
13
+
14
+ **Không tính là đã có logo**: ô chữ cái đầu tên sản phẩm (dấu cũ của skill, tên file kiểu
15
+ `brand-mark`), favicon mặc định của framework (tia sét tím của Vite, logo Next, `favicon.ico` của
16
+ create-react-app). Gặp chúng thì thay theo file này, giữ tên component đang được import.
17
+
18
+ Logo ở đây là **logo đơn giản cho dự án mới**: một dấu hình học và tên sản phẩm. Không phải bộ
19
+ nhận diện của agency; người dùng có logo thật thì thay vào một chỗ.
20
+
21
+ ---
22
+
23
+ ## Hình: dấu trong ô màu nhấn, tên bên phải
24
+
25
+ ```
26
+ ╭────╮
27
+ │ ◐ │ Tên sản phẩm ô size-8 bo 8px, nền màu nhấn, dấu trắng size-5
28
+ ╰────╯
29
+ ```
30
+
31
+ - **Dấu là SVG vẽ tay, không phải chữ cái trong ô.** Ô chữ cái đầu là cách của avatar và của
32
+ logo công ty giả trong dữ liệu mẫu (`S16`); dùng nó cho logo sản phẩm thì đầu sidebar trông
33
+ như một dòng danh sách, sản phẩm không có mặt.
34
+ - **Không lấy icon lucide làm dấu.** Cùng bộ với icon các mục sidebar ngay bên dưới, logo đọc ra
35
+ là một mục nav.
36
+ - **viewBox `0 0 24 24`, vẽ trong vùng 4–20**, một đến ba hình cơ bản (tròn, vuông bo, cung,
37
+ thanh). Khối đặc, hoặc nét `strokeWidth` từ 2.5; không chi tiết nào mảnh hơn 2 đơn vị, vì ở
38
+ 16px (favicon) chúng biến mất.
39
+ - **Một màu, `currentColor`**: không gradient, không bóng, không màu thứ hai. Màu đến từ ô
40
+ (`bg-primary text-primary-foreground`), nên đổi `--primary` là logo đổi theo, nấc Xám của
41
+ wireframe và dark mode tự đúng.
42
+ - **Tên sản phẩm** `text-sm font-semibold text-foreground truncate`, cách ô `gap-2.5`, cùng font
43
+ của app. Không vẽ tên thành SVG, không đổi font riêng cho tên.
44
+ - **Đừng ra hình sáo**: tia sét, ngôi sao lấp lánh, lục giác, khối lập phương, chữ cái trong
45
+ vòng tròn. Mười app mới thì sáu cái dùng các hình này, người dùng nhìn là biết logo đặt tạm.
46
+ Cũng đừng ra hình người ta nhận ra ngay là logo của một hãng lớn.
47
+
48
+ ## Ba hướng, nặn từ brief
49
+
50
+ Lấy tên sản phẩm và việc chính ở `U1`, `U2`. Mỗi hướng một dấu:
51
+
52
+ | Hướng | Cách nặn | Ví dụ |
53
+ | --- | --- | --- |
54
+ | **1. Chữ đầu dựng bằng khối** (khuyên dùng khi tên ngắn, dễ nhớ) | Chữ cái đầu tên dựng lại từ hình cơ bản, không gõ bằng font: có một khoảng cắt hay một góc lệch để nó thành dấu, không còn là chữ | "Lumen": chữ L từ hai thanh, góc trong là một chấm tròn |
55
+ | **2. Ẩn dụ việc chính** | Một hình gợi việc người dùng làm, trừu tượng hoá tới mức không còn là icon | đặt lịch: hai ô vuông bo lệch nhau; tài chính: ba thanh tăng dần, thanh cuối là nửa tròn |
56
+ | **3. Hai hình ghép** | Hai hình cơ bản chồng hay cắt nhau, phần giao là khoảng trống | vòng tròn bị một hình vuông cắt mất góc |
57
+
58
+ Ghi một câu vì sao cho mỗi hướng, bám tên hay việc chính, không viết "hiện đại, tối giản".
59
+
60
+ ## Kiểm trước khi đưa
61
+
62
+ Render dấu ở **16px, 32px, 64px**, trên nền màu nhấn và trên nền trắng (dấu `text-foreground`),
63
+ xem ảnh:
64
+
65
+ - 16px còn nhận ra hình, không thành một đốm.
66
+ - Ba hướng khác nhau thật khi nhìn nhỏ, không phải một hình xoay ba kiểu.
67
+ - Đặt cạnh icon các mục sidebar không lẫn vào chúng.
68
+
69
+ Hướng nào hỏng một câu thì vẽ lại hướng đó, không đưa lên wireframe.
70
+
71
+ ## Code
72
+
73
+ ```tsx
74
+ // src/components/product-brand/product-mark.tsx
75
+ import type { ComponentProps } from "react";
76
+
77
+ // Dấu của sản phẩm. Thay path khi có logo thật.
78
+ export function ProductMark(props: ComponentProps<"svg">) {
79
+ return (
80
+ <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" {...props}>
81
+ <path d="M6 4h3.5v12.5H18V20H6z" />
82
+ <circle cx="15.5" cy="8.5" r="2.5" />
83
+ </svg>
84
+ );
85
+ }
86
+ ```
87
+
88
+ ```tsx
89
+ // src/components/product-brand/product-brand.tsx
90
+ import { cn } from "@/lib/utils";
91
+ import { ProductMark } from "./product-mark";
92
+
93
+ interface ProductBrandProps {
94
+ name: string;
95
+ className?: string;
96
+ }
97
+
98
+ export default function ProductBrand({ name, className }: ProductBrandProps) {
99
+ return (
100
+ <span className={cn("flex min-w-0 items-center gap-2.5", className)}>
101
+ <span className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground">
102
+ <ProductMark className="size-5" />
103
+ </span>
104
+ <span className="truncate text-sm font-semibold text-foreground">{name}</span>
105
+ </span>
106
+ );
107
+ }
108
+ ```
109
+
110
+ - Sidebar thu gọn: ô đứng yên, tên bị cắt và mờ đi như chữ các mục (`layouts/app.md`, thu gọn
111
+ sidebar). Màn xác thực và trang lỗi đứng riêng dùng đúng component này, không vẽ bản thứ hai.
112
+ - **Favicon từ cùng dấu**: một file SVG gồm ô vuông bo nền màu nhấn và dấu trắng (tab trình duyệt
113
+ sáng hay tối đều thấy). Next.js App Router đặt ở `app/icon.svg`; dự án khác `public/favicon.svg`
114
+ kèm `<link rel="icon" type="image/svg+xml" href="/favicon.svg">`. Xoá favicon mặc định của
115
+ framework để không còn hai cái.
116
+
117
+ ## Trong luồng thiết kế
118
+
119
+ - **Đề xin logo** ("làm logo", "dựng logo", "thay logo", "design a logo"; dòng logo ở câu 1 của
120
+ `SKILL.md`): vẽ **trang chọn logo** (dưới), dừng chờ chọn. Đây là cổng duy nhất của lối này.
121
+ Đề có "luôn" ("làm logo luôn") hoặc ghi sẵn hướng thì bỏ trang, dựng thẳng.
122
+ - **Wireframe (`U3`)**: dự án chưa có logo thì thanh công cụ có nhóm **Logo: 1 · 2 · 3** (`?logo=`),
123
+ đổi dấu ở mọi chỗ có logo trên trang. Người dùng chọn logo cùng lúc chọn phương án, không thêm
124
+ cổng. Mở không tham số thì hướng khuyên dùng.
125
+ - **Dựng luôn, không wireframe**: vẽ hướng khuyên dùng, không hỏi.
126
+
127
+ ### Trang chọn logo ⚑
128
+
129
+ Một file `$TMPDIR/evon-design/logo.html`, khung và thanh công cụ như wireframe của `U3`
130
+ (`design-process.md`: Tailwind trình duyệt, token của dự án, thanh sáng một dòng, segmented
131
+ control, khung lý do). Chưa chọn thì không đụng file nào của dự án.
132
+
133
+ - **Thanh công cụ**: **Logo: 1 · 2 · 3** (`?logo=`, hướng khuyên dùng có chấm màu nhấn) ·
134
+ **Màu** (công tắc: tắt là nấc Xám, bật là màu nhấn của dự án) · Nhấn (dự án chưa có màu brand,
135
+ như `U3`).
136
+ - **Khung lý do**: tên hướng, nhãn "Khuyên dùng", một câu vì sao bám tên hay việc chính.
137
+ - **Mỗi hướng hiện dấu ở chỗ nó sẽ sống**, không hiện một dấu to giữa trang trắng:
138
+ 1. Đầu sidebar thật của dự án (ô, tên, vài mục nav có icon bên dưới), cả bản mở và bản thu gọn.
139
+ 2. Khối logo của màn đăng nhập.
140
+ 3. Một dải tab trình duyệt sáng và một dải tối, favicon 16px cạnh tên trang.
141
+ 4. Hàng cỡ 16, 32, 64px trên nền màu nhấn và trên nền trắng.
142
+ - Chụp ảnh trang ở cả ba hướng, tự xem theo "Kiểm trước khi đưa" (trên) rồi mới gửi.
143
+ - Gửi link bấm được kèm một câu: *"Chọn `1`, `2` hay `3`; trả lời `ok` là dựng hướng khuyên dùng.
144
+ Muốn sửa một hướng thì nói (ví dụ "2 nhưng bo tròn hơn")."* Người dùng xin sửa thì vẽ lại hướng
145
+ đó trên cùng trang, gửi lại.
146
+ - **Lúc giao** một dòng trong phần mặc định của `S15`: *"Logo: hướng [n], [một câu vì sao]. Dấu
147
+ ở `product-mark.tsx`, có logo thật thì thay path ở đó; muốn hướng khác thì nói."*
@@ -0,0 +1,43 @@
1
+ # Ô nhập mã OTP
2
+
3
+ Sáu ô một số, dùng ở màn xác thực email, đăng nhập hai bước. Màn đứng riêng giữa
4
+ trang thì theo card một mình (`M29`) và chiều cao `h-12` của form xác thực
5
+ (`layouts/form.md`).
6
+
7
+ ```html
8
+ <p class="text-sm text-muted">Mã gồm 6 số vừa được gửi tới</p>
9
+ <p class="text-sm font-medium">
10
+ <!-- In email bằng EmailText (description-list.md), không wrap-anywhere trơn -->
11
+ tran.anh.tuan@gmail.com
12
+ <a href="#" class="ml-1 whitespace-nowrap font-normal text-muted underline underline-offset-2 outline-hidden hover:text-foreground">Đổi email</a>
13
+ </p>
14
+
15
+ <fieldset class="mt-6">
16
+ <legend class="mb-2 text-sm font-medium">Mã xác thực</legend>
17
+ <div class="grid grid-cols-6 gap-2 sm:gap-3">
18
+ <!-- Ô đầu có autocomplete="one-time-code" và maxlength="6": iOS tự điền cả sáu số vào
19
+ ô đầu, onInput chia lại sang năm ô sau. maxlength="1" thì tự điền bị cắt còn một số. -->
20
+ <input inputmode="numeric" autocomplete="one-time-code" maxlength="6" aria-label="Số thứ 1 trên 6"
21
+ class="aspect-square w-full min-w-0 rounded-xl border border-border-strong bg-surface text-center text-2xl font-semibold tabular-nums outline-hidden focus:border-focus focus:ring-2 focus:ring-focus" />
22
+ <!-- 5 ô còn lại y hệt nhưng maxlength="1", aria-label "Số thứ n trên 6", không có autocomplete -->
23
+ </div>
24
+ <!-- Dòng lỗi luôn có mặt, rỗng khi chưa lỗi: giữ chỗ để nút Xác nhận không tụt xuống ngay dưới con trỏ -->
25
+ <p aria-live="polite" class="mt-2 min-h-4 text-xs text-red-600"></p>
26
+ </fieldset>
27
+ ```
28
+
29
+ - **Viền ô là `--border-strong`**, như mọi ô nhập (`M14`, `N5`), không lấy `--border` nhạt hơn của card.
30
+ - **Ô vuông `aspect-square`, sáu ô chia đều bề ngang**, `min-w-0` để không tràn ở 375px. Số `text-2xl font-semibold tabular-nums`, căn giữa.
31
+ - **Dán cả mã vào ô nào cũng được**, tách ra sáu ô (cần `onPaste` đọc clipboard; dự án có `input-otp` như InputOTP của shadcn thì dùng nó, nó là một ô thật vẽ thành sáu ô và lo sẵn tự điền, dán); gõ xong một số thì con trỏ sang ô sau; Backspace ở ô trống thì lùi về ô trước. Đây là cách ô phản hồi, skill lo (`N9`). Gõ đủ sáu số thì tự gửi hay chờ bấm Xác nhận là logic, người dùng quyết.
32
+ - **Email đích hiện đậm, đủ, không cắt** (`N8`), kèm link **"Đổi email"**: gõ nhầm email thì đây là lối ra duy nhất (`N6`). **Bấm vào thì về bước trước với dữ liệu đã gõ điền sẵn** (họ tên, email; mật khẩu thì để trống, không mang mật khẩu qua state của router), con trỏ nằm ở ô email vì đó là thứ cần sửa. Về form trống thì người dùng phải gõ lại mọi thứ chỉ để sửa một chữ trong email. Link `whitespace-nowrap`: email dài đẩy nó xuống dòng thì cả cụm "Đổi email" xuống cùng nhau, không bị bẻ thành "Đổi" cuối dòng trên và "email" dòng dưới (`T10`).
33
+ - Mở màn là con trỏ nằm sẵn ở ô đầu.
34
+ - **Đếm ngược gửi lại** dùng `tabular-nums`, không thì chữ số nhảy qua lại mỗi giây (`N1`). Hai trạng thái, dựng đủ cả hai (`N2`):
35
+ - Đang đếm: "Chưa nhận được mã? Gửi lại sau 0:57", cả câu `text-muted`.
36
+ - Hết đếm: "Chưa nhận được mã?" `text-muted` + **"Gửi lại mã"** là nút ghost chữ `text-foreground font-medium`, có hover. Đừng để nó xám như lúc đang đếm, trông như vẫn còn khoá (`I8`).
37
+ - **Bấm "Gửi lại mã" thì báo đã gửi**: dòng đổi thành "Đã gửi mã mới · Gửi lại sau 1:00" trong một vùng `role="status"`, chữ "Đã gửi mã mới" giữ tới khi đếm xong. Xoá sáu ô, con trỏ về ô đầu. Chỉ có bộ đếm chạy lại thì người dùng không chắc bấm đã ăn chưa, và trình đọc màn hình không nghe gì.
38
+ - **Sai mã**: cả sáu ô viền `red-500` (quầng đỏ chỉ ở ô đang focus, `input.md`), câu lỗi dưới hàng ô nói cách sửa: "Mã chưa đúng, kiểm tra lại email mới nhất". **Mã hết hạn**: câu lỗi "Mã đã hết hạn, bấm Gửi lại mã để nhận mã mới", **không** thêm nút trong câu lỗi. Hai lỗi khác nhau thì hai câu khác nhau.
39
+ - **Sau sai mã hoặc mã hết hạn: xoá sáu ô, con trỏ về ô đầu**, câu lỗi và viền đỏ giữ tới khi gõ số đầu tiên. Người dùng gần như luôn gõ lại cả mã (nhìn lại email hoặc lấy mã mới), sáu số cũ còn nằm đó thì phải Backspace sáu lần trước. Cùng cách với ô mật khẩu sau khi đăng nhập sai (`layouts/form.md`). Trang `/states` vẽ trạng thái này bằng sáu ô trống viền đỏ, không phải sáu số đỏ.
40
+ - **Một việc, một nút.** Mã hết hạn thì đếm ngược cũng đã hết, dòng dưới đã có "Gửi lại mã". Thêm "Gửi mã mới" trong câu lỗi là hai nút cùng làm một việc, khác tên, người dùng phải đoán có khác nhau không (`N3`, `N5`).
41
+ - **Dòng lỗi giữ chỗ sẵn** (`min-h-4`, bằng một dòng `text-xs`, rỗng khi chưa lỗi). Câu lỗi `text-xs` như mọi câu lỗi dưới ô (`input.md`, `layouts/form.md`). Người dùng bấm Xác nhận, lỗi hiện ra mà không giữ chỗ thì nút tụt xuống một dòng ngay dưới con trỏ (`N1`).
42
+ - **Bấm Xác nhận khi chưa đủ sáu số thì phải có phản hồi**: câu lỗi "Chưa nhập mã" (chưa gõ số nào) hoặc "Mã còn thiếu số, nhập đủ 6 số", viền đỏ ở **các ô còn trống** (số đã gõ không sai, giữ viền thường), con trỏ nhảy về ô trống đầu tiên. Bấm mà im lặng thì người dùng tưởng nút hỏng.
43
+ - Nút Xác nhận `primary` rộng hết card, `h-12`. Đang kiểm mã thì spinner thay chỗ icon hoặc thêm trước chữ, chữ giữ nguyên (`components/button.md`).
@@ -0,0 +1,100 @@
1
+ # Ô nhập số lượng
2
+
3
+ Số lượng trong giỏ, số ghế, số bản in: một số nguyên nhỏ trong khoảng biết trước.
4
+ Nút − và + hai bên, số ở giữa gõ thẳng được. Số lớn hoặc có phần thập phân (giá,
5
+ cân nặng) thì dùng ô nhập thường (`input.md`), không dùng khuôn này.
6
+
7
+ ```tsx
8
+ <div
9
+ className={cn(
10
+ // Mượn nguyên class của ô nhập (viền, nền, bo, focus, lỗi, cao h-11 md:h-10),
11
+ // bỏ padding ngang, ôm vừa nội dung. Khung đổi viền khi ô số bên trong focus.
12
+ getFieldControlClasses(hasError, true),
13
+ "inline-flex h-11 w-fit items-stretch overflow-hidden px-0 md:h-10",
14
+ isDisabled && "cursor-not-allowed bg-background text-muted",
15
+ )}
16
+ >
17
+ <Button
18
+ variant="ghost"
19
+ aria-label="Giảm số lượng"
20
+ aria-controls={id}
21
+ tabIndex={-1}
22
+ disabled={isDecreaseDisabled}
23
+ onMouseDown={(event) => event.preventDefault()}
24
+ onClick={() => handleStep(-1)}
25
+ className="group h-full w-11 shrink-0 rounded-none p-0 hover:bg-transparent md:w-10"
26
+ >
27
+ {/* Vùng bấm là cả ô cao (44px ở màn hẹp), nền rê chỉ là ô vuông bo thụt vào. */}
28
+ <span className="grid size-8 place-items-center rounded-lg text-muted transition-colors group-enabled:group-hover:bg-foreground/5 group-enabled:group-hover:text-foreground">
29
+ <Minus className="size-4" aria-hidden />
30
+ </span>
31
+ </Button>
32
+
33
+ <input
34
+ id={id}
35
+ type="text"
36
+ inputMode="numeric"
37
+ role="spinbutton"
38
+ aria-valuemin={min}
39
+ aria-valuemax={max}
40
+ aria-valuenow={value}
41
+ aria-invalid={hasError || undefined}
42
+ aria-describedby={describedById}
43
+ autoComplete="off"
44
+ // Nhiều hơn số chữ số của max đúng một: gõ hay dán quá tay thì vẫn kéo về max.
45
+ maxLength={String(max).length + 1}
46
+ disabled={isDisabled}
47
+ className="w-12 min-w-0 bg-transparent text-center tabular-nums outline-hidden disabled:cursor-not-allowed"
48
+ />
49
+
50
+ {/* Nút + y hệt nút −, icon Plus */}
51
+ </div>
52
+ ```
53
+
54
+ ## Hình
55
+
56
+ - **Một khung duy nhất, viền của ô nhập** (`border-border-strong`, `rounded-xl`,
57
+ focus viền nhấn + quầng mờ khi ô số bên trong focus). Không kẻ vạch dọc giữa nút và số:
58
+ thêm hai đường chia là thêm tín hiệu cho việc ba phần đã tự rõ (`N3`).
59
+ - **Nền rê của nút − + là ô vuông `size-8 rounded-lg` thụt vào giữa ô**, cùng khuôn nút
60
+ xoá trong ô tìm (`input.md`, `N5`). **Không** cho nền rê phủ kín cả khúc cao từ viền tới
61
+ viền: nền đó cắt ngang lưng chừng khung, không vạch chia nào đỡ mép trong, một bên vuông
62
+ thành một bên theo góc bo, trông như ô bị khuyết một mảng. Vùng **bấm** vẫn là cả khúc cao, `w-11` màn hẹp, `md:w-10` (`N9`); chỉ
63
+ phần **nhìn thấy** là thụt vào.
64
+ - Icon `Minus` / `Plus` `size-4`, `text-muted`, rê vào đậm lên `text-foreground`.
65
+ - Tới đầu nào thì nút phía đó `disabled`: mờ `opacity-50`, con trỏ cấm, rê không đổi nền.
66
+ Nút giữ chỗ, không ẩn (`N1`).
67
+ - Số `tabular-nums`, căn giữa, ô số `w-12` đủ hai ba chữ số.
68
+ - **Bị khoá**: cả khung chìm xuống `bg-background`, số `text-muted`, hai nút mờ; lý do
69
+ khoá nói ngay dưới ô như ô nhập thường.
70
+
71
+ ## Hành vi
72
+
73
+ - `type="text" inputMode="numeric" role="spinbutton"`, **không `type="number"`**: ô số của
74
+ trình duyệt tự vẽ mũi tên lên xuống chồng lên hai nút, và nhận cả `1e2`, `-3`.
75
+ - **Số gõ vào chỉ áp khi rời ô hoặc bấm Enter.** Gõ dở "9" trong "99" mà đã áp thì nơi dùng
76
+ tính lại tiền theo từng phím. Ô gõ trống rồi rời đi thì **giữ số cũ**, không tự điền `min`
77
+ (`N7`). Số ngoài khoảng thì kéo về trong khoảng khi áp.
78
+ - Đang gõ dở mà bấm + thì cộng vào số đang gõ, không vào số cũ.
79
+ - **`maxLength` bằng số chữ số của `max` cộng một**, không bằng đúng số chữ số. Bằng đúng
80
+ thì trình duyệt cắt bớt chữ số thừa trước khi ô kịp kéo về: kho còn 8 mà gõ 20 ra 2, ô
81
+ tới 99 mà dán 150 ra 15, một số sai lặng lẽ thay vì số tối đa. Thêm một chữ số thì số
82
+ bị cắt vẫn luôn lớn hơn `max`, và kéo về đúng `max`.
83
+ - Phím trong ô theo mẫu spinbutton: mũi tên lên xuống đổi 1, PageUp/PageDown đổi 10,
84
+ Home/End về `min`/`max`.
85
+ - Hai nút `tabIndex={-1}`: ô số đã đổi được bằng phím, Tab qua ba điểm dừng cho một ô là
86
+ thừa. `onMouseDown` chặn mặc định để bấm − + không kéo tiêu điểm ra khỏi ô đang gõ,
87
+ và trên điện thoại bàn phím không bật lên theo.
88
+
89
+ ## Giới hạn và lỗi
90
+
91
+ - **Giới hạn biết trước là `max`, không phải lỗi.** Kho còn 8 thì `max={8}`: nút + mờ ở 8,
92
+ gõ 20 thì về 8, dòng gợi ý dưới ô nói con số thật: *"Còn 8 sản phẩm."* Không để
93
+ `max={99}` rồi báo lỗi khi người dùng vượt 8: để người ta bấm một nút rồi mới mắng là
94
+ nút đó không nên bấm được.
95
+ - **Trạng thái lỗi dành cho điều ô không biết trước**: kho giảm sau khi hàng đã nằm trong
96
+ giỏ, server từ chối lúc đặt. Khi đó `max` đã là 8 mà số đang là 12: viền đỏ, nút + mờ,
97
+ câu lỗi thay dòng gợi ý và nói cách sửa (*"Kho vừa giảm còn 8, giảm xuống 8 hoặc ít
98
+ hơn."*). Bấm − một lần thì về thẳng 8.
99
+ - Dòng gợi ý nói giới hạn thật của thứ đang mua, không phải khoảng kỹ thuật chung chung
100
+ ("Từ 1 đến 99"). Không có giới hạn đáng nói thì không có dòng gợi ý.
@@ -0,0 +1,16 @@
1
+ # Thanh trượt khoảng giá (range slider)
2
+
3
+ Lọc theo một khoảng số: giá, diện tích, số lượng. Gồm **thanh trượt hai tay cầm
4
+ + hai ô nhập "Từ" / "Đến"**. Ô nhập theo `input.md`, dòng 0 kết quả theo
5
+ `empty-state.md`.
6
+
7
+ - **Luôn có hai ô nhập đi kèm thanh trượt.** Thanh trượt đi nhanh theo bậc, ô nhập là đường vào thứ hai và là chỗ đặt số lẻ mà bậc trượt không tới (7.350.000). Hai ô `tabular-nums`, `text-base` ở mobile (iOS phóng to trang khi ô dưới 16px), hậu tố `đ` xám nằm trong ô, nhãn "Từ" / "Đến" ở trên.
8
+ - **Số trong ô chỉ áp khi rời ô hoặc bấm Enter**, không áp theo từng ký tự: gõ dở "5" trong "5.000.000" mà áp ngay thì đầu kia bị kéo theo, và danh sách nhảy loạn (`N7`).
9
+ - **Tay cầm `size-5`, viền 2px, nhưng vùng bấm 44×44** (`before:-inset-3.5`, neo theo *border box* — `-inset-3` chỉ ra 40px vì viền đã ăn 4px; số âm giữ có chủ ý, `N11` bước 4: không có cách dương nào nới vùng bấm mà giữ tay cầm 20px, comment ngay trên dòng). **Cả dải rãnh cao 44px** (`h-11`) và bấm vào rãnh thì tay cầm gần nhất chạy tới, nên trên điện thoại không phải nhắm trúng vòng nhỏ (`N9`).
10
+ - **Vùng kéo `touch-none`**: chạm vào dải này thì không cuộn trang theo. Đây là đánh đổi có chủ ý của dải 44px.
11
+ - **Hai tay cầm không đổi chỗ cho nhau.** Kéo sát thì chúng chạm nhau và dừng (Từ = Đến), không vượt qua. Lúc đang chồng nhau, bấm phím sang phải là tay cầm phải đi, sang trái là tay cầm trái đi.
12
+ - **Bàn phím** (`N9`): Tab tới từng tay cầm, mũi tên đi một bậc, PageUp/PageDown đi bậc lớn, Home/End về hai đầu. Sau khi kéo, tiêu điểm ở đúng tay cầm vừa kéo.
13
+ - **Hai đầu rãnh có nhãn giá trị nhỏ nhất và lớn nhất** (`0 đ`, `50.000.000 đ`), `text-xs text-muted`.
14
+ - **Lọc ra 0 kết quả không tô đỏ gì cả** (`M30`, `empty-state.md`): một dòng `text-sm text-muted` ở chỗ đáng lẽ có danh sách, nói số cụ thể và cách nới lọc ("Không có sản phẩm nào từ 47.500.000 đ trở lên. Đắt nhất đang bán là 42.900.000 đ, hạ giá Từ xuống để xem thêm"). Dòng đó và dòng đếm kết quả cùng `py-6` nên khối không co giãn khi đổi qua lại (`N1`).
15
+ - **Khoá**: rãnh, tay cầm và hai ô cùng chìm xuống, con trỏ `not-allowed`, và **nói lý do ngay bên dưới** ("Gói Cơ bản chưa lọc được theo giá") — khoá mà không nói vì sao là bế tắc (`N6`).
16
+ - Ràng buộc nghiệp vụ (khoảng tối thiểu, bước làm tròn) là **logic của người dùng**, skill không tự bịa ra để có thêm một ca lỗi (`N10`).