@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,198 @@
|
|
|
1
|
+
# Đang tải (loading)
|
|
2
|
+
|
|
3
|
+
Câu cần hỏi trước khi vẽ: **người dùng đang chờ cái gì, và trên màn đã có gì rồi.**
|
|
4
|
+
Mỗi ca một hình. Lỗi hay gặp nhất của AI là dùng một hình cho mọi ca: cứ chờ là
|
|
5
|
+
skeleton, cứ bấm là spinner.
|
|
6
|
+
|
|
7
|
+
| Ca | Hình |
|
|
8
|
+
| --- | --- |
|
|
9
|
+
| Tải lần đầu, dữ liệu qua mạng | Khung chờ đúng hình (mục dưới) |
|
|
10
|
+
| Tải lần đầu, dữ liệu đọc từ máy (localStorage, state sẵn có) | Không khung chờ, không spinner. Một dòng chữ mờ như `empty-state.md`, chỉ khác chữ |
|
|
11
|
+
| **Tải lần hai**: đổi lọc, đổi trang, đổi tab, gõ tìm | **Giữ dữ liệu cũ** + thanh mảnh trên đầu khung |
|
|
12
|
+
| **Đổi ngay trên màn**: toggle, tick, kéo thẻ, đổi thứ tự | Đổi luôn, không spinner. Lỗi thì trả lại như cũ + toast |
|
|
13
|
+
| Bấm nút gửi, lưu, tạo | Spinner thế chỗ icon trong nút (`button.md`) |
|
|
14
|
+
| Tự lưu (cài đặt, trình soạn thảo) | Dấu "Đang lưu… / Đã lưu" cạnh chỗ vừa đổi |
|
|
15
|
+
| "Xem thêm" cuối danh sách | Spinner đè giữa nút, nút giữ bề rộng (`timeline.md`) |
|
|
16
|
+
| **Việc chạy lâu**: xuất tệp, nhập dữ liệu | Có tiến độ, chạy nền, xong báo toast |
|
|
17
|
+
| Tải tệp lên | `file-upload.md` |
|
|
18
|
+
| AI đang trả lời | `chat.md` |
|
|
19
|
+
|
|
20
|
+
Không bao giờ một vòng xoay giữa màn thay cho cả trang (`I19`). Chuyển trang thì
|
|
21
|
+
header, sidebar đứng yên, chỉ vùng nội dung thành khung chờ.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## Khung chờ (dữ liệu qua mạng, lần đầu)
|
|
26
|
+
|
|
27
|
+
Theo `I19`: khung chờ **đúng hình** dòng thật, để lúc dữ liệu về trang không nhảy.
|
|
28
|
+
|
|
29
|
+
```html
|
|
30
|
+
<ul aria-busy="true" class="divide-y divide-border">
|
|
31
|
+
<li class="flex items-center gap-3 px-4 py-3"> <!-- cùng padding, cùng divide với dòng thật -->
|
|
32
|
+
<div class="size-8 shrink-0 animate-pulse rounded-full bg-foreground/5 motion-reduce:animate-none"></div>
|
|
33
|
+
<div class="min-w-0 flex-1 space-y-2">
|
|
34
|
+
<div class="h-3 w-2/5 animate-pulse rounded-full bg-foreground/5 motion-reduce:animate-none"></div>
|
|
35
|
+
<div class="h-3 w-1/4 animate-pulse rounded-full bg-foreground/5 motion-reduce:animate-none"></div>
|
|
36
|
+
</div>
|
|
37
|
+
<div class="h-3 w-16 animate-pulse rounded-full bg-foreground/5 motion-reduce:animate-none"></div>
|
|
38
|
+
</li>
|
|
39
|
+
</ul>
|
|
40
|
+
<span class="sr-only" role="status">Đang tải danh sách khách hàng</span>
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
- **Vệt chờ là lớp phủ `bg-foreground/5`, không `bg-background`** (`M21`). Bản sáng hai cách ra cùng một màu
|
|
44
|
+
(`#f4f4f4` / `#f4f4f6` trên card trắng); bản tối `bg-background` tối hơn card, cả khung chờ thành dãy lỗ đen
|
|
45
|
+
khoét xuống, còn lớp phủ thì sáng lên mờ như mọi vùng tô khác.
|
|
46
|
+
- **Mượn nguyên khuôn dòng thật**: cùng cỡ avatar, cùng padding, cùng đường chia `divide-y`. Dòng thật có đường chia mà khung chờ không có thì lúc dữ liệu về vẫn thấy cả khối đổi hình.
|
|
47
|
+
- **Thanh chữ cao `h-3`, chiều dài lệch nhau** giữa các dòng (`w-2/5`, `w-1/2`, `w-1/3`…). Dài bằng nhau thì trông như sọc kẻ, không giống chữ.
|
|
48
|
+
- Số dòng bằng số dòng mỗi trang, hoặc đủ lấp khung, không bịa 3 dòng cho một khung 10 dòng.
|
|
49
|
+
- **Bảng nhóm thì khung chờ bắt đầu bằng một hàng nhóm**, không thẳng vào dòng dữ liệu, kẻo lúc dữ liệu về cả bảng tụt một hàng (`../layouts/app.md`, mục Bảng nhóm theo trạng thái).
|
|
50
|
+
- `animate-pulse` luôn đi kèm `motion-reduce:animate-none`. Trình đọc màn hình không thấy khung, nên phải có `aria-busy` và một câu `sr-only`.
|
|
51
|
+
- **Không bao giờ hiện câu rỗng trong lúc chờ.** "Chưa có khách hàng nào" chớp lên rồi danh sách hiện ra là người dùng vừa được báo sai một lần. Chưa biết rỗng hay không thì là đang tải.
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
## Tải lần hai: giữ dữ liệu cũ
|
|
56
|
+
|
|
57
|
+
Đổi lọc, đổi trang, đổi tab, gõ tìm, kéo lại: trên màn **đã có dữ liệu**. Thay cả bảng
|
|
58
|
+
bằng khung chờ lần nữa là xoá mất thứ người dùng đang nhìn, rồi vẽ lại; mỗi phím gõ
|
|
59
|
+
trong ô tìm là bảng chớp một lần. Giữ nguyên dữ liệu cũ tới khi dữ liệu mới về, chỉ
|
|
60
|
+
báo "đang chạy" bằng một thanh mảnh chạy ở mép trên khung.
|
|
61
|
+
|
|
62
|
+
```html
|
|
63
|
+
<div aria-busy="true" class="relative overflow-hidden rounded-2xl border border-border">
|
|
64
|
+
<!-- Thanh chạy: chỉ có khi đang tải lại -->
|
|
65
|
+
<div class="absolute inset-x-0 top-0 h-0.5 overflow-hidden">
|
|
66
|
+
<div class="h-full w-1/3 animate-progress-slide bg-primary motion-reduce:animate-pulse"></div>
|
|
67
|
+
</div>
|
|
68
|
+
<table>…dữ liệu cũ, giữ nguyên…</table>
|
|
69
|
+
</div>
|
|
70
|
+
<span class="sr-only" role="status">Đang tải kết quả</span>
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
```css
|
|
74
|
+
@theme {
|
|
75
|
+
--animate-progress-slide: progress-slide 1.2s ease-in-out infinite;
|
|
76
|
+
@keyframes progress-slide {
|
|
77
|
+
from { translate: -100% 0; }
|
|
78
|
+
to { translate: 300% 0; }
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
- **Dữ liệu cũ không mờ, không khoá.** Mờ đi là chữ tụt dưới ngưỡng tương phản và đọc ra là "bị khoá"; người dùng vẫn đọc, vẫn chọn được dòng cũ trong lúc chờ.
|
|
84
|
+
- **Thanh nằm trong khung đang tải**, không ở đỉnh trang: đổi lọc của bảng thì thanh ở mép bảng, mắt đang nhìn đúng chỗ đó. Thanh đè lên viền trên, không chèn thêm chỗ (`N1`).
|
|
85
|
+
- **Thứ vừa bấm đổi ngay**: chip lọc đã chọn, tab đã chuyển, số trang đã tô. Chỉ phần dữ liệu là chờ.
|
|
86
|
+
- Kết quả về là 0 dòng thì sang câu rỗng (`empty-state.md`). **Tải lần hai hỏng thì giữ dữ liệu cũ, báo bằng toast lỗi có Thử lại** (`../layouts/overlay.md`); khối "Không tải được…" của `empty-state.md` chỉ dành cho khi chưa có gì trên màn.
|
|
87
|
+
- **Hỏng thì tab, chip, số trang trả về giá trị khớp dữ liệu đang hiện**, như ca đổi ngay trên màn. Để tab "Đang giao dịch 19" sáng trên một bảng toàn khách của "Tất cả", chân bảng ghi 32, là màn tự nói sai; câu giải thích "bảng đang hiện kết quả trước đó" là vá chỗ sai đó bằng chữ. Toast nói cái chưa tải được ("Chưa tải được khách hàng Đang giao dịch"), Thử lại là bấm lại đúng lựa chọn đó. **Riêng ô tìm giữ nguyên chữ đã gõ**, không xoá thứ người dùng gõ; toast "Chưa tìm được “ng”" + Thử lại.
|
|
88
|
+
- Gõ tìm thì kết quả về sau mới thắng: phản hồi của "ng" về sau phản hồi của "nguyen" không được đè lên. Huỷ hay bỏ phản hồi cũ là logic người dùng (`N10`), skill chỉ nhắc.
|
|
89
|
+
- Dự án có React Query, SWR thì đây là `placeholderData: keepPreviousData` / `keepPreviousData`, thanh mảnh hiện theo `isFetching`; React thuần thì `useTransition`, thanh theo `isPending`.
|
|
90
|
+
|
|
91
|
+
---
|
|
92
|
+
|
|
93
|
+
## Thời điểm hiện: chờ 300ms, đã hiện thì giữ 500ms
|
|
94
|
+
|
|
95
|
+
Dữ liệu về trong 200ms mà vẫn vẽ khung chờ là màn **chớp** một cái: khung hiện rồi
|
|
96
|
+
biến ngay, nhìn như lỗi. Ngược lại, khung vừa hiện được 50ms đã biến cũng chớp.
|
|
97
|
+
|
|
98
|
+
- **Chưa tới 300ms thì chưa vẽ gì**: khung giữ đúng chiều cao, trống. Không câu rỗng, không khung chờ.
|
|
99
|
+
- **Đã hiện thì giữ tối thiểu 500ms**, kể cả dữ liệu về ngay sau đó.
|
|
100
|
+
- Áp cho khung chờ, thanh mảnh tải lần hai, và chữ "Đang lưu…". **Không áp cho spinner trong nút**: spinner thế chỗ icon nên không xô gì, và người vừa bấm cần thấy nút đã nhận.
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
// Hiện cờ đang tải sau delayMs; đã hiện thì giữ ít nhất minVisibleMs.
|
|
104
|
+
export function useDelayedLoading(isLoading: boolean, delayMs = 300, minVisibleMs = 500) {
|
|
105
|
+
const [isVisible, setIsVisible] = useState(false);
|
|
106
|
+
const shownAtRef = useRef(0);
|
|
107
|
+
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
if (isLoading) {
|
|
110
|
+
if (isVisible) return;
|
|
111
|
+
|
|
112
|
+
const showTimer = window.setTimeout(() => {
|
|
113
|
+
shownAtRef.current = Date.now();
|
|
114
|
+
setIsVisible(true);
|
|
115
|
+
}, delayMs);
|
|
116
|
+
return () => window.clearTimeout(showTimer);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (!isVisible) return;
|
|
120
|
+
|
|
121
|
+
const remainingMs = Math.max(0, minVisibleMs - (Date.now() - shownAtRef.current));
|
|
122
|
+
const hideTimer = window.setTimeout(() => setIsVisible(false), remainingMs);
|
|
123
|
+
return () => window.clearTimeout(hideTimer);
|
|
124
|
+
}, [isLoading, isVisible, delayMs, minVisibleMs]);
|
|
125
|
+
|
|
126
|
+
return isVisible;
|
|
127
|
+
}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Lúc giữ 500ms mà dữ liệu đã về thì vẫn hiện dữ liệu ngay; chỉ thanh mảnh hay chữ
|
|
131
|
+
"Đang lưu…" ở lại cho đủ giờ. Khung chờ lần đầu thì đợi đủ giờ rồi mới thay bằng dữ liệu:
|
|
132
|
+
|
|
133
|
+
```tsx
|
|
134
|
+
const isSkeletonVisible = useDelayedLoading(isLoading);
|
|
135
|
+
|
|
136
|
+
{isSkeletonVisible ? <CustomerListSkeleton /> : isLoading ? <div className="min-h-96" /> : <CustomerList />}
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
---
|
|
140
|
+
|
|
141
|
+
## Đổi ngay trên màn
|
|
142
|
+
|
|
143
|
+
Toggle, tick việc xong, kéo thẻ sang cột khác, đổi thứ tự, gắn nhãn, ghim: **đổi
|
|
144
|
+
luôn trên màn lúc bấm**, không spinner, không khoá điều khiển trong lúc chờ máy chủ.
|
|
145
|
+
Đây là việc nhỏ, gần như luôn thành công, và người dùng thường bấm liền mấy cái.
|
|
146
|
+
|
|
147
|
+
- **Lỗi thì trả lại như cũ** (công tắc gạt về, thẻ về cột cũ, đúng vị trí cũ) **kèm toast lỗi** nói cái gì chưa lưu được và có Thử lại: "Chưa chuyển được 'Sửa trang thanh toán' sang Đang làm". Trả lại im lặng thì người dùng tưởng mình bấm trượt. Tên việc dài thì cắt ~30 ký tự + `…` (`../layouts/overlay.md`, Toast), dòng mô tả nói thẻ đang nằm đâu ("Thẻ đã về lại cột Cần làm").
|
|
148
|
+
- Toast lỗi theo `../layouts/overlay.md`: không tự tắt, `role="alert"`. Chỗ đã có dấu tự lưu (hàng cài đặt) thì câu lỗi nằm ở dấu đó, không thêm toast (mục dưới).
|
|
149
|
+
- **Không dùng cho việc không đổi lại được hoặc máy chủ phải quyết**: thanh toán, gửi lời mời, xoá vĩnh viễn, tạo bản ghi mà màn kế tiếp cần mã của nó. Mấy việc đó là nút đang xử lý (`button.md`).
|
|
150
|
+
- Gạt thì đổi tại chỗ, có xảy ra hay không là logic (`N10`); skill chỉ dựng đủ hai hình: đã đổi, và đã trả lại + toast.
|
|
151
|
+
|
|
152
|
+
---
|
|
153
|
+
|
|
154
|
+
## Tự lưu: "Đang lưu… / Đã lưu"
|
|
155
|
+
|
|
156
|
+
Trang cài đặt không nút Lưu tổng (`../layouts/app.md`) và trình soạn thảo tự lưu cần
|
|
157
|
+
một dấu nhỏ nói đã lưu chưa, đặt **cạnh chỗ vừa đổi**: cạnh nhãn của hàng cài đặt,
|
|
158
|
+
cạnh tiêu đề tài liệu ở header trình soạn thảo.
|
|
159
|
+
|
|
160
|
+
```html
|
|
161
|
+
<span role="status" class="inline-flex items-center gap-1 text-xs text-muted">
|
|
162
|
+
<!-- Đang lưu: chỉ chữ -->
|
|
163
|
+
Đang lưu…
|
|
164
|
+
<!-- Đã lưu: <i data-lucide="check" class="size-3.5"></i> Đã lưu -->
|
|
165
|
+
</span>
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
| Trạng thái | Hình |
|
|
169
|
+
| --- | --- |
|
|
170
|
+
| Đang lưu | "Đang lưu…" `text-xs text-muted`, không spinner. Hiện theo luật 300ms |
|
|
171
|
+
| Đã lưu | icon `check` `size-3.5` + "Đã lưu", cùng màu `text-muted`, không xanh lá. Cài đặt: tắt sau ~2 giây. Trình soạn thảo: ở lại, thành "Đã lưu lúc 14:32" |
|
|
172
|
+
| Không lưu được | **Điều khiển trả về giá trị đã lưu** (công tắc gạt về, select về lựa chọn cũ), cạnh nhãn "Chưa lưu được" `text-red-600` + link chữ "Thử lại" (áp lại đúng thay đổi vừa hỏng). Ở lại tới khi lưu được. Không thêm toast: lỗi đã nói tại chỗ (`N3`) |
|
|
173
|
+
|
|
174
|
+
- **Hỏng mà công tắc vẫn nằm ở vị trí mới thì màn đang nói sai**: công tắc tắt cạnh "Chưa lưu được", người đọc không biết máy chủ đang bật hay tắt. Trả về giá trị đã lưu, như ca đổi ngay trên màn. **Riêng ô chữ giữ nguyên chữ đã gõ**, không xoá thứ người dùng gõ.
|
|
175
|
+
- **Không xanh lá cho "Đã lưu"**: lưu là việc thường xuyên, như dấu `Check` xám của bước công cụ trong `chat.md`. Xanh lá để dành cho trạng thái "đang ổn" (`rules-color.md`).
|
|
176
|
+
- Dấu nằm trong cùng dòng với nhãn, `flex-wrap`, đổi chữ thì chỉ nó dài ra, không đẩy điều khiển (`N1`).
|
|
177
|
+
- Công tắc mở luồng (quét mã, nhập mật khẩu) thì không có dấu này, xem `choice-controls.md`.
|
|
178
|
+
|
|
179
|
+
---
|
|
180
|
+
|
|
181
|
+
## Việc chạy lâu
|
|
182
|
+
|
|
183
|
+
Xuất tệp, nhập dữ liệu, tạo báo cáo, chạy hàng loạt: vài giây tới vài phút. Ba điều:
|
|
184
|
+
|
|
185
|
+
1. **Có tiến độ.** Biết tổng thì thanh có số: "Đã nhập 340 / 1.200 dòng", thanh `h-2` `role="progressbar"` (`charts.md`, "Thanh tiến độ đứng riêng"). Không biết tổng thì chữ nói đang làm bước nào ("Đang gom đơn tháng 9…"), không thanh chạy vô tận.
|
|
186
|
+
2. **Chạy nền.** Không khoá cả màn bằng modal chờ. Bấm xong thì toast tiến độ ở lại tới khi xong: tiêu đề ngắn "Đang xuất 1.240 đơn", dòng dưới "340 / 1.240 · xong sẽ báo bạn". Tiêu đề dài kiểu "Đang xuất 1.240 đơn, xong sẽ báo bạn" ở màn hẹp rớt một chữ xuống dòng hai (`T10`). Người dùng làm việc khác. Màn nhập dữ liệu có modal thì đóng modal được, việc vẫn chạy.
|
|
187
|
+
3. **Xong báo toast, kèm hành động**: "Đã xuất 1.240 đơn" + nút "Tải xuống"; "Đã nhập 1.180 dòng, 20 dòng lỗi" + "Xem dòng lỗi". Hỏng thì toast lỗi có Thử lại.
|
|
188
|
+
|
|
189
|
+
- Dưới ~3 giây thì không cần chạy nền: nút đang xử lý là đủ (`button.md`).
|
|
190
|
+
- **Nút đã bấm không quay spinner khi toast tiến độ đã hiện** (`N3`): một việc, một chỗ báo đang chạy. Spinner trong nút cộng thanh trong toast là hai tín hiệu cho một việc, mắt không biết nhìn chỗ nào. Nút giữ icon và chữ, không mờ, `aria-disabled` tới khi xong để không ai bấm xuất hai lần (`button.md`, đang xử lý, bỏ phần spinner).
|
|
191
|
+
|
|
192
|
+
---
|
|
193
|
+
|
|
194
|
+
## Spinner
|
|
195
|
+
|
|
196
|
+
- Một hình cho cả app: `LoaderCircle` lucide, `animate-spin motion-reduce:animate-none`, cỡ bằng icon nó thế chỗ (`size-4` trong nút).
|
|
197
|
+
- **Một việc đang chạy, một spinner** (`N3`). Spinner trong nút thì không thêm thanh mảnh, không thêm chữ "Đang gửi…" ở chỗ khác.
|
|
198
|
+
- Spinner chỉ thế chỗ một icon hoặc đè giữa một nút. Không đứng một mình giữa khối thay cho dữ liệu: chỗ đó là khung chờ.
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# Logo sản phẩm ⚑
|
|
2
|
+
|
|
3
|
+
**Dự án đã có logo thì dùng của họ**, không vẽ lại, không chỉnh (logo là nhận diện, `V1` trong
|
|
4
|
+
`review.md`). File này chỉ dùng khi dự án mới chưa có logo mà màn có chỗ cho logo: đầu sidebar,
|
|
5
|
+
màn xác thực (`layouts/form.md`), trang lỗi đứng riêng (`layouts/app.md`). Việc nhỏ hơn một màn
|
|
6
|
+
mà màn đó không có chỗ cho logo thì không vẽ (`S3`).
|
|
7
|
+
|
|
8
|
+
Tìm trước khi vẽ:
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
find . -path ./node_modules -prune -o \( -iname "*logo*" -o -iname "*brand*" -o -iname "icon.svg" -o -iname "favicon.svg" \) -print 2>/dev/null | head
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
**Không tính là đã có logo**: ô chữ cái đầu tên sản phẩm (dấu cũ của skill, tên file kiểu
|
|
15
|
+
`brand-mark`), favicon mặc định của framework (tia sét tím của Vite, logo Next, `favicon.ico` của
|
|
16
|
+
create-react-app). Gặp chúng thì thay theo file này, giữ tên component đang được import.
|
|
17
|
+
|
|
18
|
+
Logo ở đây là **logo đơn giản cho dự án mới**: một dấu hình học và tên sản phẩm. Không phải bộ
|
|
19
|
+
nhận diện của agency; người dùng có logo thật thì thay vào một chỗ.
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## Hình: dấu trong ô màu nhấn, tên bên phải
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
╭────╮
|
|
27
|
+
│ ◐ │ Tên sản phẩm ô size-8 bo 8px, nền màu nhấn, dấu trắng size-5
|
|
28
|
+
╰────╯
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
- **Dấu là SVG vẽ tay, không phải chữ cái trong ô.** Ô chữ cái đầu là cách của avatar và của
|
|
32
|
+
logo công ty giả trong dữ liệu mẫu (`S16`); dùng nó cho logo sản phẩm thì đầu sidebar trông
|
|
33
|
+
như một dòng danh sách, sản phẩm không có mặt.
|
|
34
|
+
- **Không lấy icon lucide làm dấu.** Cùng bộ với icon các mục sidebar ngay bên dưới, logo đọc ra
|
|
35
|
+
là một mục nav.
|
|
36
|
+
- **viewBox `0 0 24 24`, vẽ trong vùng 4–20**, một đến ba hình cơ bản (tròn, vuông bo, cung,
|
|
37
|
+
thanh). Khối đặc, hoặc nét `strokeWidth` từ 2.5; không chi tiết nào mảnh hơn 2 đơn vị, vì ở
|
|
38
|
+
16px (favicon) chúng biến mất.
|
|
39
|
+
- **Một màu, `currentColor`**: không gradient, không bóng, không màu thứ hai. Màu đến từ ô
|
|
40
|
+
(`bg-primary text-primary-foreground`), nên đổi `--primary` là logo đổi theo, nấc Xám của
|
|
41
|
+
wireframe và dark mode tự đúng.
|
|
42
|
+
- **Tên sản phẩm** `text-sm font-semibold text-foreground truncate`, cách ô `gap-2.5`, cùng font
|
|
43
|
+
của app. Không vẽ tên thành SVG, không đổi font riêng cho tên.
|
|
44
|
+
- **Đừng ra hình sáo**: tia sét, ngôi sao lấp lánh, lục giác, khối lập phương, chữ cái trong
|
|
45
|
+
vòng tròn. Mười app mới thì sáu cái dùng các hình này, người dùng nhìn là biết logo đặt tạm.
|
|
46
|
+
Cũng đừng ra hình người ta nhận ra ngay là logo của một hãng lớn.
|
|
47
|
+
|
|
48
|
+
## Ba hướng, nặn từ brief
|
|
49
|
+
|
|
50
|
+
Lấy tên sản phẩm và việc chính ở `U1`, `U2`. Mỗi hướng một dấu:
|
|
51
|
+
|
|
52
|
+
| Hướng | Cách nặn | Ví dụ |
|
|
53
|
+
| --- | --- | --- |
|
|
54
|
+
| **1. Chữ đầu dựng bằng khối** (khuyên dùng khi tên ngắn, dễ nhớ) | Chữ cái đầu tên dựng lại từ hình cơ bản, không gõ bằng font: có một khoảng cắt hay một góc lệch để nó thành dấu, không còn là chữ | "Lumen": chữ L từ hai thanh, góc trong là một chấm tròn |
|
|
55
|
+
| **2. Ẩn dụ việc chính** | Một hình gợi việc người dùng làm, trừu tượng hoá tới mức không còn là icon | đặt lịch: hai ô vuông bo lệch nhau; tài chính: ba thanh tăng dần, thanh cuối là nửa tròn |
|
|
56
|
+
| **3. Hai hình ghép** | Hai hình cơ bản chồng hay cắt nhau, phần giao là khoảng trống | vòng tròn bị một hình vuông cắt mất góc |
|
|
57
|
+
|
|
58
|
+
Ghi một câu vì sao cho mỗi hướng, bám tên hay việc chính, không viết "hiện đại, tối giản".
|
|
59
|
+
|
|
60
|
+
## Kiểm trước khi đưa
|
|
61
|
+
|
|
62
|
+
Render dấu ở **16px, 32px, 64px**, trên nền màu nhấn và trên nền trắng (dấu `text-foreground`),
|
|
63
|
+
xem ảnh:
|
|
64
|
+
|
|
65
|
+
- 16px còn nhận ra hình, không thành một đốm.
|
|
66
|
+
- Ba hướng khác nhau thật khi nhìn nhỏ, không phải một hình xoay ba kiểu.
|
|
67
|
+
- Đặt cạnh icon các mục sidebar không lẫn vào chúng.
|
|
68
|
+
|
|
69
|
+
Hướng nào hỏng một câu thì vẽ lại hướng đó, không đưa lên wireframe.
|
|
70
|
+
|
|
71
|
+
## Code
|
|
72
|
+
|
|
73
|
+
```tsx
|
|
74
|
+
// src/components/product-brand/product-mark.tsx
|
|
75
|
+
import type { ComponentProps } from "react";
|
|
76
|
+
|
|
77
|
+
// Dấu của sản phẩm. Thay path khi có logo thật.
|
|
78
|
+
export function ProductMark(props: ComponentProps<"svg">) {
|
|
79
|
+
return (
|
|
80
|
+
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" {...props}>
|
|
81
|
+
<path d="M6 4h3.5v12.5H18V20H6z" />
|
|
82
|
+
<circle cx="15.5" cy="8.5" r="2.5" />
|
|
83
|
+
</svg>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
```tsx
|
|
89
|
+
// src/components/product-brand/product-brand.tsx
|
|
90
|
+
import { cn } from "@/lib/utils";
|
|
91
|
+
import { ProductMark } from "./product-mark";
|
|
92
|
+
|
|
93
|
+
interface ProductBrandProps {
|
|
94
|
+
name: string;
|
|
95
|
+
className?: string;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export default function ProductBrand({ name, className }: ProductBrandProps) {
|
|
99
|
+
return (
|
|
100
|
+
<span className={cn("flex min-w-0 items-center gap-2.5", className)}>
|
|
101
|
+
<span className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground">
|
|
102
|
+
<ProductMark className="size-5" />
|
|
103
|
+
</span>
|
|
104
|
+
<span className="truncate text-sm font-semibold text-foreground">{name}</span>
|
|
105
|
+
</span>
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
- Sidebar thu gọn: ô đứng yên, tên bị cắt và mờ đi như chữ các mục (`layouts/app.md`, thu gọn
|
|
111
|
+
sidebar). Màn xác thực và trang lỗi đứng riêng dùng đúng component này, không vẽ bản thứ hai.
|
|
112
|
+
- **Favicon từ cùng dấu**: một file SVG gồm ô vuông bo nền màu nhấn và dấu trắng (tab trình duyệt
|
|
113
|
+
sáng hay tối đều thấy). Next.js App Router đặt ở `app/icon.svg`; dự án khác `public/favicon.svg`
|
|
114
|
+
kèm `<link rel="icon" type="image/svg+xml" href="/favicon.svg">`. Xoá favicon mặc định của
|
|
115
|
+
framework để không còn hai cái.
|
|
116
|
+
|
|
117
|
+
## Trong luồng thiết kế
|
|
118
|
+
|
|
119
|
+
- **Đề xin logo** ("làm logo", "dựng logo", "thay logo", "design a logo"; dòng logo ở câu 1 của
|
|
120
|
+
`SKILL.md`): vẽ **trang chọn logo** (dưới), dừng chờ chọn. Đây là cổng duy nhất của lối này.
|
|
121
|
+
Đề có "luôn" ("làm logo luôn") hoặc ghi sẵn hướng thì bỏ trang, dựng thẳng.
|
|
122
|
+
- **Wireframe (`U3`)**: dự án chưa có logo thì thanh công cụ có nhóm **Logo: 1 · 2 · 3** (`?logo=`),
|
|
123
|
+
đổi dấu ở mọi chỗ có logo trên trang. Người dùng chọn logo cùng lúc chọn phương án, không thêm
|
|
124
|
+
cổng. Mở không tham số thì hướng khuyên dùng.
|
|
125
|
+
- **Dựng luôn, không wireframe**: vẽ hướng khuyên dùng, không hỏi.
|
|
126
|
+
|
|
127
|
+
### Trang chọn logo ⚑
|
|
128
|
+
|
|
129
|
+
Một file `$TMPDIR/evon-design/logo.html`, khung và thanh công cụ như wireframe của `U3`
|
|
130
|
+
(`design-process.md`: Tailwind trình duyệt, token của dự án, thanh sáng một dòng, segmented
|
|
131
|
+
control, khung lý do). Chưa chọn thì không đụng file nào của dự án.
|
|
132
|
+
|
|
133
|
+
- **Thanh công cụ**: **Logo: 1 · 2 · 3** (`?logo=`, hướng khuyên dùng có chấm màu nhấn) ·
|
|
134
|
+
**Màu** (công tắc: tắt là nấc Xám, bật là màu nhấn của dự án) · Nhấn (dự án chưa có màu brand,
|
|
135
|
+
như `U3`).
|
|
136
|
+
- **Khung lý do**: tên hướng, nhãn "Khuyên dùng", một câu vì sao bám tên hay việc chính.
|
|
137
|
+
- **Mỗi hướng hiện dấu ở chỗ nó sẽ sống**, không hiện một dấu to giữa trang trắng:
|
|
138
|
+
1. Đầu sidebar thật của dự án (ô, tên, vài mục nav có icon bên dưới), cả bản mở và bản thu gọn.
|
|
139
|
+
2. Khối logo của màn đăng nhập.
|
|
140
|
+
3. Một dải tab trình duyệt sáng và một dải tối, favicon 16px cạnh tên trang.
|
|
141
|
+
4. Hàng cỡ 16, 32, 64px trên nền màu nhấn và trên nền trắng.
|
|
142
|
+
- Chụp ảnh trang ở cả ba hướng, tự xem theo "Kiểm trước khi đưa" (trên) rồi mới gửi.
|
|
143
|
+
- Gửi link bấm được kèm một câu: *"Chọn `1`, `2` hay `3`; trả lời `ok` là dựng hướng khuyên dùng.
|
|
144
|
+
Muốn sửa một hướng thì nói (ví dụ "2 nhưng bo tròn hơn")."* Người dùng xin sửa thì vẽ lại hướng
|
|
145
|
+
đó trên cùng trang, gửi lại.
|
|
146
|
+
- **Lúc giao** một dòng trong phần mặc định của `S15`: *"Logo: hướng [n], [một câu vì sao]. Dấu
|
|
147
|
+
ở `product-mark.tsx`, có logo thật thì thay path ở đó; muốn hướng khác thì nói."*
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# Ô nhập mã OTP
|
|
2
|
+
|
|
3
|
+
Sáu ô một số, dùng ở màn xác thực email, đăng nhập hai bước. Màn đứng riêng giữa
|
|
4
|
+
trang thì theo card một mình (`M29`) và chiều cao `h-12` của form xác thực
|
|
5
|
+
(`layouts/form.md`).
|
|
6
|
+
|
|
7
|
+
```html
|
|
8
|
+
<p class="text-sm text-muted">Mã gồm 6 số vừa được gửi tới</p>
|
|
9
|
+
<p class="text-sm font-medium">
|
|
10
|
+
<!-- In email bằng EmailText (description-list.md), không wrap-anywhere trơn -->
|
|
11
|
+
tran.anh.tuan@gmail.com
|
|
12
|
+
<a href="#" class="ml-1 whitespace-nowrap font-normal text-muted underline underline-offset-2 outline-hidden hover:text-foreground">Đổi email</a>
|
|
13
|
+
</p>
|
|
14
|
+
|
|
15
|
+
<fieldset class="mt-6">
|
|
16
|
+
<legend class="mb-2 text-sm font-medium">Mã xác thực</legend>
|
|
17
|
+
<div class="grid grid-cols-6 gap-2 sm:gap-3">
|
|
18
|
+
<!-- Ô đầu có autocomplete="one-time-code" và maxlength="6": iOS tự điền cả sáu số vào
|
|
19
|
+
ô đầu, onInput chia lại sang năm ô sau. maxlength="1" thì tự điền bị cắt còn một số. -->
|
|
20
|
+
<input inputmode="numeric" autocomplete="one-time-code" maxlength="6" aria-label="Số thứ 1 trên 6"
|
|
21
|
+
class="aspect-square w-full min-w-0 rounded-xl border border-border-strong bg-surface text-center text-2xl font-semibold tabular-nums outline-hidden focus:border-focus focus:ring-2 focus:ring-focus" />
|
|
22
|
+
<!-- 5 ô còn lại y hệt nhưng maxlength="1", aria-label "Số thứ n trên 6", không có autocomplete -->
|
|
23
|
+
</div>
|
|
24
|
+
<!-- Dòng lỗi luôn có mặt, rỗng khi chưa lỗi: giữ chỗ để nút Xác nhận không tụt xuống ngay dưới con trỏ -->
|
|
25
|
+
<p aria-live="polite" class="mt-2 min-h-4 text-xs text-red-600"></p>
|
|
26
|
+
</fieldset>
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
- **Viền ô là `--border-strong`**, như mọi ô nhập (`M14`, `N5`), không lấy `--border` nhạt hơn của card.
|
|
30
|
+
- **Ô vuông `aspect-square`, sáu ô chia đều bề ngang**, `min-w-0` để không tràn ở 375px. Số `text-2xl font-semibold tabular-nums`, căn giữa.
|
|
31
|
+
- **Dán cả mã vào ô nào cũng được**, tách ra sáu ô (cần `onPaste` đọc clipboard; dự án có `input-otp` như InputOTP của shadcn thì dùng nó, nó là một ô thật vẽ thành sáu ô và lo sẵn tự điền, dán); gõ xong một số thì con trỏ sang ô sau; Backspace ở ô trống thì lùi về ô trước. Đây là cách ô phản hồi, skill lo (`N9`). Gõ đủ sáu số thì tự gửi hay chờ bấm Xác nhận là logic, người dùng quyết.
|
|
32
|
+
- **Email đích hiện đậm, đủ, không cắt** (`N8`), kèm link **"Đổi email"**: gõ nhầm email thì đây là lối ra duy nhất (`N6`). **Bấm vào thì về bước trước với dữ liệu đã gõ điền sẵn** (họ tên, email; mật khẩu thì để trống, không mang mật khẩu qua state của router), con trỏ nằm ở ô email vì đó là thứ cần sửa. Về form trống thì người dùng phải gõ lại mọi thứ chỉ để sửa một chữ trong email. Link `whitespace-nowrap`: email dài đẩy nó xuống dòng thì cả cụm "Đổi email" xuống cùng nhau, không bị bẻ thành "Đổi" cuối dòng trên và "email" dòng dưới (`T10`).
|
|
33
|
+
- Mở màn là con trỏ nằm sẵn ở ô đầu.
|
|
34
|
+
- **Đếm ngược gửi lại** dùng `tabular-nums`, không thì chữ số nhảy qua lại mỗi giây (`N1`). Hai trạng thái, dựng đủ cả hai (`N2`):
|
|
35
|
+
- Đang đếm: "Chưa nhận được mã? Gửi lại sau 0:57", cả câu `text-muted`.
|
|
36
|
+
- Hết đếm: "Chưa nhận được mã?" `text-muted` + **"Gửi lại mã"** là nút ghost chữ `text-foreground font-medium`, có hover. Đừng để nó xám như lúc đang đếm, trông như vẫn còn khoá (`I8`).
|
|
37
|
+
- **Bấm "Gửi lại mã" thì báo đã gửi**: dòng đổi thành "Đã gửi mã mới · Gửi lại sau 1:00" trong một vùng `role="status"`, chữ "Đã gửi mã mới" giữ tới khi đếm xong. Xoá sáu ô, con trỏ về ô đầu. Chỉ có bộ đếm chạy lại thì người dùng không chắc bấm đã ăn chưa, và trình đọc màn hình không nghe gì.
|
|
38
|
+
- **Sai mã**: cả sáu ô viền `red-500` (quầng đỏ chỉ ở ô đang focus, `input.md`), câu lỗi dưới hàng ô nói cách sửa: "Mã chưa đúng, kiểm tra lại email mới nhất". **Mã hết hạn**: câu lỗi "Mã đã hết hạn, bấm Gửi lại mã để nhận mã mới", **không** thêm nút trong câu lỗi. Hai lỗi khác nhau thì hai câu khác nhau.
|
|
39
|
+
- **Sau sai mã hoặc mã hết hạn: xoá sáu ô, con trỏ về ô đầu**, câu lỗi và viền đỏ giữ tới khi gõ số đầu tiên. Người dùng gần như luôn gõ lại cả mã (nhìn lại email hoặc lấy mã mới), sáu số cũ còn nằm đó thì phải Backspace sáu lần trước. Cùng cách với ô mật khẩu sau khi đăng nhập sai (`layouts/form.md`). Trang `/states` vẽ trạng thái này bằng sáu ô trống viền đỏ, không phải sáu số đỏ.
|
|
40
|
+
- **Một việc, một nút.** Mã hết hạn thì đếm ngược cũng đã hết, dòng dưới đã có "Gửi lại mã". Thêm "Gửi mã mới" trong câu lỗi là hai nút cùng làm một việc, khác tên, người dùng phải đoán có khác nhau không (`N3`, `N5`).
|
|
41
|
+
- **Dòng lỗi giữ chỗ sẵn** (`min-h-4`, bằng một dòng `text-xs`, rỗng khi chưa lỗi). Câu lỗi `text-xs` như mọi câu lỗi dưới ô (`input.md`, `layouts/form.md`). Người dùng bấm Xác nhận, lỗi hiện ra mà không giữ chỗ thì nút tụt xuống một dòng ngay dưới con trỏ (`N1`).
|
|
42
|
+
- **Bấm Xác nhận khi chưa đủ sáu số thì phải có phản hồi**: câu lỗi "Chưa nhập mã" (chưa gõ số nào) hoặc "Mã còn thiếu số, nhập đủ 6 số", viền đỏ ở **các ô còn trống** (số đã gõ không sai, giữ viền thường), con trỏ nhảy về ô trống đầu tiên. Bấm mà im lặng thì người dùng tưởng nút hỏng.
|
|
43
|
+
- Nút Xác nhận `primary` rộng hết card, `h-12`. Đang kiểm mã thì spinner thay chỗ icon hoặc thêm trước chữ, chữ giữ nguyên (`components/button.md`).
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# Ô nhập số lượng
|
|
2
|
+
|
|
3
|
+
Số lượng trong giỏ, số ghế, số bản in: một số nguyên nhỏ trong khoảng biết trước.
|
|
4
|
+
Nút − và + hai bên, số ở giữa gõ thẳng được. Số lớn hoặc có phần thập phân (giá,
|
|
5
|
+
cân nặng) thì dùng ô nhập thường (`input.md`), không dùng khuôn này.
|
|
6
|
+
|
|
7
|
+
```tsx
|
|
8
|
+
<div
|
|
9
|
+
className={cn(
|
|
10
|
+
// Mượn nguyên class của ô nhập (viền, nền, bo, focus, lỗi, cao h-11 md:h-10),
|
|
11
|
+
// bỏ padding ngang, ôm vừa nội dung. Khung đổi viền khi ô số bên trong focus.
|
|
12
|
+
getFieldControlClasses(hasError, true),
|
|
13
|
+
"inline-flex h-11 w-fit items-stretch overflow-hidden px-0 md:h-10",
|
|
14
|
+
isDisabled && "cursor-not-allowed bg-background text-muted",
|
|
15
|
+
)}
|
|
16
|
+
>
|
|
17
|
+
<Button
|
|
18
|
+
variant="ghost"
|
|
19
|
+
aria-label="Giảm số lượng"
|
|
20
|
+
aria-controls={id}
|
|
21
|
+
tabIndex={-1}
|
|
22
|
+
disabled={isDecreaseDisabled}
|
|
23
|
+
onMouseDown={(event) => event.preventDefault()}
|
|
24
|
+
onClick={() => handleStep(-1)}
|
|
25
|
+
className="group h-full w-11 shrink-0 rounded-none p-0 hover:bg-transparent md:w-10"
|
|
26
|
+
>
|
|
27
|
+
{/* Vùng bấm là cả ô cao (44px ở màn hẹp), nền rê chỉ là ô vuông bo thụt vào. */}
|
|
28
|
+
<span className="grid size-8 place-items-center rounded-lg text-muted transition-colors group-enabled:group-hover:bg-foreground/5 group-enabled:group-hover:text-foreground">
|
|
29
|
+
<Minus className="size-4" aria-hidden />
|
|
30
|
+
</span>
|
|
31
|
+
</Button>
|
|
32
|
+
|
|
33
|
+
<input
|
|
34
|
+
id={id}
|
|
35
|
+
type="text"
|
|
36
|
+
inputMode="numeric"
|
|
37
|
+
role="spinbutton"
|
|
38
|
+
aria-valuemin={min}
|
|
39
|
+
aria-valuemax={max}
|
|
40
|
+
aria-valuenow={value}
|
|
41
|
+
aria-invalid={hasError || undefined}
|
|
42
|
+
aria-describedby={describedById}
|
|
43
|
+
autoComplete="off"
|
|
44
|
+
// Nhiều hơn số chữ số của max đúng một: gõ hay dán quá tay thì vẫn kéo về max.
|
|
45
|
+
maxLength={String(max).length + 1}
|
|
46
|
+
disabled={isDisabled}
|
|
47
|
+
className="w-12 min-w-0 bg-transparent text-center tabular-nums outline-hidden disabled:cursor-not-allowed"
|
|
48
|
+
/>
|
|
49
|
+
|
|
50
|
+
{/* Nút + y hệt nút −, icon Plus */}
|
|
51
|
+
</div>
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Hình
|
|
55
|
+
|
|
56
|
+
- **Một khung duy nhất, viền của ô nhập** (`border-border-strong`, `rounded-xl`,
|
|
57
|
+
focus viền nhấn + quầng mờ khi ô số bên trong focus). Không kẻ vạch dọc giữa nút và số:
|
|
58
|
+
thêm hai đường chia là thêm tín hiệu cho việc ba phần đã tự rõ (`N3`).
|
|
59
|
+
- **Nền rê của nút − + là ô vuông `size-8 rounded-lg` thụt vào giữa ô**, cùng khuôn nút
|
|
60
|
+
xoá trong ô tìm (`input.md`, `N5`). **Không** cho nền rê phủ kín cả khúc cao từ viền tới
|
|
61
|
+
viền: nền đó cắt ngang lưng chừng khung, không vạch chia nào đỡ mép trong, một bên vuông
|
|
62
|
+
thành một bên theo góc bo, trông như ô bị khuyết một mảng. Vùng **bấm** vẫn là cả khúc cao, `w-11` màn hẹp, `md:w-10` (`N9`); chỉ
|
|
63
|
+
phần **nhìn thấy** là thụt vào.
|
|
64
|
+
- Icon `Minus` / `Plus` `size-4`, `text-muted`, rê vào đậm lên `text-foreground`.
|
|
65
|
+
- Tới đầu nào thì nút phía đó `disabled`: mờ `opacity-50`, con trỏ cấm, rê không đổi nền.
|
|
66
|
+
Nút giữ chỗ, không ẩn (`N1`).
|
|
67
|
+
- Số `tabular-nums`, căn giữa, ô số `w-12` đủ hai ba chữ số.
|
|
68
|
+
- **Bị khoá**: cả khung chìm xuống `bg-background`, số `text-muted`, hai nút mờ; lý do
|
|
69
|
+
khoá nói ngay dưới ô như ô nhập thường.
|
|
70
|
+
|
|
71
|
+
## Hành vi
|
|
72
|
+
|
|
73
|
+
- `type="text" inputMode="numeric" role="spinbutton"`, **không `type="number"`**: ô số của
|
|
74
|
+
trình duyệt tự vẽ mũi tên lên xuống chồng lên hai nút, và nhận cả `1e2`, `-3`.
|
|
75
|
+
- **Số gõ vào chỉ áp khi rời ô hoặc bấm Enter.** Gõ dở "9" trong "99" mà đã áp thì nơi dùng
|
|
76
|
+
tính lại tiền theo từng phím. Ô gõ trống rồi rời đi thì **giữ số cũ**, không tự điền `min`
|
|
77
|
+
(`N7`). Số ngoài khoảng thì kéo về trong khoảng khi áp.
|
|
78
|
+
- Đang gõ dở mà bấm + thì cộng vào số đang gõ, không vào số cũ.
|
|
79
|
+
- **`maxLength` bằng số chữ số của `max` cộng một**, không bằng đúng số chữ số. Bằng đúng
|
|
80
|
+
thì trình duyệt cắt bớt chữ số thừa trước khi ô kịp kéo về: kho còn 8 mà gõ 20 ra 2, ô
|
|
81
|
+
tới 99 mà dán 150 ra 15, một số sai lặng lẽ thay vì số tối đa. Thêm một chữ số thì số
|
|
82
|
+
bị cắt vẫn luôn lớn hơn `max`, và kéo về đúng `max`.
|
|
83
|
+
- Phím trong ô theo mẫu spinbutton: mũi tên lên xuống đổi 1, PageUp/PageDown đổi 10,
|
|
84
|
+
Home/End về `min`/`max`.
|
|
85
|
+
- Hai nút `tabIndex={-1}`: ô số đã đổi được bằng phím, Tab qua ba điểm dừng cho một ô là
|
|
86
|
+
thừa. `onMouseDown` chặn mặc định để bấm − + không kéo tiêu điểm ra khỏi ô đang gõ,
|
|
87
|
+
và trên điện thoại bàn phím không bật lên theo.
|
|
88
|
+
|
|
89
|
+
## Giới hạn và lỗi
|
|
90
|
+
|
|
91
|
+
- **Giới hạn biết trước là `max`, không phải lỗi.** Kho còn 8 thì `max={8}`: nút + mờ ở 8,
|
|
92
|
+
gõ 20 thì về 8, dòng gợi ý dưới ô nói con số thật: *"Còn 8 sản phẩm."* Không để
|
|
93
|
+
`max={99}` rồi báo lỗi khi người dùng vượt 8: để người ta bấm một nút rồi mới mắng là
|
|
94
|
+
nút đó không nên bấm được.
|
|
95
|
+
- **Trạng thái lỗi dành cho điều ô không biết trước**: kho giảm sau khi hàng đã nằm trong
|
|
96
|
+
giỏ, server từ chối lúc đặt. Khi đó `max` đã là 8 mà số đang là 12: viền đỏ, nút + mờ,
|
|
97
|
+
câu lỗi thay dòng gợi ý và nói cách sửa (*"Kho vừa giảm còn 8, giảm xuống 8 hoặc ít
|
|
98
|
+
hơn."*). Bấm − một lần thì về thẳng 8.
|
|
99
|
+
- Dòng gợi ý nói giới hạn thật của thứ đang mua, không phải khoảng kỹ thuật chung chung
|
|
100
|
+
("Từ 1 đến 99"). Không có giới hạn đáng nói thì không có dòng gợi ý.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Thanh trượt khoảng giá (range slider)
|
|
2
|
+
|
|
3
|
+
Lọc theo một khoảng số: giá, diện tích, số lượng. Gồm **thanh trượt hai tay cầm
|
|
4
|
+
+ hai ô nhập "Từ" / "Đến"**. Ô nhập theo `input.md`, dòng 0 kết quả theo
|
|
5
|
+
`empty-state.md`.
|
|
6
|
+
|
|
7
|
+
- **Luôn có hai ô nhập đi kèm thanh trượt.** Thanh trượt đi nhanh theo bậc, ô nhập là đường vào thứ hai và là chỗ đặt số lẻ mà bậc trượt không tới (7.350.000). Hai ô `tabular-nums`, `text-base` ở mobile (iOS phóng to trang khi ô dưới 16px), hậu tố `đ` xám nằm trong ô, nhãn "Từ" / "Đến" ở trên.
|
|
8
|
+
- **Số trong ô chỉ áp khi rời ô hoặc bấm Enter**, không áp theo từng ký tự: gõ dở "5" trong "5.000.000" mà áp ngay thì đầu kia bị kéo theo, và danh sách nhảy loạn (`N7`).
|
|
9
|
+
- **Tay cầm `size-5`, viền 2px, nhưng vùng bấm 44×44** (`before:-inset-3.5`, neo theo *border box* — `-inset-3` chỉ ra 40px vì viền đã ăn 4px; số âm giữ có chủ ý, `N11` bước 4: không có cách dương nào nới vùng bấm mà giữ tay cầm 20px, comment ngay trên dòng). **Cả dải rãnh cao 44px** (`h-11`) và bấm vào rãnh thì tay cầm gần nhất chạy tới, nên trên điện thoại không phải nhắm trúng vòng nhỏ (`N9`).
|
|
10
|
+
- **Vùng kéo `touch-none`**: chạm vào dải này thì không cuộn trang theo. Đây là đánh đổi có chủ ý của dải 44px.
|
|
11
|
+
- **Hai tay cầm không đổi chỗ cho nhau.** Kéo sát thì chúng chạm nhau và dừng (Từ = Đến), không vượt qua. Lúc đang chồng nhau, bấm phím sang phải là tay cầm phải đi, sang trái là tay cầm trái đi.
|
|
12
|
+
- **Bàn phím** (`N9`): Tab tới từng tay cầm, mũi tên đi một bậc, PageUp/PageDown đi bậc lớn, Home/End về hai đầu. Sau khi kéo, tiêu điểm ở đúng tay cầm vừa kéo.
|
|
13
|
+
- **Hai đầu rãnh có nhãn giá trị nhỏ nhất và lớn nhất** (`0 đ`, `50.000.000 đ`), `text-xs text-muted`.
|
|
14
|
+
- **Lọc ra 0 kết quả không tô đỏ gì cả** (`M30`, `empty-state.md`): một dòng `text-sm text-muted` ở chỗ đáng lẽ có danh sách, nói số cụ thể và cách nới lọc ("Không có sản phẩm nào từ 47.500.000 đ trở lên. Đắt nhất đang bán là 42.900.000 đ, hạ giá Từ xuống để xem thêm"). Dòng đó và dòng đếm kết quả cùng `py-6` nên khối không co giãn khi đổi qua lại (`N1`).
|
|
15
|
+
- **Khoá**: rãnh, tay cầm và hai ô cùng chìm xuống, con trỏ `not-allowed`, và **nói lý do ngay bên dưới** ("Gói Cơ bản chưa lọc được theo giá") — khoá mà không nói vì sao là bế tắc (`N6`).
|
|
16
|
+
- Ràng buộc nghiệp vụ (khoảng tối thiểu, bước làm tròn) là **logic của người dùng**, skill không tự bịa ra để có thêm một ca lỗi (`N10`).
|