@haposoft/cafekit 0.16.7 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +74 -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 +17 -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 +24 -7
- package/src/claude/scripts/spec-resolver.cjs +1 -2
- package/src/claude/scripts/validate-spec-output.cjs +5 -32
- 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 +34 -41
- 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 +2 -2
- 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 +7 -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,552 @@
|
|
|
1
|
+
# Bố cục khối nổi
|
|
2
|
+
|
|
3
|
+
Modal, panel trượt, dropdown, command palette, panel thông báo, toast. Không có wireframe thì dựng đúng khuôn
|
|
4
|
+
dưới đây, báo một dòng lúc giao. Xem câu 4 trong `../../SKILL.md`.
|
|
5
|
+
|
|
6
|
+
**Nền, viền, bóng của khung** (`M15`, `M21`, `M23`), một bảng cho mọi khối nổi:
|
|
7
|
+
|
|
8
|
+
| Khối | Khung |
|
|
9
|
+
| --- | --- |
|
|
10
|
+
| Dropdown, menu, popover, select, toast, command palette | `border border-border bg-surface-overlay shadow-popover` (command palette `shadow-modal`) |
|
|
11
|
+
| Modal, hộp xác nhận, panel trượt | `border border-border bg-surface shadow-modal` |
|
|
12
|
+
|
|
13
|
+
Nền sáng thì `bg-surface-overlay` bằng card và viền gần như không thấy, nên bản sáng trông
|
|
14
|
+
như cũ. Nền tối thì lớp nổi nhỏ sáng hơn card một bậc, viền 1px tách khung khỏi trang, bóng
|
|
15
|
+
đậm hơn. Modal giữ `bg-surface` vì header dính, card trong thân đều ăn theo nó.
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## Hộp xác nhận
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
┌──────────────────────────────────┐
|
|
23
|
+
│ (🗑) Xoá dự án? │ <- icon cùng hàng tiêu đề
|
|
24
|
+
│ **Website bán hàng 2026** │ <- tên đối tượng nổi lên
|
|
25
|
+
│ cùng 48 công việc sẽ bị │
|
|
26
|
+
│ xoá vĩnh viễn. │
|
|
27
|
+
│ │
|
|
28
|
+
│ [Huỷ] [Xoá dự án] │
|
|
29
|
+
└──────────────────────────────────┘
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
```html
|
|
33
|
+
<div role="alertdialog" aria-labelledby="confirm-title" aria-describedby="confirm-desc" class="w-full max-w-md rounded-2xl border border-border bg-surface p-6 shadow-modal">
|
|
34
|
+
<!-- Lưới hai cột: hàng đầu là icon + tiêu đề (items-center tự đưa tâm icon về tâm dòng tiêu
|
|
35
|
+
đề, không cần nhích bằng margin âm). Thân (câu hậu quả, ô gõ lại tên) dưới sm trải hết bề
|
|
36
|
+
rộng, thẳng mép nút; từ sm nằm ở cột chữ, thẳng mép tiêu đề. -->
|
|
37
|
+
<div class="grid grid-cols-[auto_minmax(0,1fr)] items-center gap-x-4">
|
|
38
|
+
<div class="flex size-10 shrink-0 items-center justify-center rounded-full bg-rose-500/10">
|
|
39
|
+
<i data-lucide="trash-2" class="size-5 text-rose-700"></i>
|
|
40
|
+
</div>
|
|
41
|
+
<h2 id="confirm-title" class="text-lg font-semibold">Xoá dự án?</h2>
|
|
42
|
+
<!-- mt-2 dưới sm: cách đáy icon 8px. sm:mt-0.5: hàng đầu cao 40px, tiêu đề 28px nằm giữa
|
|
43
|
+
(dư 6px dưới), cộng 2px ra 8px chữ tới chữ như T30. -->
|
|
44
|
+
<div class="col-span-2 mt-2 min-w-0 sm:col-span-1 sm:col-start-2 sm:mt-0.5">
|
|
45
|
+
<p id="confirm-desc" class="text-sm/6 text-muted">
|
|
46
|
+
<span class="font-medium text-foreground">Website bán hàng 2026</span>
|
|
47
|
+
cùng 48 công việc bên trong sẽ bị xoá vĩnh viễn, không khôi phục được.
|
|
48
|
+
</p>
|
|
49
|
+
<!-- Ô gõ lại tên (nếu có): mt-4, cùng khối thân này -->
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
<div class="mt-6 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
|
53
|
+
<!-- Huỷ: nút phụ nền --secondary. Xoá: nền rose mờ theo I4. Cả hai chỉ có chữ. -->
|
|
54
|
+
</div>
|
|
55
|
+
</div>
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
- Rộng `max-w-md`, căn giữa màn, nền phủ đen mờ phía sau.
|
|
59
|
+
- Tiêu đề là **câu hỏi**, thân là hậu quả cụ thể có tên đối tượng.
|
|
60
|
+
- **Tên đối tượng `font-medium text-foreground`**, phần còn lại của câu `text-muted`. Đó là thứ người dùng cần liếc thấy để biết đang xoá đúng cái. Để cả câu cùng màu xám thì tên chìm vào câu. Tên dài thì cho xuống dòng, **không `truncate`**: xác nhận mà không đọc được hết tên thì không xác nhận được gì. Tên đối tượng là email (xoá tài khoản, gỡ thành viên) thì xuống dòng ở sau `@`, không vỡ giữa tên miền (`EmailText` ở `../components/description-list.md`).
|
|
61
|
+
- **Icon tròn `size-10` nằm cùng hàng với tiêu đề**, bên trái; lưới `items-center` đưa tâm icon về tâm dòng tiêu đề. Đặt icon thành một hàng riêng phía trên thì hộp cao thêm ~60px mà không thêm thông tin.
|
|
62
|
+
- **Dưới `sm`, chỉ tiêu đề đứng cạnh icon; thân hộp trải hết bề rộng**, thẳng mép trái với hai nút xếp dọc. Để cả thân trong cột chữ thụt 56px sau icon thì ô gõ lại tên hẹp hơn nút, lệch mép nút 56px, còn câu hậu quả bị ép thêm một dòng (ở 375px: ô 239px ở x=96, nút 295px ở x=40, câu năm dòng; đổi sang lưới thì ô, câu, nút cùng mép x=40, câu bốn dòng, hộp thấp đi 12px). Từ `sm` nút co theo chữ và nằm bên phải, thân về cột chữ như cũ. Probe báo lỗi này ở mục "Ô nhập lệch mép với nút rộng hết khung".
|
|
63
|
+
- **Tiêu đề cách thân `mt-2`, thân `text-sm/6`** (dòng cao 24px), không `mt-1` + dòng 20px mặc định. Thân hộp xác nhận thường chạy hai ba dòng có tên đối tượng đậm xen chữ xám; dòng 20px thì dấu chồng hai tầng (`ệ`, `ở`, `ữ`) chạm sát dòng trên, còn 4px dưới tiêu đề làm tiêu đề dính vào thân. Bộ component phổ biến cho React cách 8px, Material cách 16px; skill giữ 8px vì thân hộp xác nhận ngắn, 16px thì tiêu đề tách khỏi câu nó mở đầu. Xem `T30`.
|
|
64
|
+
- **Tiêu đề `text-lg font-semibold`, luôn đậm hơn tên đối tượng** (`font-medium`). Tiêu đề thiếu `font-semibold` thì tên dài hai dòng lấn át câu hỏi, mắt đọc tên trước.
|
|
65
|
+
- **Nút trong hộp xác nhận chỉ có chữ, không icon.** Đây là ngoại lệ có tên của `I1`: icon thùng rác đã đứng ở đầu hộp, gắn thêm vào nút là hai tín hiệu cho một ý (`M6`). Chữ trên nút lặp lại động từ và đối tượng: "Xoá dự án", không chỉ "Xoá" hay "Đồng ý".
|
|
66
|
+
- Nút phá huỷ nằm bên phải cùng và là nút duy nhất mang màu cảnh báo. Huỷ là nút phụ nền `--secondary`, và là nơi tiêu điểm rơi vào khi mở, để Enter không xoá nhầm.
|
|
67
|
+
- Màn hẹp dưới `sm`: hai nút xếp dọc, rộng hết, nút xoá ở trên (`flex-col-reverse`).
|
|
68
|
+
- Không dùng modal cho thứ chỉ để thông báo. Cái đó là toast.
|
|
69
|
+
- Duyệt thì dựng hộp ở trạng thái mở sẵn, không cần danh sách bấm được hay bộ đếm "đã xoá mấy dự án".
|
|
70
|
+
|
|
71
|
+
**Hộp xác nhận cho việc không nguy hiểm** (ba câu của `I4` đều "không": không mất dữ liệu,
|
|
72
|
+
không kết thúc thứ gì đang chạy, không cắt quyền của ai). Có việc cần hỏi lại vì đụng nhiều
|
|
73
|
+
thứ một lúc: gửi lại lời mời hàng loạt, gửi email cho 240 khách hàng, xuất bản thay đổi cho
|
|
74
|
+
cả nhóm. "Không mất dữ liệu" thôi thì **chưa đủ** để vào khuôn này: huỷ gói, đăng xuất cũng
|
|
75
|
+
không mất dữ liệu mà vẫn đỏ (`I4`). Cùng khuôn trên, chỉ đổi màu:
|
|
76
|
+
- icon tròn `bg-background`, glyph `text-foreground` (không `rose`);
|
|
77
|
+
- nút xác nhận `primary` (ngoại lệ có tên của `I2`: đây là nút duy nhất đi tiếp của hộp),
|
|
78
|
+
vẫn lặp động từ + đối tượng: "Gửi lại 12 lời mời";
|
|
79
|
+
- Huỷ vẫn là nút `--secondary` và nhận tiêu điểm khi mở.
|
|
80
|
+
|
|
81
|
+
**Đăng xuất không thuộc nhóm này**: đăng xuất ở skill này là việc nguy hiểm (`I4`), hộp
|
|
82
|
+
"Đăng xuất các thiết bị khác?" đỏ như hộp xoá, chủ dự án chốt; đừng dùng lại nó làm ví dụ
|
|
83
|
+
cho hộp trung tính.
|
|
84
|
+
**Huỷ gói trả phí cũng không thuộc nhóm này** (`I4`), hộp "Huỷ gói Pro?" đỏ như hộp xoá dù vẫn
|
|
85
|
+
dùng được tới hết kỳ; đừng để icon xám, nút xác nhận đen.
|
|
86
|
+
|
|
87
|
+
**Hộp xác nhận có ô gõ lại tên** (xoá workspace, tổ chức; khi nào dựng xem `D3` ở
|
|
88
|
+
`../system.md`). Cùng khuôn trên, thêm ô dưới câu hậu quả, trong khối thân (`mt-4`):
|
|
89
|
+
- Nhãn là một câu, tên đậm ở giữa: "Nhập **Evondev Studio** để xác nhận", `text-sm`, tên
|
|
90
|
+
`font-semibold`, xuống dòng chứ không cắt (`N8`). Không placeholder ghi sẵn tên: trông như
|
|
91
|
+
đã điền hộ.
|
|
92
|
+
- Mở hộp là con trỏ vào ô (thay cho Huỷ). Hộp là `<form>`: Enter trong ô bấm nút xoá, nút
|
|
93
|
+
cao bằng ô (`h-11 md:h-10`). Nút xoá `disabled` tới khi gõ khớp; gõ sai không báo đỏ.
|
|
94
|
+
- So khớp phân biệt hoa thường, bỏ khoảng trắng hai đầu, chuẩn hoá `NFC` cả hai bên (bộ gõ
|
|
95
|
+
tiếng Việt có thể ra dấu tổ hợp, nhìn y hệt mà so chuỗi lệch).
|
|
96
|
+
- Dọn chữ lúc mở hộp, không lúc đóng: mở lại không còn tên gõ sẵn.
|
|
97
|
+
- Xoá lỗi: khung lỗi dưới ô nói dữ liệu còn hay mất ("…workspace và dữ liệu vẫn còn
|
|
98
|
+
nguyên."), chữ đã gõ giữ lại, nút xoá mở lại để thử lần nữa.
|
|
99
|
+
- Bấm ra ngoài không đóng (`I20`), Esc và Huỷ vẫn đóng.
|
|
100
|
+
|
|
101
|
+
## Modal có nội dung
|
|
102
|
+
|
|
103
|
+
```
|
|
104
|
+
┌─────────────────────────────┐
|
|
105
|
+
│ Tiêu đề [×] │ <- header cố định
|
|
106
|
+
├─────────────────────────────┤
|
|
107
|
+
│ nội dung cuộn được │
|
|
108
|
+
│ │
|
|
109
|
+
├─────────────────────────────┤
|
|
110
|
+
│ [Huỷ] [LƯU] │ <- footer cố định
|
|
111
|
+
└─────────────────────────────┘
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Header và footer đứng yên, chỉ thân cuộn. Modal cao quá 80% màn thì đổi sang
|
|
115
|
+
panel trượt hoặc trang riêng.
|
|
116
|
+
|
|
117
|
+
**Modal có form** (mời thành viên, đổi tên, thêm nhanh):
|
|
118
|
+
|
|
119
|
+
- **Đường chia header/footer chỉ có khi thân thật sự cuộn.** Form hai ba ô không cuộn thì bỏ cả hai đường, dùng khoảng trắng (`gap-6`). Ba khối chia kẻ cho một form ngắn là nặng hơn nội dung.
|
|
120
|
+
- **Mô tả dưới tiêu đề `mt-2 text-sm/6 text-muted`** như hộp xác nhận (`T30`).
|
|
121
|
+
- **Mô tả dưới tiêu đề chạy tới sát cột nút ✕**: chỉ header chừa `pr-10` cho nút đóng, đừng đặt `max-w` hẹp cho câu mô tả. Thêm `text-pretty` để không rớt một chữ xuống dòng cuối ("…để tham gia / nhóm.").
|
|
122
|
+
- Có ô nhập nên **bấm ra ngoài không đóng** (`I20`); đóng bằng ✕, Huỷ, Esc. Mở ra thì tiêu điểm vào ô đầu tiên.
|
|
123
|
+
- Nút chính ở footer là `primary` (một hành động duy nhất của modal, `I2`), **chỉ có chữ** theo `I1`: "Gửi lời mời", không icon. Nút Huỷ `secondary`. Cả hai `h-11 md:h-10`, cao bằng ô nhập.
|
|
124
|
+
- **Trạng thái đang gửi**: nút chỉ chữ thì **spinner đè lên giữa nút, chữ `invisible`** (vẫn chiếm chỗ), nút `aria-disabled` + `aria-busy`, không `disabled`. Chèn spinner cạnh chữ là nút rộng ra, đẩy Huỷ sang trái. Xem `../components/button.md`.
|
|
125
|
+
- Câu lỗi dưới ô nói **cách sửa**, theo bảng "Ô trống thì viết gì" trong `form.md` (một nguồn): "Email phải có dấu @".
|
|
126
|
+
- **Modal mời thành viên: ô email là tag input** (`../components/tag-input.md`), mời nhiều người một lần, nút ghi số ("Gửi 3 lời mời"); email trùng phân biệt "Đã là thành viên" với "Đã mời …, chưa chấp nhận". Chi tiết ở "Trang thành viên và phân quyền" trong `app.md`.
|
|
127
|
+
|
|
128
|
+
**Modal xem bản ghi có nút trước / sau** (chi tiết đơn hàng, hoá đơn, phiếu nhập):
|
|
129
|
+
|
|
130
|
+
```
|
|
131
|
+
┌──────────────────────────────────────────┐
|
|
132
|
+
│ Đơn #10248 [⧉] ‹ › │ ✕ │ <- header trắng, đứng yên
|
|
133
|
+
│ (● Chờ xử lý) Đặt lúc 14:32 · 22/09/2026 │
|
|
134
|
+
├──────────────────────────────────────────┤
|
|
135
|
+
│ ┌ Sản phẩm ─────────────────────────────┐ │ <- thân bg-background, card trắng
|
|
136
|
+
│ │ Áo sơ mi linen tay dài 900.000 đ │ │
|
|
137
|
+
│ │ Màu be · Size M 2 × 450.000 đ │ │
|
|
138
|
+
│ └───────────────────────────────────────┘ │
|
|
139
|
+
│ ┌ Thanh toán ───────────────────────────┐ │
|
|
140
|
+
│ │ Tạm tính · 3 sản phẩm 2.500.000 đ │ │
|
|
141
|
+
│ │ Tổng cộng 2.450.000 đ │ │
|
|
142
|
+
│ └───────────────────────────────────────┘ │
|
|
143
|
+
└──────────────────────────────────────────┘
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
- **Neo đỉnh, không căn giữa dọc**: khung ngoài `items-start`, modal cách đỉnh một khoảng cố định (`mt-16 sm:mt-[8vh]`). Mỗi bản ghi cao một khác (một sản phẩm, hay bốn sản phẩm tên dài ba dòng); căn giữa thì mỗi lần bấm Đơn sau cả header trượt lên xuống, nút ‹ › chạy khỏi con trỏ, bấm liền hai lần là trúng nền (`N1`). Neo đỉnh thì đổi đơn header đứng yên dù modal cao 600px hay 820px.
|
|
147
|
+
- **Chiều cao theo dữ liệu nên luôn có trần**: `max-h-[calc(100dvh-8vh-1rem)]`, header đứng yên, chỉ thân cuộn. Câu "cao quá 80% màn thì đổi sang panel" ở trên là lúc chọn khuôn; một đơn có 20 sản phẩm thì thân cuộn, không đổi khuôn giữa chừng.
|
|
148
|
+
- **Header hai dòng**: dòng một là tiêu đề `text-lg font-semibold` "Đơn #10248", **chỉ phần mã `font-mono`** (`T17`), chữ "Đơn" giữ font thường; nút `copy` là icon button ngay sau mã. Cụm phải `‹ › │ ✕`: ‹ › là icon button ghost có tooltip "Đơn trước", "Đơn sau"; vạch đứng `h-5 w-px bg-border` tách ✕ ra vì đóng khác loại với đi tiếp. Dòng hai là badge trạng thái đơn và mốc đặt đủ năm (mốc đứng riêng, `T16b`).
|
|
149
|
+
- **Tới đầu hoặc cuối danh sách thì nút đó `disabled`**, mờ nhưng vẫn chiếm chỗ, không ẩn (`N1`: ẩn thì › trượt sang chỗ của ‹). Tiêu điểm đang nằm trên nút vừa thành `disabled` thì **chuyển sang nút còn lại**, không để rơi về `body`; người dùng bàn phím vẫn đứng trong cụm điều hướng.
|
|
150
|
+
- **Thân `bg-background p-6`, mỗi khối một card trắng** (`card.md`), cách nhau `gap-4`. Header trắng, thân xám là đủ tách, không kẻ thêm đường dưới header. Card chỉ có tiêu đề thì **không `min-h-10`** (xem `card.md`).
|
|
151
|
+
- **Hàng sản phẩm**: trái là tên `font-medium` và phân loại `text-sm text-muted` ("Màu be · Size M"); phải là thành tiền `font-medium tabular-nums` và "2 × 450.000 đ" `text-sm text-muted`. Tên dài xuống dòng, không `truncate` (`N8`); cột tiền `shrink-0 text-right` bám đỉnh hàng. Không có phân loại thì bỏ hẳn dòng phụ, không ghi `—`. Đường kẻ giữa các hàng theo `F25`.
|
|
152
|
+
- **Khối thanh toán là biên lai**: nhãn trái, số tiền **bám mép phải** (`justify-between`), không dùng cột nhãn `7rem` của `description-list.md`: tiền phải thẳng một mép phải để cộng trừ bằng mắt. "Tạm tính · 24 sản phẩm" đếm theo **số lượng**, không theo số dòng. Giảm giá ghi kèm mã `font-mono` và số âm. Tổng cộng có đường kẻ trên, `text-lg font-semibold`, là con số nặng nhất khối. Phương thức và trạng thái thanh toán (badge) nằm sau một đường kẻ nữa; hàng có badge thì `items-baseline` để nhãn thẳng dòng chữ trong badge. Số tiền `whitespace-nowrap shrink-0`, nhãn `min-w-0` co và xuống dòng (`T16` trong `rules-type.md`): ở 375px "Tạm tính · 1 sản phẩm" chiếm gần nửa hàng, để nhãn cứng thì "đ" rớt xuống dòng riêng.
|
|
153
|
+
- **Số liệu giữa các khối phải khớp** (`S6`): tạm tính bằng tổng thành tiền, tổng cộng bằng tạm tính cộng phí trừ giảm giá, trạng thái thanh toán hợp với trạng thái đơn (đơn huỷ đã trả thì "Đã hoàn tiền", thanh toán khi nhận hàng đang giao thì "Chưa thanh toán").
|
|
154
|
+
- Mở modal thì tiêu điểm vào khung hoặc tiêu đề, **không vào nút ‹ đầu tiên** (tooltip bật ngay lúc mở, xem bẫy ở mục "Chuyển động").
|
|
155
|
+
- Đề không nói tới thao tác trên đơn (xác nhận, huỷ, in) thì không dựng footer; báo một dòng lúc giao. Có thì footer theo khuôn footer của panel trượt: nút giữ kiểu theo vai, không theo số lượng.
|
|
156
|
+
|
|
157
|
+
## Panel trượt
|
|
158
|
+
|
|
159
|
+
Trượt từ phải, `w-full sm:w-[28rem]` (dưới `sm` phủ hết bề ngang, 448px rộng hơn điện thoại 375px), dùng khi nội dung dài hoặc người dùng
|
|
160
|
+
cần nhìn thấy danh sách phía sau. Không dùng panel cho một câu xác nhận.
|
|
161
|
+
|
|
162
|
+
- **Lớp phủ sau panel mờ: `bg-black/15`.** Panel tồn tại để người dùng **vẫn thấy danh sách phía sau**; lớp phủ đặc che kín danh sách là mất đúng lý do dùng panel (lớp phủ xám đục thì nền trang biến thành một mảng xám chết). Modal thì `bg-black/30`, vì modal cần tách hẳn người dùng khỏi trang.
|
|
163
|
+
- **Ba tầng: header, thân cuộn, footer.** Header `px-6 pt-5 pb-4 border-b border-border` gồm tiêu đề, dòng trạng thái + thời gian, nút ⋯ và ✕ cùng hàng tiêu đề. Thân `flex-1 overflow-y-auto px-6 py-6`: **luôn có `pt` riêng**, không để tiêu đề mục đầu dính sát đường kẻ header. Footer `border-t border-border px-6 py-4`, nút căn phải, luôn đứng đáy dù thân ngắn.
|
|
164
|
+
- Nhãn và giá trị trong panel theo `components/description-list.md`, cột nhãn `7rem`.
|
|
165
|
+
- **Nút ở footer giữ kiểu theo vai, không theo số lượng.** Đơn đã huỷ mất nút chính, footer chỉ còn "In hoá đơn": nó **vẫn `secondary`** như lúc đứng cạnh nút chính. Không đẩy lên `primary` (đơn đã huỷ không còn hành động chính, tô đen là giả làm việc quan trọng) và không đổi sang `outline` (cùng một nút mà mỗi đơn một kiểu, `N5`). Nút footer chỉ có chữ (`I1`).
|
|
166
|
+
- Chuyển động theo mục "Chuyển động" cuối file: panel trượt từ mép phải vào.
|
|
167
|
+
|
|
168
|
+
**Panel xem bản ghi có tab** (khách hàng, dự án, ticket: tên, trạng thái, nút thao tác nhanh, hàng tab):
|
|
169
|
+
|
|
170
|
+
- **Phần cố định chỉ là hàng tên**: avatar, tên, nút ⋯ và ✕. Trạng thái, dòng phụ (công ty), hàng nút thao tác nhanh nằm đầu thân cuộn và **cuộn đi**; hàng tab `sticky top-0 z-10 bg-surface` trong thân cuộn, đường kẻ dưới tab tràn hai mép panel: thân cuộn không padding ngang, mỗi khối trong thân tự `px-6`, hàng tab `px-6` bên trong, không kéo ra bằng `-mx-6` (`N11`). Giữ cố định cả khối thì với tên hai dòng và tên công ty dài, phần đứng yên cao ~240px: laptop 800px mất gần một phần ba, điện thoại mất gần nửa, vùng đọc tab Tin nhắn còn một mẩu. Cuộn xuống thì còn lại tên + ✕ + tab, đủ biết đang xem ai và đang ở tab nào.
|
|
171
|
+
- **Tên là chữ nặng nhất panel** (`text-lg font-semibold`). Số liệu, tiêu đề mục, không thứ gì trong thân to hơn tên. Ô số liệu trong panel theo mục "Trong panel trượt hay cột hẹp" ở `../components/charts.md`: một khung 2×2, số `text-lg`, không phải bốn card rời số `text-3xl` (bốn card thì số to nhất panel, tên khách đứng hàng hai).
|
|
172
|
+
- **Đổi tab thì hàng tab đứng yên dưới con trỏ** (`N1`): đang dính đỉnh thì cuộn về ngay dưới hàng tab, không về 0; chưa dính thì giữ nguyên chỗ cuộn. Về 0 lúc đang dính là hàng tab tụt xuống dưới khối trạng thái, trượt khỏi chỗ vừa bấm. Muốn vậy thì nội dung tab `min-h` bằng vùng cuộn trừ hàng tab, để tab ngắn (tin nhắn trống) không kéo hàng tab xuống. Trang phía sau đứng yên. Mũi tên trái/phải chuyển tab (`../components/small-controls.md`).
|
|
173
|
+
- Danh sách trong tab (tin nhắn, tệp, hoạt động) ghi giờ theo `T16b`: năm hiện tại thì bỏ năm.
|
|
174
|
+
|
|
175
|
+
## Dropdown
|
|
176
|
+
|
|
177
|
+
Bám mép trái của nút mở, rộng tối thiểu bằng nút. Mục nguy hiểm tách xuống cuối,
|
|
178
|
+
cách bằng một đường kẻ — hover của nó theo `I4`, đường kẻ tràn hết bề ngang theo
|
|
179
|
+
`F25`. Không quá 8 mục, hơn thì thêm ô tìm.
|
|
180
|
+
|
|
181
|
+
**Mỗi mục là một phần tử bấm được rộng hết hàng** (`I29`): `flex w-full` đặt trên
|
|
182
|
+
chính `<button>` hay `<a>`, không đặt trên phần tử bọc ngoài. Dùng shadcn thì
|
|
183
|
+
mục có link phải là `<DropdownMenuItem asChild>`.
|
|
184
|
+
|
|
185
|
+
**Gần mép thì lật, không tràn ra ngoài.** Menu mở từ dòng cuối bảng mà vẫn đổ xuống thì
|
|
186
|
+
nó trùm qua hàng phân trang và lòi khỏi card. Không đủ chỗ bên dưới
|
|
187
|
+
thì lật lên trên nút, sát mép phải thì canh phải. Dùng thư viện popover có sẵn của dự án
|
|
188
|
+
thì bật `collisionPadding`, tự dựng thì đo `getBoundingClientRect` trước khi mở.
|
|
189
|
+
|
|
190
|
+
**Lớp nổi không có thanh cuộn, trừ danh sách dài.** Menu, lịch, popover lọc co theo nội dung;
|
|
191
|
+
đừng gõ tay `width` / `height` nhỏ hơn thứ bên trong. Hụt 1–2px là lòi thanh cuộn cả hai chiều,
|
|
192
|
+
máy bật "luôn hiện thanh cuộn" thì thành hai vệt xám to đè lên nội dung. Thẻ `[popover]` gốc
|
|
193
|
+
trình duyệt đặt sẵn `overflow: auto`, nên khung cố định nào hụt cũng ra cuộn. Chỉ listbox, menu
|
|
194
|
+
dài quá `max-h` mới cuộn dọc, và không lớp nổi nào cuộn ngang. Cần giữ khung cố định (lịch đổi
|
|
195
|
+
tầng, `choice-controls.md`) thì lấy cỡ từ nội dung to nhất, không đoán số (lịch khung cao
|
|
196
|
+
272px mà lưới ngày cần 288px, rộng hụt 2px, là ra cuộn cả hai chiều).
|
|
197
|
+
|
|
198
|
+
**Tự dựng: có đủ bề rộng rồi mới đo chiều cao, và menu đổi cỡ thì đo lại.** Menu
|
|
199
|
+
mở lên (`top = đỉnh nút − chiều cao menu`) mà rộng theo nút đọc từ state thì lần mở
|
|
200
|
+
đầu state còn `width: 0`. Menu rộng 0 nên chữ xuống dòng từng từ, cao hàng trăm px,
|
|
201
|
+
`top` âm bị kẹp về mép trên màn. Render lại thì bề rộng đúng nhưng `top` không tính lại:
|
|
202
|
+
menu tài khoản chân sidebar trôi lên tận đầu sidebar, đè lên nav, cách nút mở cả màn.
|
|
203
|
+
Chỉ bị **lần mở đầu sau khi tải trang, hoặc sau khi thu/mở
|
|
204
|
+
sidebar** (bề rộng cũ còn trong state), nên trông như lỗi "lúc có lúc không". Cách làm:
|
|
205
|
+
gán bề rộng thẳng vào DOM từ `triggerRect.width` **trước khi** đọc `offsetHeight`, hoặc
|
|
206
|
+
đo lại bằng `ResizeObserver` trên menu. Kiểm tra: tải lại trang, bấm mở ngay lần đầu;
|
|
207
|
+
thu rồi mở sidebar, bấm mở lại. Menu phải nằm sát nút cả hai lần. Radix, Floating UI
|
|
208
|
+
đã lo việc này, lỗi chỉ có ở menu tự dựng.
|
|
209
|
+
|
|
210
|
+
**Bo góc và khoảng cách, theo `M19`:**
|
|
211
|
+
|
|
212
|
+
```html
|
|
213
|
+
<!-- Khung chỉ padding dọc, mỗi nhóm mục px-1: vạch giữa hai nhóm tự chạm mép (F25, N11) -->
|
|
214
|
+
<div class="min-w-56 rounded-2xl border border-border bg-surface-overlay py-1 shadow-popover">
|
|
215
|
+
<div class="px-1">
|
|
216
|
+
<button class="flex h-10 w-full cursor-pointer items-center gap-2.5 rounded-xl px-3 text-sm outline-hidden hover:bg-item-hover focus-visible:bg-item-hover">…</button>
|
|
217
|
+
</div>
|
|
218
|
+
<!-- Vạch chia cùng token với viền khung, không border-strong: đậm hơn viền là vạch nổi hơn khung.
|
|
219
|
+
Khung cũng border-border: bóng shadow-popover đã tách khung khỏi trang. Khung và vạch cùng border-strong
|
|
220
|
+
thì menu kẻ ô như bảng -->
|
|
221
|
+
<hr class="my-1 border-border" />
|
|
222
|
+
<div class="px-1">
|
|
223
|
+
<!-- Mục nguy hiểm: lúc thường y như mục khác, rê vào mới đỏ (I4) -->
|
|
224
|
+
<button class="group flex h-10 w-full cursor-pointer items-center gap-2.5 rounded-xl px-3 text-sm text-foreground outline-hidden hover:bg-rose-500/10 hover:text-rose-700 focus-visible:bg-rose-500/10 focus-visible:text-rose-700 dark:hover:text-rose-400 dark:focus-visible:text-rose-400">
|
|
225
|
+
<i data-lucide="trash-2" class="size-4 shrink-0 text-muted group-hover:text-rose-700 group-focus-visible:text-rose-700 dark:group-hover:text-rose-400 dark:group-focus-visible:text-rose-400"></i>
|
|
226
|
+
Xoá
|
|
227
|
+
</button>
|
|
228
|
+
</div>
|
|
229
|
+
</div>
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
Mục xoá: chữ **`rose-700`**, không `rose-500` (3.2:1 trên nền `rose-500/10`,
|
|
233
|
+
trượt 4.5:1). Lúc chưa rê thì chữ `--foreground`, icon `text-muted` như mọi mục,
|
|
234
|
+
đỏ sẵn là sai `I4`.
|
|
235
|
+
|
|
236
|
+
| Thứ | Giá trị | Vì sao |
|
|
237
|
+
| --- | --- | --- |
|
|
238
|
+
| Khung | `rounded-2xl` 16px | |
|
|
239
|
+
| Khe quanh mục | 4px: `p-1` ở khung; menu có vạch chia thì `py-1` ở khung + `px-1` ở từng nhóm mục | Khe hở giữa nền hover và mép khung; vạch chạm mép mà không cần `-mx-1` (`F25`) |
|
|
240
|
+
| Mục | `h-10` 40px | Cùng chiều cao link sidebar, nút, ô nhập. Mục 36px trông chật |
|
|
241
|
+
| Nền hover của mục | `rounded-xl` 12px | **16 = 12 + 4**, hai góc đồng tâm. Mục cao 40px nên bo 12px (`F1`) |
|
|
242
|
+
|
|
243
|
+
Dùng shadcn / Radix thì thay cả `hover:` lẫn `focus-visible:` bằng
|
|
244
|
+
`data-[highlighted]:bg-item-hover`, để chuột và phím mũi tên dùng chung **một**
|
|
245
|
+
mục sáng (`I13`).
|
|
246
|
+
|
|
247
|
+
Nền hover **thụt vào** cách mép khung, không tràn sát mép. Cái khe đó cùng với
|
|
248
|
+
góc đồng tâm là thứ làm menu trông mềm. **Bo mục và padding khung đi thành cặp**:
|
|
249
|
+
mục `rounded-xl` thì khung `p-1`; giữ `p-2` cũ mà nâng mục lên `rounded-xl` là
|
|
250
|
+
sai công thức, khe hở ở góc rộng ra trong khi ở cạnh vẫn 8px, góc trông phình.
|
|
251
|
+
|
|
252
|
+
Menu gọn, mục cao dưới 40px (`py-1.5`, `text-xs`) thì hạ về cặp cũ: mục
|
|
253
|
+
`rounded-lg`, khung `p-2`.
|
|
254
|
+
|
|
255
|
+
**Khe 4px là cho khối hẹp, đừng nâng lên 8px.** Dropdown, select, popover dạng
|
|
256
|
+
danh sách rộng 224–320px: khe `p-1` là chuẩn chung của các thư viện menu (Radix, shadcn;
|
|
257
|
+
menu macOS 5px). Nâng lên `p-2` thì mỗi bên mất thêm 4px bề ngang cho chữ, và nền
|
|
258
|
+
sáng trông như lơ lửng giữa khung. **Khối rộng từ ~480px (command palette) thì
|
|
259
|
+
khe `p-2`**: ở bề ngang đó khe 4px làm nền sáng thành một thanh chạy gần hết khung,
|
|
260
|
+
góc nền sáng gần chạm góc khung. Xem "Command palette" bên dưới.
|
|
261
|
+
|
|
262
|
+
**Dropdown dài phải cuộn** thì `max-h-76` và chớp thanh cuộn lúc mở, như select
|
|
263
|
+
(`I18`): mục cuối bị cắt ngang là tín hiệu duy nhất lúc đứng yên.
|
|
264
|
+
|
|
265
|
+
### Menu con (dropdown đa cấp)
|
|
266
|
+
|
|
267
|
+
Mục cha: icon + chữ + `ChevronRight` `size-4 text-muted` ở mép phải. Menu con **cùng
|
|
268
|
+
khung** với menu cha (`rounded-2xl p-1`, viền, `shadow-popover`, mục `h-10 rounded-xl`).
|
|
269
|
+
|
|
270
|
+
**Màn đủ chỗ cho hai khung cạnh nhau: bay ra cạnh menu cha.**
|
|
271
|
+
|
|
272
|
+
- Cách mép menu cha 4px. **Hàng đầu thẳng hàng mục cha** (kéo khung lên đúng viền + `p-1`). Thiếu chỗ bên dưới thì đẩy lên cho đáy cách mép màn 8px, không lật.
|
|
273
|
+
- Bên phải hết chỗ thì bay sang trái (avatar góc phải header). Mũi tên mở / lùi đổi chiều theo phía bay.
|
|
274
|
+
- Rê vào mục cha chờ ~100ms mới mở (lướt ngang qua không bật). Rời đi chờ ~250ms mới đóng, để đi chéo sang menu con không tắt giữa chừng. Radix có sẵn cả hai.
|
|
275
|
+
- Menu con đang mở thì **mục cha giữ nền sáng**, để biết menu con thuộc mục nào khi con trỏ đã sang bên kia.
|
|
276
|
+
- Phím: `→` mở và sáng mục đầu, `←` / `Esc` đóng và trả tiêu điểm về mục cha, `↑↓` đi trong menu con.
|
|
277
|
+
|
|
278
|
+
**Màn hẹp (hai khung không đứng cạnh nhau được): đổi tại chỗ, không xổ ra trong menu cha.**
|
|
279
|
+
Bấm mục cha thì **cả nội dung menu thay bằng menu con**, hàng đầu là nút lùi
|
|
280
|
+
`‹ Chuyển tài khoản` (`ChevronLeft` + tên mục cha, `h-10`, cùng khuôn mục), rồi một
|
|
281
|
+
đường chia. Bề rộng giữ nguyên, chiều cao theo nội dung mới. Tiêu điểm sang mục đầu,
|
|
282
|
+
`←` / `Esc` lùi về menu cha và sáng lại mục cha. YouTube, Facebook làm menu tài khoản
|
|
283
|
+
đúng kiểu này.
|
|
284
|
+
|
|
285
|
+
Đừng xổ danh sách ngay dưới mục cha (chevron xoay xuống như nhóm sidebar): ở 375px menu
|
|
286
|
+
tài khoản cao gần 600px, bốn tài khoản xổ ra thẳng
|
|
287
|
+
mép với các mục thường nên không đọc ra là con của mục nào, và tài khoản đang dùng
|
|
288
|
+
hiện **hai lần liền nhau** (đầu menu, rồi hàng đầu danh sách).
|
|
289
|
+
|
|
290
|
+
**Hàng trong menu con không cao hơn mục thường quá một bậc.** Mục một dòng `h-10`;
|
|
291
|
+
hàng hai dòng (tên + dòng phụ) `h-12`, avatar `size-8`. Mỗi trường **một dòng**,
|
|
292
|
+
không trường nào xuống dòng: email xuống dòng trước `@` làm mỗi
|
|
293
|
+
hàng tài khoản ba dòng, cao ~68px, menu con nặng hơn hẳn menu cha. Chữ dài thì cắt
|
|
294
|
+
theo "Cắt email" bên dưới, không bẻ dòng.
|
|
295
|
+
|
|
296
|
+
### Menu tài khoản
|
|
297
|
+
|
|
298
|
+
Mở từ avatar trên header, hoặc từ hàng profile chân sidebar (`app.md`).
|
|
299
|
+
|
|
300
|
+
- **Tài khoản chỉ có một lối vào.** Đề bảo đặt avatar trên header mà chân sidebar đã có hàng profile thì chuyển hẳn lên header, bỏ hàng profile, báo một dòng lúc giao. Hai chỗ mở cùng một menu là một ý nói hai lần (`N3`); các app lớn đều chỉ có một.
|
|
301
|
+
- **Đầu menu mở từ avatar: tên + email, không avatar.** Avatar vừa bấm nằm ngay bên trên; lặp lại một avatar 40px ở đầu menu thì nó thành thứ nặng nhất menu. Tên `text-sm font-medium truncate`, email `text-xs text-muted` một dòng, khối `px-3 py-2`. Mở từ chân sidebar thì chỉ email, vì tên đã ở hàng profile.
|
|
302
|
+
- Thứ tự: đầu menu, đường chia, Hồ sơ / Trợ giúp…, **Chuyển tài khoản** (chỉ khi có từ 2 tài khoản), đường chia, Đăng xuất (`I4`).
|
|
303
|
+
- **Hàng tài khoản trong menu con**: avatar `size-8` (màu theo `avatar.md`), tên `text-sm font-medium truncate`, email một dòng, và ô `size-4` luôn giữ chỗ ở mép phải cho dấu `Check` của tài khoản đang dùng. Chỉ dấu tick, không tô nền, không chữ đậm thêm. Hàng `role="menuitemradio"`. Bấm tài khoản đang dùng thì chỉ đóng menu.
|
|
304
|
+
- Menu con rộng `w-72`. Menu cha từ avatar header cũng `w-72` cho hai khung cân nhau.
|
|
305
|
+
|
|
306
|
+
**Cắt email: cắt phần trước `@`, giữ nguyên tên miền.** Hai tài khoản của một người
|
|
307
|
+
thường cùng phần trước `@`, chỉ khác tên miền, nên cắt ở cuối
|
|
308
|
+
(`tran.nguyen.anh.tuan.khang@ev…`) là mất đúng phần để phân biệt. Xuống dòng thì
|
|
309
|
+
giữ được chữ nhưng hàng cao gấp rưỡi. Tách hai `span`, phần trước `@` co lại, gói
|
|
310
|
+
thành một component (vd `AccountEmail`) dùng chung:
|
|
311
|
+
|
|
312
|
+
```tsx
|
|
313
|
+
<span className="flex min-w-0 text-xs text-muted" title={email}>
|
|
314
|
+
<span className="min-w-0 truncate">{localPart}</span>
|
|
315
|
+
{/* shrink-0: tên miền không co. max-w-full: tên miền dài hơn cả hàng (hiếm) thì nó cắt, không tràn. */}
|
|
316
|
+
<span className="max-w-full shrink-0 truncate">{domainPart}</span>
|
|
317
|
+
</span>
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
Ra `tran.ngu…@evondev-studio.com` cạnh `tran.nguyen.an…@gmail.com`: mỗi hàng một
|
|
321
|
+
dòng, vẫn phân biệt được. Email đầy đủ ở `title`. Dùng khuôn này ở mọi chỗ hiện
|
|
322
|
+
email trong menu: đầu menu, hàng tài khoản, đầu menu chân sidebar.
|
|
323
|
+
|
|
324
|
+
### Popover lọc
|
|
325
|
+
|
|
326
|
+
Nút **Lọc** trên thanh công cụ của danh sách mở một khung có vài trường (người phụ
|
|
327
|
+
trách, khoảng ngày, mức ưu tiên…) và nút xác nhận. Khác dropdown ở chỗ bên trong là
|
|
328
|
+
**một form nhỏ**, không phải danh sách mục.
|
|
329
|
+
|
|
330
|
+
```
|
|
331
|
+
┌──────────────────────────────────┐
|
|
332
|
+
│ Người phụ trách │ <- nhãn text-sm font-medium
|
|
333
|
+
│ [Tất cả người phụ trách ▾] │ <- select, h-10, rộng hết khung
|
|
334
|
+
│ Hạn chót Xoá │ <- "Xoá" chỉ hiện khi đã có khoảng
|
|
335
|
+
│ [Mọi hạn chót ▦] │
|
|
336
|
+
│ Mức ưu tiên │
|
|
337
|
+
│ (Thấp) (Trung bình) (Cao) (…) │ <- chip bật/tắt, xuống dòng được
|
|
338
|
+
├──────────────────────────────────┤ <- F25, tràn hai mép
|
|
339
|
+
│ Xoá lọc [Áp dụng] │ <- "Xoá lọc" thẳng mép trái nhãn
|
|
340
|
+
└──────────────────────────────────┘
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
- **Khung** `w-96 max-w-[calc(100vw-2rem)] rounded-2xl border border-border bg-surface-overlay shadow-popover`, không `p-*`: thân `p-4 flex flex-col gap-4`, footer `border-t border-border px-4 py-3 flex justify-between`. Neo mép phải nút Lọc (dưới `sm` nút đứng riêng hàng, căn trái, thì neo mép trái). Portal ra `body` (`I22`).
|
|
344
|
+
- **Đổi gì trong khung chỉ sửa bản nháp**; bấm Áp dụng mới lọc danh sách. Esc, bấm ra ngoài là bỏ nháp. Mở lại thì nháp lấy lại bộ lọc đang áp. Đang lọc thì nút ghi "Lọc · 2" (số trường đang bật).
|
|
345
|
+
- **"Xoá lọc" là link chữ, không phải nút `ghost` có padding**: `px-0`, chữ `text-muted`, rê vào `text-foreground` + gạch chân, không nền. Nút `ghost` `px-4` đặt trong footer `px-4` làm chữ "Xoá lọc" thụt vào 16px so với mép trái mọi nhãn phía trên, cả khung có một mép chữ lệch. Cùng chữ, cùng kiểu với "Xoá" ở hàng nhãn của một trường. Không còn gì để gỡ (nháp rỗng và danh sách không lọc) thì khoá `opacity-50`.
|
|
346
|
+
- **Chip đang chọn trong khung KHÔNG tô `bg-primary`.** Footer đã có nút Áp dụng đặc `primary`; chip chọn cũng đặc đen thì khung nhỏ có ba bốn khối đen ngang nhau, mắt không biết đâu là hành động, và thứ nặng nhất khung là lựa chọn chứ không phải nút (cùng bài học với ô chọn giờ ở `../components/choice-controls.md`). Chip chọn trong khung: `bg-foreground/10 text-foreground inset-ring-1 inset-ring-foreground` (Tailwind v4). **Không `ring-1 ring-inset`**: dự án trả vòng focus lại (`I14`) thì vòng đó dùng chung biến với `ring-*`, Tab tới chip đang chọn là mất viền chọn (`W8`). `inset-ring` là lớp bóng riêng. Tailwind v3 không có `inset-ring`: dùng `shadow-[inset_0_0_0_1px_var(--foreground)]`; chưa chọn giữ `bg-foreground/5 text-foreground/70`, rê vào `bg-foreground/10`. Viền mới là tín hiệu chọn, nền chỉ đậm lên một bậc để chip chọn trông "bật" hơn chip rê. Đừng dùng nền trắng + viền đậm (chip chọn nhạt hơn chip chưa chọn, đọc ngược), cũng đừng dùng viền `1.5px` (chọn đủ bốn mức thì hàng chip thành bốn vòng đen dày, nặng ngang nút Áp dụng; 1px vẫn tách rõ chọn với chưa chọn). Hàng chip chính của màn (không có nút xác nhận bên cạnh) vẫn `bg-primary` như `../components/small-controls.md`.
|
|
347
|
+
- **Chip trong khung được xuống dòng** (`flex-wrap`): bốn mức cố định trong một ô của form, cuộn ngang trong một khung nổi là giấu mức cuối. Luật "không bao giờ wrap" của hàng chip chỉ áp cho hàng lọc của cả màn.
|
|
348
|
+
- **Ô chọn trong khung theo luật focus của nút mở** (`focus-visible:`, không `focus:`): chọn người bằng chuột xong mà ô giữ viền đen + ring thì khung có một ô trông như đang mở.
|
|
349
|
+
- **Lịch lồng trong khung dùng khuôn gọn ở mọi bề rộng**: một tháng, mốc nhanh thành hàng chip trên lưới (xuống dòng, không cuộn ngang, xem Ô chọn khoảng ngày), rộng bằng ô bấm mở. Không bung lịch hai tháng + cột mốc nhanh: ở 1280px lịch rộng ~590px mọc ra từ khung 384px, lấn sang bảng hai phía, mép phải còn cách màn 8px, thành ba lớp nổi chồng nhau. Lịch hai tháng dành cho ô khoảng ngày nằm trên trang hoặc trong form rộng.
|
|
350
|
+
|
|
351
|
+
## Phím tắt trong menu
|
|
352
|
+
|
|
353
|
+
Mục nào có phím tắt thì hiện ở **mép phải**, `text-xs text-muted`, đừng để trong
|
|
354
|
+
ngoặc giữa dòng.
|
|
355
|
+
|
|
356
|
+
```html
|
|
357
|
+
<button class="flex h-10 w-full cursor-pointer items-center gap-2.5 rounded-xl px-3 text-sm outline-hidden hover:bg-item-hover">
|
|
358
|
+
<i data-lucide="user" class="h-4 w-4 shrink-0 text-muted"></i>
|
|
359
|
+
<span class="min-w-0 flex-1 truncate text-left">Hồ sơ của bạn</span>
|
|
360
|
+
<span class="shrink-0 text-xs text-muted">⌘1</span>
|
|
361
|
+
</button>
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
Ký hiệu viết thẳng bằng ký tự (`⌘`, `⇧`, `⌥`), không bọc `<kbd>` có viền. Viền
|
|
365
|
+
quanh từng phím làm menu trông rối: ký tự `⌘` đã nói "đây là phím tắt", thêm khung
|
|
366
|
+
là tín hiệu thứ hai cho cùng một ý (`N3`).
|
|
367
|
+
|
|
368
|
+
Chỉ hiện phím tắt cho mục **thật sự có phím tắt**. Bịa ra cho đẹp thì người dùng
|
|
369
|
+
bấm không ăn, mất lòng tin ngay.
|
|
370
|
+
|
|
371
|
+
## Command palette
|
|
372
|
+
|
|
373
|
+
```
|
|
374
|
+
┌────────────────────────────────────────┐
|
|
375
|
+
│ 🔍 Tìm trang │ <- ô tìm h-14 px-5, không viền
|
|
376
|
+
├────────────────────────────────────────┤ <- đường chia tràn hai mép (F25)
|
|
377
|
+
│ ▓▓ 🏠 Tổng quan ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ │ <- mục đầu sáng sẵn, khe 8px tới mép
|
|
378
|
+
│ 📥 Hộp thư │
|
|
379
|
+
│ │
|
|
380
|
+
│ CÔNG VIỆC │ <- nhãn gần nhóm của nó
|
|
381
|
+
│ 📁 Dự án │
|
|
382
|
+
│ 📄 Tài liệu │
|
|
383
|
+
│ 👥 Khách hàng │
|
|
384
|
+
│ 📝 Hợp đồng │ <- mục cuối lộ nửa, mép dưới cắt ngang
|
|
385
|
+
└────────────────────────────────────────┘
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
```html
|
|
389
|
+
<!-- Ghim từ trên, không căn giữa dọc -->
|
|
390
|
+
<div role="dialog" aria-label="Tìm trang" class="fixed inset-x-4 top-4 mx-auto w-auto max-w-xl overflow-hidden rounded-2xl border border-border bg-surface-overlay shadow-modal sm:top-[15vh]">
|
|
391
|
+
<div class="flex h-14 items-center gap-3 border-b border-border px-5">
|
|
392
|
+
<i data-lucide="search" class="size-4 shrink-0 text-muted"></i>
|
|
393
|
+
<input class="min-w-0 flex-1 bg-transparent text-base outline-hidden placeholder:text-muted md:text-sm" placeholder="Tìm trang" />
|
|
394
|
+
</div>
|
|
395
|
+
<!-- pr-1 + gutter stable: khe phải = 4px + thanh 4px = 8px, bằng khe trái, dù có cuộn hay không -->
|
|
396
|
+
<div role="listbox" class="max-h-[min(22rem,60vh)] overflow-y-auto p-2 pr-1 [scrollbar-gutter:stable] [&::-webkit-scrollbar-track]:mt-2 [&::-webkit-scrollbar-track]:mb-4">
|
|
397
|
+
<button role="option" class="flex h-10 w-full cursor-pointer items-center gap-3 rounded-lg px-3 text-sm outline-hidden data-[selected=true]:bg-item-hover">
|
|
398
|
+
<i data-lucide="house" class="size-4 shrink-0 text-muted"></i>
|
|
399
|
+
<span class="min-w-0 flex-1 truncate text-left">Tổng quan</span>
|
|
400
|
+
</button>
|
|
401
|
+
<!-- Nhãn nhóm: trên rộng, dưới hẹp. Nhóm đầu tiên có nhãn thì pt-2. -->
|
|
402
|
+
<div class="px-3 pt-5 pb-1 text-xs font-medium uppercase tracking-wide text-muted">Công việc</div>
|
|
403
|
+
…
|
|
404
|
+
</div>
|
|
405
|
+
</div>
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
- **Ghim từ trên (`sm:top-[15vh]`), không căn giữa dọc.** Gõ để lọc thì danh sách co lại; khung căn giữa thì ô tìm nhảy lên xuống theo từng phím gõ, ngay dưới con trỏ (`N1`). Ghim từ trên thì ô tìm đứng yên, chỉ đáy khung co. Các bảng lệnh phổ biến đều ghim.
|
|
409
|
+
- **Khe `p-2`, mục `rounded-lg`**: khung 16 = 8 + 8 (`M19`). Palette rộng 576px, khe `p-1` của dropdown ở bề ngang này thì nền sáng thành một thanh gần hết khung (lý do ở "Dropdown" bên trên). Mục `h-10`, icon `size-4 text-muted`, chữ `text-sm`.
|
|
410
|
+
- **Icon ô tìm thẳng cột icon mục**: ô tìm `px-5` = khe `p-2` + mục `px-3`. Đổi một bên thì đổi cả hai.
|
|
411
|
+
- **Nhãn nhóm gần nhóm của nó**: `pt-5 pb-1`. **Đo từ chữ tới chữ, không từ mép hàng**: mục `h-10` đã có sẵn 10px khoảng thở trên và dưới chữ, nên `pt-4 pb-1.5` nhìn bằng mắt chỉ còn 26px trên, 16px dưới, nhãn vẫn lơ lửng giữa hai nhóm. `pt-5 pb-1` ra 30px trên, 14px dưới, khoảng gấp đôi, mắt gắn nhãn với nhóm bên dưới. Kiểu chữ như nhãn nhóm sidebar (`app.md`): IN HOA bằng CSS, xám. Không kẻ đường giữa các nhóm.
|
|
412
|
+
- **Chiều cao theo `I18`**: đo ở trạng thái chưa gõ, xê `max-h` từng bậc 4px tới khi mục cuối lộ khoảng một nửa. Mở palette thì chớp thanh cuộn một lần (`flashScrollbar`).
|
|
413
|
+
- **Khe hai bên bằng nhau dù có cuộn hay không**: thanh cuộn 4px chiếm chỗ bên phải, nên khung danh sách `p-2` thì khe phải thành 12px, khe trái 8px, lệch 4px mỗi khi danh sách dài. Sửa bằng `pr-1` + `[scrollbar-gutter:stable]`: luôn giữ chỗ 4px cho thanh, nên lọc còn một kết quả thì khe vẫn y như lúc cuộn.
|
|
414
|
+
- **Rãnh thanh cuộn lùi `mt-2 mb-4`** (`[&::-webkit-scrollbar-track]:mt-2 [&::-webkit-scrollbar-track]:mb-4`). Đầu trên nằm dưới đường kẻ thẳng của ô tìm, lùi 8px để thanh không dính đường kẻ. Đầu dưới chạm góc bo 16px, **lùi bằng bán kính góc bo**: đầu tròn của thanh 4px sát mép chỉ nằm trọn trong góc bo khi cách đáy từ 14px (16 − 2). `my-2` cũ để đuôi bị vát mép phải khoảng 2px, thấy rõ khi phóng to 4 lần.
|
|
415
|
+
- **Mục sáng là một**: mở ra thì mục đầu sáng sẵn, Enter chạy nó; chuột và phím mũi tên dời cùng một chỗ sáng (`I13`). cmdk dùng `data-[selected=true]:`, Radix dùng `data-[highlighted]:`. Phím mũi tên đi tới mục khuất thì cuộn nó vào tầm nhìn (`block: "nearest"`).
|
|
416
|
+
- **Lọc không phân biệt dấu** ("tai" ra "Tài liệu"). **Chỉ khớp theo tên mục và từ khoá riêng của mục** (từ đồng nghĩa, tên tiếng Anh), không khớp theo tên nhóm: gõ "tai" mà cả nhóm TÀI KHOẢN hiện ra thì "Cài đặt", "Hồ sơ của bạn" nằm trong kết quả không rõ vì sao. Khớp đầu từ xếp trước. Nhóm không còn mục nào thì ẩn luôn nhãn.
|
|
417
|
+
- **Tên dài `truncate` kèm `title`** (`T14`).
|
|
418
|
+
- **Không có kết quả**: một dòng `py-10 text-center text-sm text-muted`, "Không có trang nào khớp. Thử gõ ngắn hơn." **Không nhắc lại từ khoá**: nó nằm ngay ô tìm phía trên (`N3`), và từ khoá dài bị cắt giữa chữ thành "của phò…".
|
|
419
|
+
- **Esc đóng, bấm ra ngoài cũng đóng.** Ngoại lệ có tên của `I20`: ô duy nhất là ô tìm, đóng lỡ tay không mất gì.
|
|
420
|
+
- Chuyển động như modal (bảng "Chuyển động"). Không dựng hàng gợi ý phím (↑↓ ↵ Esc) ở đáy khi chưa được yêu cầu.
|
|
421
|
+
|
|
422
|
+
## Panel thông báo
|
|
423
|
+
|
|
424
|
+
Mở tại chỗ từ nút chuông trên header (`I24`), đóng bằng bấm ra ngoài hay Esc (`I21`).
|
|
425
|
+
|
|
426
|
+
```
|
|
427
|
+
[🔔•] <- nút chuông: lúc mở có nền như hover
|
|
428
|
+
┌─────────────────────────────────┐ <- mép trên cách đường kẻ header 8px
|
|
429
|
+
│ Thông báo Đánh dấu đã đọc │ <- hành động duy nhất, ghost h-8
|
|
430
|
+
│ [Tất cả] Chưa đọc Nhắc đến bạn │
|
|
431
|
+
├─────────────────────────────────┤
|
|
432
|
+
│ (L) **Lan Anh** đã nhắc đến • │ <- chưa đọc: chữ đậm màu, chấm phải
|
|
433
|
+
│ bạn trong **Website…** │ <- tiêu đề tối đa 2 dòng
|
|
434
|
+
│ Anh xem lại giúp em… │
|
|
435
|
+
│ 3 giờ trước │
|
|
436
|
+
│ (H) Hoàng Minh Đức đã giao… │ <- đã đọc: tiêu đề foreground/70
|
|
437
|
+
└─────────────────────────────────┘
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
- **Khung**: popover `w-96 max-w-[calc(100vw-2rem)] rounded-2xl border border-border bg-surface-overlay shadow-popover`, neo mép phải nút chuông, portal ra `body` (`I22`).
|
|
441
|
+
- **Mép trên không đứng sát đường kẻ header.** Panel neo theo nút với khoảng cách mặc định thì mép trên thường rơi cách đường kẻ header vài px, đường kẻ chọc vào góc bo của panel như hai thứ suýt khớp. Chỉnh khoảng cách (`sideOffset`) cho mép trên panel nằm **dưới đường kẻ 8px**. Luật chung cho mọi khối nổi mở từ header.
|
|
442
|
+
- **Header: tiêu đề bên trái, "Đánh dấu đã đọc" bên phải**, nút `ghost h-8`, chỉ chữ (`I1`). Chữ nút thẳng mép phải với chấm chưa đọc bên dưới: header bớt padding phải đúng bằng `px` của nút, không `-mr-3` (`button.md`, `N11`). Mọi panel thông báo thật đều có nút này; thiếu nó thì muốn dọn bốn chấm người dùng phải bấm vào bốn thông báo. Không còn gì chưa đọc thì ẩn nút. Đánh dấu cả danh sách hay chỉ tab đang xem là logic của dự án, handler để rỗng (`I25`). Không thêm icon bánh răng hay nút ⋯ khi chưa được yêu cầu.
|
|
443
|
+
- **Tab `boxed`** như tab trạng thái trên bảng (`components/small-controls.md`): chữ trơn, không số đếm (chấm trên chuông đã báo có chưa đọc).
|
|
444
|
+
- **Chiều cao danh sách = chiều cao của tab Tất cả**, chặn trên `max-h-[min(28rem,calc(100dvh-13rem))]`, cuộn trong khung theo `I18` (đầu dưới rãnh chạm góc bo: `mb-4`). Tab Tất cả chứa mọi thứ nên luôn cao nhất; panel mở ở tab đó, đo chiều cao danh sách một lần rồi đặt làm `min-height` cho các tab còn lại: bấm sang tab rỗng hay tab ít mục thì mép dưới đứng yên (`N1`). Không có thông báo nào thì panel gọn theo câu rỗng (`py-10`). Chiều cao đo ở tab Tất cả là chiều cao **đã hạ cho mục cuối lộ nửa** (`getPeekListHeight`, `I18`): thông báo cao thấp khác nhau nên không chốt được một con số.
|
|
445
|
+
Đã thử hai cách và bỏ: `min-h-72` thì sang tab rỗng vẫn sụp 448 → 288px; **cao cố định** thì hết sụp, nhưng lúc chưa có thông báo nào panel là một khối trắng 563px với một dòng chữ xám giữa lòng, trông như tải chưa xong.
|
|
446
|
+
- **Mỗi thông báo là một link rộng hết hàng** (`I29`): `flex gap-3 rounded-lg px-3 py-3 hover:bg-item-hover`, khung danh sách `p-2` (`M19`: 16 = 8 + 8). Mục nhiều dòng nên khe 8px như command palette, không 4px như menu. Bấm thì mở đối tượng và đánh dấu đã đọc, handler rỗng.
|
|
447
|
+
- **Dòng tiêu đề**: tên người và tên đối tượng `font-medium`, động từ thường ("**Lan Anh** đã nhắc đến bạn trong **Website bán hàng 2026**"). **Tối đa 2 dòng** (`line-clamp-2` + `title`): tên hợp đồng dài làm tiêu đề ba dòng, cộng hai dòng trích thì một thông báo cao bằng ba cái khác, panel mất tác dụng liếc. Câu trích `text-sm text-muted line-clamp-2`, thời gian `text-xs text-muted`.
|
|
448
|
+
- **Chưa đọc và đã đọc liếc là phân biệt được** (`N2`): chưa đọc thì chấm `size-2 rounded-full bg-foreground` bên phải (**không `bg-primary`**, xem ngay dưới), thẳng tâm dòng đầu; đã đọc thì không chấm **và tiêu đề `text-foreground/70`**. Chỉ có chấm thì trong danh sách lẫn lộn, hai mục trông y hệt nhau trừ một chấm 8px ở tận mép phải, nơi mắt đọc tới cuối cùng. Không tô nền cho mục chưa đọc: mười mục chưa đọc thành mười dải xám.
|
|
449
|
+
- **Chấm chưa đọc, tên người, tên đối tượng không đổi theo màu thương hiệu.** Dự án brand xanh lá thì chấm vẫn `--foreground` (đen, nền tối thì trắng), tên vẫn `--foreground font-medium`. Màu nhấn để dành cho nút chính của màn (`M3`): mười thông báo chưa đọc là mười chấm xanh rải dọc panel, đúng cái đã bỏ ở badge sidebar (`I15`). Tên tô màu nhấn thì đọc ra là link, trong khi cả hàng mới là chỗ bấm. Chấm cũng không đỏ: đỏ dành cho lỗi (`M30`).
|
|
450
|
+
- **Avatar `size-8`** theo `components/avatar.md`, thẳng dòng đầu tiêu đề, không căn giữa cả mục.
|
|
451
|
+
- **Rỗng**: một dòng `text-sm text-muted` theo `components/empty-state.md`. Tab Chưa đọc rỗng: "Bạn đã đọc hết thông báo". Chưa có gì: "Chưa có thông báo nào".
|
|
452
|
+
- **Nút chuông**: icon `BellDot` của lucide khi có chưa đọc, **tô đặc chấm bằng `[&_circle]:fill-current`**, `Bell` khi không. `BellDot` gốc vẽ chấm bằng nét viền, không tô: ở `size-5` nó chỉ là một vòng tròn rỗng 6px, trông như chữ o lạc vào icon. Chuông đã khoét sẵn khe quanh chấm, đừng tự đè thêm chấm `absolute`. `aria-label` kèm số: "Thông báo, 4 chưa đọc". **Lúc panel mở, nút có nền như hover** (`aria-expanded:bg-foreground/5`), để biết panel mọc ra từ đâu.
|
|
453
|
+
- Chuyển động như dropdown (bảng "Chuyển động").
|
|
454
|
+
|
|
455
|
+
## Toast
|
|
456
|
+
|
|
457
|
+
Góc trên phải hoặc đáy giữa, chọn một chỗ rồi dùng suốt.
|
|
458
|
+
|
|
459
|
+
**Vùng đọc cho trình đọc màn hình là cái khung chứa toast, có sẵn từ lúc tải trang**
|
|
460
|
+
(`<section aria-live="polite">`, như Sonner), không phải từng toast. Gắn `role="status"`
|
|
461
|
+
lên chính toast vừa chèn vào thì nhiều trình đọc màn hình không đọc. Dự án có Sonner
|
|
462
|
+
hay toast của shadcn thì dùng nó, nó lo sẵn.
|
|
463
|
+
|
|
464
|
+
**Màn hẹp dưới `sm` thì toast lên đỉnh màn, giữa.** Đáy màn là chỗ của nút chính của
|
|
465
|
+
form; toast bật ra ở đáy đúng lúc vừa bấm nút là che mất nửa nút đó trong 4 giây.
|
|
466
|
+
|
|
467
|
+
```
|
|
468
|
+
[✓] Đã sao chép liên kết <- xong việc: một dòng, co theo chữ
|
|
469
|
+
|
|
470
|
+
[✓] Đã xoá đơn #2041 [Hoàn tác] <- có hành động
|
|
471
|
+
|
|
472
|
+
[!] Không lưu được thay đổi [Thử lại] [✕] <- lỗi: hai tầng
|
|
473
|
+
Mất kết nối mạng
|
|
474
|
+
```
|
|
475
|
+
|
|
476
|
+
```html
|
|
477
|
+
<div role="status" class="flex w-auto min-w-72 max-w-md items-center gap-3 rounded-2xl border border-border bg-surface-overlay py-3 pl-4 pr-3 shadow-popover">
|
|
478
|
+
<i data-lucide="circle-check" class="size-5 shrink-0 text-emerald-600"></i>
|
|
479
|
+
<p class="min-w-0 flex-1 text-sm">Đã xoá đơn #2041</p>
|
|
480
|
+
<div class="flex shrink-0 items-center gap-1">
|
|
481
|
+
<!-- Hành động: nút thật, không phải chữ đậm trơn -->
|
|
482
|
+
<button type="button" class="h-8 cursor-pointer rounded-lg px-3 text-sm font-medium hover:bg-item-hover outline-hidden">Hoàn tác</button>
|
|
483
|
+
</div>
|
|
484
|
+
</div>
|
|
485
|
+
```
|
|
486
|
+
|
|
487
|
+
- **Rộng theo nội dung**: `w-auto min-w-72 max-w-md`. Không đặt bề rộng cố định, toast ngắn sẽ trống nửa khung bên phải.
|
|
488
|
+
- **Thứ tự cố định `[icon] [chữ] [hành động] [✕]`**, cụm hành động và ✕ luôn dồn sát mép phải. Không toast nào để nút lùi vào giữa.
|
|
489
|
+
- **Hành động là nút `h-8`** kiểu ghost, có hover, không vòng focus (`I13`). Chữ đậm trơn thì không đọc ra là bấm được (tinh thần `I7`). Tối đa một hành động.
|
|
490
|
+
- **Chữ dài quá một dòng thì tách hai tầng**, không để một câu vỡ thành ba dòng: tầng trên `text-sm font-medium` nói chuyện gì xảy ra, tầng dưới `text-sm text-muted` nói vì sao. Cụm nút căn giữa theo chiều dọc của cả khối.
|
|
491
|
+
- **Chữ không lặp lại nút** (`M6`): đã có nút Thử lại thì câu không ghi "rồi thử lại".
|
|
492
|
+
- Viết như câu thường: không viết hoa danh từ giữa câu ("đơn #2041", không "Đơn #2041"), không dấu chấm cuối toast một dòng. Cả bộ một kiểu.
|
|
493
|
+
- Tiêu đề và mô tả toast `text-pretty` (`T10`): màn hẹp toast rộng gần hết màn, câu dài vừa quá một dòng là rớt một chữ ("…xong sẽ báo" / "bạn"). Câu dài thì tách: tiêu đề ngắn nói việc, phần còn lại xuống dòng mô tả. **Tên do người dùng đặt** (tên việc, tên tệp, tên khách) trong toast cắt bằng số ký tự trong JS, giữ ~30 ký tự + `…` trong cặp ngoặc kép, như từ khoá ở `../components/empty-state.md`: tên việc dài bốn mươi chữ đặt nguyên vào câu là tiêu đề toast vỡ ba dòng.
|
|
494
|
+
- Icon `size-5` màu theo nghĩa (`rules-color.md`): xong `emerald-600`, lỗi `red-600`. Nền toast vẫn `--surface`, không tô nền màu.
|
|
495
|
+
- Toast báo xong việc: **không nút đóng** (vì nó tự tắt). Toast có Hoàn tác cũng không nút đóng.
|
|
496
|
+
- Toast báo hỏng: **có nút Thử lại và nút đóng** (vì nó không tự tắt), `role="alert"` thay cho `role="status"`.
|
|
497
|
+
- Nhiều toast cùng lúc thì xếp chồng cột, `gap-2`, cái mới nhất gần mép màn nhất. Tối đa 3 cái.
|
|
498
|
+
- Gợi ý thời gian (người dùng quyết): tự tắt sau khoảng 4 giây, có Hoàn tác thì lâu hơn và dừng đếm khi rê chuột vào. Hoàn tác, Thử lại gọi gì là handler rỗng (`onUndo`, `onRetry`).
|
|
499
|
+
- **Toast có chuyển động vào và ra** theo bảng "Chuyển động" bên dưới: trượt lên từ đáy (màn hẹp: trượt xuống từ đỉnh), hết giờ thì trượt về. Quãng trượt là cả chiều cao toast, không phải 8px: 8px trong 200ms mắt gần như không bắt được, toast trông như bật "phựt" ra.
|
|
500
|
+
- **Bẫy: `{toast && <Toast />}` thì không có chuyển động ra.** Hết giờ là phần tử bị gỡ khỏi DOM ngay, không còn gì để trượt. Giữ toast trong DOM qua một pha "đang đóng": đổi cờ → chạy chuyển động ra → `onTransitionEnd`/`onAnimationEnd` mới gỡ. Dự án có Sonner thì nó lo sẵn, dùng nó.
|
|
501
|
+
- **Toast mới thay toast cũ** thì đổi `key` để toast mới chạy lại chuyển động vào, không đổi chữ tại chỗ trong cùng một khung.
|
|
502
|
+
- `motion-reduce`: chỉ `opacity`, không trượt.
|
|
503
|
+
- **Toast có email thì email xuống tầng dưới**, tầng trên là việc vừa xong ("Đã gửi lại lời mời"), tầng dưới là email bọc `EmailText` (`../components/description-list.md`) để xuống dòng sau `@`. **Không đặt `wrap-anywhere` cho cả khối chữ của toast**: nó bẻ bất kỳ chỗ nào, ra "…@congtymi" / "nhphat.com.vn". Câu một dòng thì không cần bẻ; tầng nào có chuỗi dài không dấu cách (email, link) thì bọc riêng chuỗi đó.
|
|
504
|
+
- Duyệt toast thì bày **từng loại một bản tĩnh** cạnh nhau, không dựng nút bấm giả lỗi (phạm vi ở `../../SKILL.md`).
|
|
505
|
+
|
|
506
|
+
---
|
|
507
|
+
|
|
508
|
+
## Chuyển động
|
|
509
|
+
|
|
510
|
+
Mọi khối nổi **có chuyển động vào và ra**, không bật "phựt" ra. Chuyển động phải nói
|
|
511
|
+
được **nó đến từ đâu**: dropdown mọc ra từ nút, panel trượt vào từ mép, toast trồi lên
|
|
512
|
+
từ mép màn. Một nguồn cho cả app: modal, dropdown, select, date picker, panel, toast đều
|
|
513
|
+
lấy số ở bảng này.
|
|
514
|
+
|
|
515
|
+
| Khối | Vào | Ra |
|
|
516
|
+
| --- | --- | --- |
|
|
517
|
+
| **Modal, hộp xác nhận** | `opacity 0→1` + `scale-95→100`, gốc ở tâm, **150ms `ease-out`** | ngược lại, **100ms `ease-in`** |
|
|
518
|
+
| **Lớp phủ** (sau modal, panel) | `opacity 0→1`, **cùng thời gian và đường cong với khối nó đi kèm** (sau modal 150ms, sau panel 500ms) | cùng thời gian với khối lúc ra |
|
|
519
|
+
| **Panel trượt** | **chỉ** `translate-x-full → 0`, không `scale`, không `opacity` trên panel. **500ms** `cubic-bezier(0.32,0.72,0,1)` (đường cong sheet của iOS, `vaul`) | `0 → translate-x-full`, **350ms** cùng đường cong |
|
|
520
|
+
| **Dropdown, popover, select, date picker** | `opacity` + `scale-95→100` + **dịch 4px từ phía nút**: mở xuống thì từ trên xuống (`-translate-y-1 → 0`), lật lên thì từ dưới lên (`translate-y-1 → 0`). Gốc biến hình ở mép gần nút. 150ms `ease-out` | `opacity` + `scale-95`, 100ms `ease-in`, không dịch |
|
|
521
|
+
| **Toast** | trượt ra từ mép màn gần nhất, **quãng bằng cả chiều cao toast**: toast ở đáy `translate-y-full → 0` (dưới lên), toast ở đỉnh (màn hẹp) `-translate-y-full → 0`; kèm `opacity 0→1`. **300ms `ease-out`** | trượt ngược về mép đã ra + `opacity → 0`, **200ms `ease-in`** |
|
|
522
|
+
| **Tooltip** | chỉ `opacity`, trễ 300–500ms mới hiện, 100ms | 100ms |
|
|
523
|
+
| **Sidebar thu gọn, nhóm mở đóng** | theo `app.md`: `transition-[width]` và `grid-rows`, 200ms | như vào |
|
|
524
|
+
|
|
525
|
+
- **Tooltip mang nội dung người dùng đặt** (tên tệp, tên dự án, email) thì không `whitespace-nowrap`: `max-w-[min(20rem,calc(100vw-1rem))] whitespace-normal wrap-anywhere`. Tooltip nhãn nút ngắn ("Đơn trước") giữ một dòng. Chi tiết và ca đã dính ở `../components/tree.md`.
|
|
526
|
+
|
|
527
|
+
- **Viết `scale`, `translate` vào danh sách chuyển động.** Tailwind v4: `transition-[opacity,transform]`
|
|
528
|
+
không chạy `scale-95` hay `translate-y-1`, khung nhảy cỡ rồi mới mờ (`W10`). Dùng
|
|
529
|
+
`transition-[opacity,scale,translate]`. Đừng ghép `transition-transform` với `transition-opacity`:
|
|
530
|
+
cả hai cùng ghi `transition-property`, class sau đè class trước, còn lại một thứ chạy.
|
|
531
|
+
- **Khung hộp thoại không nằm trong lớp nền mờ đang chuyển `opacity`.** Lớp nền (`bg-black/30`) và
|
|
532
|
+
khung là hai anh em trong một khối `fixed` đứng yên, mỗi cái tự mờ, cùng thời lượng. Khung là
|
|
533
|
+
con của lớp nền thì độ mờ nhân nhau: lúc đóng khung tan nhanh hơn nền, nhìn giật. **Mở khoá cuộn trang sau khi chạy xong**, không ngay lúc bấm đóng: thanh cuộn hiện
|
|
534
|
+
lại giữa chừng làm cả trang dưới lớp nền xô ngang.
|
|
535
|
+
- **Ra nhanh hơn vào.** Vào `ease-out` (nhanh đầu, chậm cuối, như đồ vật đặt xuống), ra `ease-in` và ngắn hơn: người đã bấm đóng thì không muốn chờ.
|
|
536
|
+
- **Bẫy khi dựng panel** — panel "chạy từ trong ra, cách lề một khoảng rồi giật mạnh vào lề", tooltip nhấp nháy, cả chuyển động giật cục:
|
|
537
|
+
- **Panel dính `zoom-in-95` chép từ modal.** Phóng 95% quanh tâm thì mép phải panel bắt đầu cách lề màn ~11px, chạy xong mới nhảy vào lề. Panel chỉ `translate`, **không bao giờ `scale`**: nó đến từ mép, không mọc từ tâm.
|
|
538
|
+
- **Radix (Dialog, Sheet) chỉ chờ `@keyframes`, không chờ `transition`.** Presence của Radix đọc `animation-name` để biết khi nào gỡ phần tử; viết bằng `transition` thì lúc mở phần tử gắn vào đã ở vị trí cuối (không chạy), lúc đóng bị gỡ ngay (giật mất). Với Radix dùng `data-[state=open]:animate-in data-[state=open]:slide-in-from-right data-[state=closed]:animate-out data-[state=closed]:slide-out-to-right` (`tw-animate-css`, mặc định dịch 100%). Không Radix thì dùng `transition-transform` nhưng giữ phần tử trong DOM suốt lúc đóng.
|
|
539
|
+
- **Hai cơ chế chạy cùng lúc**: `transition-all` trên panel cộng thêm keyframe của `animate-in` là hai chuyển động giành nhau, ra cảnh giật. Chọn một.
|
|
540
|
+
- **Tiêu điểm nhảy vào nút icon có tooltip** ngay lúc mở: Radix tự focus phần tử bấm được đầu tiên (nút ⋯), tooltip của Radix mở ngay khi focus, không chờ trễ, nên nó nhấp nháy và chạy theo panel. Mở panel thì **đưa tiêu điểm vào chính khung panel hoặc tiêu đề** (`tabIndex={-1}`, `onOpenAutoFocus={(e) => { e.preventDefault(); panelRef.current?.focus() }}`); có ô nhập thì vào ô nhập đầu tiên. Không bao giờ vào nút icon.
|
|
541
|
+
- **`backdrop-blur` trên lớp phủ** bắt trình duyệt làm mờ cả trang mỗi khung hình, chuyển động rớt khung. Lớp phủ chỉ là màu đen trong suốt.
|
|
542
|
+
- **Animate `right`, `left`, `width` của panel** thay vì `transform` là tính lại bố cục mỗi khung hình. Chỉ `translate`; thêm `will-change-transform` nếu vẫn rớt khung.
|
|
543
|
+
- **Không nảy, không lố.** Không `spring` vượt đích, không `scale` dưới 95%, dropdown không dịch quá 8px. Đồ vật trong app làm việc thì đặt xuống, không nhảy ra.
|
|
544
|
+
- **Chỉ `transform` và `opacity`.** Không animate `height`, `top`, `left`, `width` (trừ sidebar thu gọn, đã có lý do riêng ở `app.md`): làm trang giật và tính lại bố cục mỗi khung hình.
|
|
545
|
+
- **Gốc biến hình đúng mép**: dropdown mở từ nút bên phải thì `origin-top-right`. Dùng Radix thì lấy sẵn `origin-(--radix-dropdown-menu-content-transform-origin)` (popover, select có biến tương tự), nó tự đổi khi menu lật.
|
|
546
|
+
- **Cách viết**: có Radix/shadcn thì dùng `data-[state=open]:animate-in data-[state=closed]:animate-out fade-in-0 zoom-in-95 slide-in-from-top-1` của `tw-animate-css` (Tailwind v4; v3 là `tailwindcss-animate`). Tự dựng thì `transition` + thuộc tính `data-state`, lúc vào dùng `@starting-style`, lúc ra giữ phần tử trong DOM tới khi chạy xong (`transition-behavior: allow-discrete` hoặc chờ `transitionend`).
|
|
547
|
+
- **`motion-reduce:`** tắt `scale` và `translate`, chỉ giữ `opacity` (hoặc tắt hẳn): người bật giảm chuyển động bị chóng mặt vì chuyển động chứ không vì mờ dần.
|
|
548
|
+
- Không chuyển động khi **tải trang**: không cho cả trang hay từng card mờ dần vào.
|
|
549
|
+
|
|
550
|
+
- **Panel 500/350ms là số chủ dự án chốt**: 300/200ms thì vụt qua như giật, `linear` 500ms thì cứng và chậm. Đường cong này chạy nhanh ở đầu rồi đậu êm, nên 500ms không thấy chậm. Đừng rút ngắn cho "nhanh hơn".
|
|
551
|
+
- **Sửa chuyển động thì duyệt bằng video, không bằng số đo**: quay tốc độ thật và bản chậm 4 lần (DevTools, Animations, 25%). Giật, lố, nhảy lề chỉ lộ ra trong bản chậm.
|
|
552
|
+
|