@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.
- package/README.md +14 -22
- package/bin/__tests__/codex-hooks-ownership.test.js +2 -2
- package/bin/__tests__/codex-hooks.test.js +0 -36
- package/bin/__tests__/codex-native.test.js +46 -88
- package/bin/__tests__/develop-contract.test.js +26 -57
- package/bin/__tests__/docs-sync-on-demand.test.js +93 -0
- package/bin/__tests__/grok-envelope.test.js +0 -56
- package/bin/__tests__/hook-payload.test.js +4 -6
- package/bin/__tests__/legacy-authority-decoupled.test.js +159 -0
- package/bin/__tests__/legacy-hooks-retired.test.js +221 -0
- package/bin/__tests__/omp-bridge.test.js +20 -17
- package/bin/__tests__/omp-hooks.test.js +0 -19
- package/bin/__tests__/optional-skill-inventory.test.js +112 -4
- package/bin/__tests__/orca-session.test.js +0 -2
- package/bin/__tests__/package-inventory.test.js +100 -541
- package/bin/__tests__/runtime-dir.test.js +0 -3
- package/bin/__tests__/runtime-schema.test.js +3 -2
- package/bin/__tests__/skill-routing-source.test.js +27 -15
- package/bin/__tests__/spec-narrowing.test.js +0 -27
- package/bin/__tests__/specs-v2-execution-closeout.test.js +4 -69
- package/bin/__tests__/ui-ux-skill.test.js +101 -0
- package/bin/__tests__/usage-hook-retired.test.js +114 -0
- package/bin/__tests__/validator-grounding.test.js +9 -28
- package/bin/phases/codex-runtime.js +24 -0
- package/bin/phases/omp-runtime.js +22 -0
- package/package.json +1 -1
- package/src/claude/CLAUDE.md +3 -10
- package/src/claude/agents/brainstormer.md +7 -3
- package/src/claude/agents/code-auditor.md +35 -50
- package/src/claude/agents/debugger.md +5 -79
- package/src/claude/agents/deployer.md +3 -5
- package/src/claude/agents/docs-keeper.md +2 -7
- package/src/claude/agents/implementer.md +6 -9
- package/src/claude/agents/inspector.md +2 -3
- package/src/claude/agents/project-manager.md +7 -15
- package/src/claude/agents/researcher.md +9 -3
- package/src/claude/agents/spec-maker.md +11 -11
- package/src/claude/hooks/__tests__/fixtures/statusline-default.golden +1 -1
- package/src/claude/hooks/__tests__/spec-gate.test.js +112 -3
- package/src/claude/hooks/__tests__/spec-state-touched.test.js +134 -0
- package/src/claude/hooks/__tests__/state.test.js +68 -0
- package/src/claude/hooks/__tests__/statusline.test.js +140 -11
- package/src/claude/hooks/agent.cjs +2 -1
- package/src/claude/hooks/lib/context.cjs +0 -89
- package/src/claude/hooks/lib/hook-payload.cjs +2 -2
- package/src/claude/hooks/rules.cjs +2 -1
- package/src/claude/hooks/session.cjs +0 -1
- package/src/claude/hooks/spec-gate.cjs +18 -28
- package/src/claude/hooks/spec-state.cjs +95 -1
- package/src/claude/hooks/state.cjs +21 -2
- package/src/claude/migration-manifest.json +30 -15
- package/src/claude/rules/ai-dev-rules.md +1 -1
- package/src/claude/rules/hook-protocols.md +8 -15
- package/src/claude/rules/manage-docs.md +9 -23
- package/src/claude/rules/orchestrator.md +3 -4
- package/src/claude/rules/review-audit-self-decision.md +3 -3
- package/src/claude/rules/skill-domain-routing.md +1 -3
- package/src/claude/rules/skill-workflow-routing.md +4 -5
- package/src/claude/rules/state-sync.md +3 -12
- package/src/claude/rules/workflow.md +10 -18
- package/src/claude/runtime.schema.json +5 -3
- package/src/claude/scripts/generate-skill-catalog.cjs +7 -6
- package/src/claude/scripts/provenance.cjs +23 -5
- package/src/claude/scripts/spec-receipt.cjs +52 -6
- package/src/claude/scripts/spec-resolver.cjs +1 -2
- package/src/claude/scripts/validate-spec-output.cjs +5 -32
- package/src/claude/scripts/workflow-policy.cjs +22 -10
- package/src/claude/settings/settings.json +1 -32
- package/src/claude/skills/{question → ask}/SKILL.md +9 -75
- package/src/claude/skills/brainstorm/SKILL.md +43 -131
- package/src/claude/skills/code-review/SKILL.md +48 -17
- package/src/claude/skills/code-review/references/spec-compliance-review.md +2 -3
- package/src/claude/skills/code-review/references/verification-gate.md +22 -27
- package/src/claude/skills/debug/SKILL.md +29 -149
- package/src/claude/skills/develop/SKILL.md +27 -36
- package/src/claude/skills/develop/references/parallel-waves.md +0 -8
- package/src/claude/skills/develop/references/quality-gate.md +15 -13
- package/src/claude/skills/docs/SKILL.md +2 -2
- package/src/claude/skills/docs/references/update-workflow.md +1 -1
- package/src/claude/skills/fix/SKILL.md +153 -0
- package/src/claude/skills/{hotfix → fix}/references/diagnosis-protocol.md +2 -2
- package/src/claude/skills/{hotfix → fix}/references/parallel-patterns.md +2 -2
- package/src/claude/skills/{hotfix → fix}/references/prevention-gate.md +3 -3
- package/src/claude/skills/{hotfix → fix}/references/review-cycle.md +1 -1
- package/src/claude/skills/git/SKILL.md +48 -9
- package/src/claude/skills/git/references/commit-protocols.md +35 -16
- package/src/claude/skills/git/references/worktree-blueprint.md +42 -6
- package/src/claude/skills/orca/SKILL.md +9 -8
- package/src/claude/skills/research/SKILL.md +2 -0
- package/src/claude/skills/scout/SKILL.md +61 -0
- package/src/claude/skills/{inspect → scout}/references/internal-inspection.md +26 -10
- package/src/claude/skills/specs/SKILL.md +22 -29
- package/src/claude/skills/specs/references/review.md +8 -6
- package/src/claude/skills/specs/references/templates.md +38 -44
- package/src/claude/skills/sync/SKILL.md +8 -15
- package/src/claude/skills/sync/references/rebind-and-audit.md +59 -0
- package/src/claude/skills/sync/references/sync-protocols.md +4 -13
- package/src/claude/skills/test/SKILL.md +22 -3
- package/src/claude/skills/test/references/execution-strategy.md +83 -0
- package/src/claude/skills/test/references/failure-triage.md +1 -1
- package/src/claude/skills/ui-ux/LICENSE +21 -0
- package/src/claude/skills/ui-ux/SKILL.md +497 -0
- package/src/claude/skills/ui-ux/references/brand-tokens.md +90 -0
- package/src/claude/skills/ui-ux/references/budgets.md +113 -0
- package/src/claude/skills/ui-ux/references/checklist.md +271 -0
- package/src/claude/skills/ui-ux/references/components/accordion.md +246 -0
- package/src/claude/skills/ui-ux/references/components/avatar.md +153 -0
- package/src/claude/skills/ui-ux/references/components/banner.md +43 -0
- package/src/claude/skills/ui-ux/references/components/breadcrumb.md +201 -0
- package/src/claude/skills/ui-ux/references/components/button.md +142 -0
- package/src/claude/skills/ui-ux/references/components/card.md +113 -0
- package/src/claude/skills/ui-ux/references/components/charts.md +376 -0
- package/src/claude/skills/ui-ux/references/components/chat.md +132 -0
- package/src/claude/skills/ui-ux/references/components/choice-controls.md +369 -0
- package/src/claude/skills/ui-ux/references/components/comment-thread.md +17 -0
- package/src/claude/skills/ui-ux/references/components/description-list.md +116 -0
- package/src/claude/skills/ui-ux/references/components/empty-state.md +98 -0
- package/src/claude/skills/ui-ux/references/components/file-upload.md +107 -0
- package/src/claude/skills/ui-ux/references/components/inline-edit.md +128 -0
- package/src/claude/skills/ui-ux/references/components/input.md +161 -0
- package/src/claude/skills/ui-ux/references/components/list-row.md +56 -0
- package/src/claude/skills/ui-ux/references/components/loading.md +198 -0
- package/src/claude/skills/ui-ux/references/components/logo.md +147 -0
- package/src/claude/skills/ui-ux/references/components/otp-input.md +43 -0
- package/src/claude/skills/ui-ux/references/components/quantity-input.md +100 -0
- package/src/claude/skills/ui-ux/references/components/range-slider.md +16 -0
- package/src/claude/skills/ui-ux/references/components/small-controls.md +340 -0
- package/src/claude/skills/ui-ux/references/components/sortable-header.md +86 -0
- package/src/claude/skills/ui-ux/references/components/tag-input.md +14 -0
- package/src/claude/skills/ui-ux/references/components/timeline.md +38 -0
- package/src/claude/skills/ui-ux/references/components/tree.md +30 -0
- package/src/claude/skills/ui-ux/references/design-process.md +650 -0
- package/src/claude/skills/ui-ux/references/layouts/app-kanban.html +303 -0
- package/src/claude/skills/ui-ux/references/layouts/app.md +1369 -0
- package/src/claude/skills/ui-ux/references/layouts/form.md +430 -0
- package/src/claude/skills/ui-ux/references/layouts/overlay.md +552 -0
- package/src/claude/skills/ui-ux/references/layouts/pricing.md +204 -0
- package/src/claude/skills/ui-ux/references/locked-rules.md +35 -0
- package/src/claude/skills/ui-ux/references/principles.md +408 -0
- package/src/claude/skills/ui-ux/references/refactor.md +245 -0
- package/src/claude/skills/ui-ux/references/responsive.md +195 -0
- package/src/claude/skills/ui-ux/references/review.md +528 -0
- package/src/claude/skills/ui-ux/references/rules-color.md +623 -0
- package/src/claude/skills/ui-ux/references/rules-form.md +235 -0
- package/src/claude/skills/ui-ux/references/rules-state.md +680 -0
- package/src/claude/skills/ui-ux/references/rules-type.md +369 -0
- package/src/claude/skills/ui-ux/references/styles.md +311 -0
- package/src/claude/skills/ui-ux/references/system.md +219 -0
- package/src/claude/skills/ui-ux/references/tailwind-v4-traps.md +237 -0
- package/src/claude/skills/ui-ux/references/tokens.css +362 -0
- package/src/claude/skills/ui-ux/scripts/lint-skill.mjs +216 -0
- package/src/claude/skills/ui-ux/scripts/package.json +9 -0
- package/src/claude/skills/ui-ux/scripts/probe.mjs +4447 -0
- package/src/claude/skills/ui-ux-pro-max/SKILL.md +1 -1
- package/src/claude/status.cjs +149 -144
- package/src/codex/AGENTS.md +8 -13
- package/src/codex/hooks/agent.cjs +2 -1
- package/src/codex/hooks/lib/spec-receipt.cjs +5 -0
- package/src/codex/hooks/lib/spec-utils.cjs +0 -7
- package/src/codex/hooks/rules.cjs +2 -1
- package/src/codex/hooks/session.cjs +0 -2
- package/src/codex/hooks/spec-gate.cjs +8 -18
- package/src/codex/hooks.json +0 -20
- package/src/codex/rules/state-sync.md +3 -12
- package/src/common/AGENTS.md +2 -2
- package/src/omp/extensions/cafekit-bridge.mjs +6 -6
- package/src/claude/hooks/__tests__/completion-authority.test.js +0 -990
- package/src/claude/hooks/__tests__/semantic-review-authority.test.js +0 -259
- package/src/claude/hooks/completion-authority-check.cjs +0 -56
- package/src/claude/hooks/completion-authority-state.cjs +0 -527
- package/src/claude/hooks/completion-authority.cjs +0 -163
- package/src/claude/hooks/semantic-review-authority.cjs +0 -154
- package/src/claude/hooks/task-scaffold-guard.cjs +0 -114
- package/src/claude/hooks/usage.cjs +0 -197
- package/src/claude/skills/delegate/SKILL.md +0 -124
- package/src/claude/skills/delegate/references/codex-delegation.md +0 -71
- package/src/claude/skills/delegate/references/grok-delegation.md +0 -84
- package/src/claude/skills/hotfix/SKILL.md +0 -289
- package/src/claude/skills/inspect/SKILL.md +0 -142
- package/src/claude/skills/loop/SKILL.md +0 -114
- package/src/claude/skills/loop/references/bounded-loop-protocol.md +0 -104
- package/src/claude/skills/loop/references/metric-and-guard-contract.md +0 -76
- package/src/claude/skills/route/SKILL.md +0 -94
- package/src/claude/skills/route/references/agent-timing.md +0 -46
- package/src/claude/skills/route/references/chaining-patterns.md +0 -53
- package/src/claude/skills/route/references/task-taxonomy.md +0 -48
- package/src/codex/hooks/completion-authority-check.cjs +0 -67
- package/src/codex/hooks/completion-authority-state.cjs +0 -527
- package/src/codex/hooks/completion-authority.cjs +0 -109
- package/src/codex/hooks/semantic-review-authority.cjs +0 -130
- package/src/codex/hooks/task-scaffold-guard.cjs +0 -63
- /package/src/claude/skills/{question → ask}/templates/question.md +0 -0
- /package/src/claude/skills/{hotfix → fix}/references/escalation-tactics.md +0 -0
- /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.
|