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