@haposoft/cafekit 0.16.6 → 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 (194) 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 +112 -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 +18 -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 +52 -6
  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/scripts/workflow-policy.cjs +22 -10
  68. package/src/claude/settings/settings.json +1 -32
  69. package/src/claude/skills/{question → ask}/SKILL.md +9 -75
  70. package/src/claude/skills/brainstorm/SKILL.md +43 -131
  71. package/src/claude/skills/code-review/SKILL.md +48 -17
  72. package/src/claude/skills/code-review/references/spec-compliance-review.md +2 -3
  73. package/src/claude/skills/code-review/references/verification-gate.md +22 -27
  74. package/src/claude/skills/debug/SKILL.md +29 -149
  75. package/src/claude/skills/develop/SKILL.md +27 -36
  76. package/src/claude/skills/develop/references/parallel-waves.md +0 -8
  77. package/src/claude/skills/develop/references/quality-gate.md +15 -13
  78. package/src/claude/skills/docs/SKILL.md +2 -2
  79. package/src/claude/skills/docs/references/update-workflow.md +1 -1
  80. package/src/claude/skills/fix/SKILL.md +153 -0
  81. package/src/claude/skills/{hotfix → fix}/references/diagnosis-protocol.md +2 -2
  82. package/src/claude/skills/{hotfix → fix}/references/parallel-patterns.md +2 -2
  83. package/src/claude/skills/{hotfix → fix}/references/prevention-gate.md +3 -3
  84. package/src/claude/skills/{hotfix → fix}/references/review-cycle.md +1 -1
  85. package/src/claude/skills/git/SKILL.md +48 -9
  86. package/src/claude/skills/git/references/commit-protocols.md +35 -16
  87. package/src/claude/skills/git/references/worktree-blueprint.md +42 -6
  88. package/src/claude/skills/orca/SKILL.md +9 -8
  89. package/src/claude/skills/research/SKILL.md +2 -0
  90. package/src/claude/skills/scout/SKILL.md +61 -0
  91. package/src/claude/skills/{inspect → scout}/references/internal-inspection.md +26 -10
  92. package/src/claude/skills/specs/SKILL.md +22 -29
  93. package/src/claude/skills/specs/references/review.md +8 -6
  94. package/src/claude/skills/specs/references/templates.md +38 -44
  95. package/src/claude/skills/sync/SKILL.md +8 -15
  96. package/src/claude/skills/sync/references/rebind-and-audit.md +59 -0
  97. package/src/claude/skills/sync/references/sync-protocols.md +4 -13
  98. package/src/claude/skills/test/SKILL.md +22 -3
  99. package/src/claude/skills/test/references/execution-strategy.md +83 -0
  100. package/src/claude/skills/test/references/failure-triage.md +1 -1
  101. package/src/claude/skills/ui-ux/LICENSE +21 -0
  102. package/src/claude/skills/ui-ux/SKILL.md +497 -0
  103. package/src/claude/skills/ui-ux/references/brand-tokens.md +90 -0
  104. package/src/claude/skills/ui-ux/references/budgets.md +113 -0
  105. package/src/claude/skills/ui-ux/references/checklist.md +271 -0
  106. package/src/claude/skills/ui-ux/references/components/accordion.md +246 -0
  107. package/src/claude/skills/ui-ux/references/components/avatar.md +153 -0
  108. package/src/claude/skills/ui-ux/references/components/banner.md +43 -0
  109. package/src/claude/skills/ui-ux/references/components/breadcrumb.md +201 -0
  110. package/src/claude/skills/ui-ux/references/components/button.md +142 -0
  111. package/src/claude/skills/ui-ux/references/components/card.md +113 -0
  112. package/src/claude/skills/ui-ux/references/components/charts.md +376 -0
  113. package/src/claude/skills/ui-ux/references/components/chat.md +132 -0
  114. package/src/claude/skills/ui-ux/references/components/choice-controls.md +369 -0
  115. package/src/claude/skills/ui-ux/references/components/comment-thread.md +17 -0
  116. package/src/claude/skills/ui-ux/references/components/description-list.md +116 -0
  117. package/src/claude/skills/ui-ux/references/components/empty-state.md +98 -0
  118. package/src/claude/skills/ui-ux/references/components/file-upload.md +107 -0
  119. package/src/claude/skills/ui-ux/references/components/inline-edit.md +128 -0
  120. package/src/claude/skills/ui-ux/references/components/input.md +161 -0
  121. package/src/claude/skills/ui-ux/references/components/list-row.md +56 -0
  122. package/src/claude/skills/ui-ux/references/components/loading.md +198 -0
  123. package/src/claude/skills/ui-ux/references/components/logo.md +147 -0
  124. package/src/claude/skills/ui-ux/references/components/otp-input.md +43 -0
  125. package/src/claude/skills/ui-ux/references/components/quantity-input.md +100 -0
  126. package/src/claude/skills/ui-ux/references/components/range-slider.md +16 -0
  127. package/src/claude/skills/ui-ux/references/components/small-controls.md +340 -0
  128. package/src/claude/skills/ui-ux/references/components/sortable-header.md +86 -0
  129. package/src/claude/skills/ui-ux/references/components/tag-input.md +14 -0
  130. package/src/claude/skills/ui-ux/references/components/timeline.md +38 -0
  131. package/src/claude/skills/ui-ux/references/components/tree.md +30 -0
  132. package/src/claude/skills/ui-ux/references/design-process.md +650 -0
  133. package/src/claude/skills/ui-ux/references/layouts/app-kanban.html +303 -0
  134. package/src/claude/skills/ui-ux/references/layouts/app.md +1369 -0
  135. package/src/claude/skills/ui-ux/references/layouts/form.md +430 -0
  136. package/src/claude/skills/ui-ux/references/layouts/overlay.md +552 -0
  137. package/src/claude/skills/ui-ux/references/layouts/pricing.md +204 -0
  138. package/src/claude/skills/ui-ux/references/locked-rules.md +35 -0
  139. package/src/claude/skills/ui-ux/references/principles.md +408 -0
  140. package/src/claude/skills/ui-ux/references/refactor.md +245 -0
  141. package/src/claude/skills/ui-ux/references/responsive.md +195 -0
  142. package/src/claude/skills/ui-ux/references/review.md +528 -0
  143. package/src/claude/skills/ui-ux/references/rules-color.md +623 -0
  144. package/src/claude/skills/ui-ux/references/rules-form.md +235 -0
  145. package/src/claude/skills/ui-ux/references/rules-state.md +680 -0
  146. package/src/claude/skills/ui-ux/references/rules-type.md +369 -0
  147. package/src/claude/skills/ui-ux/references/styles.md +311 -0
  148. package/src/claude/skills/ui-ux/references/system.md +219 -0
  149. package/src/claude/skills/ui-ux/references/tailwind-v4-traps.md +237 -0
  150. package/src/claude/skills/ui-ux/references/tokens.css +362 -0
  151. package/src/claude/skills/ui-ux/scripts/lint-skill.mjs +216 -0
  152. package/src/claude/skills/ui-ux/scripts/package.json +9 -0
  153. package/src/claude/skills/ui-ux/scripts/probe.mjs +4447 -0
  154. package/src/claude/skills/ui-ux-pro-max/SKILL.md +1 -1
  155. package/src/claude/status.cjs +149 -144
  156. package/src/codex/AGENTS.md +8 -13
  157. package/src/codex/hooks/agent.cjs +2 -1
  158. package/src/codex/hooks/lib/spec-receipt.cjs +5 -0
  159. package/src/codex/hooks/lib/spec-utils.cjs +0 -7
  160. package/src/codex/hooks/rules.cjs +2 -1
  161. package/src/codex/hooks/session.cjs +0 -2
  162. package/src/codex/hooks/spec-gate.cjs +8 -18
  163. package/src/codex/hooks.json +0 -20
  164. package/src/codex/rules/state-sync.md +3 -12
  165. package/src/common/AGENTS.md +2 -2
  166. package/src/omp/extensions/cafekit-bridge.mjs +6 -6
  167. package/src/claude/hooks/__tests__/completion-authority.test.js +0 -990
  168. package/src/claude/hooks/__tests__/semantic-review-authority.test.js +0 -259
  169. package/src/claude/hooks/completion-authority-check.cjs +0 -56
  170. package/src/claude/hooks/completion-authority-state.cjs +0 -527
  171. package/src/claude/hooks/completion-authority.cjs +0 -163
  172. package/src/claude/hooks/semantic-review-authority.cjs +0 -154
  173. package/src/claude/hooks/task-scaffold-guard.cjs +0 -114
  174. package/src/claude/hooks/usage.cjs +0 -197
  175. package/src/claude/skills/delegate/SKILL.md +0 -124
  176. package/src/claude/skills/delegate/references/codex-delegation.md +0 -71
  177. package/src/claude/skills/delegate/references/grok-delegation.md +0 -84
  178. package/src/claude/skills/hotfix/SKILL.md +0 -289
  179. package/src/claude/skills/inspect/SKILL.md +0 -142
  180. package/src/claude/skills/loop/SKILL.md +0 -114
  181. package/src/claude/skills/loop/references/bounded-loop-protocol.md +0 -104
  182. package/src/claude/skills/loop/references/metric-and-guard-contract.md +0 -76
  183. package/src/claude/skills/route/SKILL.md +0 -94
  184. package/src/claude/skills/route/references/agent-timing.md +0 -46
  185. package/src/claude/skills/route/references/chaining-patterns.md +0 -53
  186. package/src/claude/skills/route/references/task-taxonomy.md +0 -48
  187. package/src/codex/hooks/completion-authority-check.cjs +0 -67
  188. package/src/codex/hooks/completion-authority-state.cjs +0 -527
  189. package/src/codex/hooks/completion-authority.cjs +0 -109
  190. package/src/codex/hooks/semantic-review-authority.cjs +0 -130
  191. package/src/codex/hooks/task-scaffold-guard.cjs +0 -63
  192. /package/src/claude/skills/{question → ask}/templates/question.md +0 -0
  193. /package/src/claude/skills/{hotfix → fix}/references/escalation-tactics.md +0 -0
  194. /package/src/claude/skills/{hotfix → fix}/references/workflow-specialized.md +0 -0
