@comitor/ui 0.9.1 → 1.1.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 +158 -858
- package/dist/components/composite/avatar-group.d.ts +18 -2
- package/dist/components/composite/avatar-group.js +2 -5
- package/dist/components/composite/avatar-group.js.map +1 -1
- package/dist/components/composite/combobox.d.ts +31 -2
- package/dist/components/composite/combobox.js +12 -9
- package/dist/components/composite/combobox.js.map +1 -1
- package/dist/components/composite/command-palette-constants.d.ts +35 -0
- package/dist/components/composite/command-palette-constants.js +8 -0
- package/dist/components/composite/command-palette-constants.js.map +1 -0
- package/dist/components/composite/command-palette.d.ts +4 -1
- package/dist/components/composite/command-palette.js +5 -2
- package/dist/components/composite/command-palette.js.map +1 -1
- package/dist/components/composite/confirm-dialog.d.ts +11 -2
- package/dist/components/composite/confirm-dialog.js +2 -1
- package/dist/components/composite/confirm-dialog.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/shell/contrast-provider.d.ts +10 -2
- package/dist/shell/contrast-provider.js +12 -4
- package/dist/shell/contrast-provider.js.map +1 -1
- package/dist/shell/display-axis-provider.d.ts +30 -4
- package/dist/shell/display-axis-provider.js +12 -4
- package/dist/shell/display-axis-provider.js.map +1 -1
- package/dist/shell/mobile-menu.js +1 -0
- package/dist/shell/mobile-menu.js.map +1 -1
- package/dist/shell/types.d.ts +8 -1
- package/dist/shell/types.js +1 -1
- package/dist/shell/types.js.map +1 -1
- package/dist/shell/workspace-switcher.js +1 -1
- package/dist/shell/workspace-switcher.js.map +1 -1
- package/package.json +3 -7
package/README.md
CHANGED
|
@@ -8,16 +8,21 @@ component ghép sẵn và khung ứng dụng (shell) kiểu workspace × app.
|
|
|
8
8
|
- Token brand nằm ở **`styles.css`** — đổi token, bump version, mọi app nhận cùng một thay đổi.
|
|
9
9
|
- Server-component-friendly: `"use client"` chỉ nằm ở file thật sự cần.
|
|
10
10
|
|
|
11
|
-
> Trạng thái: `0.
|
|
12
|
-
>
|
|
13
|
-
> override được) và **một đợt dọn bề mặt API** (0.1.0 chưa app nào import — lần cuối đổi/xoá tên mà
|
|
14
|
-
> không ai phải viết codemod).
|
|
11
|
+
> Trạng thái: `1.0.0` — 52 file primitive (Tier 1) · 31 file composite (Tier 2) · 25 file shell
|
|
12
|
+
> (Tier 3) · 4 file chart.
|
|
15
13
|
>
|
|
16
|
-
>
|
|
17
|
-
>
|
|
18
|
-
>
|
|
19
|
-
> [
|
|
20
|
-
>
|
|
14
|
+
> Tài liệu đầy đủ: **https://design-system.comitor.ai** —
|
|
15
|
+
> [Get started](https://design-system.comitor.ai/get-started) ·
|
|
16
|
+
> [Components](https://design-system.comitor.ai/components) ·
|
|
17
|
+
> [Composites](https://design-system.comitor.ai/composites) ·
|
|
18
|
+
> [Shell](https://design-system.comitor.ai/shell) ·
|
|
19
|
+
> [Changelog](https://design-system.comitor.ai/changelog) ·
|
|
20
|
+
> [npm](https://www.npmjs.com/package/@comitor/ui).
|
|
21
|
+
> README này là bản tra nhanh: cạm bẫy, hợp đồng màu, ranh giới server/client.
|
|
22
|
+
>
|
|
23
|
+
> API đã **chốt ở `1.0.0`**: từ đây thay đổi phá vỡ tương thích đi kèm bump **major**, không còn đi
|
|
24
|
+
> kèm minor như dải `0.x` — mỗi mục breaking vẫn có bảng chuyển đổi ở
|
|
25
|
+
> [changelog](https://design-system.comitor.ai/changelog).
|
|
21
26
|
|
|
22
27
|
---
|
|
23
28
|
|
|
@@ -44,24 +49,24 @@ pnpm add react-hook-form # cho @comitor/ui/form
|
|
|
44
49
|
| `recharts` | `>=2.15.0` | — | `/chart` (kiểu đã dựng cho **cả 2.x lẫn 3.x**, xem ghi chú dưới) |
|
|
45
50
|
| `react-hook-form` | `^7.54.0` | — | `/form` (context singleton) |
|
|
46
51
|
|
|
47
|
-
> **Vì sao `recharts` là `>=` chứ không phải `^`.** `^2.15.0` loại thẳng recharts
|
|
48
|
-
> chạy tốt với gói này
|
|
49
|
-
>
|
|
50
|
-
>
|
|
51
|
-
> `
|
|
52
|
-
>
|
|
53
|
-
>
|
|
54
|
-
> `lucide-react` cũng là `>=` vì cùng lý do: thang phiên bản của nó nhảy `0.x` → `1.x` mà API
|
|
55
|
-
> icon không đổi. Đã kiểm typecheck + build với `1.33.0`.
|
|
52
|
+
> **Vì sao `recharts` và `lucide-react` là `>=` chứ không phải `^`.** `^2.15.0` loại thẳng recharts
|
|
53
|
+
> 3 — mà 3.x chạy tốt với gói này: kiểu của tầng `/chart` không dẫn xuất `payload`/`label` từ
|
|
54
|
+
> `TooltipProps`/`LegendProps` (3.x đã bỏ hai trường đó khỏi props) mà tự khai `ChartPayloadItem`,
|
|
55
|
+
> phần giao của hai bản. Đã chạy `typecheck` + `build` trên **cả recharts 2.15.4 lẫn 3.10.1**.
|
|
56
|
+
> `lucide-react` cũng vậy: thang phiên bản của nó nhảy `0.x` → `1.x` mà API icon không đổi, đã kiểm
|
|
57
|
+
> với `1.33.0`.
|
|
56
58
|
>
|
|
57
59
|
> **Hiệu ứng vẽ dần của recharts 2.15.x hỏng với React 19.2**, nên `ComitorAreaChart` /
|
|
58
|
-
> `ComitorBarChart` / `ComitorLineChart` mặc định **tắt** hiệu ứng
|
|
59
|
-
>
|
|
60
|
-
>
|
|
60
|
+
> `ComitorBarChart` / `ComitorLineChart` mặc định **tắt** hiệu ứng (§4.2). Nếu tự lắp recharts
|
|
61
|
+
> trong `<ChartContainer>` thì gói không tắt hộ được — app phải tự ghi `isAnimationActive={false}`
|
|
62
|
+
> lên từng `<Bar>`/`<Line>`/`<Area>` (§4.2).
|
|
63
|
+
|
|
64
|
+
Cài đặt từng bước, kèm bản mẫu app: https://design-system.comitor.ai/get-started
|
|
61
65
|
|
|
62
66
|
## 2. Nạp `styles.css` — ĐỌC KỸ, đây là chỗ dễ sai nhất
|
|
63
67
|
|
|
64
|
-
|
|
68
|
+
`styles.css` phải nằm trong **`app/globals.css`**, ngay sau `@import "tailwindcss"` — không phải
|
|
69
|
+
`import` trong `layout.tsx` (`layout.tsx` chỉ import `globals.css` của chính app):
|
|
65
70
|
|
|
66
71
|
```css
|
|
67
72
|
/* app/globals.css */
|
|
@@ -69,670 +74,118 @@ pnpm add react-hook-form # cho @comitor/ui/form
|
|
|
69
74
|
@import "@comitor/ui/styles.css";
|
|
70
75
|
```
|
|
71
76
|
|
|
72
|
-
```ts
|
|
73
|
-
// app/layout.tsx — chỉ import globals.css của chính app
|
|
74
|
-
import "./globals.css";
|
|
75
|
-
```
|
|
76
|
-
|
|
77
77
|
> **KHÔNG** viết `import "@comitor/ui/styles.css"` trong `layout.tsx` (thói quen Tailwind 3).
|
|
78
78
|
> File này chứa `@theme` / `@custom-variant` / `@source` — những at-rule **chỉ có nghĩa khi được
|
|
79
79
|
> Tailwind biên dịch**. Import ở `layout.tsx` thì biến CSS vẫn chạy nhưng **không utility nào**
|
|
80
80
|
> (`bg-gold-300`, `dark:*`…) được sinh ra → giao diện vỡ mà không có lỗi build.
|
|
81
81
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
```css
|
|
86
|
-
@source "../node_modules/@comitor/ui/dist";
|
|
87
|
-
```
|
|
88
|
-
|
|
89
|
-
### Font
|
|
90
|
-
|
|
91
|
-
App tự nạp Inter (subsets `latin` + `vietnamese`) và gán vào biến `--font-inter`; package chỉ tiêu thụ:
|
|
92
|
-
|
|
93
|
-
```ts
|
|
94
|
-
// app/layout.tsx
|
|
95
|
-
import { Inter } from "next/font/google";
|
|
96
|
-
|
|
97
|
-
const inter = Inter({
|
|
98
|
-
subsets: ["latin", "vietnamese"],
|
|
99
|
-
weight: ["400", "500", "600", "700", "800"],
|
|
100
|
-
display: "swap",
|
|
101
|
-
variable: "--font-inter"
|
|
102
|
-
});
|
|
103
|
-
```
|
|
104
|
-
|
|
105
|
-
Package đọc **hai** biến font, cả hai đều tuỳ chọn:
|
|
106
|
-
|
|
107
|
-
| Biến | Ai gán | Không gán thì sao |
|
|
108
|
-
|---|---|---|
|
|
109
|
-
| `--font-inter` | app (như trên) | `--font-sans` rơi về `"Inter", system-ui, sans-serif` — vẫn đúng nếu máy có Inter |
|
|
110
|
-
| `--font-inter-mono` | app, **nếu muốn** đổi font chữ đều (số trong tooltip biểu đồ, `Kbd`…) | `--font-mono` rơi về `"Geist Mono", ui-monospace, monospace` |
|
|
111
|
-
|
|
112
|
-
`--font-inter-mono` được tiêu thụ dưới dạng `var(--font-inter-mono, "Geist Mono")` — **fallback nằm ngay
|
|
113
|
-
trong `var()`** là bắt buộc: `var()` không có fallback mà biến lại chưa khai thì cả khai báo `--font-mono`
|
|
114
|
-
thành *invalid at computed-value time*, và `font-mono` rơi về font kế thừa chứ **không** rơi về phần còn
|
|
115
|
-
lại của danh sách. Muốn dùng font mono riêng thì gán y hệt cách gán `--font-inter`:
|
|
82
|
+
Dùng pnpm strict mà thấy mất sạch style thì khai thêm ở app:
|
|
83
|
+
`@source "../node_modules/@comitor/ui/dist";` (gói đã tự khai `@source "./dist"`; Tailwind v4 mặc
|
|
84
|
+
định bỏ qua `node_modules`).
|
|
116
85
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
### Dark mode
|
|
122
|
-
|
|
123
|
-
Theo **class** (`@custom-variant dark (&:is(.dark *))`), khớp `next-themes`:
|
|
124
|
-
|
|
125
|
-
```tsx
|
|
126
|
-
<ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>
|
|
127
|
-
```
|
|
86
|
+
**Font**: app tự nạp Inter (subsets `latin` + `vietnamese`) và gán `--font-inter`; gói chỉ tiêu thụ.
|
|
87
|
+
`--font-inter-mono` là tuỳ chọn, gán y hệt cách gán `--font-inter` nếu muốn đổi font chữ đều (số
|
|
88
|
+
trong tooltip biểu đồ, `Kbd`…). Không gán thì mỗi biến rơi về fallback khai sẵn trong `styles.css`.
|
|
89
|
+
Cách gán bằng `next/font`: https://design-system.comitor.ai/get-started
|
|
128
90
|
|
|
129
|
-
|
|
130
|
-
|
|
91
|
+
**Dark mode** theo **class** (`@custom-variant dark (&:is(.dark *))`), khớp `next-themes`. Lưu ý:
|
|
92
|
+
biến thể `dark` chỉ khớp **con cháu** của `.dark`, không khớp chính phần tử mang class đó. Muốn dựng
|
|
93
|
+
preview 2 theme cạnh nhau phải bọc thêm một lớp: `<div class="dark"><div>…</div></div>`.
|
|
94
|
+
Cách dựng `ThemeProvider`, thứ tự lồng với `ContrastProvider`, và `suppressHydrationWarning` bắt
|
|
95
|
+
buộc ở thẻ `<html>`: https://design-system.comitor.ai/shell
|
|
131
96
|
|
|
132
97
|
## 3. Entry point
|
|
133
98
|
|
|
134
|
-
Package có **5 entry
|
|
135
|
-
barrel chính re-export **một** file có `import` tới peer optional là mọi app dùng `@comitor/ui`
|
|
136
|
-
đều buộc phải cài gói đó — không cài thì bundler gãy ngay ở bước resolve.
|
|
99
|
+
Package có **5 entry**, tách theo peer optional mà mỗi tầng kéo theo:
|
|
137
100
|
|
|
138
101
|
| Entry | Nội dung | Peer optional kéo theo |
|
|
139
102
|
|---|---|---|
|
|
140
103
|
| `@comitor/ui` | Tier 1 (primitive) + Tier 2 (composite) + `cn` + token TS + `useIsMac` | **không có** — chạy được ở Vite, Storybook, test runner |
|
|
141
|
-
| `@comitor/ui/shell` | Tier 3:
|
|
142
|
-
| `@comitor/ui/chart` |
|
|
143
|
-
| `@comitor/ui/form` | Binding shadcn ⇄ react-hook-form
|
|
144
|
-
| `@comitor/ui/tokens` | Token dạng object TS
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
const TASK_STATUSES: StatusConfig<"todo" | "doing" | "done">[] = [
|
|
185
|
-
{ value: "todo", label: "Cần làm", shortLabel: "CL", priority: 1, ...STATUS_TONES.neutral },
|
|
186
|
-
{ value: "doing", label: "Đang làm", shortLabel: "ĐL", priority: 2, ...STATUS_TONES.info },
|
|
187
|
-
{ value: "done", label: "Hoàn thành", shortLabel: "HT", priority: 3, ...STATUS_TONES.success }
|
|
188
|
-
];
|
|
189
|
-
|
|
190
|
-
export default function TasksPage() {
|
|
191
|
-
return (
|
|
192
|
-
<PageContainer width="lg">
|
|
193
|
-
<PageHeader title="Công việc" description="Toàn bộ task của workspace" />
|
|
194
|
-
|
|
195
|
-
<div className="grid gap-4 sm:grid-cols-3">
|
|
196
|
-
<StatCard
|
|
197
|
-
label="Đang mở"
|
|
198
|
-
value={128}
|
|
199
|
-
delta={{ value: "+12,5%", direction: "up", label: "so với tuần trước" }}
|
|
200
|
-
/>
|
|
201
|
-
{/* polarity: quá hạn TĂNG là xấu → mũi tên lên phải tô đỏ, không tô xanh */}
|
|
202
|
-
<StatCard
|
|
203
|
-
label="Quá hạn"
|
|
204
|
-
value={7}
|
|
205
|
-
tone="destructive"
|
|
206
|
-
delta={{ value: "3 việc", direction: "up", polarity: "positive-down" }}
|
|
207
|
-
/>
|
|
208
|
-
<StatCard label="Hoàn thành tuần này" value={43} tone="success" />
|
|
209
|
-
</div>
|
|
210
|
-
|
|
211
|
-
<StatusPill configs={TASK_STATUSES} value="doing" />
|
|
212
|
-
</PageContainer>
|
|
213
|
-
);
|
|
214
|
-
}
|
|
215
|
-
```
|
|
216
|
-
|
|
217
|
-
### 4.2 Khung ứng dụng — `AppShell` + `WorkspaceSwitcher` + `AppLauncher`
|
|
218
|
-
|
|
219
|
-
Shell dựng trên **hai trục điều hướng** kiểu Linear/Lark:
|
|
220
|
-
|
|
221
|
-
- **Trục 1 — `WorkspaceSwitcher`**: đổi *ngữ cảnh dữ liệu* (Acme Corp ▸ Beta Ltd).
|
|
222
|
-
- **Trục 2 — `AppLauncher`**: đổi *sản phẩm* trong cùng workspace (Tasks ▸ Chat ▸ CRM ▸ HR).
|
|
223
|
-
|
|
224
|
-
Cả hai **không nhận dữ liệu qua prop** — chúng đọc từ context của `ShellProvider`, mà `AppShell`
|
|
225
|
-
đã bọc sẵn. Vì vậy chỉ cần truyền `workspaces` / `apps` / `nav` một lần cho `AppShell`.
|
|
226
|
-
|
|
227
|
-
```tsx
|
|
228
|
-
// app/(app)/layout.tsx — root layout đặt ThemeProvider, layout con đặt AppShell.
|
|
229
|
-
"use client";
|
|
230
|
-
|
|
231
|
-
import { CheckSquare, LayoutDashboard, MessageSquare, Users } from "lucide-react";
|
|
232
|
-
import { useRouter } from "next/navigation";
|
|
233
|
-
import type { AppDescriptor, NavGroup, Workspace } from "@comitor/ui/shell";
|
|
234
|
-
import { AppShell } from "@comitor/ui/shell";
|
|
235
|
-
|
|
236
|
-
const workspaces: Workspace[] = [
|
|
237
|
-
{ id: "acme", name: "Acme Corp", slug: "acme", plan: "Pro", role: "Quản trị viên", memberCount: 42 },
|
|
238
|
-
{ id: "beta", name: "Beta Ltd", slug: "beta", plan: "Free", role: "Thành viên", memberCount: 6 }
|
|
239
|
-
];
|
|
240
|
-
|
|
241
|
-
const apps: AppDescriptor[] = [
|
|
242
|
-
{ id: "tasks", name: "Công việc", icon: CheckSquare, href: "/tasks", accent: "var(--color-teal)" },
|
|
243
|
-
{ id: "chat", name: "Trò chuyện", icon: MessageSquare, href: "/chat", badge: 3 },
|
|
244
|
-
{ id: "crm", name: "CRM", icon: Users, href: "/crm", entitled: false } // chưa mua → hiện khoá
|
|
245
|
-
];
|
|
246
|
-
|
|
247
|
-
const nav: NavGroup[] = [
|
|
248
|
-
{
|
|
249
|
-
id: "main",
|
|
250
|
-
items: [
|
|
251
|
-
{ id: "overview", label: "Tổng quan", href: "/tasks", icon: LayoutDashboard },
|
|
252
|
-
{ id: "mine", label: "Của tôi", href: "/tasks/mine", icon: CheckSquare, badge: 12 }
|
|
253
|
-
]
|
|
254
|
-
}
|
|
255
|
-
];
|
|
256
|
-
|
|
257
|
-
export default function AppLayout({ children }: { children: React.ReactNode }) {
|
|
258
|
-
const router = useRouter();
|
|
259
|
-
|
|
260
|
-
return (
|
|
261
|
-
<AppShell
|
|
262
|
-
workspaces={workspaces}
|
|
263
|
-
currentWorkspaceId="acme"
|
|
264
|
-
apps={apps}
|
|
265
|
-
currentAppId="tasks"
|
|
266
|
-
nav={nav}
|
|
267
|
-
user={{ id: "u1", name: "Nguyễn Đức Thành", email: "thanh@acme.vn", role: "Quản trị viên" }}
|
|
268
|
-
onWorkspaceChange={(id) => router.push(`/w/${id}`)}
|
|
269
|
-
onAppSelect={(app) => router.push(app.href)}
|
|
270
|
-
onUpsell={(appId) => router.push(`/billing?app=${appId}`)}
|
|
271
|
-
onLogout={signOut}
|
|
272
|
-
>
|
|
273
|
-
{children}
|
|
274
|
-
</AppShell>
|
|
275
|
-
);
|
|
276
|
-
}
|
|
277
|
-
```
|
|
278
|
-
|
|
279
|
-
Cần tự lắp thay vì dùng `AppShell` trọn gói (vd header riêng)? Bọc `ShellProvider` rồi đặt
|
|
280
|
-
`WorkspaceSwitcher` / `AppLauncher` ở đâu tuỳ ý — chúng vẫn lấy dữ liệu từ context:
|
|
281
|
-
|
|
282
|
-
```tsx
|
|
283
|
-
import { AppLauncher, ShellProvider, WorkspaceSwitcher } from "@comitor/ui/shell";
|
|
284
|
-
|
|
285
|
-
<ShellProvider workspaces={workspaces} apps={apps} nav={nav} user={user}>
|
|
286
|
-
<header className="flex h-header items-center gap-2 px-3">
|
|
287
|
-
<WorkspaceSwitcher align="start" />
|
|
288
|
-
<AppLauncher /> {/* nút lưới 3×3, phím tắt ⌘/Ctrl + Shift + K */}
|
|
289
|
-
</header>
|
|
290
|
-
<main>{children}</main>
|
|
291
|
-
</ShellProvider>
|
|
292
|
-
```
|
|
293
|
-
|
|
294
|
-
**Bọc `ThemeProvider` ở root layout, NGOÀI `AppShell`** — next-themes phải nằm trên cùng để không
|
|
295
|
-
nháy theme lúc hydrate:
|
|
296
|
-
|
|
297
|
-
```tsx
|
|
298
|
-
// app/layout.tsx
|
|
299
|
-
import { ThemeProvider } from "@comitor/ui/shell";
|
|
300
|
-
|
|
301
|
-
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
|
302
|
-
{children}
|
|
303
|
-
</ThemeProvider>
|
|
304
|
-
```
|
|
305
|
-
|
|
306
|
-
App **không chạy trên Next**: truyền `pathname` và `LinkComponent` cho `ShellProvider`/`AppShell`
|
|
307
|
-
để khỏi chạm `next/navigation`.
|
|
308
|
-
|
|
309
|
-
#### Mục menu phân biệt nhau bằng query — prop `searchParams`
|
|
310
|
-
|
|
311
|
-
`usePathname()` của Next **không bao giờ** chứa `?query`. Vì vậy mục khai
|
|
312
|
-
`href: "/cai-dat?tab=thanh-vien"` được so bằng phần ĐƯỜNG DẪN (`/cai-dat`) — nó sáng khi bạn đang
|
|
313
|
-
ở `/cai-dat`, và nếu menu có nhiều tab cùng đường dẫn thì **tất cả cùng sáng**, vì shell không
|
|
314
|
-
thấy query.
|
|
315
|
-
|
|
316
|
-
Muốn đúng từng tab thì đưa query cho shell qua prop **optional** `searchParams`:
|
|
317
|
-
|
|
318
|
-
```tsx
|
|
319
|
-
"use client";
|
|
320
|
-
import { useSearchParams } from "next/navigation";
|
|
321
|
-
import { AppShell } from "@comitor/ui/shell";
|
|
322
|
-
|
|
323
|
-
export function Shell({ children }: { children: React.ReactNode }) {
|
|
324
|
-
const searchParams = useSearchParams(); // nhớ có <Suspense> ở trên trang
|
|
325
|
-
return <AppShell nav={nav} searchParams={searchParams}>{children}</AppShell>;
|
|
326
|
-
}
|
|
327
|
-
```
|
|
328
|
-
|
|
329
|
-
Nhận cả `URLSearchParams`, chuỗi `"?tab=x"`, lẫn object `searchParams` của Server Component.
|
|
330
|
-
Luật khi đã có nó:
|
|
331
|
-
|
|
332
|
-
- mục có query sáng khi query của nó là **tập con** của URL hiện tại (`?tab=a` vẫn sáng ở
|
|
333
|
-
`?tab=a&q=abc&trang=2`);
|
|
334
|
-
- mục **không** query nhường cho mục cùng đường dẫn có query khớp — tab mặc định không sáng cùng
|
|
335
|
-
lúc với tab đang mở;
|
|
336
|
-
- luật cũ "chỉ `startsWith` khi không có mục nào khớp chính xác" giữ nguyên.
|
|
337
|
-
|
|
338
|
-
> Shell **không** tự gọi `useSearchParams()`: hook đó ép trang phải bọc `<Suspense>` và đẩy trang
|
|
339
|
-
> sang render phía client khi build tĩnh. Cái giá đó phải do app chọn, không phải hệ quả của việc
|
|
340
|
-
> import shell.
|
|
341
|
-
|
|
342
|
-
> **Từ `0.2.2`, cả 5 entry — kể cả `/shell` — nạp được bằng Node ESM thuần.** Ở `0.2.1` thì không:
|
|
343
|
-
> `next@16` không khai `exports` map, nên với Node thì `next/link` chỉ là một đường dẫn file thiếu
|
|
344
|
-
> đuôi và `import("@comitor/ui/shell")` chết ngay lúc nạp
|
|
345
|
-
> (`ERR_MODULE_NOT_FOUND … Did you mean next/link.js?`), trong khi app Next không hề hấn vì bundler
|
|
346
|
-
> tự thêm đuôi. Nay shell ghi thẳng `next/link.js` / `next/navigation.js` — **cùng một file** mà
|
|
347
|
-
> bundler vẫn resolve tới, nên client-side navigation và `usePathname()` không đổi.
|
|
348
|
-
> `pnpm smoke:entries` canh lại điều này ở mỗi lần publish.
|
|
349
|
-
|
|
350
|
-
### 4.3 Token ngoài Tailwind
|
|
351
|
-
|
|
352
|
-
```ts
|
|
353
|
-
import { chart, getAvatarToneColors, layout, semantic } from "@comitor/ui/tokens";
|
|
354
|
-
|
|
355
|
-
const seriesColor = chart.light[0]; // #E8B824
|
|
356
|
-
const headerPx = layout.headerHeight; // 56
|
|
357
|
-
const errorText = semantic.dark.destructiveInk; // mực đỏ trên nền tối
|
|
358
|
-
|
|
359
|
-
// Avatar trong email HTML / canvas / PDF — cùng tone với avatar trên web
|
|
360
|
-
const { background, foreground } = getAvatarToneColors("Nguyễn Đức Thành", "light");
|
|
361
|
-
// { background: "#E0F2E9", foreground: "#216A48" }
|
|
362
|
-
```
|
|
363
|
-
|
|
364
|
-
> Hai cửa cho **một** bảng màu avatar: `getAvatarToneClasses(name)` ở `@comitor/ui` (có Tailwind → trả
|
|
365
|
-
> chuỗi class, đổi theo theme) và `getAvatarToneColors(name, mode)` ở `@comitor/ui/tokens` (không có
|
|
366
|
-
> Tailwind → trả hex đục đã trộn sẵn tint). Cùng bảng tone, cùng hàm băm, nên một người ra một màu ở cả
|
|
367
|
-
> hai nơi. **Đừng** tự băm tên rồi bốc màu từ `accent` — đó là đường ngắn nhất để web và email lệch màu.
|
|
368
|
-
|
|
369
|
-
### 4.4 Biểu đồ, và hằng của gói khi dùng ở Server Component
|
|
370
|
-
|
|
371
|
-
Hai điều phải biết trước khi vẽ biểu đồ đầu tiên. Cả hai đều do app mẫu `comitor-starter` phát
|
|
372
|
-
hiện bằng cách *dùng thật*, và cả hai đều IM LẶNG — không có lỗi, không có warning. Mục 1 có **hai
|
|
373
|
-
nửa**: một nửa gói tự lo (`Comitor*Chart`), một nửa app phải tự lo (`ChartContainer` + recharts tự
|
|
374
|
-
lắp) — đừng đọc nửa đầu rồi bỏ nửa sau.
|
|
375
|
-
|
|
376
|
-
**1. Hiệu ứng vẽ dần mặc định TẮT (từ `0.2.2`).** Với `recharts` 2.15.x + React 19.2, hiệu ứng
|
|
377
|
-
không bao giờ chạy tới khung hình cuối, nên mark đứng nguyên ở khung đầu — tức là vô hình. Đo trên
|
|
378
|
-
DOM thật, ba dạng cùng một lỗi:
|
|
379
|
-
|
|
380
|
-
| | biểu hiện trong SVG | thấy trên màn hình |
|
|
381
|
-
|---|---|---|
|
|
382
|
-
| `<Bar>` | `<g class="recharts-bar-rectangle">` **rỗng** (`Rectangle` trả `null` khi cao 0) | không có cột |
|
|
383
|
-
| `<Line>` | path có `d` đúng nhưng `stroke-dasharray="0px 634px"` | không có đường |
|
|
384
|
-
| `<Area>` | path có `d` đúng nhưng `<clipPath><rect width="0">` | không có vùng |
|
|
385
|
-
|
|
386
|
-
Kết quả giống hệt nhau: đủ trục, đủ lưới, đủ chú giải, KHÔNG có dữ liệu — và chờ bao lâu cũng
|
|
387
|
-
không tự hết. Nay ba component nhận prop **optional** `isAnimationActive`, mặc định `false`:
|
|
388
|
-
|
|
389
|
-
```tsx
|
|
390
|
-
<ComitorBarChart data={data} xAxisKey="thang" series={series} /> // hiện ngay
|
|
391
|
-
<ComitorBarChart data={data} xAxisKey="thang" series={series} isAnimationActive /> // bật lại
|
|
392
|
-
```
|
|
393
|
-
|
|
394
|
-
Bật lại chỉ nên làm khi app chạy **recharts 3.x**; trên 2.15.x nó dựng lại đúng cái biểu đồ trống
|
|
395
|
-
ở trên.
|
|
396
|
-
|
|
397
|
-
**1b. Tự lắp recharts (`ChartContainer`) thì PHẢI TỰ TẮT — gói không đặt hộ được.** Mặc định mới ở
|
|
398
|
-
trên chỉ có hiệu lực bên trong ba wrapper `Comitor*Chart`. Entry `/chart` còn một đường dùng thứ
|
|
399
|
-
hai, đúng lối shadcn quen thuộc: `<ChartContainer>` + recharts do app tự lắp. Ở đường đó
|
|
400
|
-
`<Bar>`/`<Line>`/`<Area>` là **children do app viết**, container không sờ vào được, nên trên
|
|
401
|
-
`recharts` 2.15.x app gặp lại y nguyên lỗi im lặng của `0.2.1` — đủ trục, đủ lưới, KHÔNG có dữ liệu:
|
|
402
|
-
|
|
403
|
-
```tsx
|
|
404
|
-
"use client";
|
|
405
|
-
import { ChartContainer } from "@comitor/ui/chart";
|
|
406
|
-
import { Bar, BarChart, XAxis } from "recharts";
|
|
407
|
-
|
|
408
|
-
// ✗ recharts 2.15.x — <g class="recharts-bar-rectangle"> rỗng, không cột nào
|
|
409
|
-
<ChartContainer config={{ doanhThu: { label: "Doanh thu", color: "var(--chart-1)" } }}>
|
|
410
|
-
<BarChart data={data}>
|
|
411
|
-
<XAxis dataKey="thang" />
|
|
412
|
-
<Bar dataKey="doanhThu" fill="var(--color-doanhThu)" />
|
|
413
|
-
</BarChart>
|
|
414
|
-
</ChartContainer>
|
|
415
|
-
|
|
416
|
-
// ✓ tắt tay, trên TỪNG mark
|
|
417
|
-
<Bar dataKey="doanhThu" fill="var(--color-doanhThu)" isAnimationActive={false} />
|
|
418
|
-
<Line dataKey="doanhThu" stroke="var(--color-doanhThu)" isAnimationActive={false} />
|
|
419
|
-
<Area dataKey="doanhThu" fill="var(--color-doanhThu)" isAnimationActive={false} />
|
|
420
|
-
```
|
|
421
|
-
|
|
422
|
-
Đo trên DOM thật (Next 16.3.2 · React 19.2.8 · recharts 2.15.4, `next build` + `next start`, chờ
|
|
423
|
-
5000ms sau hydrate), cùng một trang chỉ khác một prop:
|
|
424
|
-
|
|
425
|
-
| mark trong `<ChartContainer>` | thiếu prop | có `isAnimationActive={false}` |
|
|
426
|
-
|---|---|---|
|
|
427
|
-
| `<Bar>` | 3 `<g.recharts-bar-rectangle>`, **0** `path.recharts-rectangle` | **3/3** path, cao 273/410/342px |
|
|
428
|
-
| `<Line>` | `stroke-dasharray="0px 818.84px"` | `stroke-dasharray` = `null` |
|
|
429
|
-
| `<Area>` | `<clipPath><rect width="0">` | không còn rect `width="0"` |
|
|
430
|
-
|
|
431
|
-
Trên **recharts 3.x** thì bỏ prop đi cũng được — lỗi không tồn tại ở đó. Nhưng dải peer của gói là
|
|
432
|
-
`>=2.15.0`, nên cứ ghi `isAnimationActive={false}`: trên 3.x nó chỉ còn nghĩa "không hoạt hình".
|
|
433
|
-
|
|
434
|
-
**2. `CHART_COLORS` (và mọi hằng khác của gói) đọc được từ Server Component.** Ở `0.2.1` thì
|
|
435
|
-
không: hai hằng đó export từ `chart/comitor-chart.tsx`, mà file đó có `"use client"`. Bundler RSC
|
|
436
|
-
cắt ranh giới theo FILE, nên mọi export của một module client tới phía server đều là
|
|
437
|
-
client-reference proxy — `CHART_COLORS.gold` ra `undefined`, **không throw, không warning**, trong
|
|
438
|
-
khi TypeScript vẫn khai kiểu literal. Kịch bản gãy đúng như JSDoc của gói đang khuyên viết:
|
|
439
|
-
|
|
440
|
-
```tsx
|
|
441
|
-
// page.tsx — Server Component
|
|
442
|
-
const series = [{ key: "loi", color: CHART_COLORS.red }]; // 0.2.1: color = undefined (im lặng)
|
|
443
|
-
```
|
|
444
|
-
|
|
445
|
-
`0.2.2` chuyển **10 hằng** sang module không có directive (`chart/chart-colors.ts`,
|
|
446
|
-
`components/composite/*-constants.ts`) và cho barrel xuất thẳng từ đó. Tên export không đổi, chỗ
|
|
447
|
-
import không đổi:
|
|
448
|
-
|
|
449
|
-
| Đọc từ Server Component | `0.2.1` | `0.2.2` |
|
|
450
|
-
|---|---|---|
|
|
451
|
-
| `CHART_COLORS.gold` | `undefined` | `"var(--chart-1)"` |
|
|
452
|
-
| `CHART_SERIES_COLORS[0]` | `undefined` | `"var(--chart-1)"` |
|
|
453
|
-
| `DEFAULT_DATA_TABLE_LABELS.emptyTitle` | `undefined` | `"Chưa có dữ liệu"` |
|
|
454
|
-
| `VI_DATE_FORMAT` · `dateFormatForLocale(vi)` | `undefined` | `"dd/MM/yyyy"` |
|
|
455
|
-
| `DEFAULT_DATE_PRESETS.length` | `0` | `3` |
|
|
456
|
-
| `DEFAULT_DATE_RANGE_PRESETS.length` | `0` | `5` |
|
|
457
|
-
| `DEFAULT_PAGE_SIZE_OPTIONS[0]` | `undefined` | `20` |
|
|
458
|
-
| `SELECT_EMPTY_VALUE` | `undefined` | `"__empty__"` |
|
|
459
|
-
| `DEFAULT_TABLE_PAGINATION_LABELS.region` | `undefined` | `"Phân trang"` |
|
|
460
|
-
| `DEFAULT_SEARCH_FILTER_BAR_LABELS.searchPlaceholder` | `undefined` | `"Tìm kiếm…"` |
|
|
461
|
-
|
|
462
|
-
`toSelectValue` / `fromSelectValue` đi cùng `SELECT_EMPTY_VALUE`, nên nay gọi được trong Server
|
|
463
|
-
Action. `pnpm verify:directives` canh không cho hằng thuần lọt ngược vào file `"use client"`.
|
|
464
|
-
|
|
465
|
-
> **Vẫn còn nằm sau ranh giới client — 13 tên, và tất cả đều BÁO LỖI TO, không im lặng.** Chúng là
|
|
466
|
-
> hàm export từ file `"use client"`; gọi ở Server Component ném `Attempted to call <tên>() from the
|
|
467
|
-
> server but <tên> is on the client`. Đã kiểm bằng trang Server Component prerender thật
|
|
468
|
-
> (Next 16.3.2, `next build`, đọc HTML sinh ra trên đĩa): **13/13 ném đúng lỗi đó, 10/10 đối chứng
|
|
469
|
-
> chạy bình thường**.
|
|
470
|
-
>
|
|
471
|
-
> | | tên | vì sao vẫn ở đó |
|
|
472
|
-
> |---|---|---|
|
|
473
|
-
> | dựng chuỗi class (`cva`) | `buttonVariants`, `toggleVariants`, `fieldVariants`, `inputGroupAddonVariants`, `inputGroupButtonVariants` | sống cùng file với component client tương ứng |
|
|
474
|
-
> | gọi lệnh toast | `toast`, `dismissToast`, `toastPromise` | vốn chỉ có nghĩa trong trình duyệt |
|
|
475
|
-
> | hook | `useIsMac`, `useCarousel`, `useShell` (`/shell`), `useChart` (`/chart`), `useFormField` (`/form`) | hook thì đương nhiên client |
|
|
476
|
-
>
|
|
477
|
-
> Nhóm đầu là nhóm dễ vấp nhất, vì chúng chỉ ghép chuỗi class — không có lý do trực giác nào để
|
|
478
|
-
> chúng không chạy ở trang server. Muốn dựng class nút cho một `<a>` ở trang server thì để phần đó
|
|
479
|
-
> trong một client component.
|
|
480
|
-
>
|
|
481
|
-
> Chín helper `cva` còn lại **KHÔNG** kẹt — `alertVariants`, `badgeVariants`, `buttonGroupVariants`,
|
|
482
|
-
> `cardVariants`, `emptyMediaVariants`, `itemVariants`, `itemMediaVariants`,
|
|
483
|
-
> `navigationMenuTriggerStyle`, `statCardIconVariants`, cùng `cn`, đều gọi được từ Server Component
|
|
484
|
-
> (đã đo cùng lượt, cùng trang). Danh sách trên là đầy đủ, không phải trích: nó dựng từ đồ thị
|
|
485
|
-
> re-export của cả 5 entry, lọc mọi tên có giá trị runtime mà khai báo nằm trong file `"use client"`.
|
|
104
|
+
| `@comitor/ui/shell` | Tier 3: khung ứng dụng workspace × app, theme, ba trục hiển thị | `next`, `next-themes` |
|
|
105
|
+
| `@comitor/ui/chart` | Lớp shadcn bọc recharts + `Comitor*Chart` + `CHART_COLORS` | `recharts` (~100KB) |
|
|
106
|
+
| `@comitor/ui/form` | Binding shadcn ⇄ react-hook-form | `react-hook-form` |
|
|
107
|
+
| `@comitor/ui/tokens` | Token dạng object TS — cho nơi không có Tailwind: canvas, email HTML, PDF | không có |
|
|
108
|
+
|
|
109
|
+
⚠ **20 tên xuất hiện ở nhiều hơn một entry**, và `export *` chỉ báo đỏ khi trùng trong **cùng một**
|
|
110
|
+
entry — trùng xuyên entry thì im lặng hoàn toàn. Đúng **2** trong số đó là hai cài đặt khác nhau
|
|
111
|
+
(`FormField` / `FormFieldProps`: ở Tier 2 là bố cục form 12 cột, ở `/form` là wrapper của
|
|
112
|
+
`Controller`); 18 tên còn lại trỏ về cùng một binding nên lấy ở đâu cũng như nhau. Bảng đầy đủ, kèm
|
|
113
|
+
hai ca **không** trùng tên hay bị tưởng nhầm (`Toaster` ⇄ `SonnerToaster`, và `useIsMac` chỉ xuất ở
|
|
114
|
+
`@comitor/ui`): https://design-system.comitor.ai/get-started
|
|
115
|
+
|
|
116
|
+
## 4. Cạm bẫy hay gặp
|
|
117
|
+
|
|
118
|
+
### 4.1 Shell — hai trục, và mục menu phân biệt nhau bằng query
|
|
119
|
+
|
|
120
|
+
Shell dựng trên **hai trục điều hướng**: `WorkspaceSwitcher` đổi *ngữ cảnh dữ liệu*
|
|
121
|
+
(Acme Corp ▸ Beta Ltd), `AppLauncher` đổi *sản phẩm* trong cùng workspace (Tasks ▸ Chat ▸ CRM ▸ HR).
|
|
122
|
+
Cả hai **không nhận dữ liệu qua prop** — chúng đọc context của `ShellProvider` mà `AppShell` đã bọc
|
|
123
|
+
sẵn, nên `workspaces` / `apps` / `nav` chỉ truyền **một lần** cho `AppShell`.
|
|
124
|
+
|
|
125
|
+
⚠ `usePathname()` của Next **không bao giờ** chứa `?query`. Vì vậy nhiều mục cùng đường dẫn
|
|
126
|
+
(`/cai-dat?tab=a`, `?tab=b`) sẽ **cùng sáng** một lúc — không lỗi, không warning. Đưa query cho shell
|
|
127
|
+
qua prop optional `searchParams` thì mỗi tab sáng đúng phần của nó.
|
|
128
|
+
|
|
129
|
+
Cách lắp, toàn bộ luật active matching, và bản dùng ngoài Next:
|
|
130
|
+
https://design-system.comitor.ai/shell · https://design-system.comitor.ai/components/sidebar-nav
|
|
131
|
+
|
|
132
|
+
### 4.2 Biểu đồ — hiệu ứng vẽ dần, và ranh giới server/client
|
|
133
|
+
|
|
134
|
+
⚠ Với **recharts 2.15.x + React 19.2**, hiệu ứng vẽ dần không bao giờ chạy tới khung hình cuối, nên
|
|
135
|
+
mark đứng nguyên ở khung đầu — tức là vô hình: đủ trục, đủ lưới, đủ chú giải, **KHÔNG có dữ liệu**,
|
|
136
|
+
và chờ bao lâu cũng không tự hết. `ComitorAreaChart` / `ComitorBarChart` / `ComitorLineChart` vì thế
|
|
137
|
+
mặc định **tắt** hiệu ứng. Nhưng khi tự lắp recharts trong `<ChartContainer>` thì mark là children do
|
|
138
|
+
app viết, container không đặt hộ được — app **phải tự ghi `isAnimationActive={false}` lên từng**
|
|
139
|
+
`<Bar>` / `<Line>` / `<Area>`. Dải peer của gói là `>=2.15.0` nên cứ ghi: trên 3.x lỗi không tồn tại
|
|
140
|
+
và prop chỉ còn nghĩa "không hoạt hình". https://design-system.comitor.ai/components/chart
|
|
141
|
+
|
|
142
|
+
Ranh giới server/client là cạm bẫy cùng họ, và hỏng theo **hai kiểu ngược nhau**: 7 helper dựng chuỗi
|
|
143
|
+
class (`buttonVariants`, `toggleVariants`…) gọi từ Server Component thì **ném lỗi to**, còn một hằng
|
|
144
|
+
dữ liệu lọt vào sau file `"use client"` thì ra `undefined` — **không throw, không warning** — trong
|
|
145
|
+
khi TypeScript vẫn khai kiểu literal. Danh sách đầy đủ 97 tên, ai gọi được ai không:
|
|
146
|
+
https://design-system.comitor.ai/foundations/server-client
|
|
486
147
|
|
|
487
148
|
## 5. Bảng component
|
|
488
149
|
|
|
489
|
-
Số
|
|
490
|
-
|
|
491
|
-
**15** · `/tokens` **15** (xem §3 cho các tên xuất hiện ở nhiều entry). `0.2.2` **không thêm/bớt tên
|
|
492
|
-
nào**; số file tăng vì hằng dữ liệu được tách sang module riêng (§4.4). Bảng dưới liệt kê component gốc;
|
|
493
|
-
các thành phần con (`CardHeader`, `DialogFooter`, `SelectItem`…) export kèm theo file tương ứng.
|
|
494
|
-
|
|
495
|
-
### Tier 1 — primitive (`@comitor/ui`)
|
|
496
|
-
|
|
497
|
-
shadcn/ui style `new-york`, đã tùy biến token theo brand Comitor.
|
|
498
|
-
|
|
499
|
-
| Nhóm | Component |
|
|
500
|
-
|---|---|
|
|
501
|
-
| Hành động | `Button`, `ButtonGroup`, `Toggle`, `ToggleGroup`, `Kbd` |
|
|
502
|
-
| Nhập liệu | `Input`, `InputGroup`, `InputOTP`, `Textarea`, `Label`, `Field`, `Checkbox`, `RadioGroup`, `Switch`, `Slider`, `Select`, `Calendar` |
|
|
503
|
-
| Hiển thị | `Card`, `Badge`, `Avatar`, `Table`, `Separator`, `AspectRatio`, `Skeleton`, `Spinner`, `Progress`, `Empty`, `Item`, `Alert` |
|
|
504
|
-
| Lớp phủ | `Dialog`, `AlertDialog`, `Sheet`, `Drawer`, `Popover`, `HoverCard`, `Tooltip`, `DropdownMenu`, `ContextMenu`, `Menubar`, `Command` |
|
|
505
|
-
| Điều hướng | `Tabs`, `Breadcrumb`, `Pagination`, `NavigationMenu`, `Accordion`, `Collapsible` |
|
|
506
|
-
| Khác | `ScrollArea`, `Resizable`, `Carousel` |
|
|
507
|
-
|
|
508
|
-
`Alert` có ba variant: `default` · `warning` · `destructive` — hai variant sau tô CHỮ bằng bậc `-ink`
|
|
509
|
-
trên `bg-card`, không tô nền màu (bậc tô nền làm chữ chỉ được 1,59:1 ở light ✗).
|
|
510
|
-
|
|
511
|
-
`Skeleton` tô bằng `--muted` (xám trung tính), KHÔNG phải `--accent`: bậc nhạt của brand là kem
|
|
512
|
-
ngả vàng ở bảng light, nên cả một trang đang tải sẽ loang vàng — và đúng bằng màu mà `Switch`/`Tabs`
|
|
513
|
-
dùng để nói "đang bật". Ở dark hai token vốn trùng giá trị nên không đổi gì.
|
|
514
|
-
|
|
515
|
-
`Progress` có `tone`: `default` · `success` · `warning` · `destructive`. Tông `warning` KHÔNG chỉ đổi
|
|
516
|
-
màu: nó kèm vạch chéo (`hazard-stripes`) khoét rãnh xuyên phần đã chạy. Bắt buộc phải thế, vì brand
|
|
517
|
-
của Comitor là gold còn warning là yellow — `--warning-ink` ⇄ `--primary-ink` chỉ 1,00:1 ở bảng tương
|
|
518
|
-
phản cao (light) và 1,0x:1 ở cả hai bảng dark, tức đổi màu KHÔNG đổi được gì. Vạch là cùng tín hiệu
|
|
519
|
-
ấy nói bằng hình nên nó còn đọc ra ở chế độ xám (WCAG 1.4.1). Số đo và cách chứng minh "không hex nào
|
|
520
|
-
tách được hai bậc ink này" ở chú thích đầu `progress.tsx`.
|
|
521
|
-
|
|
522
|
-
> Hai file primitive **không** nằm ở entry chính: `form.tsx` → `@comitor/ui/form`;
|
|
523
|
-
> `sonner.tsx` → `@comitor/ui/shell` (tên `SonnerToaster`). Xem §3.
|
|
524
|
-
> **Không có `Sidebar` / `SidebarNav` ở Tier 1** — cố ý, không phải sót: shell Tier 3 đã có sidebar riêng
|
|
525
|
-
> (hai trục điều hướng, thu gọn, nhớ trạng thái theo workspace) và bản shadcn sẽ trùng đúng 3 tên
|
|
526
|
-
> `Sidebar` / `SidebarGroup` / `SidebarMenuItem` với Tier 3 nhưng API khác hẳn, lại giữ trạng thái bằng
|
|
527
|
-
> cookie riêng. App nào thật sự cần bản shadcn thì `npx shadcn@latest add sidebar` vào repo của mình.
|
|
528
|
-
> Lý do đầy đủ: [docs/quyet-dinh-tich-hop.md](docs/quyet-dinh-tich-hop.md) §17.
|
|
529
|
-
|
|
530
|
-
#### `CardHeader` có nút ở góc phải — prop `actions`
|
|
531
|
-
|
|
532
|
-
`CardHeader` mặc định xếp DỌC (`flex-col space-y-1.5`). Thẻ có nút ở góc phải vì thế phải ghi đè
|
|
533
|
-
đúng một chuỗi bốn lớp, ở mọi app, mọi thẻ:
|
|
534
|
-
|
|
535
|
-
```tsx
|
|
536
|
-
/* ✗ Cách cũ — chép tay, dễ sót `space-y-0` (thiếu nó thì nút bị đẩy lệch xuống) */
|
|
537
|
-
<CardHeader className="flex-row items-start justify-between gap-3 space-y-0">
|
|
538
|
-
<div>
|
|
539
|
-
<CardTitle>Thông tin đơn vị</CardTitle>
|
|
540
|
-
<CardDescription>Cập nhật lần cuối hôm qua</CardDescription>
|
|
541
|
-
</div>
|
|
542
|
-
<Button size="sm" variant="ghost">Sửa</Button>
|
|
543
|
-
</CardHeader>
|
|
544
|
-
|
|
545
|
-
/* ✓ Từ 0.2.1 — `actions` gói đúng chuỗi đó lại */
|
|
546
|
-
<CardHeader actions={<Button size="sm" variant="ghost">Sửa</Button>}>
|
|
547
|
-
<CardTitle>Thông tin đơn vị</CardTitle>
|
|
548
|
-
<CardDescription>Cập nhật lần cuối hôm qua</CardDescription>
|
|
549
|
-
</CardHeader>
|
|
550
|
-
```
|
|
150
|
+
Số file nguồn hiện tại: **52 primitive · 31 composite · 25 shell · 4 chart**. Mỗi component có trang
|
|
151
|
+
riêng kèm demo, bảng prop và cạm bẫy của nó:
|
|
551
152
|
|
|
552
|
-
|
|
553
|
-
đổi mặc định. Cụm nút nhận `actionsClassName` riêng khi cần đổi `gap`/cho xuống dòng.
|
|
554
|
-
|
|
555
|
-
Quan hệ với `CardAction`: `CardAction` là ô `ml-auto` để tự lắp khi bạn **đã** tự đặt header thành
|
|
556
|
-
hàng ngang; `actions` làm cả hai việc (đổi bố cục *và* đặt chỗ) nên là đường đi mặc định.
|
|
557
|
-
`CardAction` giữ nguyên cho ai đã dùng.
|
|
558
|
-
|
|
559
|
-
### Tier 2 — composite (`@comitor/ui`)
|
|
560
|
-
|
|
561
|
-
Giá trị thật của package: pattern lặp đi lặp lại ở mọi app, ghép từ nhiều primitive.
|
|
562
|
-
|
|
563
|
-
| Component | Công dụng |
|
|
564
|
-
|---|---|
|
|
565
|
-
| `PageHeader`, `PageContainer` | Khung đầu trang (breadcrumb + hành động) và khung nội dung chuẩn |
|
|
566
|
-
| `DataTable`, `DataTableColumnToggle` | Bảng dữ liệu: sort, chọn dòng, ẩn/hiện cột. Mọi chuỗi (kể cả `aria-label`) override được qua prop `labels` — xem `DataTableLabels` / `DEFAULT_DATA_TABLE_LABELS` |
|
|
567
|
-
| `TablePagination` | Phân trang đi kèm bảng. Chuỗi override qua prop `labels` — `TablePaginationLabels` / `DEFAULT_TABLE_PAGINATION_LABELS` (khoá có số/đơn vị chèn vào là **hàm**, vd `range(start, end, total, unitLabel)`) |
|
|
568
|
-
| `SearchFilterBar`, `FilterChips` | Thanh tìm + lọc + đếm kết quả. `SearchFilterBar` override chuỗi qua `labels` — `SearchFilterBarLabels` / `DEFAULT_SEARCH_FILTER_BAR_LABELS`; `FilterChips` nhận nhãn thẳng trong dữ liệu chip nên không có `labels` |
|
|
569
|
-
| `StatCard`, `StatusPill`, `EmptyState` | Số liệu có delta, nhãn trạng thái, trạng thái rỗng |
|
|
570
|
-
| `RouteTabs` | Tab mà mỗi mục là một **route thật** — `<nav>` chứa link, `aria-current="page"`. Dùng chung bảng lớp với `Tabs` nên nhìn không khác; đừng dùng `Tabs` cho việc này (nó dựng `role="tablist"` + `aria-controls` trỏ tới tabpanel cùng trang, mà ở đây không có). ⚠ Trên Next phải truyền `LinkComponent={Link}` |
|
|
571
|
-
| `FormSection`, `FormField` | Bố cục form 12 cột; uỷ quyền label/mô tả/lỗi xuống `Field` |
|
|
572
|
-
| `Combobox`, `MultiCombobox` | Chọn có tìm kiếm, **khớp không dấu tiếng Việt**. `aria-invalid` đổi trigger sang **viền đỏ** (`--destructive-ink`), cùng tín hiệu với `Input`; nhận cả `boolean` lẫn `"true"`/`"false"` để dùng thẳng `aria-invalid={!!errors.x}` |
|
|
573
|
-
| `DatePicker`, `DateRangePicker` | Chọn ngày/khoảng ngày, preset dựng sẵn. **`locale?: Locale`** (date-fns, mặc định `vi`) đổi CẢ lịch lẫn thứ tự ngày/tháng của ô nhập: định dạng suy từ locale bằng `dateFormatForLocale()`, nên `en-US` ra `MM/dd/yyyy` mà không phải khai gì thêm. Ba nhãn `sr-only` override bằng `labels` |
|
|
574
|
-
| `LetterAvatar`, `AvatarGroup` | Avatar chữ cái theo tên tiếng Việt (tông màu deterministic), nhóm người tham gia. Lấy tone bằng `getAvatarToneClasses(name)`; bản hex cho canvas/email/PDF là `getAvatarToneColors(name, mode)` ở `/tokens` — **cùng một bảng tone**, cùng một hàm băm |
|
|
575
|
-
| `ConfirmDialog`, `CommandPalette` | Hộp xác nhận, bảng lệnh `⌘K` |
|
|
576
|
-
| `Toaster`, `toast()`, `toastPromise()`, `dismissToast()` | Thông báo có icon theo trạng thái |
|
|
577
|
-
| `CopyButton`, `RelativeTime`, `InlineEdit`, `KeyboardHint` | Tiện ích nhỏ dùng khắp nơi. `RelativeTime` nhận **`locale?: Locale`** (date-fns, mặc định `vi`) — app đa ngôn ngữ phải truyền, không thì giao diện tiếng Anh vẫn hiện "3 phút trước". Và `now?: Date` để đo từ một mốc CỐ ĐỊNH thay vì đồng hồ thật — dùng cho dữ liệu đứng yên (bản mẫu, ảnh chụp báo cáo, test); có `now` thì nó tự tắt hẹn giờ cập nhật và kết quả trở nên tất định |
|
|
578
|
-
| `useIsMac` | Hook đi kèm `KeyboardHint` — `true` khi chạy trên macOS (render đầu luôn `false`, xem §3). **Cửa xuất duy nhất của cả gói**; `/shell` không re-export |
|
|
579
|
-
|
|
580
|
-
Kèm 2 module thuần (không phải component, dùng được cả hai phía):
|
|
581
|
-
|
|
582
|
-
| Module | Export |
|
|
583
|
-
|---|---|
|
|
584
|
-
| `status-config` | `STATUS_TONES`, `getStatusConfig`, `getStatusClasses`, `getStatusLabel`, `sortByStatusPriority` |
|
|
585
|
-
| `text-utils` | `removeVietnameseTones`, `normalizeVi`, `matchesSearch`, `getVietnameseInitials`, `truncate` |
|
|
586
|
-
|
|
587
|
-
> `text-utils` **không còn** `hashString` / `pickByString` (0.1.0 có). Hàm băm là chuyện nội bộ của phép
|
|
588
|
-
> chọn tone avatar; xuất nó ra ngoài chỉ mời app dựng lại phép chọn của riêng mình — đúng thứ làm cùng
|
|
589
|
-
> một người hiện hai màu ở web và email. Cần màu avatar thì dùng `getAvatarToneClasses` /
|
|
590
|
-
> `getAvatarToneColors`; cần một màu tuỳ ý thì truyền `toneClassName` cho `LetterAvatar`.
|
|
591
|
-
|
|
592
|
-
### Tier 3 — shell (`@comitor/ui/shell`)
|
|
593
|
-
|
|
594
|
-
| Component | Công dụng |
|
|
595
|
-
|---|---|
|
|
596
|
-
| `AppShell` | Lắp sẵn `ShellProvider` + `Sidebar` + `AppHeader` + `<main>` + `MobileMenu`; hai layout `sidebar-first` / `header-first` |
|
|
597
|
-
| `ShellProvider`, `useShell` | Context + phím tắt toàn cục; dùng khi tự lắp khung. (`useIsMac` **không** ở entry này — nhập từ `@comitor/ui`) |
|
|
598
|
-
| `WorkspaceSwitcher` | **Trục 1** — đổi ngữ cảnh dữ liệu; dùng `Popover` (không `DropdownMenu`, vì typeahead của Radix cướp phím ô tìm kiếm) |
|
|
599
|
-
| `AppLauncher`, `AppLauncherPanel` | **Trục 2** — lưới 3×3 đổi sản phẩm; app chưa mua hiện khoá và gọi `onUpsell` |
|
|
600
|
-
| `AppHeader`, `Sidebar`, `SidebarGroup`, `SidebarMenuItem`, `MobileMenu` | Thành phần khung điều hướng |
|
|
601
|
-
| `UserMenu`, `UserAvatar`, `NotificationsButton` | Vùng phải của header |
|
|
602
|
-
| `ThemeProvider`, `ThemeToggle`, `SonnerToaster` | Theme sáng/tối (next-themes) + toast bám theme |
|
|
603
|
-
| `ContrastProvider`, `ContrastToggle`, `useContrast`, `ContrastScript` | **Trục thứ hai của giao diện**: chọn BẢNG MÀU (bản duyệt comitor-ds ⇄ bản tương phản cao). Độc lập với sáng/tối; lưu ở localStorage `comitor-contrast`. Provider tự render script chống nháy — xem §6.0 |
|
|
604
|
-
| `ShellNavLink` | Link nội bộ của shell; thay `next/link` bằng `LinkComponent` khi không chạy Next |
|
|
605
|
-
| `ComitorLogo`, `ComitorLockup` | Vector brand — **không** có `"use client"`, RSC render thẳng (hex trong SVG là brand book, không tokenize) |
|
|
606
|
-
| `DEFAULT_SHELL_LABELS` + `utils` | Chuỗi hiển thị mặc định (`ShellLabels`, 32 khoá); helper `flattenNavItems`, `isNavItemActive`, `isNavBranchActive`, `toNavGroups`, `resolveCurrentApp`, `getEntitledApps`, `getLockedApps`, `appAccentStyle`, `normalizeVi`, `getVietnameseInitials` |
|
|
607
|
-
|
|
608
|
-
Hợp đồng dữ liệu (type): `Workspace`, `AppDescriptor`, `NavItem`, `NavGroup`, `ShellNav`, `ShellUser`,
|
|
609
|
-
`UserMenuItem`, `ShellLabels`, `ShellIcon`, `ShellLinkComponent`.
|
|
610
|
-
|
|
611
|
-
### Chart (`@comitor/ui/chart`)
|
|
612
|
-
|
|
613
|
-
| Component | Công dụng |
|
|
614
|
-
|---|---|
|
|
615
|
-
| `ChartContainer`, `ChartTooltip`, `ChartTooltipContent`, `ChartLegend`, `ChartLegendContent`, `ChartStyle`, `useChart` | Lớp shadcn bọc recharts (giữ tên trần theo API gốc). ⚠ Tự lắp `<Bar>`/`<Line>`/`<Area>` ở đây thì trên **recharts 2.15.x phải tự ghi `isAnimationActive={false}` lên từng mark** — container không đặt hộ được, quên là biểu đồ trống không báo lỗi (§4.4 mục 1b) |
|
|
616
|
-
| `ComitorAreaChart`, `ComitorBarChart`, `ComitorLineChart` | Bản dựng sẵn theo brand — truyền `data` + `series` là xong |
|
|
617
|
-
| `ComitorChartContainer`, `ComitorChartTooltip` | Bản tiện dụng; **có tiền tố `Comitor`** để không đụng tên trần ở trên |
|
|
618
|
-
| `CHART_COLORS`, `CHART_SERIES_COLORS` | Bảng màu series mặc định — **hằng dữ liệu thuần, đọc được từ Server Component** (§4.4) |
|
|
619
|
-
| `ChartColorName` | Khoá của `CHART_COLORS` |
|
|
620
|
-
|
|
621
|
-
### Form (`@comitor/ui/form`)
|
|
622
|
-
|
|
623
|
-
`Form`, `FormField`, `FormItem`, `FormLabel`, `FormControl`, `FormDescription`, `FormMessage`, `useFormField`
|
|
624
|
-
— binding shadcn ⇄ react-hook-form. ⚠ `FormField` ở đây **khác** `FormField` của Tier 2 (§3).
|
|
625
|
-
|
|
626
|
-
### Cơ chế `labels` — đổi ngôn ngữ mà không fork component
|
|
627
|
-
|
|
628
|
-
Chuỗi hiển thị mặc định của gói là **tiếng Việt**, kể cả chuỗi chỉ trình đọc màn hình nghe thấy
|
|
629
|
-
(`aria-label`, `sr-only`). App dùng ngôn ngữ khác override qua prop `labels` — không phải fork
|
|
630
|
-
component, không phải bọc lại.
|
|
631
|
-
|
|
632
|
-
| Component | Type | Hằng mặc định |
|
|
153
|
+
| Tầng | Là gì | Trang |
|
|
633
154
|
|---|---|---|
|
|
634
|
-
|
|
|
635
|
-
| `
|
|
636
|
-
| `
|
|
637
|
-
|
|
|
638
|
-
|
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
3. **Prop rời thắng `labels`.** `unitLabel`, `label`, `searchPlaceholder` vẫn là prop riêng và vẫn ghi
|
|
650
|
-
đè giá trị trong `labels`: chúng là chuỗi theo **nội dung** của từng bảng ("việc", "liên hệ"), còn
|
|
651
|
-
`labels` là chuỗi theo **ngôn ngữ** ("mục" / "items").
|
|
652
|
-
|
|
653
|
-
```tsx
|
|
654
|
-
const EN_PAGINATION: Partial<TablePaginationLabels> = {
|
|
655
|
-
region: "Pagination",
|
|
656
|
-
unitLabel: "items",
|
|
657
|
-
pageSizePrefix: "Show",
|
|
658
|
-
range: (start, end, total, unit) => `${start}–${end} of ${total} ${unit}`,
|
|
659
|
-
pageStatus: (page, count) => `Page ${page} of ${count}`,
|
|
660
|
-
firstPage: "First page",
|
|
661
|
-
previousPage: "Previous page",
|
|
662
|
-
nextPage: "Next page",
|
|
663
|
-
lastPage: "Last page"
|
|
664
|
-
};
|
|
665
|
-
|
|
666
|
-
<TablePagination {...paging} labels={EN_PAGINATION} unitLabel="contacts" />
|
|
667
|
-
```
|
|
668
|
-
|
|
669
|
-
`FilterChips` **không** có `labels` — nhãn của nó nằm thẳng trong dữ liệu chip, không có chuỗi nào chôn
|
|
670
|
-
trong component.
|
|
155
|
+
| **Tier 1 — primitive** · 52 file · `@comitor/ui` | shadcn/ui style `new-york`, đã tùy biến token theo brand Comitor | https://design-system.comitor.ai/components |
|
|
156
|
+
| **Tier 2 — composite** · 31 file · `@comitor/ui` | Giá trị thật của gói: pattern lặp ở mọi app, ghép từ nhiều primitive (`DataTable`, `PageHeader`, `Combobox`, `DatePicker`, `StatCard`…) | https://design-system.comitor.ai/composites |
|
|
157
|
+
| **Tier 3 — shell** · 25 file · `@comitor/ui/shell` | Khung ứng dụng workspace × app: sidebar, header, theme, ba trục hiển thị (tương phản · mật độ · cỡ chữ) | https://design-system.comitor.ai/shell |
|
|
158
|
+
| **chart** · 4 file · `@comitor/ui/chart` | Lớp shadcn bọc recharts, cùng ba bản dựng sẵn theo brand | https://design-system.comitor.ai/components/chart |
|
|
159
|
+
| **form** · `@comitor/ui/form` | Binding shadcn ⇄ react-hook-form | https://design-system.comitor.ai/components/form |
|
|
160
|
+
|
|
161
|
+
**Cơ chế `labels` — đổi ngôn ngữ mà không fork component.** Chuỗi hiển thị mặc định của gói là **tiếng
|
|
162
|
+
Việt**, kể cả chuỗi chỉ trình đọc màn hình nghe thấy (`aria-label`, `sr-only`); app dùng ngôn ngữ khác
|
|
163
|
+
override qua prop `labels`. Kiểu là `Partial<>` và component luôn trải `{ ...DEFAULT_*, ...labels }`,
|
|
164
|
+
nên truyền một khoá không làm mất phần còn lại. Component chỉ có một hai chuỗi thì mở bằng **prop
|
|
165
|
+
rời** thay cho cả một object — `clearLabel`, `pendingLabel`, `overflowLabel`, `placeholder`,
|
|
166
|
+
`emptyText`: cùng cơ chế, chỉ khác hình dạng. ⚠ Khoá có số/đơn vị chèn vào là **HÀM**, không phải
|
|
167
|
+
chuỗi có placeholder (`range(start, end, total, unitLabel)`, `pageStatus(page, pageCount)`,
|
|
168
|
+
`overflowLabel(count)`) — nối chuỗi bằng dấu cộng ở phía app là cách chắc chắn sai trật tự từ với
|
|
169
|
+
ngôn ngữ khác.
|
|
671
170
|
|
|
672
171
|
## 6. Màu và token
|
|
673
172
|
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
Gói có **hai** bảng màu, cùng một bộ component:
|
|
679
|
-
|
|
680
|
-
| | Bật bằng | Là gì |
|
|
681
|
-
|---|---|---|
|
|
682
|
-
| **Mặc định** | (không cần gì) | Bảng của **comitor-ds** — repo design system đã được duyệt. Đây là nguồn sự thật về hình thức. |
|
|
683
|
-
| **Tương phản cao** | `<html data-contrast="high">` | Bảng đã đo WCAG (chính là bảng của `0.2.x`). Dành cho màn hình độ sáng thấp, màn hình rẻ, hoặc dùng ngoài trời. |
|
|
684
|
-
|
|
685
|
-
Bảng mặc định **cố ý có những cặp dưới ngưỡng** — đó là quyết định của người duyệt, không phải lỗi bỏ
|
|
686
|
-
sót. Danh sách đầy đủ nằm ở khối `NGUỒN SỰ THẬT` đầu `styles.css`; những khoản chính:
|
|
687
|
-
|
|
688
|
-
```
|
|
689
|
-
viền checkbox/radio (--choice-edge) 2,99:1 (1.4.11 đòi 3:1)
|
|
690
|
-
rãnh Switch BẬT, gạch active Tabs (gold) 1,86:1 (1.4.11 đòi 3:1)
|
|
691
|
-
--muted-foreground trên --muted 3,58:1 (1.4.3 đòi 4,5:1)
|
|
692
|
-
chữ trắng trên nền --destructive 4,38:1 (1.4.3 đòi 4,5:1)
|
|
693
|
-
Button variant link (--gold-400) 3,15:1 (1.4.3 đòi 4,5:1)
|
|
694
|
-
```
|
|
695
|
-
|
|
696
|
-
Hai ngoại lệ giữ bản sửa ở **cả hai** bảng, vì màu chữ của bản duyệt gần trùng màu nền tới mức không
|
|
697
|
-
đọc nổi trên bất kỳ màn hình nào: `Badge` variant `warning` (1,52:1 → 6,24:1) và `success`
|
|
698
|
-
(2,82:1 → 5,88:1). Nền giữ nguyên của bản duyệt, chỉ đậm chữ.
|
|
699
|
-
|
|
700
|
-
**Cách bật trong app** — hai bước, app không sở hữu màu nào:
|
|
701
|
-
|
|
702
|
-
```tsx
|
|
703
|
-
// app/layout.tsx (Server Component)
|
|
704
|
-
import { ContrastProvider, ThemeProvider } from "@comitor/ui/shell";
|
|
705
|
-
|
|
706
|
-
<html lang="vi" suppressHydrationWarning>
|
|
707
|
-
<body>
|
|
708
|
-
<ThemeProvider>
|
|
709
|
-
<ContrastProvider>{children}</ContrastProvider>
|
|
710
|
-
</ThemeProvider>
|
|
711
|
-
</body>
|
|
712
|
-
</html>
|
|
713
|
-
```
|
|
714
|
-
|
|
715
|
-
```tsx
|
|
716
|
-
// trang cài đặt (client)
|
|
717
|
-
import { ContrastToggle } from "@comitor/ui/shell";
|
|
718
|
-
|
|
719
|
-
<ContrastToggle />
|
|
720
|
-
```
|
|
173
|
+
Nguồn token là `@comitor/ui/styles.css` (ship kèm gói, mở được ở
|
|
174
|
+
`node_modules/@comitor/ui/styles.css`); bản mirror TS là entry `@comitor/ui/tokens`. Hai hợp đồng
|
|
175
|
+
dưới đây là phần quan trọng nhất của gói — sai một trong hai thì hỏng **im lặng**, không có lỗi build.
|
|
721
176
|
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
177
|
+
**1. Có HAI bảng màu, dùng chung một bộ component.** Mặc định là bảng của **comitor-ds** — design
|
|
178
|
+
system đã được duyệt, chính là bảng trên trang tài liệu, và là nguồn sự thật về hình thức. Bật
|
|
179
|
+
`<html data-contrast="high">` thì đổi sang **bảng đã đo WCAG**, dành cho màn hình độ sáng thấp, màn
|
|
180
|
+
hình rẻ, hoặc dùng ngoài trời. Bảng mặc định **cố ý có những cặp dưới ngưỡng**: đó là quyết định của
|
|
181
|
+
người duyệt, không phải lỗi bỏ sót.
|
|
725
182
|
|
|
726
|
-
> ⚠ **Điều kiện để hai bảng cùng sống**: mọi component đi qua **token vai trò**
|
|
727
|
-
> (`--control-on`, `--control-edge`, `--
|
|
183
|
+
> ⚠ **Điều kiện để hai bảng cùng sống**: mọi component phải đi qua **token vai trò**
|
|
184
|
+
> (`--control-on`, `--control-edge`, `--choice-edge`, `--switch-track`, `--slider-track`,
|
|
728
185
|
> `--input-fill`, `--primary-ink`, `--link-ink`…) chứ **không** viết thẳng `bg-gold-300` /
|
|
729
|
-
> `bg-ash-300` / `text-gold-400`. Viết thẳng thang màu là khoá cứng một bảng, và bảng kia lặng lẽ
|
|
730
|
-
> sai. Cổng `pnpm lint:a11y` bắt được lỗi này vì nó đo **bảng tương phản cao**: giá trị viết thẳng
|
|
731
|
-
> không đổi theo bảng nên sẽ hiện ra ở đó đúng bằng con số của bảng mặc định — tức là đỏ.
|
|
186
|
+
> `bg-ash-300` / `text-gold-400`. Viết thẳng thang màu là khoá cứng một bảng, và bảng kia lặng lẽ sai.
|
|
732
187
|
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
Đây là quy tắc quan trọng nhất của gói. Mỗi màu có **ba** biến thể, đọc tên là biết dùng ở đâu:
|
|
188
|
+
**2. Mỗi màu có BA vai trò** — đọc tên là biết dùng ở đâu:
|
|
736
189
|
|
|
737
190
|
| Tên | Vai trò | Đo tương phản với | Ví dụ dùng |
|
|
738
191
|
|---|---|---|---|
|
|
@@ -740,204 +193,51 @@ render client (script inline không nằm trong nhóm phần tử React hoisted
|
|
|
740
193
|
| `--x-foreground` | chữ/icon **NẰM TRÊN** nền `--x` đặc | `--x` | `bg-destructive text-destructive-foreground` |
|
|
741
194
|
| `--x-ink` | **chính màu đó** khi đi lên nền TRANG: chữ, icon rời, viền, vạch active | `--background` / `--card` / `--muted`, hoặc tint của chính nó | `text-teal-ink`, `border-red-ink` |
|
|
742
195
|
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
| `bg-primary` / `bg-gold-300` làm rãnh `Switch` khi BẬT | Khoá cứng một bảng màu: bảng tương phản cao không đổi được nó nữa | `bg-control-on` |
|
|
788
|
-
| `bg-ash-300` làm rãnh `Switch` khi TẮT, `bg-muted` làm rãnh `Slider` | Cùng lý do — thang màu thô không theo bảng | `bg-switch-track` / `bg-slider-track` |
|
|
789
|
-
| `text-ink` làm dấu check của `Checkbox` | Khoá cứng một bảng: `--control-check` là thứ đi CẶP với `--control-on`, đổi bảng màu là nó tự theo | `text-control-check` |
|
|
790
|
-
| `border-border` làm viền của control | `--border` là ranh giới **TRANG TRÍ** và cố ý ở dưới 3:1 tại MỌI bảng màu; `--control-edge`/`--choice-edge` mới là thứ chịu ngưỡng | `border-input` (= `--control-edge`), `border-choice-edge` cho Checkbox/Radio |
|
|
791
|
-
| `bg-input/30` làm lớp tô ô nhập ở dark | Ở bảng tương phản cao `--input` là #7E8290, tô 30% ra mảng xám sáng làm placeholder tụt còn 3,22:1 ✗ | `dark:bg-input-fill` |
|
|
792
|
-
| `text-app-accent` cho icon/vạch active | accent mặc định là gold `#E8B824` → 1,7–1,9:1 trên nền sidebar ✗ | `text-app-accent-ink`, hoặc nền ĐẶC + `text-app-accent-foreground` |
|
|
793
|
-
| `text-destructive` cho chữ trên nền trang | `--destructive` là màu **NỀN**; ở bảng tương phản cao nó là bậc đậm cho chữ trắng đứng lên và làm chữ thì chỉ 3,76:1 ✗ ở dark | `text-destructive-ink` (7,15:1 light / 6,42:1 dark ở bảng tương phản cao; = `--destructive` ở bảng mặc định, đúng `text-destructive` của comitor-ds) |
|
|
794
|
-
|
|
795
|
-
> ⚠ **App ghi đè `--app-accent` thì PHẢI set kèm `--app-accent-ink`** (và `--app-accent-foreground` nếu
|
|
796
|
-
> accent sáng). Gói cố ý **không** dẫn xuất `-ink` bằng `color-mix` từ `--app-accent`: dẫn xuất tự động
|
|
797
|
-
> cho ra con số tương phản không ai kiểm, và mỗi màu nền cần một hệ số khác nhau.
|
|
798
|
-
>
|
|
799
|
-
> ⚠ **Ghi đè `--primary` thì nên set kèm `--primary-ink`** nếu app muốn một bậc tối riêng cho brand
|
|
800
|
-
> của mình. Ở bảng mặc định hai biến này trùng nhau nên quên cũng không sai màu; ở bảng tương phản cao
|
|
801
|
-
> thì `--primary-ink` điều khiển viền focus / gạch Tabs, còn control BẬT đi theo `--control-on`.
|
|
802
|
-
>
|
|
803
|
-
> ⚠ **Đổi `--teal` / `--green` / `--red` thì phải tính lại bậc `-ink` tương ứng** — chúng là hex đã đóng
|
|
804
|
-
> băng, không tự đi theo. Đóng băng là chủ ý: một chỗ duy nhất để đo lại, component không phải nhắc lại
|
|
805
|
-
> chuỗi `color-mix(…)` (dễ chép lệch % giữa `Badge`/`LetterAvatar`/`Input`), và `tokens.ts` mirror được
|
|
806
|
-
> ra hex thật cho canvas/email/PDF.
|
|
807
|
-
|
|
808
|
-
Bản mirror TS đi theo đúng hợp đồng này: `accent` (nền) ⇄ `accentInk` (mực), `status.light.success` ⇄
|
|
809
|
-
`status.light.successInk`, `semantic.light.destructive` ⇄ `semantic.light.destructiveInk`.
|
|
810
|
-
|
|
811
|
-
### 6.2 Bảng token
|
|
812
|
-
|
|
813
|
-
| Nhóm | Token | Ghi chú |
|
|
814
|
-
|---|---|---|
|
|
815
|
-
| Brand | `gold-50 … gold-600` | `gold-300 #E8B824` = `--primary` (light); dark nâng lên `gold-200 #F5D060`. `--primary-ink` = `--primary` ở bảng mặc định; ở bảng tương phản cao là `gold-500 #7A5800` (light) / `= --primary` (dark) — xem §6.0 và §6.1 |
|
|
816
|
-
| Trung tính brand | `ink`, `ink-soft`, `paper`, `warm` | `warm #EDE8D8` = `--secondary` (light) |
|
|
817
|
-
| Thang xám | `ash-50/100/300/500/700/900` | **Không phải `gray-*`** — xem cạm bẫy bên dưới |
|
|
818
|
-
| Màu phụ (NỀN) | `navy`, `teal`, `red`, `green`, `yellow` | Vai trò **tô nền** (fill đặc hoặc tint). Bảng mặc định dùng một hex cho cả hai theme (đúng comitor-ds); bảng tương phản cao nâng sáng ở dark. ⚠ `--destructive` là token RIÊNG (nền nút xoá): `#D64545` ở bảng mặc định, `#C93B3B` ở bảng tương phản cao để chữ trắng đạt 5,03:1 |
|
|
819
|
-
| Mực của màu phụ | `teal-ink`, `green-ink`, `red-ink`, `yellow-ink`, `navy-ink` | Cùng màu đó ở vai trò **chữ/icon/viền trên nền trang** — xem §6.1. Ở light là hex đã đóng băng (`#1A6668` / `#216A48` / `#9A3537` / `= gold-500` / `= navy`); ở dark trỏ về chính màu phụ |
|
|
820
|
-
| Trạng thái | `success`, `warning`, `info`, `destructive` (+ `-foreground`, `-ink`, `destructive-hover`) | Ba cái đầu alias sang `green` / `yellow` / `teal`; `destructive` ghim một giá trị cho cả hai theme. `-ink` là bậc để làm **chữ** trạng thái. `--destructive-hover` phải là token riêng vì hai bảng đi **ngược chiều**: comitor-ds viết `hover:bg-red/90` (nhạt dần), bảng tương phản cao phải đậm thêm |
|
|
821
|
-
| Ranh giới **ô nhập** | `control-edge`, `control-edge-strong`, `input`, `input-fill` | Input/Textarea/Select/Combobox/OTP, Button `outline`, Badge `outline`, rãnh Slider. `--control-edge` trỏ vào `--input`. Bảng mặc định light giữ đúng bậc của comitor-ds (`#EDEEF1`, 1,16:1) — ô nhập còn được nhận ra bằng kích thước, nhãn và placeholder. Dark thì **không** giữ được: `#1E2128` chỉ 1,10:1 trên `--card` nên đổi thành `#434A5A` (2,00:1). Bảng tương phản cao: `#7E8290` (3,83:1 / 4,64:1). `--control-edge-strong` = hover, `ash-300` light / `ash-500` dark đúng cặp bản duyệt |
|
|
822
|
-
| Ranh giới **checkbox/radio** | `choice-edge`, `choice-edge-strong` | Tách riêng vì ở đây viền **là toàn bộ control** khi chưa chọn — không nhãn trong, không placeholder, không nền — nên nó chịu ngưỡng 1.4.11 còn viền ô nhập thì không. Bảng mặc định `#959597` (2,99:1) / `#5B6479` (3,00:1 trên `--card`); bảng tương phản cao trỏ thẳng về `--input`. Hover **không** dùng được `--control-edge-strong` (`ash-300` = 1,62:1, nhạt hơn cả lúc nghỉ) nên `--choice-edge-strong` là `ash-500` |
|
|
823
|
-
| Control BẬT / ĐÃ CHỌN | `control-on`, `control-knob`, `control-check`, `switch-track`, `slider-track` | Rãnh `Switch` BẬT, ô `Checkbox` đã chọn, chấm `Radio`, đoạn `Slider`, ngày đang chọn của `Calendar` — tất cả dùng **cặp** `bg-control-on` + `text-control-check`; núm Switch là `bg-control-knob` (trắng ở cả hai bảng). Bảng mặc định `= --primary` (gold `#E8B824`); bảng tương phản cao `#B48700` — **vẫn là gold, chỉ đậm hơn**, đạt 3,28:1 ⇄ nền trang và 3,06:1 trên `--muted`. ⚠ KHÔNG dùng `gold-500 #7A5800`: đủ tương phản nhưng mắt đọc ra nâu-ô-liu, tức bảng màu thứ hai đổi luôn màu nhận diện. ⚠ Cũng KHÔNG kéo `--primary-ink` sáng theo — nó còn phải làm CHỮ (nhãn trạng thái, link) nên ở lại `gold-500`; đó chính là lý do `--control-on` và `--primary-ink` là hai token, dù ở mọi bảng khác chúng trùng giá trị |
|
|
824
|
-
| Ranh giới **trang trí** | `border`, `sidebar-border` | Khung Card, đường kẻ bảng, vạch ngăn menu. **Không chịu ngưỡng nào** — nó chỉ tách khối cho mắt dễ quét. Bảng mặc định `#EDEEF1` / `#1E2128` (1,16:1 / 1,10:1) đúng comitor-ds; bảng tương phản cao nâng lên `#A4A4A6` / `#50586B` (**2,49:1** ở cả hai theme) — thấy rõ nhưng **cố ý thấp hơn viền control một bậc**, vì kéo nó lên ngang `--input` là mất phân cấp "thao tác được ⇄ chỉ là đường kẻ", và làm hỏng luôn 4 ca của `test:a11y-gate` vốn dựa vào việc token này ở dưới 3:1 để bắt lỗi "vẽ hình control bằng token trang trí" |
|
|
825
|
-
| Link | `link-ink`, `link-ink-hover` | Button variant `link`. Tách khỏi `--primary-ink` vì comitor-ds dùng `gold-400` cho chữ link nhưng `gold-300` cho viền focus |
|
|
826
|
-
| Biểu đồ | `chart-1 … chart-5` | Đổi bảng màu biểu đồ độc lập với brand. `chart-3` trỏ vào `--navy-ink` (nét biểu đồ phải NỔI trên nền trang) |
|
|
827
|
-
| Avatar | `getAvatarToneColors(name, mode)`, `getAvatarToneIndex(name)`, type `AvatarToneColors` | Trả `{ background, foreground }` dạng **hex đục** cho canvas/email/PDF — đã trộn sẵn tint với nền `--card` vì ngoài CSS không có gì để hợp thành. Cùng bảng tone, cùng hàm băm với `getAvatarToneClasses(name)` ở `@comitor/ui`, nên một người luôn ra một màu ở mọi nơi. Khoá băm là **tên hiển thị**, không phải email/slug |
|
|
828
|
-
| Theo app | `app-accent`, `app-accent-foreground`, `app-accent-ink` | Mỗi app Comitor tô một accent riêng — khai ở CSS của app (có nhánh `.dark`), không cần build lại package. Ghi đè `--app-accent` thì **phải** set kèm `--app-accent-ink` (§6.1) |
|
|
829
|
-
| Hàng bảng | `table-row-stripe`, `table-row-hover` | Sọc xen kẽ và nền lúc rê chuột của `<Table striped>` / `<DataTable striped>`. **Đục, bắt buộc** — ô cột GHIM lấy nền bằng `bg-inherit` từ hàng, hàng còn alpha là ô ghim trong mờ và chữ của các cột đang cuộn lộ xuyên qua. Giá trị = `--muted` phủ 30% / 50% lên `--card`, đã hợp thành sẵn (`#FDFDFD` / `#FBFBFC` light, `#181B20` / `#1A1D23` dark) — đổi `--muted` hay `--card` thì tính lại |
|
|
830
|
-
| Mật độ | `--row-py`, `--menu-item-py`, `--list-item-py`, `--nav-item-py`, `--table-head-h` | Toàn bộ đường bật của trục MẬT ĐỘ (`<html data-density="compact">`). Chỉ trục DỌC — "gọn" là thấy nhiều dòng hơn, không phải chữ dính vào mép. Dùng qua `py-(--row-py)` v.v. ⚠ Mọi token ở đây PHẢI có mặc định ở `:root`: cổng tương phản không đọc khối `[data-density]`, token vắng ở `:root` thì với nó là chuỗi rỗng và `measureShape` mất khả năng phân loại hình — hỏng **im lặng** |
|
|
831
|
-
| Khung | `--sidebar-width`, `--sidebar-width-icon`, `--header-height` | Có utility `w-sidebar`, `w-sidebar-icon`, `h-header`. ⚠ Ba giá trị này tính bằng **rem** nên chúng PHÓNG THEO trục cỡ chữ; `layout` trong `@comitor/ui/tokens` mirror chúng bằng px và là số của mức **Vừa** |
|
|
832
|
-
| Đổ bóng | `--elevation-surface`, `--elevation-control`, `--elevation-card`, `--elevation-overlay` | 4 bậc độ cao, sinh utility `shadow-surface` / `shadow-control` / `shadow-card` / `shadow-overlay`. Đổi theo theme (bản dark dùng đen thuần, alpha cao hơn) |
|
|
833
|
-
| Khác | `--radius 6px` | Bo góc 6px là chữ ký thị giác. Viết bằng **px**, không phải `0.375rem`: trục cỡ chữ phóng theo tỉ lệ nên mọi giá trị rem đi theo — bo góc mà đi theo thì ở mức Lớn nó thành 6,75px |
|
|
834
|
-
|
|
835
|
-
Đổi accent theo app — khai bằng **CSS, có nhánh dark**, không phải inline style trên `<body>`:
|
|
836
|
-
|
|
837
|
-
```css
|
|
838
|
-
/* app/globals.css — sau @import "@comitor/ui/styles.css" */
|
|
839
|
-
:root {
|
|
840
|
-
--app-accent: #1f8a8b; /* NỀN đặc */
|
|
841
|
-
--app-accent-foreground: #0e1116; /* chữ trên nền đó → 4,56:1 ✓ */
|
|
842
|
-
--app-accent-ink: #1a6668; /* màu app trên nền trang → 6,68:1 trên trắng ✓ */
|
|
843
|
-
}
|
|
844
|
-
.dark {
|
|
845
|
-
--app-accent: #2dd4bf;
|
|
846
|
-
--app-accent-foreground: #0e1116; /* 10,16:1 ✓ */
|
|
847
|
-
--app-accent-ink: #2dd4bf; /* 10,16:1 trên --background ✓ */
|
|
848
|
-
}
|
|
849
|
-
```
|
|
850
|
-
|
|
851
|
-
Ba biến, không phải hai: bỏ `--app-accent-ink` thì icon/vạch active của app rơi về gold mặc định.
|
|
852
|
-
Và phải khai ở CSS chứ không phải inline style vì **một giá trị inline không có nhánh `dark`** — accent
|
|
853
|
-
sáng vừa đủ cho light mode thường quá tối trên nền `#0E1116`.
|
|
854
|
-
|
|
855
|
-
> Trong `AppLauncher`, mỗi app còn tự tô ô icon của mình qua `AppDescriptor.accent` /
|
|
856
|
-
> `accentForeground` (inline style, đúng vai trò nền + chữ-trên-nền). Ô icon dùng **nền đặc**, nên hai
|
|
857
|
-
> biến đó là đủ; `--app-accent-ink` là chuyện của cả trang.
|
|
858
|
-
|
|
859
|
-
Đổi độ nổi (bóng) toàn hệ — ghi đè đúng 4 biến `--elevation-*`, **không** ghi đè `--shadow-*`:
|
|
860
|
-
|
|
861
|
-
```css
|
|
862
|
-
/* app/globals.css — sau @import "@comitor/ui/styles.css" */
|
|
863
|
-
:root { --elevation-overlay: 0 12px 40px rgba(14, 17, 22, 0.2); }
|
|
864
|
-
```
|
|
865
|
-
|
|
866
|
-
> Vì `--shadow-*` giữ giá trị dạng `var()`, Tailwind không tách được phần màu: `shadow-card/50` và
|
|
867
|
-
> `shadow-<màu>` **không** có tác dụng. Đổi tông/độ đậm thì ghi đè `--elevation-*` như trên.
|
|
868
|
-
|
|
869
|
-
### 6.3 Cạm bẫy phải nhớ
|
|
870
|
-
|
|
871
|
-
1. **Nền hay mực? Chọn sai vai trò là hỏng tương phản, không phải hỏng thẩm mỹ.** `--x` tô nền,
|
|
872
|
-
`--x-ink` làm chữ/icon/viền trên nền trang (§6.1). Utility `text-teal` / `text-app-accent` **vẫn được
|
|
873
|
-
Tailwind sinh ra** — nó sinh cả bộ cho mọi `--color-*` — nên trình biên dịch sẽ không nhắc gì cả.
|
|
874
|
-
2. **`ash-*` chứ không phải `gray-*`.** Thang xám Comitor chỉ có 6 bậc; nếu đặt tên `gray-*` thì
|
|
875
|
-
`gray-200/400/600/800` rơi về gray mặc định của Tailwind (ám xanh) và trộn với gray Comitor
|
|
876
|
-
(ám tím) → lệch tông. Đổi namespace để hai thang không đụng nhau.
|
|
877
|
-
3. **Không sinh class động** (`` `bg-${color}-300` ``) ở bất kỳ đâu — Tailwind quét tĩnh, class động
|
|
878
|
-
bị loại khỏi bundle của app.
|
|
879
|
-
4. **Không hardcode hex trong component** — trừ vector logo brand. Đọc hex từ `/tokens` rồi nhét vào
|
|
880
|
-
style inline là mất dark mode.
|
|
881
|
-
5. `@theme **inline**` là chủ ý: `inline` nhúng thẳng `var(--x)` vào utility nên `.dark { --primary }`
|
|
882
|
-
đổi được màu tại runtime. Dùng `@theme` thường sẽ đóng băng giá trị lúc build → dark mode chết.
|
|
883
|
-
|
|
884
|
-
## 7. Phát triển
|
|
885
|
-
|
|
886
|
-
```bash
|
|
887
|
-
pnpm install
|
|
888
|
-
pnpm build # tsup: ESM, bundle:false (transpile từng file để giữ "use client"), dts
|
|
889
|
-
pnpm typecheck # tsc --noEmit
|
|
890
|
-
pnpm lint # biome check .
|
|
891
|
-
pnpm lint:fix
|
|
892
|
-
pnpm lint:a11y # hợp đồng màu: vai trò token + tương phản WCAG ở CẢ hai theme
|
|
893
|
-
pnpm test:a11y-gate # tự kiểm chính cổng trên: 56 khẳng định, cổng còn bắt đủ không
|
|
894
|
-
pnpm test:nav-active # active matching của sidebar (hàm thuần) — 34 ca
|
|
895
|
-
pnpm verify:directives # ranh giới server/client: directive còn sau build + không hằng thuần kẹt trong file client
|
|
896
|
-
pnpm smoke:entries # nạp cả 5 entry bằng Node ESM thuần, qua đúng `exports` map (chạy SAU build)
|
|
897
|
-
pnpm check:exports # publint --strict + attw
|
|
898
|
-
```
|
|
899
|
-
|
|
900
|
-
`pnpm test:nav-active` chạy `scripts/check-nav-active.mjs`: Node trần bóc kiểu ngay trên
|
|
901
|
-
`src/shell/utils.ts`, không test runner, không dependency mới. Nó khoá lại đúng lỗi mà
|
|
902
|
-
comitor-starter báo về ở `0.2.0` — mục menu có `?query` trong `href` không bao giờ sáng — cùng
|
|
903
|
-
toàn bộ hành vi active matching cũ, để lần sửa sau không đổi ngầm luật nào.
|
|
904
|
-
|
|
905
|
-
> Gặp `ERR_PNPM_IGNORED_BUILDS: esbuild` khi cài? Đã xử lý sẵn trong `pnpm-workspace.yaml`
|
|
906
|
-
> (`allowBuilds: { esbuild: true }`) — pnpm 11 không còn đọc setting từ trường `pnpm` của
|
|
907
|
-
> `package.json` nữa. esbuild cần chạy postinstall để nạp binary theo nền tảng, cấm chạy thì
|
|
908
|
-
> `pnpm build` fail.
|
|
909
|
-
|
|
910
|
-
Quy ước `"use client"`: đặt **per-file**, ở dòng đầu tiên. `tsup` chạy `bundle: false` nên mỗi file
|
|
911
|
-
`.tsx` ra một file `.js` và directive được giữ nguyên → Next.js đánh đúng ranh giới server/client.
|
|
912
|
-
File thuần dữ liệu (`lib/cn.ts`, `tokens.ts`, `shell/types.ts`) **không** có directive.
|
|
913
|
-
|
|
914
|
-
`scripts/verify-directives.mjs` đi bộ **toàn bộ `src/`** (không riêng `shell/`): file nguồn nào mở đầu
|
|
915
|
-
bằng `"use client"` thì file `dist/` tương ứng phải mở đầu y hệt — thiếu file build cũng tính là hỏng.
|
|
916
|
-
Hiện **70/102** file nguồn cần directive, trải khắp cả composite lẫn primitive (`pnpm verify:directives`
|
|
917
|
-
in ra đúng con số này — đừng chép số từ đây mà không chạy lại). Đừng thay script bằng
|
|
918
|
-
`grep -L "use client" dist/shell/*.js`: lệnh đó soi chưa tới một phần ba số file phải soi.
|
|
919
|
-
|
|
920
|
-
### Cổng hợp đồng màu — `pnpm lint:a11y`
|
|
921
|
-
|
|
922
|
-
`scripts/check-contrast.mjs` quét **toàn bộ** `src/**/*.{ts,tsx}` theo QUY TẮC (không theo danh sách
|
|
923
|
-
phát hiện) và đối chiếu với `styles.css`: token TÔ NỀN đem làm màu chữ/icon/viền là sai *vai trò*, và
|
|
924
|
-
mọi cặp chữ/nền phải đạt 4,5:1 (chữ) hoặc 3:1 (thành phần phi văn bản) ở **cả** light lẫn dark.
|
|
925
|
-
|
|
926
|
-
⚠ Cổng đo **bảng tương phản cao** (`:root` + `[data-contrast="high"]`), không đo bảng mặc định — bảng
|
|
927
|
-
mặc định là bản duyệt và đã được chấp nhận với đúng những con số dưới ngưỡng ở §6.0; bắt nó là bắt một
|
|
928
|
-
quyết định, và một cổng lúc nào cũng đỏ thì lần sau không ai chạy nữa. Hệ quả có lợi: component nào viết
|
|
929
|
-
thẳng thang màu thay vì token vai trò sẽ đỏ ngay, vì giá trị viết thẳng không đổi theo bảng. Mọi loại
|
|
930
|
-
phát hiện đều `exit 1` — kể cả lớp dùng bảng màu mặc định của Tailwind và miễn trừ đã thừa. Miễn trừ ghi
|
|
931
|
-
ở `scripts/contrast-exemptions.json`, mỗi mục bắt buộc kèm lý do bằng chữ. Hợp đồng đầy đủ + giới hạn đã
|
|
932
|
-
biết: [docs/hop-dong-mau.md](docs/hop-dong-mau.md).
|
|
933
|
-
|
|
934
|
-
App tiêu thụ chạy được cùng cổng này trên bảng token đã ghi đè của mình: lấy script từ repo rồi trỏ
|
|
935
|
-
`COMITOR_UI_STYLES` / `COMITOR_UI_SRC` sang file của app (script **không** nằm trong tarball npm).
|
|
936
|
-
|
|
937
|
-
Trước khi phát hành: bump `version`, ghi mục mới trong [CHANGELOG.md](CHANGELOG.md), rồi chạy lại **cả
|
|
938
|
-
khối lệnh ở đầu mục này** (`lint` và `lint:fix` là hai dạng của cùng một bước) — quy trình đầy đủ ở
|
|
939
|
-
[CLAUDE.md](CLAUDE.md) §Publish.
|
|
196
|
+
Phải có bậc `-ink` riêng vì bản light của màu phụ được chọn để tô **nền** nên quá nhạt cho vai trò
|
|
197
|
+
chữ — `--teal` trên tint 15% của chính nó chỉ **3,44:1** ✗, còn `--teal-ink` được **5,54:1** ✓. Ở
|
|
198
|
+
**dark** không có bậc riêng: `--x-ink` trỏ về `var(--x)`, nhờ vậy `text-teal-ink` viết **một lần** là
|
|
199
|
+
đúng cả hai theme, không cần biến thể `dark:`. ⚠ Chọn sai vai trò thì **không có gì báo**: utility
|
|
200
|
+
`text-teal` / `text-app-accent` vẫn được Tailwind sinh ra như thường.
|
|
201
|
+
|
|
202
|
+
> ⚠ **App ghi đè `--app-accent` thì PHẢI set kèm `--app-accent-ink`** (và `--app-accent-foreground`
|
|
203
|
+
> nếu accent sáng), khai bằng **CSS có nhánh `.dark`** chứ không phải inline style — một giá trị
|
|
204
|
+
> inline không có nhánh dark. Gói cố ý **không** dẫn xuất `-ink` bằng `color-mix`: dẫn xuất tự động
|
|
205
|
+
> cho ra con số tương phản không ai kiểm. Cùng luật đó khi đổi `--teal` / `--green` / `--red` — chúng
|
|
206
|
+
> là hex đã **đóng băng**, không tự đi theo.
|
|
207
|
+
|
|
208
|
+
Bảng token đầy đủ, bản đối chiếu viết-sai ⇄ viết-đúng, cách bật bảng tương phản cao, và mọi số đo:
|
|
209
|
+
https://design-system.comitor.ai/foundations/colors
|
|
210
|
+
|
|
211
|
+
## 7. Kiểm định trước mỗi lần phát hành
|
|
212
|
+
|
|
213
|
+
Mỗi bản publish đi qua cùng một chuỗi cổng (`prepublishOnly` nối chúng lại): typecheck → build →
|
|
214
|
+
`verify:directives` → `smoke:entries` → `check:exports` → `test:nav-active` → `test:a11y-gate` →
|
|
215
|
+
`lint:a11y`. Những cổng đáng để người dùng gói biết là chúng tồn tại:
|
|
216
|
+
|
|
217
|
+
- **`lint:a11y` — hợp đồng màu.** Quét **toàn bộ** `src/**/*.{ts,tsx}` theo QUY TẮC (không theo danh
|
|
218
|
+
sách phát hiện) và đối chiếu với `styles.css`: token TÔ NỀN đem làm màu chữ/icon/viền là sai
|
|
219
|
+
*vai trò*, và mọi cặp chữ/nền phải đạt 4,5:1 (chữ) hoặc 3:1 (thành phần phi văn bản) ở **cả**
|
|
220
|
+
light lẫn dark. Cổng đo **bảng tương phản cao** (`:root` + `[data-contrast="high"]`), không đo
|
|
221
|
+
bảng mặc định — bảng mặc định là bản duyệt và đã được chấp nhận với đúng những con số dưới ngưỡng
|
|
222
|
+
ở §6; bắt nó là bắt một quyết định, và một cổng lúc nào cũng đỏ thì lần sau không ai chạy nữa.
|
|
223
|
+
Mọi loại phát hiện đều `exit 1`, kể cả lớp dùng bảng màu mặc định của Tailwind và miễn trừ đã
|
|
224
|
+
thừa; miễn trừ mỗi mục bắt buộc kèm lý do bằng chữ.
|
|
225
|
+
- **`verify:directives` — ranh giới server/client.** File nguồn nào mở đầu bằng `"use client"` thì
|
|
226
|
+
file `dist/` tương ứng phải mở đầu y hệt (thiếu file build cũng tính là hỏng), và không hằng dữ
|
|
227
|
+
liệu thuần nào được kẹt trong file client (§4.2).
|
|
228
|
+
- **`smoke:entries`** nạp cả 5 entry bằng Node ESM thuần, qua đúng `exports` map (§3).
|
|
229
|
+
- **`test:a11y-gate`** tự kiểm chính cổng màu (cổng còn bắt đủ không); **`test:nav-active`** khoá
|
|
230
|
+
toàn bộ luật active matching của sidebar dưới dạng hàm thuần (§4.1).
|
|
231
|
+
|
|
232
|
+
Quy ước `"use client"` của gói: đặt **per-file**, ở dòng đầu tiên, và directive được giữ nguyên
|
|
233
|
+
trong `dist/` → Next.js đánh đúng ranh giới server/client. Module thuần dữ liệu (`lib/cn.ts`,
|
|
234
|
+
`tokens.ts`, `shell/types.ts`) **không** mang directive nên đọc được từ Server Component (§4.2).
|
|
235
|
+
|
|
236
|
+
App tiêu thụ chạy được cùng cổng màu trên bảng token đã ghi đè của mình: script nhận
|
|
237
|
+
`COMITOR_UI_STYLES` / `COMITOR_UI_SRC` trỏ sang file của app. Nhưng script **không** nằm trong
|
|
238
|
+
tarball npm — hợp đồng màu đầy đủ và các giới hạn đã biết ở
|
|
239
|
+
https://design-system.comitor.ai/foundations/colors
|
|
940
240
|
|
|
941
241
|
## 8. Giấy phép
|
|
942
242
|
|
|
943
|
-
|
|
243
|
+
MIT © Comitor
|