@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,98 @@
|
|
|
1
|
+
# Empty state
|
|
2
|
+
|
|
3
|
+
Nguồn: widget nhắc việc của một dự án thật.
|
|
4
|
+
|
|
5
|
+
```tsx
|
|
6
|
+
{isLoading ? (
|
|
7
|
+
<p className="py-6 text-center text-sm text-muted">Đang tải…</p>
|
|
8
|
+
) : visibleReminders.length === 0 ? (
|
|
9
|
+
<p className="py-6 text-center text-sm text-muted">
|
|
10
|
+
Chưa có nhắc nào ở mục này
|
|
11
|
+
</p>
|
|
12
|
+
) : (
|
|
13
|
+
<ul className="flex flex-col">…</ul>
|
|
14
|
+
)}
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
**Vì sao ổn**
|
|
18
|
+
|
|
19
|
+
Đây là chỗ AI bịa lộ liễu nhất. Mặc định nó dựng một khối to đùng: hình minh hoạ,
|
|
20
|
+
tiêu đề `text-xl font-bold`, một đoạn động viên, rồi một nút CTA. Kết quả là ô
|
|
21
|
+
trống lại nổi hơn ô có dữ liệu.
|
|
22
|
+
|
|
23
|
+
Bản đúng là **một dòng chữ mờ**:
|
|
24
|
+
|
|
25
|
+
- Đúng một câu, `text-sm text-muted`. Không tiêu đề, không hình, không icon, không nút.
|
|
26
|
+
- **Không thêm `opacity-70`.** `--muted` đã sát ngưỡng (4.95:1), mờ thêm 70% là tụt xuống ~2.8:1, trượt 4.5:1 của chữ 14px. Chữ đã mờ bằng màu thì đừng mờ thêm bằng opacity.
|
|
27
|
+
- `py-6` cho khối trống một chiều cao vừa phải, để card không xẹp xuống rồi bung lên khi dữ liệu về.
|
|
28
|
+
- Câu chữ mô tả đúng bối cảnh đang lọc ("ở mục này"), không phải câu chung chung "Không có dữ liệu".
|
|
29
|
+
- **Lọc ra 0 kết quả là trạng thái rỗng, không phải lỗi.** Không viền đỏ ô lọc, không câu đỏ: người dùng chưa nhập sai gì, khoảng lọc vẫn hợp lệ (`M30`: `red` chỉ dành cho cái phải sửa mới đi tiếp được). Vẫn là một dòng chữ mờ, đặt ở **chỗ đáng lẽ có kết quả**, và nói luôn cách nới lọc: "Không có sản phẩm nào từ 47.500.000 đ. Hạ giá Từ xuống để xem thêm."
|
|
30
|
+
- Trạng thái đang tải (khung chờ, tải lần hai, thời điểm hiện): `loading.md`. **Đang tải không bao giờ hiện câu rỗng.**
|
|
31
|
+
|
|
32
|
+
Chỉ dựng empty state có hình và CTA khi đó là màn hình chính của cả app và người
|
|
33
|
+
dùng lần đầu vào chưa có gì để làm. Trong một widget hay một tab thì không.
|
|
34
|
+
|
|
35
|
+
**Người dùng là người mở đầu** (khung chat mới) thì không dùng câu báo rỗng: đưa 2–3
|
|
36
|
+
việc bấm được ngay, vẫn không hình không tiêu đề (`N6`, `chat.md`).
|
|
37
|
+
|
|
38
|
+
## Rỗng do lọc: câu nói đúng điều đang lọc, và có lối ra
|
|
39
|
+
|
|
40
|
+
Ngoại lệ của "không nút": danh sách rỗng **vì người dùng đang tìm hay lọc** thì người
|
|
41
|
+
đó tự dẫn mình vào ngõ cụt, phải có lối ra ngay tại chỗ (`N6`). Vẫn một dòng mờ, thêm
|
|
42
|
+
**một link chữ** ngay sau câu, không nút đặc, không hình:
|
|
43
|
+
|
|
44
|
+
```tsx
|
|
45
|
+
<p className="py-6 text-center text-sm text-muted">
|
|
46
|
+
Không có khách hàng nào khớp {quotedQuery}. Thử từ khoá khác.{" "}
|
|
47
|
+
<Button variant="ghost" onClick={clearFilters}
|
|
48
|
+
className="inline h-auto min-h-0 p-0 align-baseline font-medium text-foreground underline-offset-4 hover:bg-transparent hover:underline">
|
|
49
|
+
Xoá tìm kiếm
|
|
50
|
+
</Button>
|
|
51
|
+
</p>
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
- **Câu theo đúng thứ đang lọc**: chỉ từ khoá thì nhắc lại từ khoá và "Thử từ khoá khác." (không "ngắn hơn": gõ "zzzz" 4 ký tự mà bảo gõ ngắn hơn là vô lý); chỉ chip thì "Không có khách hàng nào có nhãn này. Bỏ bớt nhãn để xem thêm."; cả hai thì câu chung. Đừng khuyên "bỏ bớt nhãn" khi không nhãn nào đang chọn.
|
|
55
|
+
- **Từ khoá dài cắt bằng số ký tự trong JS, không bằng CSS**: giữ ~24 ký tự + `…`, cặp ngoặc kép dính liền từ khoá (`"Công ty cổ phần thươ…"`), từ khoá đủ trong `title`. Cắt bằng `truncate` trên span nội tuyến thì dấu mở ngoặc rơi xuống cuối dòng trên và hở một khoảng trước dấu đóng. Từ khoá tô `--foreground`, phần câu còn lại `text-muted`.
|
|
56
|
+
- Link đổi chữ theo thứ nó gỡ: "Xoá tìm kiếm" (chỉ từ khoá), **"Xoá lọc"** (có chip) — **cùng chữ với nút "Xoá lọc" cuối hàng chip**, vì hai nút làm cùng một việc; hai chữ khác nhau ("Xoá lọc" / "Xoá bộ lọc") là bắt người dùng đoán chúng có khác nhau không (`N6`). Bấm là gỡ hết, trả tiêu điểm về ô tìm.
|
|
57
|
+
- Link màu `--foreground`, không màu nhấn: đây là lối ra, không phải hành động chính của trang.
|
|
58
|
+
- Hàng tiêu đề bảng giữ nguyên (để người dùng thấy mình vẫn ở bảng nào) nhưng **ẩn checkbox chọn tất cả**: chọn tất cả của 0 dòng là nút vô nghĩa.
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
## Cột rỗng khác danh sách rỗng
|
|
63
|
+
|
|
64
|
+
Danh sách rỗng thì một dòng chữ mờ là xong. **Cột rỗng trong một board thì
|
|
65
|
+
không**, vì cột vẫn phải nhìn ra được là một vùng thả.
|
|
66
|
+
|
|
67
|
+
```html
|
|
68
|
+
<li class="flex min-h-[7rem] items-center justify-center rounded-2xl border border-dashed border-foreground/15 px-3">
|
|
69
|
+
<p class="text-center text-sm text-muted">Chưa có việc nào</p>
|
|
70
|
+
</li>
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
- **Giữ chiều cao tối thiểu** `min-h-[7rem]`, đủ để thấy vùng thả và để các cột không cao thấp lệch nhau quá.
|
|
74
|
+
- **Viền đứt, không nền.** Cột kanban nằm thẳng trên nền trang, nên "nền chìm hơn một bậc" thành một mảng xám đặc, là khối nặng nhất cả board, nặng hơn mọi thẻ có việc. Viền đứt nói "chỗ để thả" mà không có khối (`F21` cho phép đúng chỗ này). Viền `foreground/15`, không `--border-strong`: `#eaeaea` trên nền trang `#f4f4f6` chỉ chênh 1.05:1, khung trống biến mất. Bo `rounded-2xl` bằng thẻ, để khung trống cùng khuôn với thẻ sẽ rơi vào.
|
|
75
|
+
- Chữ `text-sm` như chữ phụ của thẻ, không `text-xs`; cùng câu với nhóm rỗng của view danh sách ("Chưa có việc nào", thêm "ở nhóm này" khi nằm giữa các nhóm).
|
|
76
|
+
- Vẫn chỉ một dòng chữ. Không icon, không nút "thêm việc đầu tiên".
|
|
77
|
+
|
|
78
|
+
Cùng nguyên tắc cho ô rỗng trong lịch, và cho khung kéo thả tệp. Đây cũng là hai
|
|
79
|
+
chỗ hiếm hoi `border-dashed` được dùng, xem luật `F21` trong `../rules-form.md`.
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
## Lỗi tải
|
|
84
|
+
|
|
85
|
+
```html
|
|
86
|
+
<div role="alert" class="flex flex-col items-center gap-3 py-10 text-center">
|
|
87
|
+
<div>
|
|
88
|
+
<p class="text-sm font-medium text-red-600">Không tải được danh sách khách hàng</p>
|
|
89
|
+
<p class="mt-1 text-sm text-muted">Mất kết nối mạng</p>
|
|
90
|
+
</div>
|
|
91
|
+
<!-- Nút viền + icon rotate-cw theo I1: "Thử lại". Gọi gì là handler rỗng (onRetry). -->
|
|
92
|
+
</div>
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
- **Hai tầng như toast lỗi** (`../layouts/overlay.md`): tầng trên nói chuyện gì hỏng, `font-medium` `red-600` (`--error-text`, không `red-500`); tầng dưới `text-muted` nói vì sao. Không biết vì sao thì bỏ tầng dưới, đừng bịa.
|
|
96
|
+
- **Nút Thử lại là nút viền có icon** (`I1`), nằm ngay dưới chữ, căn giữa.
|
|
97
|
+
- Không icon to, không hình minh hoạ, không nền đỏ cả khối. Lỗi tải là tạm thời, bấm lại là xong, không cần hét lên.
|
|
98
|
+
- **Chữ rỗng và chữ lỗi cùng `py`**, để đổi qua lại giữa hai trạng thái thì khung không co giãn.
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# Khu tải tệp lên (file upload)
|
|
2
|
+
|
|
3
|
+
Một khung kéo thả ở trên, danh sách tệp ở dưới, cả hai nằm trong một card. Toast
|
|
4
|
+
chỉ báo khi cả lượt tải kết thúc (xem mục Toast). Nút theo `button.md`, thanh tiến
|
|
5
|
+
độ mượn rãnh và màu của `charts.md`, nhưng **dòng tệp có khuôn riêng** ở đây.
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
┌ Tải tài liệu lên ─────────────────────────────────────────────┐
|
|
9
|
+
│ ┌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┐ │
|
|
10
|
+
│ ╎ Kéo thả tài liệu vào đây ╎ │
|
|
11
|
+
│ ╎ PDF, Word, Excel, ảnh. Tối đa 25 MB mỗi tệp ╎ │
|
|
12
|
+
│ ╎ [ ⤒ Chọn tệp ] ╎ │
|
|
13
|
+
│ └╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┘ │
|
|
14
|
+
│ ▯ bien-ban-hop-khoi-dong-du-an-website-ban-hang… 71% ✕ │ <- đang tải: thanh mảnh
|
|
15
|
+
│ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━─────────────── │
|
|
16
|
+
│ ───────────────────────────────────────────────────────────── │
|
|
17
|
+
│ ▯ bao-cao-khao-sat-hien-trang.pdf ✕ │ <- chờ: chữ, không thanh
|
|
18
|
+
│ Đang chờ · 6,8 MB │
|
|
19
|
+
│ ───────────────────────────────────────────────────────────── │
|
|
20
|
+
│ ▯ bang-du-toan-chi-phi.xlsx ✕ │ <- hỏng: chữ đỏ + Thử lại
|
|
21
|
+
│ Mất kết nối mạng · Thử lại │
|
|
22
|
+
│ ───────────────────────────────────────────────────────────── │
|
|
23
|
+
│ ▯ logo-sao-viet.png │ <- xong: chữ, không thanh
|
|
24
|
+
│ ✓ Đã tải xong · 2,4 MB │
|
|
25
|
+
└───────────────────────────────────────────────────────────────┘
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Khung kéo thả
|
|
29
|
+
|
|
30
|
+
```html
|
|
31
|
+
<label class="flex cursor-pointer flex-col items-center rounded-xl border border-dashed border-border-strong bg-background/60 px-6 py-8 text-center transition-colors hover:bg-item-hover">
|
|
32
|
+
<input type="file" multiple class="sr-only" />
|
|
33
|
+
<!-- Máy cảm ứng không kéo thả được: ẩn câu này, còn dòng gợi ý và nút -->
|
|
34
|
+
<p class="text-sm font-medium text-foreground [@media(hover:none)]:hidden">Kéo thả tài liệu vào đây</p>
|
|
35
|
+
<p class="mt-1 text-pretty text-sm text-muted">PDF, Word, Excel, PowerPoint, ảnh JPG hoặc PNG. Tối đa 25 MB mỗi tệp</p>
|
|
36
|
+
<!-- Trông như nút viền h-9 có icon upload, nhưng là <span>: cả khung đã là chỗ bấm -->
|
|
37
|
+
<span class="mt-4 inline-flex h-9 items-center gap-2 rounded-lg border border-border-strong bg-surface px-3 text-sm font-medium">…Chọn tệp</span>
|
|
38
|
+
</label>
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
- **Cả khung là chỗ bấm** (`<label>` bọc `input type="file"` ẩn), không chỉ cái nút. "Chọn tệp" là `<span>` mang hình nút viền: lồng một `<button>` thật vào `<label>` thì bấm nút mở hộp chọn tệp hai lần.
|
|
42
|
+
- **Viền đứt `border-border-strong`** (`F21` cho phép), nền `bg-background/60`, rê chuột thì nền đậm lên `bg-background`. Nền và viền đều nhạt: khung là chỗ chờ, không phải thứ nặng nhất màn.
|
|
43
|
+
- **Đang kéo tệp vào**: viền đậm lên `border-foreground/40`, nền `bg-background`, tiêu đề đổi thành "Thả tệp để tải lên". **Không viền đen đặc** (`border-foreground`): một vòng nét đứt đen chạy quanh cả khung là thứ đậm nhất màn, chỉ để báo "thả được". Nội dung giữ nguyên chỗ, chỉ đổi chữ tiêu đề (`N1`).
|
|
44
|
+
- **Dòng gợi ý nói luật trước khi người dùng chọn**: loại tệp, cỡ tối đa. Đây là thứ họ chưa biết, không phải chữ thừa (`T20`).
|
|
45
|
+
- Khung **giữ nguyên cỡ** khi danh sách có tệp. Thu nhỏ khung lúc tệp đầu tiên vào là cả card nhảy (`N1`).
|
|
46
|
+
- **Máy cảm ứng ẩn câu "Kéo thả tài liệu vào đây"** (`[@media(hover:none)]:hidden`): điện thoại không kéo tệp vào được, câu đó là chỉ dẫn sai. Còn dòng gợi ý và nút "Chọn tệp".
|
|
47
|
+
- **Khoá** (hết dung lượng, không có quyền): khung thôi là chỗ bấm (`<div>` thay `<label>`, `input` `disabled`, bỏ hover, con trỏ thường). **Nền giữ `bg-background/60` như lúc thường**, không chìm xuống `bg-background`: đó là nền của lúc đang kéo tệp vào, khoá mà cùng nền thì hai trạng thái trái nghĩa trông như nhau.
|
|
48
|
+
- **Tiêu đề đổi thành lý do**, giữ `text-foreground font-medium`: "Dự án đã dùng hết 5 GB". Để nguyên "Kéo thả tài liệu vào đây" là mời người dùng làm đúng việc đang bị cấm. **Không làm mờ cả khối**: lý do là thứ duy nhất người dùng cần đọc ở trạng thái này (`N6`, `N8`).
|
|
49
|
+
- **Dòng dưới nói lối ra**, `text-muted`: "Xoá bớt tệp cũ hoặc nâng gói để tải thêm".
|
|
50
|
+
- **Nút "Chọn tệp" mờ bị bỏ, thay bằng nút dẫn tới lối ra** nếu có ("Nâng gói", nút viền `h-9`, bấm được). Nút khoá nằm giữa khung chỉ nói lại điều tiêu đề đã nói (`N2`, `N3`). Khoá tạm mà người dùng không tự gỡ được (dự án đã lưu trữ) thì không nút, dòng dưới nói ai gỡ được.
|
|
51
|
+
- **Không có quyền tải lên thì ẩn cả khu tải**, không dựng khung khoá. Người chỉ được xem mà thấy một khung lớn ghi "Bạn chưa có quyền tải tệp lên" ở mỗi lần mở trang là nhận một lời từ chối cho việc họ không định làm; các kho tệp lớn đều ẩn nút tải khi không có quyền. Khung khoá chỉ dành cho khoá **tạm** (hết dung lượng, dự án đã lưu trữ), thứ người đang được phép tải cần biết. Đừng lặp một ý hai tầng: "Bạn chưa có quyền tải tệp lên" / "Chỉ quản trị dự án tải tệp lên được".
|
|
52
|
+
|
|
53
|
+
## Dòng tệp
|
|
54
|
+
|
|
55
|
+
```html
|
|
56
|
+
<ul class="mt-4 divide-y divide-border">
|
|
57
|
+
<li class="flex items-center gap-3 py-3">
|
|
58
|
+
<i data-lucide="file-text" class="size-5 shrink-0 text-muted"></i>
|
|
59
|
+
<div class="min-w-0 flex-1">
|
|
60
|
+
<div class="flex items-baseline gap-3">
|
|
61
|
+
<!-- Tên cắt GIỮA (T14): phần đầu truncate, phần giữ lại = ~8 ký tự cuối của tên + đuôi, shrink-0 -->
|
|
62
|
+
<p class="flex min-w-0 flex-1 text-sm font-medium text-foreground" title="bien-ban-hop-khoi-dong-du-an-website-ban-hang.pdf"><span class="truncate">bien-ban-hop-khoi-dong-du-an-website-</span><span class="shrink-0">ban-hang.pdf</span></p>
|
|
63
|
+
<p class="shrink-0 text-sm text-muted tabular-nums">71%</p> <!-- chỉ khi đang tải -->
|
|
64
|
+
</div>
|
|
65
|
+
<!-- Tầng dưới: cao cố định, chứa THANH hoặc MỘT DÒNG CHỮ, không bao giờ cả hai -->
|
|
66
|
+
<div class="mt-1 flex min-h-5 items-center">
|
|
67
|
+
<div role="progressbar" aria-valuenow="71" aria-valuemin="0" aria-valuemax="100" aria-label="Đang tải bien-ban-hop-khoi-dong….pdf" class="h-1 w-full rounded-full bg-foreground/5">
|
|
68
|
+
<div class="h-1 rounded-full bg-primary" style="width: 71%"></div>
|
|
69
|
+
</div>
|
|
70
|
+
</div>
|
|
71
|
+
</div>
|
|
72
|
+
<div class="flex w-8 shrink-0 justify-end">
|
|
73
|
+
<!-- IconButton ✕ size-8, aria-label="Huỷ tải bien-ban-hop-khoi-dong….pdf" -->
|
|
74
|
+
</div>
|
|
75
|
+
</li>
|
|
76
|
+
</ul>
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
| Trạng thái | Góc phải dòng trên | Tầng dưới | Cột ✕ |
|
|
80
|
+
| --- | --- | --- | --- |
|
|
81
|
+
| Đang chờ | trống | `text-xs text-muted` "Đang chờ · 6,8 MB" | ✕ "Bỏ tệp …" |
|
|
82
|
+
| Đang tải | `71%` `text-muted tabular-nums` | thanh `h-1`, rãnh `bg-foreground/5` (không `bg-background`, `M21`), thanh `bg-primary` | ✕ "Huỷ tải …" |
|
|
83
|
+
| Xong | trống | icon `circle-check` `size-3.5 text-emerald-600` + `text-xs text-muted` "Đã tải xong · 2,4 MB" | trống, giữ `w-8` |
|
|
84
|
+
| Hỏng khi tải | trống | `text-xs text-red-600` "Mất kết nối mạng" · nút chữ **Thử lại** | ✕ "Bỏ tệp …" |
|
|
85
|
+
| Bị từ chối (quá cỡ, sai loại) | trống | `text-xs text-red-600` "Quá 25 MB (tệp nặng 48 MB)" / "Không nhận tệp .zip" | ✕ "Bỏ tệp …" |
|
|
86
|
+
|
|
87
|
+
- **Chỉ tệp đang tải mới có thanh.** Chờ là chưa bắt đầu, rãnh rỗng không nói gì. Bị từ chối là không bao giờ bắt đầu: rãnh xám rỗng đọc như "sắp chạy". Xong thì **thanh biến mất**: thanh xanh lá đầy + `100%` + "Đã tải xong" là ba tín hiệu cho một ý, và một cột thanh xanh đỏ đen là thứ nặng nhất màn (`N3`). Các kho tệp lớn đều bỏ thanh khi xong.
|
|
88
|
+
- **Hỏng thì bỏ thanh và bỏ số %.** "54%" trên một tệp đã hỏng là con số chết: bấm Thử lại là chạy lại từ đầu. Thanh đỏ dừng giữa chừng thêm một khối đỏ đặc cho điều câu chữ đã nói.
|
|
89
|
+
- **Tầng dưới cao cố định** (`min-h-5`), chứa thanh hoặc chữ. Dòng đổi trạng thái (chờ → tải → xong) thì cao không đổi, các dòng bên dưới không nhảy (`N1`). Chữ lỗi dài hơn một dòng ở màn hẹp thì được xuống dòng (`min-h`, không `h`), không `truncate` lý do (`N8`).
|
|
90
|
+
- **Thanh `h-1`**, không `h-2` như thanh đứng riêng: nhiều tệp tải cùng lúc là nhiều thanh xếp chồng, thanh dày thì cả danh sách thành một dàn sọc đen.
|
|
91
|
+
- **Thử lại là nút chữ nằm ngay sau lý do**, `text-xs font-medium text-foreground hover:underline underline-offset-2`, `whitespace-nowrap`, cách lý do bằng ` · `. **Vùng bấm nới bằng `relative before:absolute before:-inset-x-1.5 before:-inset-y-2`** (chữ cao 16px thành vùng 32px, số âm giữ theo `N11` như nút sao chép ở `description-list.md`): nút chữ trơn 39×16px trên điện thoại là dưới mức 32px. Dòng tệp cao hơn 32px nên vùng nới không chồng lên dòng kế. Không phải nút viền cuối hàng: nút viền đứng cạnh các ✕ làm cột hành động lệch (nút rộng, ✕ hẹp) và nặng hơn cả tên tệp. Hỏng thì **vẫn có ✕ để bỏ tệp**: chỉ có Thử lại là tệp hỏng kẹt lại mãi.
|
|
92
|
+
- **Cột ✕ rộng cố định `w-8`** ở mọi dòng, kể cả dòng xong không có nút. Số % và mép phải thanh đứng thẳng một cột (`N1`).
|
|
93
|
+
- **✕ nói đúng việc trong `aria-label`, kèm tên tệp**: đang tải là "Huỷ tải …", còn lại là "Bỏ tệp …". Tệp đã tải xong không có ✕ ở đây: xoá tài liệu đã lên là việc của danh sách tài liệu, có hộp xác nhận.
|
|
94
|
+
- **Icon theo loại tệp**, `size-5 text-muted`, một màu, **cùng một dáng tờ giấy** (họ `file-*` của lucide): `file-text` (PDF, Word), `file-spreadsheet` (Excel), `file-chart-column` (PowerPoint), `file-image` (ảnh), `file` cho phần còn lại kể cả zip. **Viền tờ giấy phải liền**: không dùng `presentation` (cái bảng, không phải tờ giấy), `file-chart-pie`, `file-archive`, `file-box`, `file-lock`… — nhóm này vẽ một hình tròn, hình hộp đè lên góc dưới trái và cắt mất viền, ở `size-5` trông như icon vỡ (lucide không có `file-presentation`). Chọn icon mới thì mở SVG xem đường viền ngoài có khép kín không. Không tô màu theo loại (`M5`). **Bảng này là nguồn chung** cho mọi chỗ hiện tệp (cây thư mục, danh sách tài liệu): dự án nên có một hàm chọn icon theo đuôi, không mỗi component một bảng.
|
|
95
|
+
- **Tên tệp một dòng, cắt giữa, giữ đuôi** (`T14`, như `tree.md`): "bien-ban-hop-khoi…ban-hang.pdf": không "bien-ban-hop-khoi-dong-du-an-web…" (cắt cuối, mất đúng phần nói loại tệp), cũng không "bien-ban-hop-khoi….docx" (chỉ giữ đuôi, `…` dính `.docx` thành bốn chấm). `title` tên đầy đủ **chỉ gắn khi tên thật sự bị cắt** (`T14`), không gắn sẵn cho mọi dòng. Số % `shrink-0`, không bao giờ bị đẩy xuống dòng.
|
|
96
|
+
- Danh sách chia `divide-y divide-border` (`F3`), không mỗi tệp một card. Thứ tự là thứ tự thêm vào, tệp mới ở dưới; không tự xếp lỗi lên đầu.
|
|
97
|
+
- Số theo một kiểu cả app: `6,8 MB`, `1,1 / 1,5 MB` (gạch chéo có cách, `charts.md`).
|
|
98
|
+
|
|
99
|
+
## Toast
|
|
100
|
+
|
|
101
|
+
- **Xong cả lượt**: một toast "Đã tải lên 3 tài liệu", không toast cho từng tệp. Lượt có tệp hỏng thì **không** bắn toast xong.
|
|
102
|
+
- **Có tệp hỏng**: danh sách đang hiện trên màn thì **không bắn toast lỗi**, dòng tệp đã nói và đã có Thử lại (`N3`). Toast lỗi chỉ dành cho lúc khu tải không còn trên màn (modal đã đóng, đã sang trang khác): hai tầng "1 tệp chưa tải lên được" / "Mất kết nối mạng", nút Thử lại + ✕ theo `../layouts/overlay.md`. Lúc nào bắn là logic (`N10`); skill chỉ dựng hai bản tĩnh để duyệt.
|
|
103
|
+
|
|
104
|
+
## Ví dụ tĩnh cần dựng
|
|
105
|
+
|
|
106
|
+
Chưa có tệp · đang kéo vào khung · danh sách đủ năm trạng thái (có một tên rất dài) ·
|
|
107
|
+
khung bị khoá vì hết dung lượng · hai toast. Ở 375px: tên dài cắt giữa vẫn còn đuôi, % và ✕ không bị đẩy xuống, câu "Kéo thả…" đã ẩn.
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# Tên sửa tại chỗ
|
|
2
|
+
|
|
3
|
+
Tên bản ghi ở đầu trang (dự án, tài liệu, bảng) đổi được ngay tại chỗ, không mở form.
|
|
4
|
+
Ô sửa tại chỗ trong dòng bảng (hạn chót, vai trò) là chuyện khác, xem `../layouts/app.md`.
|
|
5
|
+
Email và mật khẩu không sửa tại chỗ (`../layouts/app.md`, trang hồ sơ).
|
|
6
|
+
|
|
7
|
+
## Hành vi
|
|
8
|
+
|
|
9
|
+
- **Bấm vào tên thì thành ô nhập, chữ chọn sẵn** để gõ đè luôn. Enter hoặc nút ✓ thì lưu,
|
|
10
|
+
Esc hoặc nút ✕ thì huỷ, bấm ra ngoài thì lưu (như hầu hết app quản lý công việc).
|
|
11
|
+
**Vùng chữ dài nhiều dòng (mô tả, ghi chú) thì bấm ra ngoài không lưu mà giữ ô mở**, chỉ ✓ / ✕ mới
|
|
12
|
+
thoát: bấm nhầm ra ngoài giữa chừng thì lưu một đoạn viết dở hoặc mất nó (hệ thiết kế lớn khuyên
|
|
13
|
+
vậy cho vùng chữ dài). Tên một dòng thì bấm ra ngoài vẫn lưu.
|
|
14
|
+
- **Để trống thì không lưu**: viền đỏ, câu lỗi dưới ô ("Chưa nhập tên dự án", nói việc cần
|
|
15
|
+
làm, `layouts/form.md`), ô vẫn mở. Gõ lại chữ thì lỗi mất, dòng lỗi giữ chỗ tới lúc thoát.
|
|
16
|
+
Lưu tên đã bỏ khoảng trắng hai đầu; tên không đổi thì không gọi lưu.
|
|
17
|
+
- **Hai nút ✓ ✕ luôn có**, cho chuột và màn chạm (điện thoại không có Esc, `N9`). Rê vào thấy
|
|
18
|
+
tooltip kèm phím tắt. Hai nút `onMouseDown={preventDefault}`: không có thì ô blur trước, mà
|
|
19
|
+
blur là lưu, nên bấm Huỷ thành lưu.
|
|
20
|
+
- **Enter lúc bộ gõ đang gạch chân chữ** (`event.nativeEvent.isComposing`) là chốt chữ tiếng
|
|
21
|
+
Việt, không phải lưu.
|
|
22
|
+
- **Esc chặn nổi bọt**: tên nằm trong panel trượt hay modal thì Esc chỉ huỷ sửa, không đóng panel.
|
|
23
|
+
- **Tiêu điểm**: thoát bằng phím hay nút thì về lại nút tên; bấm ra ngoài thì để yên chỗ vừa bấm.
|
|
24
|
+
- **Ô là `<textarea rows={1}>` tự giãn** (`field-sizing-content`), không `<input>`: tên dài xuống
|
|
25
|
+
dòng như lúc đứng yên, không cuộn ngang mất chữ đầu (`N8`). Dán chữ nhiều dòng thì gộp bằng
|
|
26
|
+
dấu cách; `enterKeyHint="done"`.
|
|
27
|
+
- **Lúc đứng yên là nút `ghost` nằm trong `<h1>`**, không phải `<h1>` bấm được. Bút chì
|
|
28
|
+
`size-4 text-muted` nằm cuối dòng chữ, đi theo chữ cuối khi xuống dòng. Tên ở đầu trang là
|
|
29
|
+
việc phụ nên bút chì chỉ hiện lúc rê và lúc Tab tới; màn chạm thì luôn hiện (`I11`). Trình đọc
|
|
30
|
+
màn hình đọc "Website bán hàng 2026. Sửa tên dự án" (`sr-only`).
|
|
31
|
+
|
|
32
|
+
## Bố cục: chữ không được nhúc nhích
|
|
33
|
+
|
|
34
|
+
Bấm vào tên thì chỉ khung hiện ra, chữ đứng yên từng pixel (`N1`). Ba thứ phải khớp:
|
|
35
|
+
|
|
36
|
+
1. **Cùng khuôn chữ.** Nút tên và ô nhập cùng `px-2`, viền 1px (nút viền trong suốt), dòng
|
|
37
|
+
`leading-7`, cao 44/40px (`min-h-11 py-1.75 md:min-h-10 md:py-1.25`), cỡ chữ đúng cỡ `<h1>`
|
|
38
|
+
của đầu trang (`text-xl font-semibold`, `../layouts/app.md`).
|
|
39
|
+
2. **Chữ thẳng cột với phần còn lại của đầu trang, khung tràn ra trái.** Nền rê và viền ô nằm
|
|
40
|
+
ngoài chữ, lùi ra trái đúng padding + viền (8 + 1 = 9px). Không lùi thì tên thụt 9px so với
|
|
41
|
+
link cấp cha, câu mô tả, hàng tab ngay trên dưới nó. Các app có tên sửa tại chỗ đều giữ chữ thẳng cột, khung
|
|
42
|
+
mới là thứ tràn ra. Đây là ngoại lệ của `N11` (vùng bấm nở ra ngoài chữ): không có cách không
|
|
43
|
+
âm nào giữ được cả chữ thẳng cột lẫn khung bao quanh chữ.
|
|
44
|
+
3. **Cùng bề rộng dòng chữ ở hai trạng thái.** Từ `sm` hai nút đứng cạnh ô, ăn mất 96px
|
|
45
|
+
(104px ở `sm`, nút 44px). Lúc đứng yên tên cũng chừa đúng chỗ đó (`sm:pr-26 md:pr-24`), không
|
|
46
|
+
thì bấm vào là tên dài xuống dòng khác đi. Dưới `sm` hai nút xuống hàng dưới, nằm phải, chung hàng
|
|
47
|
+
với câu lỗi: để bên phải thì ở 375px ô chỉ còn ~200px, tên ngắn cũng vỡ hai dòng.
|
|
48
|
+
|
|
49
|
+
```tsx
|
|
50
|
+
// Đứng yên
|
|
51
|
+
<h1
|
|
52
|
+
className={cn(
|
|
53
|
+
// Số âm: khung nở ra ngoài chữ, chữ thẳng cột với đầu trang (N11 cách 4). 9px = px-2 + viền.
|
|
54
|
+
"-ml-2.25 min-w-0",
|
|
55
|
+
// Chừa chỗ hai nút ✓ ✕ của lúc sửa: tên xuống dòng y hệt ở hai trạng thái.
|
|
56
|
+
"sm:pr-26 md:pr-24",
|
|
57
|
+
)}
|
|
58
|
+
>
|
|
59
|
+
<Button
|
|
60
|
+
ref={triggerRef}
|
|
61
|
+
variant="ghost"
|
|
62
|
+
onClick={startEditing}
|
|
63
|
+
className={cn(
|
|
64
|
+
"group w-fit max-w-full justify-start border border-transparent px-2 text-left",
|
|
65
|
+
"min-h-11 py-1.75 md:min-h-10 md:py-1.25",
|
|
66
|
+
"text-xl leading-7 font-semibold text-foreground hover:text-foreground",
|
|
67
|
+
)}
|
|
68
|
+
>
|
|
69
|
+
<span className="min-w-0">
|
|
70
|
+
{value}
|
|
71
|
+
<span className="sr-only">. {editLabel}</span>
|
|
72
|
+
<Pencil
|
|
73
|
+
className={cn(
|
|
74
|
+
"ml-2 inline-block size-4 align-[-1px] text-muted transition-opacity",
|
|
75
|
+
"opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100",
|
|
76
|
+
"[@media(hover:none)]:opacity-100",
|
|
77
|
+
)}
|
|
78
|
+
aria-hidden
|
|
79
|
+
/>
|
|
80
|
+
</span>
|
|
81
|
+
</Button>
|
|
82
|
+
</h1>
|
|
83
|
+
|
|
84
|
+
// Đang sửa
|
|
85
|
+
<div
|
|
86
|
+
onKeyDown={handleKeyDown}
|
|
87
|
+
onBlur={handleBlur}
|
|
88
|
+
className={cn(
|
|
89
|
+
// Số âm đặt trên khối lưới, không trên textarea: textarea không tự giãn theo margin âm,
|
|
90
|
+
// mép phải hụt 9px so với hàng nút ở màn hẹp.
|
|
91
|
+
"-ml-2.25 grid min-w-0 grid-cols-[minmax(0,1fr)_auto] items-start gap-2",
|
|
92
|
+
)}
|
|
93
|
+
>
|
|
94
|
+
<textarea
|
|
95
|
+
ref={fieldRef}
|
|
96
|
+
rows={1}
|
|
97
|
+
enterKeyHint="done"
|
|
98
|
+
aria-label="Tên dự án"
|
|
99
|
+
aria-invalid={hasError || undefined}
|
|
100
|
+
className={cn(
|
|
101
|
+
fieldControlClasses, // viền, nền, bo, focus, lỗi của ô nhập (input.md)
|
|
102
|
+
"col-span-2 sm:col-span-1",
|
|
103
|
+
"field-sizing-content resize-none px-2",
|
|
104
|
+
"min-h-11 py-1.75 md:min-h-10 md:py-1.25",
|
|
105
|
+
"text-xl leading-7 font-semibold md:text-xl",
|
|
106
|
+
)}
|
|
107
|
+
/>
|
|
108
|
+
|
|
109
|
+
<div className="col-start-2 row-start-2 flex gap-2 sm:row-start-1">
|
|
110
|
+
{/* Tooltip "Lưu · Enter", "Huỷ · Esc" bọc từng nút */}
|
|
111
|
+
<Button icon={Check} aria-label="Lưu" onMouseDown={keepFieldFocused} onClick={save} className="size-11 p-0 md:size-10" />
|
|
112
|
+
<Button icon={X} aria-label="Huỷ" onMouseDown={keepFieldFocused} onClick={cancel} className="size-11 p-0 md:size-10" />
|
|
113
|
+
</div>
|
|
114
|
+
|
|
115
|
+
{/* Câu lỗi thụt lại 9px: thẳng chữ trong ô và thẳng cột với đầu trang, không thẳng khung. */}
|
|
116
|
+
{hasMessageRow ? (
|
|
117
|
+
<div className="col-start-1 row-start-2 min-w-0 pl-2.25">
|
|
118
|
+
<FieldMessage error={error} isSpaceReserved />
|
|
119
|
+
</div>
|
|
120
|
+
) : null}
|
|
121
|
+
</div>
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Đổi `px-2` thì đổi luôn ba chỗ `2.25` (9px). Đổi cỡ nút ✓ ✕ thì đổi `sm:pr-26 md:pr-24` (hai nút + hai
|
|
125
|
+
khoảng `gap-2`).
|
|
126
|
+
|
|
127
|
+
*Phép thử:* đo `x` của chữ tên bằng `Range` và `x` của link cấp cha / câu mô tả: bằng nhau. Bấm
|
|
128
|
+
vào một tên dài ở 1280px: số dòng và chữ đầu mỗi dòng không đổi.
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# Input
|
|
2
|
+
|
|
3
|
+
Nguồn: `speak-now/src/components/input/input.tsx`
|
|
4
|
+
|
|
5
|
+
```tsx
|
|
6
|
+
const baseClasses = "outline-hidden transition-colors text-foreground placeholder:text-muted";
|
|
7
|
+
|
|
8
|
+
const variantClasses = {
|
|
9
|
+
// Viền và MÀU viền viết cùng một chỗ, đúng như nút outline: `border border-border-strong`.
|
|
10
|
+
default: "w-full h-11 md:h-10 bg-surface dark:bg-white/4 border border-border-strong rounded-xl text-base px-4 md:text-sm",
|
|
11
|
+
ghost: "bg-transparent border-0 p-0",
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const stateClasses = error
|
|
15
|
+
? "border-red-500 focus:ring-2 focus:ring-red-500/10" // lúc thường chỉ viền đỏ, quầng chỉ khi đang gõ
|
|
16
|
+
: "focus:border-focus focus:ring-2 focus:ring-focus";
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
**Vì sao ổn**
|
|
20
|
+
|
|
21
|
+
- Input luôn `bg-surface`, không bao giờ trong suốt. Ô nhập trong suốt trên nền trang thì người dùng không thấy nó là ô nhập. Đây là luật cứng, kể cả khi thư viện gốc mặc định `bg-transparent`.
|
|
22
|
+
- **Focus = viền `--border-focus` + ring mờ `--ring-focus` dày 2px (`ring-2`)** (`I13`, chủ dự án chốt). Ring phải mờ tới mức là vầng sáng, không thành vòng viền thứ hai: đừng tăng độ đậm của `--ring-focus`. Ô lỗi cùng công thức, đổi sang đỏ. **Select, combobox dùng y hệt**, kể cả lúc đang mở (`components/choice-controls.md`).
|
|
23
|
+
- **Viền dùng `--border-strong`, không phải `--border`.** Ô nhập cùng nền trắng với card, nên viền là thứ duy nhất báo "đây là chỗ gõ". Viền card và đường chia thì là trang trí, nhạt được; viền ô nhập thì không (`M14`). Viền này chỉ ~1.27:1, chưa đạt WCAG 1.4.11: đánh đổi có chủ ý, xem `P3` trong `styles.md`.
|
|
24
|
+
- **Nền tối giữ viền**, chỉ đổi nền sang trắng phủ mờ `dark:bg-white/4`. Bỏ viền (`dark:border-transparent`) để nền mờ tự báo vùng gõ là mất ranh giới ô; mọi bộ thiết kế lớn đều giữ viền ở nền tối (`M32`).
|
|
25
|
+
- **Viền ô nhập và viền nút outline phải là CÙNG một class**, `border-border-strong`. Đặt ô nhập cạnh nút mà viền ô mờ hơn là đã lấy nhầm `border-border`. Dựng xong thì grep `border-border\b` trong file ô nhập, textarea, select: phải ra 0.
|
|
26
|
+
- Trạng thái lỗi cũng theo đúng công thức đó, chỉ đổi màu: viền đỏ đặc `red-500`, **quầng `red-500/10` chỉ khi ô đang focus**. Quầng cả lúc nghỉ thì mỗi ô lỗi mang ba tín hiệu đỏ (viền, quầng, câu), form có bốn ô lỗi là đỏ loang cả màn. **Câu lỗi thì `text-xs red-600`**, cùng cỡ với chữ gợi ý (`layouts/form.md`), không `red-500`: viền chỉ cần 3:1 nhưng chữ nhỏ cần 4.5:1, `red-500` trên nền trắng chỉ 3.8:1. Không Tailwind thì `--error`, `--error-ring`, `--error-text` trong `tokens.css`.
|
|
27
|
+
- Bo `rounded-xl`, cùng bậc với nút, nên input và nút đứng cạnh nhau bằng vai.
|
|
28
|
+
- **`text-base` trên mobile rồi thu về `md:text-sm`** — luật `R8`, áp cho cả `textarea` và `select`.
|
|
29
|
+
- **`h-11 md:h-10`**: 40px trên desktop, bằng link sidebar và mục menu; 44px ở màn hẹp cho vừa ngón tay. Nút trong cùng form đổi theo y hệt (`budgets.md`). Form đăng nhập/đăng ký đứng riêng được lên `h-12`. Không dùng `h-12` làm mặc định trong app: thô, và lệch bậc với mọi thứ khác.
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## Một field đầy đủ
|
|
34
|
+
|
|
35
|
+
Nhãn, ô, câu lỗi. Ba phần, và nhãn phải gắn vào ô theo `I26`:
|
|
36
|
+
|
|
37
|
+
```tsx
|
|
38
|
+
<div className="space-y-1.5">
|
|
39
|
+
{/* w-fit: không có nó thì bấm vào khoảng trắng bên phải chữ cũng focus ô */}
|
|
40
|
+
<label htmlFor={id} className="w-fit cursor-pointer text-sm font-medium">
|
|
41
|
+
{label}
|
|
42
|
+
</label>
|
|
43
|
+
<input id={id} className={...} />
|
|
44
|
+
{/* Dòng dưới ô luôn có mặt, min-h-4 bằng một dòng text-xs: lỗi hiện ra hay mất đi thì ô bên dưới không nhảy (form.md) */}
|
|
45
|
+
<p className="min-h-4 text-xs text-red-600 dark:text-red-400">{error}</p>
|
|
46
|
+
</div>
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
`id` phải là duy nhất trong trang. Hai field cùng `id` thì bấm nhãn nào cũng
|
|
50
|
+
focus về ô đầu tiên. Dựng nhiều field thì dùng `useId()`.
|
|
51
|
+
|
|
52
|
+
Placeholder theo `T25`: mặc định không có, chỉ thêm khi nó nói điều nhãn chưa nói. Đúng
|
|
53
|
+
ngôn ngữ đã chốt ở `T24`.
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
## Ô mật khẩu
|
|
58
|
+
|
|
59
|
+
Luật ở `I27`. Ô bọc `relative`, nút `absolute` bên phải, và ô chừa `pr-11`:
|
|
60
|
+
|
|
61
|
+
```tsx
|
|
62
|
+
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
|
|
63
|
+
|
|
64
|
+
<div className="relative">
|
|
65
|
+
<input
|
|
66
|
+
id={id}
|
|
67
|
+
type={isPasswordVisible ? "text" : "password"}
|
|
68
|
+
className="... w-full pr-11"
|
|
69
|
+
/>
|
|
70
|
+
<button
|
|
71
|
+
type="button"
|
|
72
|
+
onClick={() => setIsPasswordVisible((isVisible) => !isVisible)}
|
|
73
|
+
aria-label={isPasswordVisible ? "Ẩn mật khẩu" : "Hiện mật khẩu"}
|
|
74
|
+
className="absolute inset-y-0 right-1 my-auto flex size-10 cursor-pointer items-center justify-center rounded-lg text-muted outline-hidden hover:text-foreground"
|
|
75
|
+
>
|
|
76
|
+
{isPasswordVisible ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
|
|
77
|
+
</button>
|
|
78
|
+
</div>
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
- `type="button"`, không phải mặc định `submit`.
|
|
82
|
+
- Nút `size-10` sát phải `right-1`, vừa khít `pr-11` của ô (`I27`).
|
|
83
|
+
- **Căn giữa dọc bằng `inset-y-0 my-auto` cộng cỡ cố định** (`size-*`), không `top-1/2 -translate-y-1/2` (`N11`). Áp cho mọi icon, nút nằm trong ô dưới đây. Hai cách trùng từng pixel. Quên cỡ thì khối `inset-y-0` giãn cao bằng cả ô (nút mắt 40px thành 48px).
|
|
84
|
+
- Nút nằm **trong** ô, không phải chữ "Hiện" nằm ngoài bên cạnh — chữ ngoài làm hàng bị lệch so với các field khác.
|
|
85
|
+
- Nút không có nền, không viền. Nó là hành động phụ trong ô, hiện lên bằng màu chữ khi rê vào.
|
|
86
|
+
|
|
87
|
+
---
|
|
88
|
+
|
|
89
|
+
## Icon trái trong ô — tuỳ chọn, mặc định không có
|
|
90
|
+
|
|
91
|
+
**Không tự thêm, cũng không hỏi trước.** Dựng ô trơn rồi báo một dòng lúc giao
|
|
92
|
+
(`SKILL.md` câu 3). Người dùng muốn có icon thì:
|
|
93
|
+
|
|
94
|
+
```tsx
|
|
95
|
+
<div className="relative">
|
|
96
|
+
<Mail className="absolute inset-y-0 left-3.5 my-auto size-4 text-muted" aria-hidden />
|
|
97
|
+
<input className="... w-full pl-11" />
|
|
98
|
+
</div>
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
- Icon **xám** (`text-muted`), không phải màu nhấn. Nó là chỉ dấu, không phải nội dung.
|
|
102
|
+
- Ô phải chừa `pl-11`. Quên là chữ gõ đè lên icon.
|
|
103
|
+
- `aria-hidden` — nhãn đã nói ô này là gì rồi.
|
|
104
|
+
- Thêm thì **thêm cho cả form**, không phải chỉ ô email. Một ô có icon một ô không thì hai ô lệch lề chữ.
|
|
105
|
+
- Nguồn icon theo `F15`: dự án dùng gì thì lấy ở đó — `lucide-react`, Heroicons, hay SVG thuần.
|
|
106
|
+
|
|
107
|
+
Ô mật khẩu có cả icon trái lẫn nút mắt thì cần **cả** `pl-11` và `pr-11`.
|
|
108
|
+
|
|
109
|
+
---
|
|
110
|
+
|
|
111
|
+
## Ô tìm (`type="search"`)
|
|
112
|
+
|
|
113
|
+
**Tắt nút × có sẵn của trình duyệt, dựng nút xoá riêng.** `type="search"` tự vẽ một
|
|
114
|
+
nút × theo màu của trình duyệt: Chrome tô **xanh dương** theo accent hệ điều hành khi
|
|
115
|
+
ô đang focus, Safari vẽ vòng tròn xám kiểu khác. Một chấm xanh lạc giữa app đen trắng,
|
|
116
|
+
không theo token nào. Lỗi là **màu và hình** của
|
|
117
|
+
nút trình duyệt, không phải việc có nút: đừng bỏ hẳn nút ×, người gõ
|
|
118
|
+
một câu dài ở 375px không có cách xoá nhanh, chữ trôi khuất bên trái (chủ dự án: "tìm kiếm mà không có clear cũng kì"). Ô tìm của các app lớn đều có nút xoá.
|
|
119
|
+
|
|
120
|
+
```tsx
|
|
121
|
+
<div className="relative">
|
|
122
|
+
<input
|
|
123
|
+
ref={inputRef}
|
|
124
|
+
type="search"
|
|
125
|
+
className="... w-full pr-10 [&::-webkit-search-cancel-button]:appearance-none [&::-webkit-search-decoration]:appearance-none"
|
|
126
|
+
/>
|
|
127
|
+
{query ? (
|
|
128
|
+
<Button variant="ghost" icon={X} aria-label="Xoá từ khoá"
|
|
129
|
+
onClick={() => { clearQuery(); inputRef.current?.focus(); }}
|
|
130
|
+
className="absolute inset-y-0 right-1 my-auto size-8 min-h-8 rounded-lg p-0 text-muted hover:text-foreground" />
|
|
131
|
+
) : null}
|
|
132
|
+
</div>
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
- Nút có nền rê nằm trong ô thì nền là ô vuông `size-8 rounded-lg` thụt vào, không phủ kín từ viền tới viền. Nút − + của ô số lượng cùng khuôn này (`quantity-input.md`).
|
|
136
|
+
- Nút xoá **chỉ hiện khi ô có chữ**, icon `X` `size-4` xám, rê vào đậm lên. Bấm xong **trả tiêu điểm về ô** để gõ lại ngay.
|
|
137
|
+
- Giữ `type="search"`: bàn phím điện thoại hiện nút Tìm, Escape vẫn xoá từ khoá.
|
|
138
|
+
- Ô chừa `pr-10` để chữ dài không chui dưới nút.
|
|
139
|
+
- Tìm ra 0 kết quả thì khối rỗng có thêm lối "Xoá tìm kiếm" (`empty-state.md`, "Rỗng do lọc").
|
|
140
|
+
|
|
141
|
+
---
|
|
142
|
+
|
|
143
|
+
## Viền lúc focus ở dark mode
|
|
144
|
+
|
|
145
|
+
Đừng dùng `focus:border-primary` chung cho cả hai theme. Ở nền tối, `--primary`
|
|
146
|
+
là gần trắng nên viền hoá thành sợi trắng đặc, gắt.
|
|
147
|
+
|
|
148
|
+
Dùng `--border-focus` thay vì `--primary`:
|
|
149
|
+
|
|
150
|
+
```html
|
|
151
|
+
<input class="... border border-border-strong focus:border-focus focus:ring-2 focus:ring-focus" />
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
```js
|
|
155
|
+
// tailwind.config
|
|
156
|
+
borderColor: { focus: "var(--border-focus)" },
|
|
157
|
+
ringColor: { focus: "var(--ring-focus)" },
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Nền sáng thì `--border-focus` chính là màu nhấn, giữ nguyên như cũ. Nền tối thì
|
|
161
|
+
nó là màu nhấn ở độ đục 42% (3.6:1, đạt WCAG 3:1). Xem luật `M22` trong `../rules-color.md`.
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Dòng trong danh sách
|
|
2
|
+
|
|
3
|
+
Nguồn: dòng nhắc việc của một dự án thật.
|
|
4
|
+
|
|
5
|
+
```tsx
|
|
6
|
+
<li className="group flex items-center gap-3 rounded-xl px-1 py-2 hover:bg-item-hover">
|
|
7
|
+
<IconButton ... /> {/* hành động chính, luôn hiện */}
|
|
8
|
+
|
|
9
|
+
<div className="min-w-0 flex-1">
|
|
10
|
+
<div className="flex min-w-0 items-center gap-2">
|
|
11
|
+
<span className="shrink-0 rounded-full px-2 py-0.5 text-xs font-medium">
|
|
12
|
+
{person.label}
|
|
13
|
+
</span>
|
|
14
|
+
<p className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">
|
|
15
|
+
{reminder.title}
|
|
16
|
+
</p>
|
|
17
|
+
</div>
|
|
18
|
+
<p className="mt-0.5 text-xs text-muted">
|
|
19
|
+
{cadence} · <span className={DUE_TONE_CLASS[tone]}>{dueLabel}</span>
|
|
20
|
+
</p>
|
|
21
|
+
</div>
|
|
22
|
+
|
|
23
|
+
<div className="flex items-center gap-0.5">
|
|
24
|
+
<IconButton {/* hành động phụ, ẩn */}
|
|
25
|
+
className="pointer-events-none opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:opacity-100 [@media(hover:none)]:pointer-events-auto [@media(hover:none)]:opacity-100"
|
|
26
|
+
/>
|
|
27
|
+
</div>
|
|
28
|
+
</li>
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
**Vì sao ổn**
|
|
32
|
+
|
|
33
|
+
- Dòng này có nền rê **vì rê vào hiện nút ẩn** bên phải. Dòng chỉ để đọc, không link, không nút ẩn thì không có nền rê (`I9`, vế ngược).
|
|
34
|
+
- Hover là **nền rê `hover:bg-item-hover`** (nền sáng: chìm xuống đúng màu nền trang). Dòng có ô icon hay badge nền `bg-background` thì ô đó đảo sang `group-hover:bg-surface` lúc rê, không thì nó biến mất (`I32`) — được vì dòng này thụt vào, có bo góc. Dòng tràn hết bề ngang khung (bảng) thì dùng `--surface-hover`, xem `I10`, tức dòng tối nhẹ đi chứ không sáng lên, không viền, không nhấc bóng. Cảm giác như con trỏ ấn xuống mặt giấy.
|
|
35
|
+
- **IconButton trong dòng rê `hover:bg-foreground/8`**, không `hover:bg-item-hover` như dòng: rê vào nút thì nền nút trùng nền dòng, nút mất hẳn. `/5` chồng lên nền dòng đang rê cũng gần trùng, nên trong dòng là `/8` (`I10`, `small-controls.md`).
|
|
36
|
+
- Ba tầng ưu tiên rõ rệt trong một dòng: hành động chính luôn hiện bên trái, nội dung ở giữa, hành động phụ ẩn bên phải. Không phải mọi nút đều đòi được nhìn thấy cùng lúc.
|
|
37
|
+
- Nút phụ ẩn bằng `opacity-0` kèm `pointer-events-none`. Thiếu vế thứ hai thì nút vô hình vẫn ăn cú bấm. **Mở lại bằng ba đường**: rê vào (`group-hover`), Tab tới (`group-focus-within`, không có thì Tab rơi vào một nút vô hình), và máy không có chuột (`[@media(hover:none)]`, kèm cả `pointer-events-auto`, không thì nút hiện ra mà chạm không ăn). Có xoá thì theo `I11`: luôn hiện.
|
|
38
|
+
- **Cỡ nút hiện thẳng trong dòng theo độ dày của danh sách.** Danh sách thưa (dòng có ô icon hoặc avatar `size-10`, dưới ~10 dòng: phiên đăng nhập, tích hợp, khoá API) thì nút cỡ form `h-11 md:h-10 rounded-xl`, bằng ô icon và bằng các nút khác trên trang. Bảng dày (dòng 48–56px, nhiều dòng) mới dùng nút nhỏ `h-8 rounded-lg`. Màn cảm ứng: nút 32px dưới cỡ bấm khuyến nghị 44px.
|
|
39
|
+
- `min-w-0` xuất hiện ở cả hai tầng bọc ngoài `truncate`. Thiếu nó thì flex item không chịu co, tiêu đề dài đẩy vỡ dòng. Đây là lỗi hay gặp nhất trong danh sách.
|
|
40
|
+
- Màu chỉ dùng để báo hạn, ba mức: **quá hạn** chữ hổ phách `text-amber-700` kèm số ngày ("quá hạn 2 ngày"), **hôm nay** chữ `text-foreground font-medium` không màu, **còn xa** `text-muted`. Quá hạn là "cần chú ý" theo `M7`, không phải đỏ: đỏ là lỗi người dùng phải sửa mới đi tiếp (`M30`). Hôm nay không phải cảnh báo nên không màu, và nhờ vậy không trùng hổ phách với quá hạn. Ngoài ba chỗ đó cả dòng là đen trắng xám.
|
|
41
|
+
- Dòng phụ là `text-xs text-muted`, ngăn cách bằng dấu `·` chứ không phải gạch dài hay dấu gạch ngang.
|
|
42
|
+
- **Dòng phụ nhiều mảnh (từ 4 mảnh) thì ở màn hẹp chia hai dòng theo nghĩa**, không để trình duyệt tự ngắt. Ngắt tự do thì dấu `·` rớt lên đầu dòng sau ("· Chưa dùng lần nào"), và chỗ ngắt đổi theo độ dài từng dòng nên năm dòng năm kiểu. Gom mảnh thành hai nhóm (nhận dạng / thời gian), mỗi nhóm `block sm:inline`, dấu `·` giữa hai nhóm chỉ hiện từ `sm`:
|
|
43
|
+
|
|
44
|
+
```tsx
|
|
45
|
+
<p className="mt-0.5 text-xs text-muted">
|
|
46
|
+
<span className="block sm:inline">
|
|
47
|
+
<span className="font-mono">evd_live_…a3f9</span> · Toàn quyền
|
|
48
|
+
</span>
|
|
49
|
+
<span aria-hidden className="hidden sm:inline"> · </span>
|
|
50
|
+
<span className="block sm:inline">Dùng 2 phút trước · Không hết hạn</span>
|
|
51
|
+
</p>
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Từ `sm` vẫn là một dòng như cũ. Nhóm nào vẫn quá dài ở 375px thì mỗi mảnh trong nhóm `whitespace-nowrap`, dấu cách giữa hai mảnh nằm ngoài span (không thì cả nhóm không có chỗ ngắt và tràn khung).
|
|
55
|
+
- Hai cỡ chữ trong một dòng, không hơn: `text-sm` cho tiêu đề, `text-xs` cho phụ và cho nhãn. Không `text-[10px]`: dấu tiếng Việt chồng hai tầng ở 10px dính vào nhau. Nhãn là pill `rounded-full` như badge (`F1`, `M7`), không `rounded` 4px.
|
|
56
|
+
- **Dòng bo góc xếp chồng thì chừa 2px** (`space-y-0.5` trên danh sách). Hai dòng cạnh nhau cùng sáng nền — một đang chọn, một đang rê chuột — mà dính mép thì đọc ra thành **một khối cao gấp đôi**, không phải hai dòng. Dòng tràn hết bề ngang khung (bảng) thì không cần, vì nó không bo góc và đã có đường kẻ chia.
|