@@ -0,0 +1,246 @@
1
+ # Accordion
2
+
3
+ Danh sách mục mở/đóng tại chỗ: FAQ, "xem chi tiết" theo từng mục. Mỗi mục là một
4
+ **nút tiêu đề** rộng hết hàng, bấm thì **nội dung** trượt ra ngay dưới.
5
+
6
+ Nhóm ô tuỳ chọn thu gọn **trong form** ("Cài đặt nâng cao") không dùng khung accordion
7
+ này: xem "Khu thu gọn trong form" ở cuối file.
8
+
9
+ Khi nào dùng: danh sách dài hơn 6 mục, hoặc mỗi nội dung dài quá 3 dòng; dưới ngưỡng
10
+ đó thì hiện hết (`I17`). Ngoại lệ: FAQ trang giá luôn accordion (`../layouts/pricing.md`).
11
+ Mọi thứ mở/đóng dựng theo đúng công thức ở đây, không dùng `<details>` (`I30`).
12
+
13
+ ---
14
+
15
+ ## Mẫu
16
+
17
+ Mỗi mục là một component con giữ `isOpen` riêng, nên mở nhiều mục cùng lúc được.
18
+
19
+ ```tsx
20
+ // accordion-item.tsx
21
+ interface AccordionItemProps {
22
+ title: string;
23
+ children: ReactNode;
24
+ }
25
+
26
+ export default function AccordionItem({ title, children }: AccordionItemProps) {
27
+ const [isOpen, setIsOpen] = useState(false);
28
+ const panelId = useId();
29
+
30
+ return (
31
+ <div>
32
+ <h3>
33
+ <button
34
+ type="button"
35
+ aria-expanded={isOpen}
36
+ aria-controls={panelId}
37
+ onClick={() => setIsOpen(!isOpen)}
38
+ // py-4 CỐ ĐỊNH ở cả hai trạng thái: chữ luôn cách đều hai mép nút.
39
+ className="group flex w-full cursor-pointer items-center justify-between gap-4 px-5 py-4 text-left text-pretty text-sm font-medium text-foreground outline-hidden"
40
+ >
41
+ {title}
42
+ <ChevronDown
43
+ className={cn(
44
+ "size-4 shrink-0 text-muted transition-[rotate,color] duration-200 group-hover:text-foreground motion-reduce:transition-none",
45
+ isOpen && "rotate-180",
46
+ )}
47
+ aria-hidden
48
+ />
49
+ </button>
50
+ </h3>
51
+
52
+ <div
53
+ id={panelId}
54
+ inert={!isOpen}
55
+ className={cn(
56
+ "grid transition-[grid-template-rows] duration-200 ease-out motion-reduce:transition-none",
57
+ isOpen && "grid-rows-[1fr]",
58
+ !isOpen && "grid-rows-[0fr]",
59
+ )}
60
+ >
61
+ <div className="min-h-0 overflow-hidden">
62
+ {/* px-5 bằng đúng nút ở trên, không max-w, không pr riêng: lấp kín bề ngang. */}
63
+ <div className="px-5 pb-4 text-pretty text-sm/6 text-muted">{children}</div>
64
+ </div>
65
+ </div>
66
+ </div>
67
+ );
68
+ }
69
+
70
+ // Khung: MỘT khung trắng, các mục chia bằng divide-y.
71
+ <div className="divide-y divide-border overflow-hidden rounded-2xl border border-border bg-surface">
72
+ {items.map((item) => (
73
+ <AccordionItem key={item.id} title={item.title}>{item.body}</AccordionItem>
74
+ ))}
75
+ </div>
76
+ ```
77
+
78
+ `<button>` thuần cho gọn ví dụ. Dự án có `Button` dùng chung thì dùng nó, miễn giữ được
79
+ dòng tràn hết bề ngang, `rounded-none`, và **tắt nền hover của variant** (`ghost` có
80
+ `hover:bg-foreground/5`): thêm `hover:bg-transparent`.
81
+
82
+ ---
83
+
84
+ ## Luật
85
+
86
+ **Chuyển động**
87
+ - **Trượt bằng `grid-rows` 0fr ↔ 1fr, `duration-200 ease-out`**, chevron xoay cùng nhịp.
88
+ Không `<details>`, không render có điều kiện, không `hidden`: đều mở đóng tức thì, bấm là
89
+ giật (`I30`). Không đo chiều cao bằng JS.
90
+ - **Mục đó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.
91
+ - **`motion-reduce:transition-none`** trên cả khối trượt và chevron.
92
+
93
+ **Padding: mỗi khối tự đứng được**
94
+ - **Nút tiêu đề `px-5 py-4`, cố định ở cả lúc mở và đóng.** Đừng bớt `pb` khi mở để kéo
95
+ nội dung lại gần: tô nền nút thì chữ cách mép trên 17px, mép dưới 7px.
96
+ - **Nội dung `px-5 pb-4`, không `pt`**: nối tiếp padding dưới của nút. Đo:
97
+ mép trên → tiêu đề 17px, tiêu đề → nội dung 20px, nội dung → mép dưới 20px. Đừng để
98
+ nội dung `py-3` (tô màu khối nào cũng đều, nhưng nội dung cách tiêu đề 32px mà cách vạch
99
+ dưới 16px, trông thuộc về vạch dưới); chủ dự án chọn nối tiếp.
100
+ - **Nội dung cùng `px` với nút, không `pr` riêng, không `max-w`.** `pr-12` (để chữ không
101
+ chạy dưới chevron) làm lề phải 48px mà lề trái 20px; `max-w-[65ch]` làm khối nội dung
102
+ hụt 55px so với khối bọc. Bề rộng đặt ở **khung ngoài**.
103
+ - **Khung đủ rộng để tiêu đề dài nhất vừa một dòng ở desktop.** Đừng thu khung cho nội dung
104
+ ≤ 75 ký tự: nội dung 1–2 câu đọc một hơi, không tính `T11`; khung hẹp làm tiêu đề xuống
105
+ dòng khi hàng còn trống. Nội dung dài từ 3 dòng trở
106
+ lên mới xét `T11`, và khi đó thường là nên rút nội dung.
107
+ - **Không số âm** để kéo nội dung lên (`N11`): margin âm trên khối con `overflow-hidden`
108
+ làm mục đang đóng lòi dòng đầu nội dung ra.
109
+
110
+ **Hover và focus**
111
+ - **Không nền hover. Rê vào thì chevron đậm lên** (`group-hover:text-foreground`) cộng
112
+ con trỏ bàn tay. Ngoại lệ của `I10`. Đừng dùng:
113
+ - nền `--surface-hover` (`#f8f8fa`) trên nút: gần màu nền trang `#f4f4f6` ngay ngoài mép
114
+ khung, hàng như bị khoét; mục đang mở thì nửa trên xám nửa dưới trắng;
115
+ - nền phủ cả mục (`has-[]`): vẫn là mảng gần màu nền trang chạm mép khung;
116
+ - hàng thụt vào có bo góc: một ô xám gần màu nền trang nằm trong card, như lỗ khoét.
117
+
118
+ **Khung và chữ**
119
+ - **Một khung trắng, `divide-y divide-border`** (`M13`). Không để vạch kẻ thẳng trên nền
120
+ trang xám: không khung thì vạch đọc ra như đường ngăn trang lơ lửng.
121
+ - **Tiêu đề mục `text-sm font-medium`, `text-pretty`**, không `text-balance` dù là `h3`: nó
122
+ chung hàng với chevron, `balance` làm câu xuống dòng khi mới được nửa hàng (`T10`).
123
+ - **Nội dung `text-sm/6 text-muted`.**
124
+ - **Đóng hết lúc vào trang**, trừ khi đề nói mở sẵn mục nào.
125
+
126
+ ---
127
+
128
+ ## Khu thu gọn trong form
129
+
130
+ "Cài đặt nâng cao", "Tuỳ chọn khác": một nhóm ô mà đa số người dùng bỏ qua, nằm giữa
131
+ các ô của form. Dựng thành **một dòng chữ có chevron liền sau**, không khung, bấm thì các
132
+ ô trượt ra **thẳng cột và rộng bằng các ô khác của form**. Đây là cách hầu hết app làm với
133
+ tuỳ chọn phụ trong form tạo mới.
134
+
135
+ Cùng cơ chế trượt, `inert`, chevron với accordion ở trên; khác ở chỗ không khung, nút
136
+ không `px`, và khối cắt chỉ cắt dọc.
137
+
138
+ ```tsx
139
+ // form-disclosure.tsx
140
+ interface FormDisclosureProps {
141
+ title: string;
142
+ // Cha giữ trạng thái: bấm gửi mà ô bên trong bị sai thì cha phải tự mở khu ra.
143
+ isOpen: boolean;
144
+ onOpenChange: (isOpen: boolean) => void;
145
+ children: ReactNode;
146
+ }
147
+
148
+ export default function FormDisclosure({ title, isOpen, onOpenChange, children }: FormDisclosureProps) {
149
+ const panelId = useId();
150
+
151
+ return (
152
+ <div>
153
+ {/* h2 khi form nằm ngay dưới h1 của trang. */}
154
+ <h2>
155
+ <Button
156
+ type="button"
157
+ variant="ghost"
158
+ aria-expanded={isOpen}
159
+ aria-controls={panelId}
160
+ onClick={() => onOpenChange(!isOpen)}
161
+ // px-0: chữ thẳng mép trái với nhãn các ô. w-fit: vùng bấm là chữ + chevron,
162
+ // không phải cả hàng. Không nền hover, rê vào thì chevron đậm lên như accordion.
163
+ className="group h-11 w-fit gap-1.5 rounded-md px-0 py-0 text-sm font-medium text-foreground hover:bg-transparent md:h-10"
164
+ >
165
+ {title}
166
+ <ChevronDown
167
+ className={cn(
168
+ "size-4 shrink-0 text-muted transition-[rotate,color] duration-200 group-hover:text-foreground motion-reduce:transition-none",
169
+ isOpen && "rotate-180",
170
+ )}
171
+ aria-hidden
172
+ />
173
+ </Button>
174
+ </h2>
175
+
176
+ <div
177
+ id={panelId}
178
+ inert={!isOpen}
179
+ className={cn(
180
+ "grid transition-[grid-template-rows] duration-200 ease-out motion-reduce:transition-none",
181
+ isOpen && "grid-rows-[1fr]",
182
+ !isOpen && "grid-rows-[0fr]",
183
+ )}
184
+ >
185
+ {/* overflow-y-clip, KHÔNG overflow-hidden: chỉ cắt dọc cho lúc trượt. Ô nhập rộng
186
+ bằng khối này, overflow-hidden cắt mất quầng focus 2px ở hai bên ô. */}
187
+ <div className="min-h-0 overflow-y-clip">
188
+ {/* pt-2.5 cộng khoảng dưới chữ của nút = 20px, bằng gap giữa các ô. */}
189
+ <div className="flex flex-col gap-5 pt-2.5">{children}</div>
190
+ </div>
191
+ </div>
192
+ </div>
193
+ );
194
+ }
195
+ ```
196
+
197
+ Ô sai nằm trong khu đang đóng: bấm gửi thì mở khu rồi đưa con trỏ vào ô đó.
198
+
199
+ ```tsx
200
+ // Khu đang đóng mang inert, focus() vào ô bên trong không ăn: gỡ inert ngay trong lượt này.
201
+ flushSync(() => setIsAdvancedOpen(true));
202
+
203
+ // Focus ngay nhưng chưa cuộn: khu còn đang trượt, scrollIntoView lúc này cuộn lệch.
204
+ // Trượt xong (200ms) mới cuộn ô vào giữa màn.
205
+ document.getElementById(fieldId)?.focus({ preventScroll: true });
206
+ window.setTimeout(() => focusFieldById(fieldId), accordionDurationMs);
207
+ ```
208
+
209
+ **Luật**
210
+ - **Không khung, không vạch kẻ trên dưới khu.** Dựng
211
+ bằng khung accordion (viền `--border`, `rounded-xl`) lồng trong card form trắng thì
212
+ - lúc đóng, hàng có viền bo góc, rộng bằng ô nhập, chevron ở mép phải: nhìn y một ô
213
+ select tên "Cài đặt nâng cao";
214
+ - ô bên trong thụt 21px, hẹp hơn ô phía trên 42px: form
215
+ có hai mép trái;
216
+ - mở ra thì đáy khung và vạch trên hàng nút là hai đường kẻ cách nhau 24px;
217
+ - **Chevron liền sau chữ (`gap-1.5`)**, không đẩy ra mép phải. Chevron ở mép phải của một
218
+ hàng rộng bằng ô nhập là dấu hiệu của select.
219
+ - **Các ô bên trong như mọi ô khác của form**: cùng mép trái, cùng bề rộng, cùng `gap-5`.
220
+ Đo lại bằng `getBoundingClientRect`: nhãn ô đầu trong khu và nhãn ô phía trên cùng `left`.
221
+ - **`overflow-y-clip` trên khối cắt.** Ở 375 và 1280px: quầng focus đủ bốn
222
+ phía, giữa lúc trượt không lòi chữ, không cuộn ngang. Ô cuối khu là ô nhập không có
223
+ dòng gợi ý thì thêm `pb-1` vào khối nội dung cho quầng dưới.
224
+ - **Khoảng cách tự ra từ `h-10` của nút**: gợi ý của ô trên → tiêu đề 32px, tiêu đề → nhãn
225
+ ô đầu 22px. Tiêu đề gần nhóm của nó hơn, không cần kẻ thêm.
226
+ - **Chỉ giấu thứ có mặc định dùng được.** Lựa chọn quyết định ai thấy dữ liệu (riêng tư /
227
+ công khai) thì đưa ra ngoài khu: hầu hết app để nó ngay trên form tạo, người tạo phải thấy
228
+ nó trước khi bấm. Khu này không tính theo ngưỡng `I17`: nó giấu để đường chính ngắn, không
229
+ phải vì dài.
230
+ - **Đóng lúc vào trang.** Bấm gửi mà ô trong khu sai thì mở ra như mẫu trên.
231
+
232
+ ---
233
+
234
+ ## Kiểm
235
+
236
+ - Tô nền đặc khác nhau cho nút, khối bọc nội dung, nội dung, ở lúc đóng, mở và hover
237
+ (`REVIEW.md` bước 4): chữ cách đều các mép trong từng khối, nội dung lấp kín khối bọc,
238
+ không lòi màu khối bọc.
239
+ - Đo bằng `Range` trên chữ: mép trên → tiêu đề ≈ 17px, tiêu đề → nội dung ≈ 20px, nội dung
240
+ → mép dưới ≈ 20px. Mục đóng giữ nguyên chiều cao khi mục khác mở.
241
+ - Chụp giữa lúc đang trượt (~90ms sau khi bấm): không lòi chữ ở mục đang đóng.
242
+ - Tab qua các nút; Enter và Space mở đóng; Tab không vào nội dung của mục đóng.
243
+ - Ở 375px, tiêu đề dài không xuống dòng khi mới được nửa hàng.
244
+ - Khu thu gọn trong form: lúc đóng có trông như ô select không; mở ra thì nhãn và ô bên trong
245
+ có cùng `left` và bề rộng với ô phía trên không; focus ô nhập trong khu, chụp cận mép trái
246
+ mép phải xem quầng có bị cắt không; bấm gửi khi ô trong khu sai và khu đang đóng.
@@ -0,0 +1,153 @@
1
+ # Avatar
2
+
3
+ **Dự án đã có component avatar thì dùng của họ** (luật câu 2, mục 0). File này chỉ
4
+ dùng khi grep ra rỗng.
5
+
6
+ ---
7
+
8
+ ## Không có ảnh thì là chữ cái đầu trên nền pastel
9
+
10
+ Nền màu nhạt, chữ **cùng sắc** nhưng đậm hơn. Không phải chữ trắng trên nền đặc —
11
+ nền đặc năm sáu màu cạnh nhau trong một danh sách là thành bảng màu cầu vồng.
12
+
13
+ ```
14
+ ╭───╮ ╭───╮ ╭───╮ ╭───╮
15
+ │ A │ │ H │ │ B │ │ L │ nền pastel, chữ cùng sắc đậm hơn,
16
+ ╰───╯ ╰───╯ ╰───╯ ╰───╯ viền mảnh cùng sắc
17
+ ```
18
+
19
+ ```tsx
20
+ interface AvatarTone {
21
+ background: string;
22
+ text: string;
23
+ ring: string;
24
+ }
25
+
26
+ const avatarTones: AvatarTone[] = [
27
+ { background: "bg-emerald-50 dark:bg-emerald-500/15", text: "text-emerald-700 dark:text-emerald-300", ring: "ring-emerald-200 dark:ring-emerald-500/30" },
28
+ { background: "bg-sky-50 dark:bg-sky-500/15", text: "text-sky-700 dark:text-sky-300", ring: "ring-sky-200 dark:ring-sky-500/30" },
29
+ { background: "bg-indigo-50 dark:bg-indigo-500/15", text: "text-indigo-700 dark:text-indigo-300", ring: "ring-indigo-200 dark:ring-indigo-500/30" },
30
+ { background: "bg-pink-50 dark:bg-pink-500/15", text: "text-pink-700 dark:text-pink-300", ring: "ring-pink-200 dark:ring-pink-500/30" },
31
+ { background: "bg-amber-50 dark:bg-amber-500/15", text: "text-amber-700 dark:text-orange-400", ring: "ring-amber-200 dark:ring-amber-500/30" },
32
+ { background: "bg-violet-50 dark:bg-violet-500/15", text: "text-violet-700 dark:text-violet-300", ring: "ring-violet-200 dark:ring-violet-500/30" },
33
+ ];
34
+
35
+ // Cùng một người thì luôn ra cùng một màu, ở mọi màn, mọi lần tải lại.
36
+ function getAvatarTone(seed: string): AvatarTone {
37
+ let hash = 0;
38
+ for (let i = 0; i < seed.length; i++) {
39
+ hash = (hash * 31 + seed.charCodeAt(i)) | 0;
40
+ }
41
+
42
+ return avatarTones[Math.abs(hash) % avatarTones.length];
43
+ }
44
+
45
+ // "Trần Nguyễn Anh Tuấn" -> "T"; tên một chữ -> chữ đó
46
+ function getInitial(name: string): string {
47
+ return name.trim().charAt(0).toLocaleUpperCase("vi");
48
+ }
49
+ ```
50
+
51
+ ```tsx
52
+ // Seed là id (hoặc email), KHÔNG phải tên hiển thị — xem "Vì sao ổn" bên dưới
53
+ const tone = getAvatarTone(user.id);
54
+
55
+ <span
56
+ className={cn(
57
+ "inline-flex size-10 shrink-0 items-center justify-center rounded-full",
58
+ "text-sm font-semibold ring-1",
59
+ tone.background,
60
+ tone.text,
61
+ tone.ring,
62
+ )}
63
+ aria-hidden
64
+ >
65
+ {getInitial(name)}
66
+ </span>
67
+ ```
68
+
69
+ **Không dùng Tailwind** thì thay class bằng mã dưới đây. Đây chính là các class
70
+ trên của Tailwind v4 đổi ra hex, nên hai kiểu dự án ra cùng một màu. Màu avatar là
71
+ màu nhận diện, không mang nghĩa, nên không vào `tokens.css`: đặt thẳng trong mảng.
72
+
73
+ | Sắc | Nền `-50` | Chữ `-700` | Viền `-200` | Tương phản chữ/nền |
74
+ | --- | --- | --- | --- | --- |
75
+ | emerald | `#ecfdf5` | `#007a55` | `#a4f4cf` | 5.1:1 |
76
+ | sky | `#f0f9ff` | `#0069a8` | `#b8e6fe` | 5.5:1 |
77
+ | indigo | `#eef2ff` | `#432dd7` | `#c6d2ff` | 7.2:1 |
78
+ | pink | `#fdf2f8` | `#c6005c` | `#fccee8` | 5.4:1 |
79
+ | amber | `#fffbeb` | `#bb4d00` | `#fee685` | 4.9:1 |
80
+ | violet | `#f5f3ff` | `#7008e7` | `#ddd6ff` | 6.7:1 |
81
+
82
+ **Nền tối** (`M32`): nền `-50` sang tối là sáu đốm trắng giữa màn đen. Nền là sắc `-500`
83
+ phủ 15%, chữ `-300`, viền sắc `-500` phủ 30%; class `dark:` đã nằm sẵn trong mảng trên. Riêng amber chữ
84
+ **`orange-400`**: `amber-300` trôi sang vàng 35° so với `amber-700` của bản sáng (`M7`, "Bản tối chọn theo sắc").
85
+ Không Tailwind thì chữ: emerald `#5ee9b5`, sky `#74d4ff`, indigo `#a3b3ff`, pink `#fda5d5`,
86
+ amber `#ff8904` (orange-400), violet `#c4b4ff`; nền và viền là `color-mix(in srgb, <mã -500> 15%, transparent)`
87
+ và `30%` với emerald `#00bc7d`, sky `#00a6f4`, indigo `#615fff`, pink `#f6339a`, amber `#fe9a00`,
88
+ violet `#8e51ff`.
89
+
90
+ ```ts
91
+ const avatarTones: AvatarTone[] = [
92
+ { background: "#ecfdf5", text: "#007a55", ring: "#a4f4cf" },
93
+ // ... năm sắc còn lại theo bảng, giữ đúng thứ tự để cùng id ra cùng màu
94
+ ];
95
+ // style={{ background: tone.background, color: tone.text, boxShadow: `0 0 0 1px ${tone.ring}` }}
96
+ ```
97
+
98
+ **Vì sao ổn**
99
+
100
+ - **Màu lấy theo `id` hoặc email, không theo tên.** Hai người trùng tên vẫn khác màu, và đổi tên hiển thị thì màu không nhảy. Lấy ngẫu nhiên lúc render thì mỗi lần tải lại là một màu khác — người dùng nhận ra nhau bằng màu, màu nhảy là mất.
101
+ - **Cùng một người thì mọi chỗ cùng màu, trên cùng một màn cũng vậy.** Avatar ở header, chân sidebar, trang hồ sơ, danh sách thành viên gọi chung một component với cùng seed. Chỗ hay lệch là avatar cỡ to trên trang hồ sơ được dựng riêng: header nền chàm mà trang hồ sơ nền hổ phách, cùng chữ "T".
102
+ - **Chữ cái theo tên đã lưu, không theo ô đang sửa, và không bao giờ là "?".** Tên rỗng (tài khoản mới, chưa đặt tên) thì lấy chữ đầu email.
103
+ - **Sáu sắc, cố định.** Đủ để một danh sách mười người trông khác nhau, ít đủ để không thành cầu vồng. Đừng sinh màu từ hash ra HSL tự do — sẽ ra những sắc bùn xỉn không ai chọn. Màu nhận diện của dự án, board, danh mục (`M34`) dùng chung đúng sáu sắc này.
104
+ - **Không có `red`, không có `rose`.** Hai sắc đó đã có nghĩa trong app: lỗi và hành động nguy hiểm (`M30`). Avatar một người mà đỏ thì trông như tài khoản đó đang có vấn đề. Hồng thì dùng `pink`.
105
+ - **Nền `-50`, chữ `-700`, viền `-200`**: cả ba cùng một sắc. Chữ `-700` trên nền `-50` đạt tương phản đọc được ở cỡ `text-sm`.
106
+ - **`aria-hidden`** — tên người dùng đã hiện ngay cạnh avatar. Trình đọc màn hình đọc "T" trước tên là thừa.
107
+ - Viền dùng `ring-1`, không `border` — xem `M17` và `card.md`: thứ có kích thước cố định thì dùng ring để viền không ăn vào kích thước.
108
+
109
+ ---
110
+
111
+ ## Một chữ hay hai chữ
112
+
113
+ **Một chữ.** Tên tiếng Việt lấy hai chữ đầu thì ra "TN" cho "Trần Nguyễn…" —
114
+ hai chữ **họ**, không ai nhận ra ai. Lấy chữ đầu tên gọi ("Tuấn" → "T") thì đúng
115
+ hơn, nhưng tách được tên gọi khỏi họ đệm trong mọi trường hợp là không làm được.
116
+
117
+ Một chữ cái đầu của chuỗi tên thì luôn đúng, luôn đoán được, và màu nền đã lo
118
+ phần phân biệt.
119
+
120
+ Dự án đã quen hai chữ (đang dùng khắp nơi) thì theo dự án, đừng đổi.
121
+
122
+ App tiếng Anh thì ngược lại: hai chữ, đầu tên và đầu họ (`Jane Doe` → `JD`). Tên
123
+ tiếng Anh tên gọi đứng trước, họ đứng cuối, nên hai chữ luôn tách đúng (`T28`).
124
+
125
+ ---
126
+
127
+ ## Chồng lên nhau
128
+
129
+ Nhóm người, danh sách thành viên, "3 người đang xem":
130
+
131
+ ```tsx
132
+ // -space-x-2: chồng lên nhau là bản chất của nhóm avatar, không có cách dương nào (N11 bước 4).
133
+ <div className="flex -space-x-2">
134
+ {members.map((member) => (
135
+ <Avatar key={member.id} className="ring-2 ring-surface" {...member} />
136
+ ))}
137
+ </div>
138
+ ```
139
+
140
+ - **`ring-2 ring-surface`** thay cho viền màu khi chồng — vòng trắng cắt rời từng avatar khỏi cái sau nó. Không có vòng này thì các hình tròn dính thành một cục.
141
+ - Chồng `-space-x-2` với cỡ `size-10`. Chồng sâu hơn thì chữ cái bị che.
142
+ - Quá 4 người thì hiện 3 cái rồi một ô `+5` cùng cỡ, nền `--background`, chữ `--muted`.
143
+
144
+ ---
145
+
146
+ ## Có ảnh
147
+
148
+ ```tsx
149
+ <Image src={avatarUrl} alt="" width={40} height={40} className="size-10 rounded-full object-cover ring-1 ring-border" />
150
+ ```
151
+
152
+ `alt=""` cùng lý do với `aria-hidden` ở trên. Ảnh lỗi thì rơi về chữ cái đầu, không
153
+ để trống một vòng tròn xám.
@@ -0,0 +1,43 @@
1
+ # Thanh thông báo (banner)
2
+
3
+ Nằm **trong trang**, ngay dưới đầu trang hoặc đầu form. Không nổi, không bóng,
4
+ không tự tắt. Khác toast (`../layouts/overlay.md`): toast báo một việc vừa xong
5
+ rồi đi; banner báo một **tình trạng** còn kéo dài tới khi được xử lý.
6
+
7
+ ```html
8
+ <div role="status" class="flex gap-3 rounded-2xl border border-amber-200 bg-amber-50 p-4">
9
+ <i data-lucide="triangle-alert" class="mt-0.5 size-5 shrink-0 text-amber-700"></i>
10
+ <div class="min-w-0 flex-1">
11
+ <p class="text-sm font-medium text-amber-800">Gói Pro hết hạn sau 3 ngày</p>
12
+ <p class="mt-0.5 text-sm text-pretty text-foreground/80">Sau 25/09/2026, tài khoản về gói Miễn phí và chỉ giữ 3 thành viên</p>
13
+ </div>
14
+ <!-- Nút và ✕ chung một cụm, căn giữa theo chiều dọc với khối chữ -->
15
+ <div class="flex shrink-0 items-center gap-1 self-center">
16
+ <!-- nút viền nền trắng h-9, chỉ chữ: "Gia hạn gói" -->
17
+ <!-- IconButton ✕, aria-label "Đóng thông báo" -->
18
+ </div>
19
+ </div>
20
+ ```
21
+
22
+ ## Ba tông
23
+
24
+ | Tông | Khi nào | Nền · viền | Icon | Tiêu đề | ✕ |
25
+ | --- | --- | --- | --- | --- | --- |
26
+ | Thông tin | Không ai làm sai gì, chỉ cần biết | `bg-background` · `border-border` | `info` `text-muted` | `text-foreground` | Có |
27
+ | Cần chú ý | Sắp thành chuyện nếu không làm gì | `bg-amber-50` · `border-amber-200` | `triangle-alert` `text-amber-700` | `text-amber-800` | Có |
28
+ | Lỗi | Đã hỏng, phải xử lý | `bg-red-50` · `border-red-200` (`M30`) | `circle-alert` `text-red-600` | `text-red-700` | **Không** |
29
+
30
+ Không có banner `rose`: nguy hiểm là lời nhắc trước khi bấm, không phải một
31
+ tình trạng (`M30`). Không có banner xanh lá "thành công": xong việc thì là toast.
32
+
33
+ ## Luật
34
+
35
+ - **Chỉ icon và tiêu đề mang màu. Mô tả `text-foreground/80`**, không tô cả đoạn. Mô tả `text-pretty` (`T10`): banner rộng hết cột, câu hai dòng dễ trơ một chữ ở dòng cuối ("…để giữ gói" / "Pro."). Hai dòng cùng hổ phách, cùng đỏ thì cả khối hét lên, và câu mô tả dài đọc mỏi hơn chữ tối.
36
+ - **Hai tầng**: tiêu đề `font-medium` nói chuyện gì; mô tả nói hệ quả hoặc vì sao, có số, có mốc thời gian. Một câu đủ thì bỏ tầng dưới.
37
+ - **Nút và ✕ chung một cụm, `self-center`** theo khối chữ. Đừng để nút căn giữa còn ✕ bám góc trên: hai thứ cùng hàng mà lệch cao là lỗi thấy ngay.
38
+ - **Nút trong banner chỉ có chữ, không icon**, nền trắng viền mảnh (`outline`), `h-9`. Cùng ngoại lệ với hộp xác nhận: icon đã đứng ở đầu khối. Tối đa một nút. Không nút `primary` trong banner: màu tông đã đủ gọi mắt.
39
+ - **Chỉ dẫn tới một chỗ khác thì là link hoặc nút**, không để thành câu chữ trơn. "Đổi địa chỉ nhận trong Cài đặt, mục Thanh toán" thì "Cài đặt" là link, hoặc thêm nút "Mở cài đặt".
40
+ - **Lỗi không có ✕**: đứng đó tới khi xử lý xong. Thông tin và cần chú ý có ✕. Đóng rồi có hiện lại không, bao lâu hiện lại là logic, người dùng quyết.
41
+ - **Rộng bằng vùng nội dung**, bo `rounded-2xl`, `p-4`. Không bóng (`M15`). Tối đa hai banner chồng nhau một lúc; nhiều hơn là trang đang có vấn đề khác cần gom lại.
42
+ - **`role`**: lỗi `role="alert"`, còn lại `role="status"`.
43
+ - Tiền trong câu dùng `đ`, không `₫` (`charts.md`).
@@ -0,0 +1,201 @@
1
+ # Đường dẫn (breadcrumb)
2
+
3
+ Dòng các cấp cha của trang đang xem, mỗi cấp là link. Nằm ở một trong hai chỗ: thanh header
4
+ `h-16` của khung app, hoặc trên tên trang trong đầu trang (`../layouts/app.md`, "Đầu trang trong
5
+ vùng nội dung"). Không bao giờ cả hai.
6
+
7
+ ## Khi nào có
8
+
9
+ - **Trang nằm sâu hơn mục sidebar đang sáng**: chi tiết bản ghi, form tạo, trang con của một
10
+ bản ghi. Trang danh sách cấp một thì mục sidebar đã nói đủ, không đường dẫn; tên trang trên
11
+ thanh header là `<h1>`.
12
+ - **Không dùng cho luồng nhiều bước** (đó là thanh các bước, `../layouts/form.md`) và **không
13
+ phải lịch sử duyệt**: đường dẫn ghi cây trang, vào từ đâu cũng ra một dòng như nhau.
14
+
15
+ ## Hình
16
+
17
+ ```
18
+ Khách hàng › Công ty TNHH Minh Phát › Đơn hàng <- chỉ các cấp CHA, đều là link
19
+ Đơn DH-10412 <- <h1>, không có trong đường dẫn
20
+ ```
21
+
22
+ - **Chỉ ghi các cấp cha, không ghi trang đang đứng.** Tên trang là `<h1>` ngay dưới (hoặc
23
+ ngay cạnh trên thanh header), ghi lại là lặp. Nhiều bộ thiết kế thêm trang hiện tại làm mục
24
+ cuối, nhưng các bộ đặt đường dẫn ngay trên tên trang thì bỏ nó đi, và skill luôn ở ca đó.
25
+ Ca hiếm đường dẫn là chỗ **duy nhất** ghi tên trang (trình duyệt tệp, không có tiêu đề
26
+ nào khác): mục cuối là chữ trơn `font-medium text-foreground` có `aria-current="page"`,
27
+ không phải link.
28
+ - **Chữ mỗi mục đúng bằng `<h1>` của trang đích.** Bấm "Công ty TNHH Minh Phát" mà ra trang
29
+ tên "Hồ sơ khách hàng" thì người đọc không biết mình vừa đi đâu.
30
+ - **`text-sm text-muted`, rê vào `hover:text-foreground`, không gạch chân, không nền.** Cùng
31
+ cách rê của link sidebar: đường dẫn là thanh điều hướng, không phải link trong câu văn.
32
+ - **Dấu ngăn `ChevronRight` `size-4` `text-muted`, `aria-hidden`**, `gap-2` cả ở `<ol>` lẫn
33
+ trong `<li>`. Nét dấu › chỉ rộng 4px giữa hộp 16px, nên nét chữ tới nét dấu là 14px mỗi bên.
34
+ Dấu "/" cũng phổ biến ngang, skill chọn một kiểu cho cả app (`D1`), đừng trộn.
35
+ - **Mỗi mục `inline-flex h-8 items-center`**: chữ 20px nhưng vùng bấm 32px dọc. Ở đầu trang,
36
+ tên trang ngay dưới **không `mt`**: hàng 32px đã chừa 6px dưới chữ.
37
+ - **Tên dài: `max-w-48 truncate` kèm `title`**, cắt từng mục, không cắt cả dòng. 192px ở
38
+ `text-sm` là khoảng 28 ký tự.
39
+ - **Không xuống dòng** (`R6`): đường dẫn đọc theo một chiều, rớt nửa sau xuống hàng hai thì dấu
40
+ › đầu hàng đọc như mục mới. Chật thì cắt tên và thu gọn theo mục dưới.
41
+ - **Tab tới: chữ gạch chân**, không vòng (`I13`).
42
+
43
+ ## Đường dài
44
+
45
+ ```
46
+ Tài liệu › … › Hợp đồng 2026 › Quý 3 <- 5 cấp cha, giữa gom vào "…"
47
+ └ Dự án Minh Phát
48
+ Pháp lý
49
+ ```
50
+
51
+ - **Tới 3 cấp cha thì hiện hết.** Từ 4 cấp: giữ **cấp đầu** và **hai cấp gần nhất**, các cấp
52
+ giữa gom vào một nút "…". Cấp đầu cho biết đang ở khu nào, hai cấp gần là chỗ hay quay về
53
+ nhất.
54
+ - **Nút "…" mở dropdown menu**, không bung tại chỗ: bung ra là dòng dài lại và phải xuống
55
+ dòng. `aria-label="Hiện các cấp bị ẩn"`, `aria-haspopup="menu"`. Menu liệt kê các cấp ẩn
56
+ theo thứ tự từ trên xuống, mỗi mục là link, khuôn mục menu ở `../layouts/overlay.md`.
57
+ - **Nút "…" trông như một mục chữ, không phải icon button.** Hộp ôm sát icon (`h-5 w-4 p-0`,
58
+ icon `MoreHorizontal size-4`), không nền, `text-muted` rê vào `text-foreground`, đang mở giữ
59
+ `text-foreground`. Vùng bấm nới bằng `before:` (`before:absolute before:-inset-x-1.5
60
+ before:-inset-y-1.5`, 28×32px, cùng cách tay cầm ở `range-slider.md`). Đừng dùng nút `ghost size-8`: padding 8px mỗi bên, nét "…"
61
+ cách dấu › 22px trong khi chữ cách dấu 12px, và rê vào hiện một ô nền xám giữa hàng chữ
62
+ chỉ đổi màu.
63
+
64
+ ## Màn hẹp (dưới `sm`)
65
+
66
+ ```
67
+ ☰ ‹ Khách hàng 🔔 (T)
68
+ ```
69
+
70
+ - **Chỉ còn cấp cha gần nhất, có `ChevronLeft` đứng trước**: một link "quay về", không phải
71
+ đường dẫn thu nhỏ. 375px không đủ chỗ cho ba mục cắt còn "Khách…", "Công t…". Các cấp xa
72
+ hơn vẫn tới được qua sidebar.
73
+ - **Vùng bấm `h-10`** (`h-10 sm:h-8`): trên điện thoại đây là nút quay lại, bấm bằng ngón cái.
74
+ - Tên cấp cha vẫn `max-w-48 truncate`, không bị ép chung hàng với mục khác nên hiếm khi cắt.
75
+ - Tên bị cắt có dấu "…" thì khe tới dấu › có thể rộng hơn vài px: trình duyệt cắt ở ký tự cuối
76
+ còn vừa, phần thừa để trống. Không sửa được bằng CSS, đừng đuổi theo.
77
+
78
+ ## Khung
79
+
80
+ ```tsx
81
+ interface BreadcrumbItem {
82
+ label: string;
83
+ href: string;
84
+ }
85
+
86
+ interface BreadcrumbProps {
87
+ // Chỉ các cấp cha, từ xa tới gần. Không đưa trang đang đứng vào đây.
88
+ items: BreadcrumbItem[];
89
+ className?: string;
90
+ }
91
+
92
+ // Link cao 32px để dễ bấm; Tab tới thì <span> chữ bên trong gạch chân (breadcrumbTextClass).
93
+ const breadcrumbLinkClass =
94
+ "group inline-flex h-8 max-w-48 items-center text-muted outline-hidden transition-colors hover:text-foreground";
95
+
96
+ const breadcrumbTextClass = cn(
97
+ "truncate rounded-sm",
98
+ "group-focus-visible:underline",
99
+ );
100
+
101
+ export default function Breadcrumb({ items, className }: BreadcrumbProps) {
102
+ const nearestParent = items.at(-1);
103
+ if (!nearestParent) return null;
104
+
105
+ // Từ 4 cấp: giữ cấp đầu và hai cấp gần nhất, cấp giữa vào menu "…".
106
+ const isCollapsed = items.length > 3;
107
+ const leadingItems = isCollapsed ? items.slice(0, 1) : [];
108
+ const hiddenItems = isCollapsed ? items.slice(1, -2) : [];
109
+ const trailingItems = isCollapsed ? items.slice(-2) : items;
110
+
111
+ return (
112
+ <nav aria-label="Đường dẫn" className={cn("min-w-0", className)}>
113
+ {/* Màn hẹp: một link quay về cấp cha gần nhất. */}
114
+ <Link
115
+ href={nearestParent.href}
116
+ title={nearestParent.label}
117
+ className={cn(breadcrumbLinkClass, "h-10 gap-1 text-sm sm:hidden")}
118
+ >
119
+ {/* Vòng bọc cả ‹ lẫn chữ; pr-1.5 bù khoảng trống bên trái nét ‹ trong hộp icon. */}
120
+ <span className={cn(breadcrumbTextClass, "inline-flex min-w-0 items-center gap-1 pr-1.5")}>
121
+ <ChevronLeft className="size-4 shrink-0" aria-hidden />
122
+ <span className="truncate">{nearestParent.label}</span>
123
+ </span>
124
+ </Link>
125
+
126
+ <ol className="hidden min-w-0 items-center gap-2 text-sm sm:flex">
127
+ {leadingItems.map((item) => (
128
+ <BreadcrumbCrumb key={item.href} item={item} hasSeparator={false} />
129
+ ))}
130
+
131
+ {hiddenItems.length > 0 ? (
132
+ <li className="flex shrink-0 items-center gap-2">
133
+ <ChevronRight className="size-4 text-muted" aria-hidden />
134
+ <DropdownMenu>
135
+ <DropdownMenuTrigger asChild>
136
+ {/* Trông như một mục chữ: hộp ôm sát icon, không nền. Vùng bấm nới ra 28×32px
137
+ bằng before: (số âm có chủ ý, N11: nới vùng bấm mà không đẩy hình). */}
138
+ <Button
139
+ variant="ghost"
140
+ aria-label="Hiện các cấp bị ẩn"
141
+ className={cn(
142
+ "group relative h-5 min-h-0 w-4 rounded-sm p-0 text-muted hover:bg-transparent hover:text-foreground",
143
+ "before:absolute before:-inset-x-1.5 before:-inset-y-1.5",
144
+ "aria-expanded:text-foreground",
145
+ )}
146
+ >
147
+ <MoreHorizontal className="size-4" aria-hidden />
148
+ </Button>
149
+ </DropdownMenuTrigger>
150
+ <DropdownMenuContent align="start">
151
+ {hiddenItems.map((item) => (
152
+ <DropdownMenuItem key={item.href} asChild>
153
+ <Link href={item.href}>{item.label}</Link>
154
+ </DropdownMenuItem>
155
+ ))}
156
+ </DropdownMenuContent>
157
+ </DropdownMenu>
158
+ </li>
159
+ ) : null}
160
+
161
+ {trailingItems.map((item, index) => (
162
+ <BreadcrumbCrumb key={item.href} item={item} hasSeparator={isCollapsed || index > 0} />
163
+ ))}
164
+ </ol>
165
+ </nav>
166
+ );
167
+ }
168
+
169
+ // breadcrumb-crumb.tsx: một mục, dấu › đứng trước trong cùng <li>.
170
+ export default function BreadcrumbCrumb({ item, hasSeparator }: BreadcrumbCrumbProps) {
171
+ return (
172
+ <li className="flex min-w-0 items-center gap-2">
173
+ {hasSeparator ? <ChevronRight className="size-4 shrink-0 text-muted" aria-hidden /> : null}
174
+ <Link href={item.href} title={item.label} className={breadcrumbLinkClass}>
175
+ <span className={breadcrumbTextClass}>{item.label}</span>
176
+ </Link>
177
+ </li>
178
+ );
179
+ }
180
+ ```
181
+
182
+ `Link` là link của router dự án (`next/link`, `react-router`). Dự án đã có `ActionMenu` hay
183
+ `DropdownMenu` bọc sẵn thì dùng cái đó cho nút "…", đừng dựng menu thứ hai.
184
+
185
+ ## Không làm
186
+
187
+ - **Không dropdown trên từng mục để chuyển sang bản ghi cùng cấp** (bấm "Dự án A ⌄" ra danh
188
+ sách dự án). Đường dẫn để đi lên; đổi workspace, đổi dự án là việc của sidebar
189
+ (`../layouts/app.md`). Hai việc chung một chỗ thì bấm vào tên không biết sẽ đi hay mở menu.
190
+ - Không icon nhà ở mục đầu, không in đậm mục nào: mục nào cũng là cấp cha ngang nhau.
191
+ - Không ghi lại đường dẫn ở đầu trang khi thanh header đã có.
192
+
193
+ ## Kiểm
194
+
195
+ - Đường dẫn có trùng chữ với `<h1>` ngay dưới không? Có thì đang ghi trang hiện tại.
196
+ - Bấm từng mục: tên trang đích có đúng chữ trên mục không?
197
+ - Ở 1280px với 5 cấp cha: một hàng, có "…" mở menu, Tab tới "…" rồi Enter mở được?
198
+ - Nét dấu › tới nét chữ hai bên bằng nhau, kể cả quanh "…" (probe đo: "dấu ngăn cách không đều").
199
+ - Ở 375px: chỉ còn "‹ Cấp cha", cao 40px, không có mục nào bị cắt còn vài chữ. Tab tới: chữ gạch chân.
200
+ - Rê vào mục và vào "…": chỉ đổi màu, không hiện ô nền. Tab tới: chỉ gạch chân, không vòng.
201
+ - Trình đọc màn hình: `nav` có tên "Đường dẫn", mục trong `ol`, dấu › không bị đọc.