@comitor/ui 0.9.1 → 1.0.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 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.2.0` — 51 primitive + 24 composite + 18 file shell + chart, đã qua **một đợt sửa theo
12
- > review** (tương phản màu theo WCAG, thang đổ bóng 4 bậc, a11y tầng shell, chuỗi hiển thị của Tier 2
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
- > ⚠ **`0.2.0` phá vỡ tương thích với `0.1.0`**: đổi tên hàm màu avatar, bỏ `avatarPalette` /
17
- > `LETTER_AVATAR_TONES` / `hashString` / `pickByString`, `useIsMac` chỉ còn một cửa, `DataTable` đổi
18
- > tên prop, và **giá trị một số token màu đã đổi**. Bảng chuyển đổi đầy đủ:
19
- > [CHANGELOG.md](CHANGELOG.md). Trong dải `0.x`, thay đổi phá vỡ tương thích đi kèm bump **minor**;
20
- > API chốt ở `1.0.0`.
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 3 — mà 3.x
48
- > chạy tốt với gói này, nên app nào đã lên 3.x cũng ăn một dòng `unmet peer` không có cách sửa.
49
- > Kiểu của tầng `/chart` không còn dẫn xuất `payload`/`label` từ `TooltipProps`/`LegendProps`
50
- > của recharts (3.x đã bỏ hai trường đó khỏi props, chuyển sang context nội bộ) mà tự khai
51
- > `ChartPayloadItem` — phần giao của hai bản. `pnpm typecheck && pnpm build` được chạy **trên cả
52
- > recharts 2.15.4 lẫn 3.10.1** trước khi nới dải này.
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 từ `0.2.2` (§4.4). Nếu tự lắp
59
- > recharts trong `<ChartContainer>` thì gói không tắt hộ được — app phải tự ghi
60
- > `isAnimationActive={false}` lên từng `<Bar>`/`<Line>`/`<Area>` (§4.4 mục 1b).
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
- Đặt import trong **`app/globals.css`**, ngay sau `@import "tailwindcss"`:
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
- `styles.css` đã tự khai `@source "./dist"` để Tailwind quét class trong package (Tailwind v4 mặc
83
- định bỏ qua `node_modules`). Nếu dùng pnpm strict và thấy style bị mất, khai thêm ở app:
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
- ```ts
118
- const mono = Geist_Mono({ subsets: ["latin"], display: "swap", variable: "--font-inter-mono" });
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
- Lưu ý: biến thể `dark` chỉ khớp **con cháu** của `.dark`, không khớp chính phần tử mang class đó.
130
- Muốn dựng preview 2 theme cạnh nhau phải bọc thêm một lớp: `<div class="dark"><div>…</div></div>`.
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**. Lý do tách: `tsup` build ở chế độ `bundle: false` (ESM per-file), nên chỉ cần
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: `AppShell`, `Sidebar`, `AppHeader`, `WorkspaceSwitcher`, `AppLauncher`, `UserMenu`, `ThemeProvider`, `SonnerToaster` | `next`, `next-themes` |
142
- | `@comitor/ui/chart` | `Chart*` (shadcn) + `ComitorAreaChart/BarChart/LineChart` + `CHART_COLORS` | `recharts` (~100KB) |
143
- | `@comitor/ui/form` | Binding shadcn ⇄ react-hook-form: `Form`, `FormField`, `FormItem`, `FormControl`, `FormMessage`, `useFormField` | `react-hook-form` |
144
- | `@comitor/ui/tokens` | Token dạng object TS | không — cho nơi không có Tailwind: canvas, email HTML, PDF |
145
- | `@comitor/ui/styles.css` | Nguồn token Tailwind v4 | xem §2 |
146
-
147
- ### Tên xuất hiện ở nhiều entry
148
-
149
- Số tên mỗi entry xuất ra (đo bằng trình biên dịch TypeScript trên đúng 5 file entry, giải alias về khai
150
- báo gốc — không đếm tay): `.` **637** · `/shell` **70** · `/chart` **29** · `/form` **15** · `/tokens`
151
- **15**. Đúng **19 tên** xuất hiện ở nhiều hơn một entry, gom thành 4 nhóm dưới đây — `export *` chỉ báo
152
- đỏ khi trùng tên trong **cùng một** entry, nên trùng xuyên entry phải tra bằng bảng này:
153
-
154
- | Tên | Ở `@comitor/ui` | Ở entry khác | Cùng một cài đặt? |
155
- |---|---|---|---|
156
- | `FormField`, `FormFieldProps` | Tier 2 — bố cục 12 cột (label + control + mô tả + lỗi) | `/form` — wrapper của `Controller` (API shadcn) | **KHÔNG** — hai component khác hẳn nhau |
157
- | `normalizeVi` | `composite/text-utils.ts` | `/shell` — **re-export đúng bản đó** | **CÓ** — một thân hàm duy nhất (`0.2.0` gộp; trước đó là hai khai báo riêng giống nhau chữ-cho-chữ) |
158
- | `getVietnameseInitials` | `composite/text-utils.ts` (`toLocaleUpperCase("vi-VN")`) | `/shell` — **re-export đúng bản đó** | **CÓ** — một thân hàm duy nhất |
159
- | 15 token: `gold`, `ash`, `accent`, `accentInk`, `accentForeground`, `brandNeutral`, `semantic`, `status`, `chart`, `layout`, `typography`, `ThemeMode`, `getAvatarToneColors`, `getAvatarToneIndex`, `AvatarToneColors` | `export * from "./tokens.js"` | `/tokens` — **cùng một module** | **CÓ** — `/tokens` là tập con của `.`, tách ra cho nơi chỉ cần hằng số (email HTML, PDF, canvas) |
160
-
161
- Quy tắc dùng: hằng ngày lấy bản ở `@comitor/ui`; chỉ với tới `/form` khi app đã dùng react-hook-form.
162
- Chỉ `FormField` là thật sự hai component khác nhau, phải nhìn đúng entry.
163
-
164
- `0.2.0` đã dọn ba chỗ của `0.1.0`: **`useIsMac`** (từng có hai thân hàm khác nhau ở `.` và `/shell`)
165
- nay chỉ còn cửa `@comitor/ui`; **`avatarPalette`** (bảng màu avatar thứ hai) bị xoá; **`normalizeVi`**
166
- gộp về một binding. Còn đúng **`FormField`/`FormFieldProps`** là hai cài đặt khác nhau trong bảng trên
167
- — 16/18 tên còn lại đều trỏ vào cùng một binding. Xem [CHANGELOG.md](CHANGELOG.md).
168
-
169
- > **`Toaster` KHÔNG phải tên trùng.** Có hai toaster nhưng chúng mang hai tên: `Toaster` ở
170
- > `@comitor/ui` (toast có icon theo trạng thái, nhận prop `theme`) và **`SonnerToaster`** ở
171
- > `@comitor/ui/shell` (tự đọc `useTheme()` của next-themes). Dùng `SonnerToaster` khi app đã có
172
- > `ThemeProvider` của shell, còn lại dùng `Toaster`. Đừng gộp hai cái làm một.
173
-
174
- ## 4. Ví dụ
175
-
176
- ### 4.1 Trang nội dung — primitive + composite
177
-
178
- ```tsx
179
- // Không có "use client": PageHeader/StatCard/StatusPill là component tĩnh → RSC render thẳng.
180
- import { PageContainer, PageHeader, StatCard, STATUS_TONES, StatusPill } from "@comitor/ui";
181
- import type { StatusConfig } from "@comitor/ui";
182
-
183
- // Bảng trạng thái là của APP, không phải của package — truyền vào qua `configs`.
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ố liệu thực tế của `0.2.2`: **51 file primitive · 29 file composite · 18 file shell · 3 file chart**
490
- (chưa kể barrel) — số tên xuất ra theo entry: `.` **637** · `/shell` **70** · `/chart` **29** · `/form`
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
- Không truyền `actions` thì lớp và cây DOM **y hệt `0.2.0`** — prop này chỉ thêm một nhánh, không
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
- | `DataTable`, `DataTableColumnToggle` | `DataTableLabels` | `DEFAULT_DATA_TABLE_LABELS` |
635
- | `TablePagination` | `TablePaginationLabels` | `DEFAULT_TABLE_PAGINATION_LABELS` |
636
- | `SearchFilterBar` | `SearchFilterBarLabels` | `DEFAULT_SEARCH_FILTER_BAR_LABELS` |
637
- | Toàn bộ Tier 3 (`AppShell`/`ShellProvider` truyền xuống) | `ShellLabels` (32 khoá) | `DEFAULT_SHELL_LABELS` |
638
- | `ThemeToggle` (dùng độc lập, ngoài shell) | `ThemeToggleLabels` = 4 khoá theme của `ShellLabels` | `DEFAULT_SHELL_LABELS` |
639
-
640
- Ba quy tắc chung cho mọi bộ nhãn:
641
-
642
- 1. **`Partial<>` — override từng khoá.** Component luôn trải `{ ...DEFAULT_*, ...labels }`, nên truyền
643
- một khoá không làm mất phần còn lại.
644
- 2. **Khoá có số/đơn vị chèn vào là HÀM, không phải chuỗi có placeholder.** Nối chuỗi bằng dấu cộng ở
645
- phía app là cách chắc chắn sai trật tự từ với ngôn ngữ khác:
646
- `range(start, end, total, unitLabel)`, `pageStatus(page, pageCount)`, `filterCount(count)`,
647
- `pageSizeSelect(unitLabel)`. `resultSummary` còn trả `ReactNode` vì con số cần thẻ riêng
648
- (`tabular-nums` để số không nhảy cột).
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
- Toàn bộ token nằm ở [`styles.css`](./styles.css); bản mirror TS ở [`src/tokens.ts`](./src/tokens.ts).
675
-
676
- ### 6.0 HAI bảng màu — đọc trước §6.1
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
- `ContrastProvider` tự render script chống nháy trong `<body>` — app **không** phải thêm gì vào
723
- `<head>`, và **đừng** đặt `<ContrastScript />` vào `<head>`: React 19 báo lỗi console ở mọi lần
724
- render client (script inline không nằm trong nhóm phần tử React hoisted được).
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`, `--control-check`, `--switch-track`, `--slider-track`,
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
- ### 6.1 Hợp đồng BA VAI TRÒ của màu — đọc trước khi dùng bất kỳ màu nào
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
- **Vì sao phải có bậc `-ink` riêng thay vì dùng thẳng `--teal` / `--green` / `--red`**: bản light của màu
744
- phụ được chọn để tô **nền** nên quá nhạt cho vai trò chữ. Đo trên tint 15% của chính nó — nền thật mà
745
- chữ initials avatar và badge tonal đứng lên:
746
-
747
- ```
748
- teal #1F8A8B trên #DDEDEE = 3,44:1 ✗ → --teal-ink #1A6668 = 5,54:1 ✓
749
- green #2EA56A trên #E0F2E9 = 2,69:1 ✗ → --green-ink #216A48 = 5,60:1 ✓
750
- red #D64545 trên #F9E3E3 = 3,57:1 ✗ → --red-ink #9A3537 = 5,83:1 ✓
751
- ```
752
-
753
- Ở **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**
754
- là đúng cả hai theme, không cần biến thể `dark:`. Ở bảng **mặc định**, comitor-ds dùng cùng một hex cho
755
- cả hai theme nên bậc ink ở dark bằng bậc ở light; ở bảng **tương phản cao**, khối
756
- `.dark[data-contrast="high"]` nâng `--teal/--green/--red/--yellow` lên bậc sáng và `--x-ink` đi theo.
757
-
758
- > ⚠ Hợp đồng `-ink` chỉ còn ràng buộc chặt ở các cụm **tonal** mà comitor-ds không có (`Badge`
759
- > success/warning, `StatusPill`, `LetterAvatar`, `StatCard`, `Toast`) — đó là chỗ chữ đứng trên tint
760
- > của chính nó và bậc ink là thứ duy nhất giữ chữ đọc được. Với primitive **có** trong comitor-ds, bản
761
- > duyệt thắng: ở bảng mặc định `--primary-ink` = `--primary` và `--destructive-ink` = `--destructive`.
762
-
763
- Ba vai trò áp cho mọi họ token:
764
-
765
- | Họ | Nền | Chữ trên nền đó | Trên nền trang |
766
- |---|---|---|---|
767
- | Màu phụ | `--teal` `--green` `--red` `--yellow` `--navy` | — (dùng `--ink`/`--paper` tuỳ nền) | `--teal-ink` `--green-ink` `--red-ink` `--yellow-ink` `--navy-ink` |
768
- | Trạng thái | `--success` `--warning` `--info` `--destructive` | `--success-foreground` … `--destructive-foreground` | `--success-ink` `--warning-ink` `--info-ink` `--destructive-ink` |
769
- | Brand | `--primary` | `--primary-foreground` | `--primary-ink` |
770
- | Accent theo app | `--app-accent` | `--app-accent-foreground` | `--app-accent-ink` (mặc định `= --primary-ink`) |
771
-
772
- Brand cũng có bậc `-ink`, nhưng **giá trị của nó phụ thuộc bảng màu** (§6.0): gold `#E8B824` là màu
773
- **sáng** nên tô nền thì tuyệt, còn làm ranh giới trạng thái trên nền trang chỉ được **1,86:1**.
774
- comitor-ds chấp nhận con số đó, nên ở bảng mặc định `--primary-ink` = `--primary`; ở bảng tương phản
775
- cao nó mượn `--gold-500` `#7A5800` (**6,51:1** ✓ light) và trỏ về `--primary` ở dark (**12,67:1** ✓).
776
- ⚠ `--control-on` KHÔNG còn trỏ vào `--primary-ink`: ở bảng tương phản cao chúng tách ra (`#B48700`
777
- so với `#7A5800`) vì `--primary-ink` còn phải làm CHỮ. Component vẫn viết `bg-control-on`. `--ring` **cố ý vẫn là** `--primary` ở cả hai bảng: vòng focus thuộc tiêu
778
- chí AAA của WCAG 2.2, ngoài mục tiêu của gói.
779
-
780
- Các lỗi hay gặp nhất, và cách sửa. **Số đo trong bảng là của bảng TƯƠNG PHẢN CAO** — đó là bảng có hợp
781
- đồng; ở bảng mặc định vài cặp thấp hơn theo đúng bản duyệt (§6.0):
782
-
783
- | Viết sai | Hỏng ở đâu | Viết đúng |
784
- |---|---|---|
785
- | `text-red` cho dòng báo lỗi | 4,38:1 ở light ✗; ở dark thì đây là thang brand thô | `text-destructive-ink` |
786
- | `text-success` / `text-info` / `text-warning` cho chữ hoặc icon trạng thái | Token TÔ NỀN: trên tint `/15` của chính nó chỉ 2,69:1 / 3,44:1 ✗ ở light; trên nền trang 3,13:1 / 4,14:1 ✗ | `text-success-ink` / `text-info-ink` / `text-warning-ink` (5,5–6,7:1 light · 7,1–12,4:1 dark) |
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
- [MIT](./LICENSE) © Comitor
243
+ MIT © Comitor