@comitor/ui 1.8.2 → 1.9.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/dist/components/composite/icon-avatar.d.ts +83 -0
- package/dist/components/composite/icon-avatar.js +57 -0
- package/dist/components/composite/icon-avatar.js.map +1 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/avatar-tone.d.ts +27 -1
- package/dist/lib/avatar-tone.js +15 -0
- package/dist/lib/avatar-tone.js.map +1 -1
- package/dist/shell/sidebar-menu-item.js +16 -1
- package/dist/shell/sidebar-menu-item.js.map +1 -1
- package/dist/shell/sidebar.js +3 -3
- package/dist/shell/sidebar.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { ComponentProps, ComponentType } from 'react';
|
|
3
|
+
import { AvatarToneName } from '../../lib/avatar-tone.js';
|
|
4
|
+
import { LetterAvatarSize } from './letter-avatar.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Icon của một app/thực thể, đặt trong ô nhận diện tô màu — EM RUỘT của `LetterAvatar`.
|
|
8
|
+
*
|
|
9
|
+
* ── VÌ SAO KHÔNG PHẢI MỘT PROP CỦA `LetterAvatar` ────────────────────────────────────────
|
|
10
|
+
* Hai component chia nhau TOÀN BỘ phần màu (cùng bảng 8 tone, cùng hàm băm, cùng ba nhánh
|
|
11
|
+
* ảnh/nội dung/`sr-only`) và chỉ khác ở thứ nằm giữa ô: chữ cái đầu, hay một icon. Gộp lại thành
|
|
12
|
+
* `<LetterAvatar icon={…}>` thì `name` mang hai nghĩa cùng lúc — vừa là chuỗi để lấy initials,
|
|
13
|
+
* vừa là hạt giống màu — và mọi chỗ gọi phải đọc JSDoc mới biết `initials` có được vẽ hay không.
|
|
14
|
+
* Tách ra thì mỗi bên có đúng một hình dạng, và phần dùng chung nằm ở `lib/avatar-tone.ts`, nơi
|
|
15
|
+
* nó vốn đã ở.
|
|
16
|
+
*
|
|
17
|
+
* ── MÀU: SUY THEO TÊN, TRỪ KHI ĐƯỢC CHỌN TAY ────────────────────────────────────────────
|
|
18
|
+
* Mặc định màu suy từ `seed` (rơi về `name` khi không có) bằng đúng hàm băm của `LetterAvatar`,
|
|
19
|
+
* nên cùng một app luôn ra cùng một màu ở mọi sản phẩm Comitor — và ở cả bản canvas/email/PDF, vì
|
|
20
|
+
* `getAvatarToneColors` của `@comitor/ui/tokens` đọc cùng bảng tone.
|
|
21
|
+
*
|
|
22
|
+
* ⚠ Với app thì `seed` phải là KHOÁ, không phải tên: tên đổi theo ngôn ngữ giao diện. Đo được
|
|
23
|
+
* 4/6 app đổi màu khi chuyển vi ⇄ en — xem JSDoc của `seed`.
|
|
24
|
+
*
|
|
25
|
+
* ⚠ Đó là điều bảng màu ghi cứng KHÔNG làm được, và là lý do component này ra đời: một danh mục
|
|
26
|
+
* app tô bằng năm token chọn tay thì sản phẩm thứ sáu hoặc phải trùng màu với sản phẩm khác, hoặc
|
|
27
|
+
* phải chờ ai đó vào sửa bảng. Ở đây thêm một app là nó tự có màu.
|
|
28
|
+
*
|
|
29
|
+
* `tone` cho phép ghi đè bằng MỘT TÊN trong 8 tone (`AVATAR_TONE_NAMES`) — cả bảng, không phải
|
|
30
|
+
* vài màu. Tên lạ thì rơi về màu suy theo `name`; xem `getAvatarToneByName` để biết vì sao không ném.
|
|
31
|
+
*
|
|
32
|
+
* ── HÌNH: VUÔNG BO GÓC, KHÔNG TRÒN ──────────────────────────────────────────────────────
|
|
33
|
+
* Tròn là hình của NGƯỜI. Ô vuông bo góc là hình của một THỨ (app, workspace, dự án) — cùng quy
|
|
34
|
+
* ước mà `LetterAvatar square` đang dùng cho workspace. `rounded` để mở lại nhánh tròn cho chỗ
|
|
35
|
+
* hiếm cần nó, nhưng mặc định là vuông.
|
|
36
|
+
*
|
|
37
|
+
* ── ⚠ ICON KHÔNG BAO GIỜ NHẬN MÀU CÓ ALPHA ─────────────────────────────────────────────
|
|
38
|
+
* Nền của tone là tint có alpha (`bg-teal/15`), nhưng CHỮ/ICON thì luôn là một bậc màu ĐẶC
|
|
39
|
+
* (`text-teal-ink`). Đó không phải tình cờ: nét icon cắt nhau mà mang alpha thì chỗ giao bị tô hai
|
|
40
|
+
* lần và lộ vệt nối — đo được và ghi ở `docs/hop-dong-mau.md` §7b. Bảng tone vốn đã đúng, và
|
|
41
|
+
* component này không được thêm `opacity-*` hay `/NN` lên `<Icon>`.
|
|
42
|
+
*/
|
|
43
|
+
/**
|
|
44
|
+
* Hình dạng icon mà component nhận: một component React lấy `className`.
|
|
45
|
+
*
|
|
46
|
+
* Đủ rộng cho `lucide-react` và cho icon tự vẽ, và KHÔNG nhận `ReactNode` (một element đã dựng
|
|
47
|
+
* sẵn) — nhận element thì chỗ gọi tự đặt cỡ, và cỡ icon sẽ trôi khỏi `size` của ô.
|
|
48
|
+
*/
|
|
49
|
+
type IconAvatarIcon = ComponentType<{
|
|
50
|
+
className?: string;
|
|
51
|
+
}>;
|
|
52
|
+
interface IconAvatarProps extends Omit<ComponentProps<"span">, "children"> {
|
|
53
|
+
/** Tên hiển thị — tên gọi TRỢ NĂNG của ô, và là hạt giống màu mặc định khi không có `seed`. */
|
|
54
|
+
name: string;
|
|
55
|
+
/**
|
|
56
|
+
* Hạt giống màu, khi nó KHÁC tên hiển thị. Mặc định dùng `name`.
|
|
57
|
+
*
|
|
58
|
+
* ── ⚠ VÌ SAO PROP NÀY BẮT BUỘC PHẢI CÓ, ĐÃ ĐO ─────────────────────────────────────────
|
|
59
|
+
* `LetterAvatar` băm theo tên và điều đó đúng cho NGƯỜI: tên người không đổi theo ngôn ngữ
|
|
60
|
+
* giao diện. Tên APP thì có — và nó phá đúng lời hứa "cùng một app luôn cùng màu".
|
|
61
|
+
*
|
|
62
|
+
* Đo trên đúng danh mục Comitor hiện có, băm theo tên đã dịch: **4/6 app đổi màu** khi người
|
|
63
|
+
* dùng chuyển vi ⇄ en (`starter` #E7E7E8 → #FCF2D2, `tasks` #FCF2D2 → #F9E3E3, `hr` và `mes`
|
|
64
|
+
* cũng đổi). Một người đổi ngôn ngữ và thấy cả dải ứng dụng đổi màu sẽ không nghĩ đó là tính
|
|
65
|
+
* năng.
|
|
66
|
+
*
|
|
67
|
+
* Vì vậy chỗ nào có một định danh BẤT BIẾN thì truyền nó vào đây: với app là `key`
|
|
68
|
+
* (`"tasks"`, `"crm"`), với workspace là `slug`. `name` vẫn là thứ trình đọc màn hình đọc lên.
|
|
69
|
+
*/
|
|
70
|
+
seed?: string;
|
|
71
|
+
/** Icon vẽ ở giữa ô. Thiếu cả `icon` lẫn `src` thì ô vẫn vẽ, chỉ trống — không ném. */
|
|
72
|
+
icon?: IconAvatarIcon;
|
|
73
|
+
/** Ảnh tải lên. Có thì nó THẮNG `icon` — ảnh là thứ người dùng chủ động chọn cho đúng app này. */
|
|
74
|
+
src?: string | null;
|
|
75
|
+
size?: LetterAvatarSize;
|
|
76
|
+
/** Chọn tay một tone trong `AVATAR_TONE_NAMES`. Tên lạ hoặc bỏ trống → suy theo `name`. */
|
|
77
|
+
tone?: AvatarToneName | string | null;
|
|
78
|
+
/** `md` = vuông bo góc (mặc định, hình của một THỨ) · `full` = tròn (hình của NGƯỜI). */
|
|
79
|
+
rounded?: "md" | "full";
|
|
80
|
+
}
|
|
81
|
+
declare function IconAvatar({ name, seed, icon: Icon, src, size, tone, rounded, className, ...props }: IconAvatarProps): React.JSX.Element;
|
|
82
|
+
|
|
83
|
+
export { IconAvatar, type IconAvatarIcon, type IconAvatarProps };
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { getAvatarTone, getAvatarToneByName } from "../../lib/avatar-tone.js";
|
|
3
|
+
import { cn } from "../../lib/cn.js";
|
|
4
|
+
const iconSizeClasses = {
|
|
5
|
+
xs: "size-3.5",
|
|
6
|
+
sm: "size-4",
|
|
7
|
+
md: "size-4",
|
|
8
|
+
lg: "size-5",
|
|
9
|
+
xl: "size-6"
|
|
10
|
+
};
|
|
11
|
+
const boxSizeClasses = {
|
|
12
|
+
xs: "size-6",
|
|
13
|
+
sm: "size-7",
|
|
14
|
+
md: "size-9",
|
|
15
|
+
lg: "size-11",
|
|
16
|
+
xl: "size-14"
|
|
17
|
+
};
|
|
18
|
+
function IconAvatar({
|
|
19
|
+
name,
|
|
20
|
+
seed,
|
|
21
|
+
icon: Icon,
|
|
22
|
+
src,
|
|
23
|
+
size = "md",
|
|
24
|
+
tone,
|
|
25
|
+
rounded = "md",
|
|
26
|
+
className,
|
|
27
|
+
...props
|
|
28
|
+
}) {
|
|
29
|
+
const toneClasses = (getAvatarToneByName(tone) ?? getAvatarTone(seed ?? name)).classes;
|
|
30
|
+
return /* @__PURE__ */ jsxs(
|
|
31
|
+
"span",
|
|
32
|
+
{
|
|
33
|
+
"data-slot": "icon-avatar",
|
|
34
|
+
title: name,
|
|
35
|
+
className: cn(
|
|
36
|
+
"relative inline-flex shrink-0 select-none items-center justify-center overflow-hidden",
|
|
37
|
+
rounded === "full" ? "rounded-full" : "rounded-md",
|
|
38
|
+
boxSizeClasses[size],
|
|
39
|
+
!src && toneClasses,
|
|
40
|
+
className
|
|
41
|
+
),
|
|
42
|
+
...props,
|
|
43
|
+
children: [
|
|
44
|
+
src ? (
|
|
45
|
+
// Cùng lý do với `LetterAvatar`: gói dùng chung không phụ thuộc `next/*`.
|
|
46
|
+
// biome-ignore lint/performance/noImgElement: package dùng chung không phụ thuộc next/*
|
|
47
|
+
/* @__PURE__ */ jsx("img", { src, alt: "", className: "size-full object-cover", loading: "lazy", decoding: "async" })
|
|
48
|
+
) : Icon ? /* @__PURE__ */ jsx(Icon, { className: iconSizeClasses[size] }) : null,
|
|
49
|
+
/* @__PURE__ */ jsx("span", { className: "sr-only", children: name })
|
|
50
|
+
]
|
|
51
|
+
}
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
export {
|
|
55
|
+
IconAvatar
|
|
56
|
+
};
|
|
57
|
+
//# sourceMappingURL=icon-avatar.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../src/components/composite/icon-avatar.tsx"],"sourcesContent":["import type { ComponentProps, ComponentType } from \"react\";\nimport { type AvatarToneName, getAvatarTone, getAvatarToneByName } from \"../../lib/avatar-tone.js\";\nimport { cn } from \"../../lib/cn.js\";\nimport type { LetterAvatarSize } from \"./letter-avatar.js\";\n\n/**\n * Icon của một app/thực thể, đặt trong ô nhận diện tô màu — EM RUỘT của `LetterAvatar`.\n *\n * ── VÌ SAO KHÔNG PHẢI MỘT PROP CỦA `LetterAvatar` ────────────────────────────────────────\n * Hai component chia nhau TOÀN BỘ phần màu (cùng bảng 8 tone, cùng hàm băm, cùng ba nhánh\n * ảnh/nội dung/`sr-only`) và chỉ khác ở thứ nằm giữa ô: chữ cái đầu, hay một icon. Gộp lại thành\n * `<LetterAvatar icon={…}>` thì `name` mang hai nghĩa cùng lúc — vừa là chuỗi để lấy initials,\n * vừa là hạt giống màu — và mọi chỗ gọi phải đọc JSDoc mới biết `initials` có được vẽ hay không.\n * Tách ra thì mỗi bên có đúng một hình dạng, và phần dùng chung nằm ở `lib/avatar-tone.ts`, nơi\n * nó vốn đã ở.\n *\n * ── MÀU: SUY THEO TÊN, TRỪ KHI ĐƯỢC CHỌN TAY ────────────────────────────────────────────\n * Mặc định màu suy từ `seed` (rơi về `name` khi không có) bằng đúng hàm băm của `LetterAvatar`,\n * nên cùng một app luôn ra cùng một màu ở mọi sản phẩm Comitor — và ở cả bản canvas/email/PDF, vì\n * `getAvatarToneColors` của `@comitor/ui/tokens` đọc cùng bảng tone.\n *\n * ⚠ Với app thì `seed` phải là KHOÁ, không phải tên: tên đổi theo ngôn ngữ giao diện. Đo được\n * 4/6 app đổi màu khi chuyển vi ⇄ en — xem JSDoc của `seed`.\n *\n * ⚠ Đó là điều bảng màu ghi cứng KHÔNG làm được, và là lý do component này ra đời: một danh mục\n * app tô bằng năm token chọn tay thì sản phẩm thứ sáu hoặc phải trùng màu với sản phẩm khác, hoặc\n * phải chờ ai đó vào sửa bảng. Ở đây thêm một app là nó tự có màu.\n *\n * `tone` cho phép ghi đè bằng MỘT TÊN trong 8 tone (`AVATAR_TONE_NAMES`) — cả bảng, không phải\n * vài màu. Tên lạ thì rơi về màu suy theo `name`; xem `getAvatarToneByName` để biết vì sao không ném.\n *\n * ── HÌNH: VUÔNG BO GÓC, KHÔNG TRÒN ──────────────────────────────────────────────────────\n * Tròn là hình của NGƯỜI. Ô vuông bo góc là hình của một THỨ (app, workspace, dự án) — cùng quy\n * ước mà `LetterAvatar square` đang dùng cho workspace. `rounded` để mở lại nhánh tròn cho chỗ\n * hiếm cần nó, nhưng mặc định là vuông.\n *\n * ── ⚠ ICON KHÔNG BAO GIỜ NHẬN MÀU CÓ ALPHA ─────────────────────────────────────────────\n * Nền của tone là tint có alpha (`bg-teal/15`), nhưng CHỮ/ICON thì luôn là một bậc màu ĐẶC\n * (`text-teal-ink`). Đó không phải tình cờ: nét icon cắt nhau mà mang alpha thì chỗ giao bị tô hai\n * lần và lộ vệt nối — đo được và ghi ở `docs/hop-dong-mau.md` §7b. Bảng tone vốn đã đúng, và\n * component này không được thêm `opacity-*` hay `/NN` lên `<Icon>`.\n */\n\n/**\n * Hình dạng icon mà component nhận: một component React lấy `className`.\n *\n * Đủ rộng cho `lucide-react` và cho icon tự vẽ, và KHÔNG nhận `ReactNode` (một element đã dựng\n * sẵn) — nhận element thì chỗ gọi tự đặt cỡ, và cỡ icon sẽ trôi khỏi `size` của ô.\n */\nexport type IconAvatarIcon = ComponentType<{ className?: string }>;\n\n/** Cỡ icon bên trong, theo cỡ ô. Ô `md` (36px) + icon 16px là tỉ lệ đang dùng ở dải ứng dụng. */\nconst iconSizeClasses: Record<LetterAvatarSize, string> = {\n xs: \"size-3.5\",\n sm: \"size-4\",\n md: \"size-4\",\n lg: \"size-5\",\n xl: \"size-6\"\n};\n\nconst boxSizeClasses: Record<LetterAvatarSize, string> = {\n xs: \"size-6\",\n sm: \"size-7\",\n md: \"size-9\",\n lg: \"size-11\",\n xl: \"size-14\"\n};\n\nexport interface IconAvatarProps extends Omit<ComponentProps<\"span\">, \"children\"> {\n /** Tên hiển thị — tên gọi TRỢ NĂNG của ô, và là hạt giống màu mặc định khi không có `seed`. */\n name: string;\n /**\n * Hạt giống màu, khi nó KHÁC tên hiển thị. Mặc định dùng `name`.\n *\n * ── ⚠ VÌ SAO PROP NÀY BẮT BUỘC PHẢI CÓ, ĐÃ ĐO ─────────────────────────────────────────\n * `LetterAvatar` băm theo tên và điều đó đúng cho NGƯỜI: tên người không đổi theo ngôn ngữ\n * giao diện. Tên APP thì có — và nó phá đúng lời hứa \"cùng một app luôn cùng màu\".\n *\n * Đo trên đúng danh mục Comitor hiện có, băm theo tên đã dịch: **4/6 app đổi màu** khi người\n * dùng chuyển vi ⇄ en (`starter` #E7E7E8 → #FCF2D2, `tasks` #FCF2D2 → #F9E3E3, `hr` và `mes`\n * cũng đổi). Một người đổi ngôn ngữ và thấy cả dải ứng dụng đổi màu sẽ không nghĩ đó là tính\n * năng.\n *\n * Vì vậy chỗ nào có một định danh BẤT BIẾN thì truyền nó vào đây: với app là `key`\n * (`\"tasks\"`, `\"crm\"`), với workspace là `slug`. `name` vẫn là thứ trình đọc màn hình đọc lên.\n */\n seed?: string;\n /** Icon vẽ ở giữa ô. Thiếu cả `icon` lẫn `src` thì ô vẫn vẽ, chỉ trống — không ném. */\n icon?: IconAvatarIcon;\n /** Ảnh tải lên. Có thì nó THẮNG `icon` — ảnh là thứ người dùng chủ động chọn cho đúng app này. */\n src?: string | null;\n size?: LetterAvatarSize;\n /** Chọn tay một tone trong `AVATAR_TONE_NAMES`. Tên lạ hoặc bỏ trống → suy theo `name`. */\n tone?: AvatarToneName | string | null;\n /** `md` = vuông bo góc (mặc định, hình của một THỨ) · `full` = tròn (hình của NGƯỜI). */\n rounded?: \"md\" | \"full\";\n}\n\nexport function IconAvatar({\n name,\n seed,\n icon: Icon,\n src,\n size = \"md\",\n tone,\n rounded = \"md\",\n className,\n ...props\n}: IconAvatarProps) {\n const toneClasses = (getAvatarToneByName(tone) ?? getAvatarTone(seed ?? name)).classes;\n\n return (\n <span\n data-slot=\"icon-avatar\"\n title={name}\n className={cn(\n \"relative inline-flex shrink-0 select-none items-center justify-center overflow-hidden\",\n rounded === \"full\" ? \"rounded-full\" : \"rounded-md\",\n boxSizeClasses[size],\n !src && toneClasses,\n className\n )}\n {...props}\n >\n {src ? (\n // Cùng lý do với `LetterAvatar`: gói dùng chung không phụ thuộc `next/*`.\n // biome-ignore lint/performance/noImgElement: package dùng chung không phụ thuộc next/*\n <img src={src} alt=\"\" className=\"size-full object-cover\" loading=\"lazy\" decoding=\"async\" />\n ) : Icon ? (\n <Icon className={iconSizeClasses[size]} />\n ) : null}\n {/* Tên cho trình đọc màn hình ở MỌI nhánh — icon là hình trang trí, ô màu không có chữ nào,\n nên thiếu dòng này thì đây là một ô câm. Cùng khuôn với `LetterAvatar`. */}\n <span className=\"sr-only\">{name}</span>\n </span>\n );\n}\n"],"mappings":"AAgHI,SAeI,KAfJ;AA/GJ,SAA8B,eAAe,2BAA2B;AACxE,SAAS,UAAU;AAkDnB,MAAM,kBAAoD;AAAA,EACxD,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AACN;AAEA,MAAM,iBAAmD;AAAA,EACvD,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AACN;AAgCO,SAAS,WAAW;AAAA,EACzB;AAAA,EACA;AAAA,EACA,MAAM;AAAA,EACN;AAAA,EACA,OAAO;AAAA,EACP;AAAA,EACA,UAAU;AAAA,EACV;AAAA,EACA,GAAG;AACL,GAAoB;AAClB,QAAM,eAAe,oBAAoB,IAAI,KAAK,cAAc,QAAQ,IAAI,GAAG;AAE/E,SACE;AAAA,IAAC;AAAA;AAAA,MACC,aAAU;AAAA,MACV,OAAO;AAAA,MACP,WAAW;AAAA,QACT;AAAA,QACA,YAAY,SAAS,iBAAiB;AAAA,QACtC,eAAe,IAAI;AAAA,QACnB,CAAC,OAAO;AAAA,QACR;AAAA,MACF;AAAA,MACC,GAAG;AAAA,MAEH;AAAA;AAAA;AAAA;AAAA,UAGC,oBAAC,SAAI,KAAU,KAAI,IAAG,WAAU,0BAAyB,SAAQ,QAAO,UAAS,SAAQ;AAAA,YACvF,OACF,oBAAC,QAAK,WAAW,gBAAgB,IAAI,GAAG,IACtC;AAAA,QAGJ,oBAAC,UAAK,WAAU,WAAW,gBAAK;AAAA;AAAA;AAAA,EAClC;AAEJ;","names":[]}
|
package/dist/index.d.ts
CHANGED
|
@@ -13,6 +13,7 @@ export { FilterChipItem, FilterChips, FilterChipsProps } from './components/comp
|
|
|
13
13
|
export { FormField, FormFieldColSpan, FormFieldControlProps, FormFieldProps } from './components/composite/form-field.js';
|
|
14
14
|
export { SELECT_EMPTY_VALUE, fromSelectValue, toSelectValue } from './components/composite/form-field-constants.js';
|
|
15
15
|
export { FormSection, FormSectionAccent, FormSectionProps } from './components/composite/form-section.js';
|
|
16
|
+
export { IconAvatar, IconAvatarIcon, IconAvatarProps } from './components/composite/icon-avatar.js';
|
|
16
17
|
export { InlineEdit, InlineEditProps } from './components/composite/inline-edit.js';
|
|
17
18
|
export { KeyboardHint, KeyboardHintProps, useIsMac } from './components/composite/keyboard-hint.js';
|
|
18
19
|
export { LetterAvatar, LetterAvatarProps, LetterAvatarSize, getAvatarToneClasses } from './components/composite/letter-avatar.js';
|
|
@@ -79,9 +80,9 @@ export { Textarea, TextareaProps } from './components/ui/textarea.js';
|
|
|
79
80
|
export { Toggle, ToggleProps, ToggleVariantProps, toggleVariants } from './components/ui/toggle.js';
|
|
80
81
|
export { ToggleGroup, ToggleGroupItem, ToggleGroupItemProps, ToggleGroupProps, ToggleGroupVariantProps } from './components/ui/toggle-group.js';
|
|
81
82
|
export { Tooltip, TooltipContent, TooltipContentProps, TooltipProps, TooltipProvider, TooltipProviderProps, TooltipTrigger, TooltipTriggerProps } from './components/ui/tooltip.js';
|
|
83
|
+
export { AVATAR_TONE_NAMES, AvatarToneColors, AvatarToneName, getAvatarToneIndex } from './lib/avatar-tone.js';
|
|
82
84
|
export { cn } from './lib/cn.js';
|
|
83
85
|
export { ThemeMode, accent, accentForeground, accentInk, ash, brandNeutral, chart, getAvatarToneColors, gold, highContrast, layout, semantic, status, typography } from './tokens.js';
|
|
84
|
-
export { AvatarToneColors, getAvatarToneIndex } from './lib/avatar-tone.js';
|
|
85
86
|
export { DateRange } from 'react-day-picker';
|
|
86
87
|
import 'react';
|
|
87
88
|
import 'date-fns';
|
package/dist/index.js
CHANGED
|
@@ -13,6 +13,7 @@ export * from "./components/composite/filter-chips.js";
|
|
|
13
13
|
export * from "./components/composite/form-field.js";
|
|
14
14
|
export * from "./components/composite/form-field-constants.js";
|
|
15
15
|
export * from "./components/composite/form-section.js";
|
|
16
|
+
import { IconAvatar } from "./components/composite/icon-avatar.js";
|
|
16
17
|
export * from "./components/composite/inline-edit.js";
|
|
17
18
|
export * from "./components/composite/keyboard-hint.js";
|
|
18
19
|
import { getAvatarToneClasses, LetterAvatar } from "./components/composite/letter-avatar.js";
|
|
@@ -85,9 +86,12 @@ export * from "./components/ui/textarea.js";
|
|
|
85
86
|
export * from "./components/ui/toggle.js";
|
|
86
87
|
export * from "./components/ui/toggle-group.js";
|
|
87
88
|
export * from "./components/ui/tooltip.js";
|
|
89
|
+
import { AVATAR_TONE_NAMES } from "./lib/avatar-tone.js";
|
|
88
90
|
export * from "./lib/cn.js";
|
|
89
91
|
export * from "./tokens.js";
|
|
90
92
|
export {
|
|
93
|
+
AVATAR_TONE_NAMES,
|
|
94
|
+
IconAvatar,
|
|
91
95
|
LetterAvatar,
|
|
92
96
|
getAvatarToneClasses,
|
|
93
97
|
getVietnameseInitials,
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts"],"sourcesContent":["/**\n * Entry chính \"@comitor/ui\" — Tier 1 (primitive) + Tier 2 (composite) + lib + token.\n *\n * Quy tắc của entry này: **chỉ được phụ thuộc peer BẮT BUỘC** (react, react-dom,\n * lucide-react, tailwindcss). Không `next/*`, không `next-themes`, không `recharts`,\n * không `react-hook-form` — đó đều là peer OPTIONAL. Vì `tsup` chạy `bundle: false`\n * (ESM per-file), chỉ cần barrel này re-export một file có import optional peer là\n * mọi app import \"@comitor/ui\" đều buộc phải cài gói đó, nếu không bundler gãy ở\n * bước resolve. Vì vậy 3 nhánh dưới đây nằm ở entry riêng:\n *\n * - Tier 3 shell (next/*, next-themes) → \"@comitor/ui/shell\"\n * - chart (recharts) → \"@comitor/ui/chart\"\n * - binding react-hook-form → \"@comitor/ui/form\"\n *\n * ⚠ Hai file trong `components/ui/` CỐ Ý không có mặt ở đây:\n * - `form.tsx` → xuất qua \"@comitor/ui/form\" (react-hook-form).\n * - `sonner.tsx` → xuất qua \"@comitor/ui/shell\" dưới tên `SonnerToaster`\n * (nó gọi `useTheme()` của next-themes). Bản dùng hằng ngày là composite\n * `Toaster`/`toast` ở Tier 2 — không phụ thuộc next-themes, có sẵn ở entry này.\n *\n * ⚠ Mọi import nội bộ phải giữ đuôi `.js`: output là ESM per-file, Node ESM thuần\n * không tự suy ra phần mở rộng (bundler thì tha, Node thì không).\n */\n\n/*\n * ⚠ Sáu file `*-constants` bên dưới KHÔNG phải chỗ để gom code cho gọn — chúng là RANH GIỚI\n * SERVER/CLIENT. Component chủ (`command-palette.tsx`, `data-table.tsx`, `date-picker.tsx`,\n * `form-field.tsx`, `search-filter-bar.tsx`, `table-pagination.tsx`) đều mở đầu bằng `\"use client\"`,\n * mà mọi export của một module client đến tay Server Component dưới dạng client-reference proxy:\n * đọc ra `undefined`, KHÔNG throw, KHÔNG warning, trong khi TypeScript vẫn khai kiểu thật. Nhãn mặc\n * định / preset / sentinel là hằng dữ liệu thuần nên phải nằm ngoài ranh giới đó, và barrel này\n * phải xuất chúng THẲNG — re-export vòng qua file client là vô hiệu.\n * Cùng lý do với `chart/chart-colors.ts`; chi tiết ghi ở đầu file đó.\n * `scripts/verify-directives.mjs` canh không cho hằng thuần lọt ngược vào file client.\n */\n\n// ─── Tier 2 — composite (pattern lặp lại ở mọi app Comitor) ───\nexport * from \"./components/composite/avatar-group.js\";\nexport * from \"./components/composite/combobox.js\";\nexport * from \"./components/composite/command-palette.js\";\nexport * from \"./components/composite/command-palette-constants.js\";\nexport * from \"./components/composite/confirm-dialog.js\";\nexport * from \"./components/composite/copy-button.js\";\nexport * from \"./components/composite/data-table.js\";\nexport * from \"./components/composite/data-table-constants.js\";\nexport * from \"./components/composite/date-picker.js\";\nexport * from \"./components/composite/date-picker-constants.js\";\nexport * from \"./components/composite/empty-state.js\";\nexport * from \"./components/composite/filter-chips.js\";\nexport * from \"./components/composite/form-field.js\";\nexport * from \"./components/composite/form-field-constants.js\";\nexport * from \"./components/composite/form-section.js\";\nexport * from \"./components/composite/inline-edit.js\";\nexport * from \"./components/composite/keyboard-hint.js\";\nexport type { LetterAvatarProps, LetterAvatarSize } from \"./components/composite/letter-avatar.js\";\n// Xuất CHỌN LỌC (không `export *`): file này còn `AVATAR_TONES` nhập từ `lib/avatar-tone.js`\n// và các helper nội bộ khác. Liệt kê tường minh thì thêm helper riêng tư vào file KHÔNG tự động\n// biến nó thành API công khai — hàng rào mà `export *` không dựng được.\nexport { getAvatarToneClasses, LetterAvatar } from \"./components/composite/letter-avatar.js\";\nexport * from \"./components/composite/page-container.js\";\nexport * from \"./components/composite/page-header.js\";\nexport * from \"./components/composite/relative-time.js\";\nexport * from \"./components/composite/route-tabs.js\";\nexport * from \"./components/composite/search-filter-bar.js\";\nexport * from \"./components/composite/search-filter-bar-constants.js\";\nexport * from \"./components/composite/stat-card.js\";\nexport * from \"./components/composite/status-config.js\";\nexport * from \"./components/composite/status-pill.js\";\nexport * from \"./components/composite/table-pagination.js\";\nexport * from \"./components/composite/table-pagination-constants.js\";\n// Xuất CHỌN LỌC vì cùng lý do với `letter-avatar` ở trên: `text-utils.ts` là chỗ dễ bị thêm\n// helper nội bộ nhất của Tier 2 (0.1.0 từng rò `hashString`/`pickByString` ra API công khai\n// đúng theo đường này). Năm hàm dưới đây là hợp đồng, phần còn lại của file thì không.\nexport {\n getVietnameseInitials,\n matchesSearch,\n normalizeVi,\n removeVietnameseTones,\n truncate\n} from \"./components/composite/text-utils.js\";\nexport * from \"./components/composite/toast.js\";\n// ─── Tier 1 — primitive (shadcn/ui \"new-york\", đã custom theo brand Comitor) ───\nexport * from \"./components/ui/accordion.js\";\nexport * from \"./components/ui/alert.js\";\nexport * from \"./components/ui/alert-dialog.js\";\nexport * from \"./components/ui/aspect-ratio.js\";\nexport * from \"./components/ui/avatar.js\";\nexport * from \"./components/ui/badge.js\";\nexport * from \"./components/ui/breadcrumb.js\";\nexport * from \"./components/ui/button.js\";\nexport * from \"./components/ui/button-group.js\";\nexport * from \"./components/ui/calendar.js\";\nexport * from \"./components/ui/card.js\";\nexport * from \"./components/ui/carousel.js\";\nexport * from \"./components/ui/checkbox.js\";\nexport * from \"./components/ui/collapsible.js\";\nexport * from \"./components/ui/command.js\";\nexport * from \"./components/ui/context-menu.js\";\nexport * from \"./components/ui/dialog.js\";\nexport * from \"./components/ui/drawer.js\";\nexport * from \"./components/ui/dropdown-menu.js\";\nexport * from \"./components/ui/empty.js\";\nexport * from \"./components/ui/field.js\";\nexport * from \"./components/ui/hover-card.js\";\nexport * from \"./components/ui/input.js\";\nexport * from \"./components/ui/input-group.js\";\nexport * from \"./components/ui/input-otp.js\";\nexport * from \"./components/ui/item.js\";\nexport * from \"./components/ui/kbd.js\";\nexport * from \"./components/ui/label.js\";\nexport * from \"./components/ui/menubar.js\";\nexport * from \"./components/ui/navigation-menu.js\";\nexport * from \"./components/ui/pagination.js\";\nexport * from \"./components/ui/popover.js\";\nexport * from \"./components/ui/progress.js\";\nexport * from \"./components/ui/radio-group.js\";\nexport * from \"./components/ui/resizable.js\";\nexport * from \"./components/ui/scroll-area.js\";\nexport * from \"./components/ui/select.js\";\nexport * from \"./components/ui/separator.js\";\nexport * from \"./components/ui/sheet.js\";\nexport * from \"./components/ui/skeleton.js\";\nexport * from \"./components/ui/slider.js\";\nexport * from \"./components/ui/spinner.js\";\nexport * from \"./components/ui/switch.js\";\nexport * from \"./components/ui/table.js\";\nexport * from \"./components/ui/tabs.js\";\n/* Bảng lớp của Tabs xuất THẲNG từ module thuần — `RouteTabs` và `Tabs` dùng chung nó, và hằng\n dữ liệu đi vòng qua file \"use client\" sẽ thành proxy ở Server Component (CHANGELOG 0.2.2 §2). */\nexport * from \"./components/ui/tabs-variants.js\";\nexport * from \"./components/ui/textarea.js\";\nexport * from \"./components/ui/toggle.js\";\nexport * from \"./components/ui/toggle-group.js\";\nexport * from \"./components/ui/tooltip.js\";\n\n// ─── lib + token ───\nexport * from \"./lib/cn.js\";\nexport * from \"./tokens.js\";\n"],"mappings":"AAqCA,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AAKd,SAAS,sBAAsB,oBAAoB;AACnD,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AAId;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,cAAc;AAEd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AAGd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"sourcesContent":["/**\n * Entry chính \"@comitor/ui\" — Tier 1 (primitive) + Tier 2 (composite) + lib + token.\n *\n * Quy tắc của entry này: **chỉ được phụ thuộc peer BẮT BUỘC** (react, react-dom,\n * lucide-react, tailwindcss). Không `next/*`, không `next-themes`, không `recharts`,\n * không `react-hook-form` — đó đều là peer OPTIONAL. Vì `tsup` chạy `bundle: false`\n * (ESM per-file), chỉ cần barrel này re-export một file có import optional peer là\n * mọi app import \"@comitor/ui\" đều buộc phải cài gói đó, nếu không bundler gãy ở\n * bước resolve. Vì vậy 3 nhánh dưới đây nằm ở entry riêng:\n *\n * - Tier 3 shell (next/*, next-themes) → \"@comitor/ui/shell\"\n * - chart (recharts) → \"@comitor/ui/chart\"\n * - binding react-hook-form → \"@comitor/ui/form\"\n *\n * ⚠ Hai file trong `components/ui/` CỐ Ý không có mặt ở đây:\n * - `form.tsx` → xuất qua \"@comitor/ui/form\" (react-hook-form).\n * - `sonner.tsx` → xuất qua \"@comitor/ui/shell\" dưới tên `SonnerToaster`\n * (nó gọi `useTheme()` của next-themes). Bản dùng hằng ngày là composite\n * `Toaster`/`toast` ở Tier 2 — không phụ thuộc next-themes, có sẵn ở entry này.\n *\n * ⚠ Mọi import nội bộ phải giữ đuôi `.js`: output là ESM per-file, Node ESM thuần\n * không tự suy ra phần mở rộng (bundler thì tha, Node thì không).\n */\n\n/*\n * ⚠ Sáu file `*-constants` bên dưới KHÔNG phải chỗ để gom code cho gọn — chúng là RANH GIỚI\n * SERVER/CLIENT. Component chủ (`command-palette.tsx`, `data-table.tsx`, `date-picker.tsx`,\n * `form-field.tsx`, `search-filter-bar.tsx`, `table-pagination.tsx`) đều mở đầu bằng `\"use client\"`,\n * mà mọi export của một module client đến tay Server Component dưới dạng client-reference proxy:\n * đọc ra `undefined`, KHÔNG throw, KHÔNG warning, trong khi TypeScript vẫn khai kiểu thật. Nhãn mặc\n * định / preset / sentinel là hằng dữ liệu thuần nên phải nằm ngoài ranh giới đó, và barrel này\n * phải xuất chúng THẲNG — re-export vòng qua file client là vô hiệu.\n * Cùng lý do với `chart/chart-colors.ts`; chi tiết ghi ở đầu file đó.\n * `scripts/verify-directives.mjs` canh không cho hằng thuần lọt ngược vào file client.\n */\n\n// ─── Tier 2 — composite (pattern lặp lại ở mọi app Comitor) ───\nexport * from \"./components/composite/avatar-group.js\";\nexport * from \"./components/composite/combobox.js\";\nexport * from \"./components/composite/command-palette.js\";\nexport * from \"./components/composite/command-palette-constants.js\";\nexport * from \"./components/composite/confirm-dialog.js\";\nexport * from \"./components/composite/copy-button.js\";\nexport * from \"./components/composite/data-table.js\";\nexport * from \"./components/composite/data-table-constants.js\";\nexport * from \"./components/composite/date-picker.js\";\nexport * from \"./components/composite/date-picker-constants.js\";\nexport * from \"./components/composite/empty-state.js\";\nexport * from \"./components/composite/filter-chips.js\";\nexport * from \"./components/composite/form-field.js\";\nexport * from \"./components/composite/form-field-constants.js\";\nexport * from \"./components/composite/form-section.js\";\nexport { IconAvatar, type IconAvatarIcon, type IconAvatarProps } from \"./components/composite/icon-avatar.js\";\nexport * from \"./components/composite/inline-edit.js\";\nexport * from \"./components/composite/keyboard-hint.js\";\nexport type { LetterAvatarProps, LetterAvatarSize } from \"./components/composite/letter-avatar.js\";\n// Xuất CHỌN LỌC (không `export *`): file này còn `AVATAR_TONES` nhập từ `lib/avatar-tone.js`\n// và các helper nội bộ khác. Liệt kê tường minh thì thêm helper riêng tư vào file KHÔNG tự động\n// biến nó thành API công khai — hàng rào mà `export *` không dựng được.\nexport { getAvatarToneClasses, LetterAvatar } from \"./components/composite/letter-avatar.js\";\nexport * from \"./components/composite/page-container.js\";\nexport * from \"./components/composite/page-header.js\";\nexport * from \"./components/composite/relative-time.js\";\nexport * from \"./components/composite/route-tabs.js\";\nexport * from \"./components/composite/search-filter-bar.js\";\nexport * from \"./components/composite/search-filter-bar-constants.js\";\nexport * from \"./components/composite/stat-card.js\";\nexport * from \"./components/composite/status-config.js\";\nexport * from \"./components/composite/status-pill.js\";\nexport * from \"./components/composite/table-pagination.js\";\nexport * from \"./components/composite/table-pagination-constants.js\";\n// Xuất CHỌN LỌC vì cùng lý do với `letter-avatar` ở trên: `text-utils.ts` là chỗ dễ bị thêm\n// helper nội bộ nhất của Tier 2 (0.1.0 từng rò `hashString`/`pickByString` ra API công khai\n// đúng theo đường này). Năm hàm dưới đây là hợp đồng, phần còn lại của file thì không.\nexport {\n getVietnameseInitials,\n matchesSearch,\n normalizeVi,\n removeVietnameseTones,\n truncate\n} from \"./components/composite/text-utils.js\";\nexport * from \"./components/composite/toast.js\";\n// ─── Tier 1 — primitive (shadcn/ui \"new-york\", đã custom theo brand Comitor) ───\nexport * from \"./components/ui/accordion.js\";\nexport * from \"./components/ui/alert.js\";\nexport * from \"./components/ui/alert-dialog.js\";\nexport * from \"./components/ui/aspect-ratio.js\";\nexport * from \"./components/ui/avatar.js\";\nexport * from \"./components/ui/badge.js\";\nexport * from \"./components/ui/breadcrumb.js\";\nexport * from \"./components/ui/button.js\";\nexport * from \"./components/ui/button-group.js\";\nexport * from \"./components/ui/calendar.js\";\nexport * from \"./components/ui/card.js\";\nexport * from \"./components/ui/carousel.js\";\nexport * from \"./components/ui/checkbox.js\";\nexport * from \"./components/ui/collapsible.js\";\nexport * from \"./components/ui/command.js\";\nexport * from \"./components/ui/context-menu.js\";\nexport * from \"./components/ui/dialog.js\";\nexport * from \"./components/ui/drawer.js\";\nexport * from \"./components/ui/dropdown-menu.js\";\nexport * from \"./components/ui/empty.js\";\nexport * from \"./components/ui/field.js\";\nexport * from \"./components/ui/hover-card.js\";\nexport * from \"./components/ui/input.js\";\nexport * from \"./components/ui/input-group.js\";\nexport * from \"./components/ui/input-otp.js\";\nexport * from \"./components/ui/item.js\";\nexport * from \"./components/ui/kbd.js\";\nexport * from \"./components/ui/label.js\";\nexport * from \"./components/ui/menubar.js\";\nexport * from \"./components/ui/navigation-menu.js\";\nexport * from \"./components/ui/pagination.js\";\nexport * from \"./components/ui/popover.js\";\nexport * from \"./components/ui/progress.js\";\nexport * from \"./components/ui/radio-group.js\";\nexport * from \"./components/ui/resizable.js\";\nexport * from \"./components/ui/scroll-area.js\";\nexport * from \"./components/ui/select.js\";\nexport * from \"./components/ui/separator.js\";\nexport * from \"./components/ui/sheet.js\";\nexport * from \"./components/ui/skeleton.js\";\nexport * from \"./components/ui/slider.js\";\nexport * from \"./components/ui/spinner.js\";\nexport * from \"./components/ui/switch.js\";\nexport * from \"./components/ui/table.js\";\nexport * from \"./components/ui/tabs.js\";\n/* Bảng lớp của Tabs xuất THẲNG từ module thuần — `RouteTabs` và `Tabs` dùng chung nó, và hằng\n dữ liệu đi vòng qua file \"use client\" sẽ thành proxy ở Server Component (CHANGELOG 0.2.2 §2). */\nexport * from \"./components/ui/tabs-variants.js\";\nexport * from \"./components/ui/textarea.js\";\nexport * from \"./components/ui/toggle.js\";\nexport * from \"./components/ui/toggle-group.js\";\nexport * from \"./components/ui/tooltip.js\";\n/*\n * `IconAvatar` — em ruột của `LetterAvatar`, dùng chung bảng tone. Xuất kèm `AVATAR_TONE_NAMES`\n * và kiểu `AvatarToneName` vì prop `tone` chỉ có nghĩa khi chỗ gọi liệt kê được tập chọn (màn\n * quản trị cần dựng một `<Select>` từ đúng tập đó).\n *\n * ⚠ KHÔNG xuất `AVATAR_TONES` lẫn `getAvatarToneIndex` ra entry này: bảng thô và hàm băm vẫn là\n * nội bộ, đúng như `lib/avatar-tone.ts` dặn — xuất chúng là mời app dựng lại phép chọn tone của\n * riêng nó, tức cùng một tên ra hai màu.\n */\nexport { AVATAR_TONE_NAMES, type AvatarToneName } from \"./lib/avatar-tone.js\";\n\n// ─── lib + token ───\nexport * from \"./lib/cn.js\";\nexport * from \"./tokens.js\";\n"],"mappings":"AAqCA,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,SAAS,kBAA6D;AACtE,cAAc;AACd,cAAc;AAKd,SAAS,sBAAsB,oBAAoB;AACnD,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AAId;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,cAAc;AAEd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AAGd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AAUd,SAAS,yBAA8C;AAGvD,cAAc;AACd,cAAc;","names":[]}
|
|
@@ -69,6 +69,15 @@ interface AvatarTonePalette {
|
|
|
69
69
|
card: string;
|
|
70
70
|
}
|
|
71
71
|
interface AvatarTone {
|
|
72
|
+
/**
|
|
73
|
+
* Tên gọi của tone — để chỗ dùng CHỌN TAY một màu thay vì để hàm băm quyết.
|
|
74
|
+
*
|
|
75
|
+
* ⚠ Tên là một phần của HỢP ĐỒNG: nó đi vào cột database của app (`apps.icon_color` bên
|
|
76
|
+
* `comitor-account`) và vào prop `tone` của `IconAvatar`. Đổi tên một tone là làm mọi hàng đã
|
|
77
|
+
* lưu trỏ vào hư không — và hỏng đó IM LẶNG, vì giá trị lạ chỉ rơi về màu suy theo tên.
|
|
78
|
+
* Thêm tone thì thêm tên mới; đừng đổi tên cũ.
|
|
79
|
+
*/
|
|
80
|
+
readonly name: AvatarToneName;
|
|
72
81
|
/** Vai trò DOM: chuỗi class token tĩnh (kèm biến thể `dark:`). */
|
|
73
82
|
readonly classes: string;
|
|
74
83
|
/** Vai trò canvas/email/PDF: đúng những gì `classes` nói, tính ra hex đục ở light. */
|
|
@@ -111,6 +120,15 @@ interface AvatarTone {
|
|
|
111
120
|
* nên không cần biến thể `dark:` nào. Nhờ vậy tên trong class khớp đúng tên trong công thức hex
|
|
112
121
|
* ngay bên dưới (`p.accentInk.teal`) — soi hai vế cạnh nhau là thấy lệch ngay.
|
|
113
122
|
*/
|
|
123
|
+
/**
|
|
124
|
+
* Tên của 8 tone, ĐÚNG THỨ TỰ bảng bên dưới — thứ tự ấy cũng là chỉ số băm, nên hai thứ không
|
|
125
|
+
* được trôi khỏi nhau. Đây là bảng màu mà chỗ dùng chọn tay khi không muốn để tên quyết định.
|
|
126
|
+
*
|
|
127
|
+
* Tên lấy theo SẮC, không theo vai trò ngữ nghĩa: đây là màu ĐỊNH DANH, không phải màu trạng
|
|
128
|
+
* thái — xem lý do dài ở docblock của `AVATAR_TONES`.
|
|
129
|
+
*/
|
|
130
|
+
declare const AVATAR_TONE_NAMES: readonly ["gold", "teal", "green", "red", "yellow", "navy", "ash", "neutral"];
|
|
131
|
+
type AvatarToneName = (typeof AVATAR_TONE_NAMES)[number];
|
|
114
132
|
declare const AVATAR_TONES: AvatarToneTable<AvatarTone>;
|
|
115
133
|
/**
|
|
116
134
|
* Chỉ số tone theo tên — phép chọn màu avatar DUY NHẤT của cả gói.
|
|
@@ -122,5 +140,13 @@ declare const AVATAR_TONES: AvatarToneTable<AvatarTone>;
|
|
|
122
140
|
declare function getAvatarToneIndex(name: string): number;
|
|
123
141
|
/** Tone (cả class lẫn công thức hex) ứng với một tên. */
|
|
124
142
|
declare function getAvatarTone(name: string): AvatarTone;
|
|
143
|
+
/**
|
|
144
|
+
* Tone theo TÊN — cửa cho chỗ dùng chọn tay một màu.
|
|
145
|
+
*
|
|
146
|
+
* Trả `undefined` cho tên lạ, và nơi gọi hiểu đó là "rơi về màu suy theo tên". KHÔNG ném: giá trị
|
|
147
|
+
* này đến từ database (`apps.icon_color`), nơi một hàng cũ hoàn toàn có thể mang tên của một tone
|
|
148
|
+
* đã bị xoá — làm sập cả dải ứng dụng vì một chữ trong một hàng là đổi sai lấy tệ hơn.
|
|
149
|
+
*/
|
|
150
|
+
declare function getAvatarToneByName(name: string | null | undefined): AvatarTone | undefined;
|
|
125
151
|
|
|
126
|
-
export { AVATAR_TONES, type AvatarToneColors, type AvatarTonePalette, getAvatarTone, getAvatarToneIndex };
|
|
152
|
+
export { AVATAR_TONES, AVATAR_TONE_NAMES, type AvatarToneColors, type AvatarToneName, type AvatarTonePalette, getAvatarTone, getAvatarToneByName, getAvatarToneIndex };
|
package/dist/lib/avatar-tone.js
CHANGED
|
@@ -3,44 +3,53 @@ function tintOver(color, over, alpha) {
|
|
|
3
3
|
const mixed = [0, 1, 2].map((i) => Math.round(channel(color, i) * alpha + channel(over, i) * (1 - alpha)));
|
|
4
4
|
return `#${mixed.map((v) => v.toString(16).padStart(2, "0")).join("")}`.toUpperCase();
|
|
5
5
|
}
|
|
6
|
+
const AVATAR_TONE_NAMES = ["gold", "teal", "green", "red", "yellow", "navy", "ash", "neutral"];
|
|
6
7
|
const AVATAR_TONES = [
|
|
7
8
|
{
|
|
9
|
+
name: "gold",
|
|
8
10
|
classes: "bg-gold-100 text-gold-600 dark:bg-gold-500/30 dark:text-gold-100",
|
|
9
11
|
light: (p) => ({ background: p.gold[100], foreground: p.gold[600] }),
|
|
10
12
|
dark: (p) => ({ background: tintOver(p.gold[500], p.card, 0.3), foreground: p.gold[100] })
|
|
11
13
|
},
|
|
12
14
|
{
|
|
15
|
+
name: "teal",
|
|
13
16
|
classes: "bg-teal/15 text-teal-ink",
|
|
14
17
|
light: (p) => ({ background: tintOver(p.accent.teal, p.card, 0.15), foreground: p.accentInk.teal }),
|
|
15
18
|
dark: (p) => ({ background: tintOver(p.accent.teal, p.card, 0.15), foreground: p.accentInk.teal })
|
|
16
19
|
},
|
|
17
20
|
{
|
|
21
|
+
name: "green",
|
|
18
22
|
classes: "bg-green/15 text-green-ink",
|
|
19
23
|
light: (p) => ({ background: tintOver(p.accent.green, p.card, 0.15), foreground: p.accentInk.green }),
|
|
20
24
|
dark: (p) => ({ background: tintOver(p.accent.green, p.card, 0.15), foreground: p.accentInk.green })
|
|
21
25
|
},
|
|
22
26
|
{
|
|
27
|
+
name: "red",
|
|
23
28
|
classes: "bg-red/15 text-red-ink",
|
|
24
29
|
light: (p) => ({ background: tintOver(p.accent.red, p.card, 0.15), foreground: p.accentInk.red }),
|
|
25
30
|
dark: (p) => ({ background: tintOver(p.accent.red, p.card, 0.15), foreground: p.accentInk.red })
|
|
26
31
|
},
|
|
27
32
|
{
|
|
33
|
+
name: "yellow",
|
|
28
34
|
classes: "bg-yellow/25 text-yellow-ink dark:bg-yellow/20",
|
|
29
35
|
light: (p) => ({ background: tintOver(p.accent.yellow, p.card, 0.25), foreground: p.accentInk.yellow }),
|
|
30
36
|
dark: (p) => ({ background: tintOver(p.accent.yellow, p.card, 0.2), foreground: p.accentInk.yellow })
|
|
31
37
|
},
|
|
32
38
|
{
|
|
33
39
|
// chart-3 = navy ở light, = navy-ink (bậc sáng) ở dark — nền tint cũng đi theo đúng màu đó.
|
|
40
|
+
name: "navy",
|
|
34
41
|
classes: "bg-chart-3/15 text-chart-3",
|
|
35
42
|
light: (p) => ({ background: tintOver(p.accent.navy, p.card, 0.15), foreground: p.accentInk.navy }),
|
|
36
43
|
dark: (p) => ({ background: tintOver(p.accentInk.navy, p.card, 0.15), foreground: p.accentInk.navy })
|
|
37
44
|
},
|
|
38
45
|
{
|
|
46
|
+
name: "ash",
|
|
39
47
|
classes: "bg-ash-100 text-ash-700 dark:bg-ash-500/25 dark:text-ash-100",
|
|
40
48
|
light: (p) => ({ background: p.ash[100], foreground: p.ash[700] }),
|
|
41
49
|
dark: (p) => ({ background: tintOver(p.ash[500], p.card, 0.25), foreground: p.ash[100] })
|
|
42
50
|
},
|
|
43
51
|
{
|
|
52
|
+
name: "neutral",
|
|
44
53
|
classes: "bg-ink/10 text-ink-soft dark:bg-paper/15 dark:text-paper",
|
|
45
54
|
light: (p) => ({ background: tintOver(p.brandNeutral.ink, p.card, 0.1), foreground: p.brandNeutral.inkSoft }),
|
|
46
55
|
dark: (p) => ({ background: tintOver(p.brandNeutral.paper, p.card, 0.15), foreground: p.brandNeutral.paper })
|
|
@@ -60,9 +69,15 @@ function getAvatarToneIndex(name) {
|
|
|
60
69
|
function getAvatarTone(name) {
|
|
61
70
|
return AVATAR_TONES[getAvatarToneIndex(name)] ?? AVATAR_TONES[0];
|
|
62
71
|
}
|
|
72
|
+
function getAvatarToneByName(name) {
|
|
73
|
+
if (!name) return void 0;
|
|
74
|
+
return AVATAR_TONES.find((tone) => tone.name === name);
|
|
75
|
+
}
|
|
63
76
|
export {
|
|
64
77
|
AVATAR_TONES,
|
|
78
|
+
AVATAR_TONE_NAMES,
|
|
65
79
|
getAvatarTone,
|
|
80
|
+
getAvatarToneByName,
|
|
66
81
|
getAvatarToneIndex
|
|
67
82
|
};
|
|
68
83
|
//# sourceMappingURL=avatar-tone.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/lib/avatar-tone.ts"],"sourcesContent":["/**\n * NGUỒN SỰ THẬT DUY NHẤT của màu avatar chữ cái — cả gói chỉ có bảng này.\n *\n * File nội bộ (không nằm trong barrel nào): app không import trực tiếp được, mà đi qua đúng\n * hai cửa tuỳ theo chỗ vẽ:\n * • có Tailwind (DOM) → `getAvatarToneClasses(name)` ở `composite/letter-avatar.tsx`\n * • không có Tailwind (canvas, → `getAvatarToneColors(name, mode)` ở `tokens.ts`\n * email HTML, PDF)\n *\n * Vì sao MỘT bảng chứ không phải hai (một bảng class + một bảng hex \"ảnh chiếu\"): hai bảng viết\n * tay song song thì chỉ cần một lần thêm/xoá/đảo tone là cùng một người ra hai màu khác nhau giữa\n * web và email — mà không có gì báo. Ở đây mỗi tone là MỘT phần tử mang cả chuỗi class lẫn công\n * thức hex tương ứng, nằm sát nhau trên vài dòng liền: thêm tone là thêm cả ba, không quên được.\n * Thứ tự phần tử = chỉ số băm, nên thứ tự cũng chỉ tồn tại ở một chỗ.\n *\n * Vì sao công thức hex là HÀM nhận `palette` chứ không đọc thẳng token: `tokens.ts` là entry\n * `@comitor/ui/tokens`; nếu file này `import { accent } from \"../tokens.js\"` thì thành vòng import\n * (tokens.ts phải import ngược lại bảng này) — vòng có khởi tạo hằng ở giữa là lỗi TDZ lúc chạy.\n * Nhận bảng màu qua tham số thì phụ thuộc chỉ đi MỘT chiều: tokens.ts → file này.\n */\n\n/** Đúng 8 tone — chốt ở tầng kiểu: `getAvatarToneIndex` băm theo độ dài này. */\ntype AvatarToneTable<T> = readonly [T, T, T, T, T, T, T, T];\n\n/** Cặp nền + màu chữ (hex đục) của một tone avatar. */\nexport interface AvatarToneColors {\n background: string;\n foreground: string;\n}\n\n/**\n * Các bậc màu mà công thức hex bên dưới đọc vào — `tokens.ts` truyền xuống một bộ cho light,\n * một bộ cho dark. Khai tường minh bằng `string` (không `typeof gold`) vì bản light và bản dark\n * là hai bộ giá trị khác nhau, không cùng một kiểu literal.\n */\nexport interface AvatarTonePalette {\n /** Thang gold — cần bậc 100 · 500 · 600. */\n gold: { readonly 100: string; readonly 500: string; readonly 600: string };\n /** Thang xám ash — cần bậc 100 · 500 · 700. */\n ash: { readonly 100: string; readonly 500: string; readonly 700: string };\n /** Trung tính brand — mực và giấy. */\n brandNeutral: { readonly ink: string; readonly inkSoft: string; readonly paper: string };\n /** Màu phụ ở vai trò TÔ NỀN (`accent.light` hoặc `accent.dark`). */\n accent: {\n readonly teal: string;\n readonly green: string;\n readonly red: string;\n readonly yellow: string;\n readonly navy: string;\n };\n /** Cùng màu phụ đó ở vai trò CHỮ trên nền trang (`accentInk.light` hoặc `accentInk.dark`). */\n accentInk: {\n readonly teal: string;\n readonly green: string;\n readonly red: string;\n readonly yellow: string;\n readonly navy: string;\n };\n /** Nền mà tint hợp thành lên — `--card` của theme. */\n card: string;\n}\n\ninterface AvatarTone {\n /** Vai trò DOM: chuỗi class token tĩnh (kèm biến thể `dark:`). */\n readonly classes: string;\n /** Vai trò canvas/email/PDF: đúng những gì `classes` nói, tính ra hex đục ở light. */\n readonly light: (p: AvatarTonePalette) => AvatarToneColors;\n /** Nhánh `dark:` của `classes`. */\n readonly dark: (p: AvatarTonePalette) => AvatarToneColors;\n}\n\n/**\n * Trộn `color` phủ `alpha` lên nền `over`, trả về hex đục.\n *\n * Tái hiện đúng phép mà trình duyệt làm với `bg-teal/15`: Tailwind sinh ra màu CÓ alpha, còn nền\n * phía dưới (`--card`) mới quyết định hex cuối cùng. Nơi không có CSS thì không có gì để hợp\n * thành, nên hex phải mang sẵn kết quả đã trộn.\n */\nfunction tintOver(color: string, over: string, alpha: number): string {\n const channel = (hex: string, i: number) => Number.parseInt(hex.slice(1 + i * 2, 3 + i * 2), 16);\n const mixed = [0, 1, 2].map((i) => Math.round(channel(color, i) * alpha + channel(over, i) * (1 - alpha)));\n return `#${mixed.map((v) => v.toString(16).padStart(2, \"0\")).join(\"\")}`.toUpperCase();\n}\n\n/**\n * 8 tone deterministic. Phía class TẤT CẢ là token tĩnh — không ghép chuỗi lúc chạy (`bg-${c}-100`)\n * vì Tailwind quét tĩnh, class ghép động sẽ không có mặt trong CSS của app; cũng không dùng hex\n * inline vì như vậy là mất dark mode.\n *\n * ─ VÌ SAO LÀ MÀU PHỤ + CHART CHỨ KHÔNG PHẢI TOKEN SEMANTIC ─\n * Đây là màu ĐỊNH DANH (phân biệt người), không phải màu TRẠNG THÁI — gán `--success` cho một con\n * người là sai nghĩa. Bộ màu phụ + bảng chart là nhóm token DUY NHẤT có đủ 8 sắc khác nhau và có\n * bản riêng cho dark, nên vẫn đổi theo theme và app vẫn ghi đè được.\n *\n * ─ TƯƠNG PHẢN: VÌ SAO BA TONE PHẢI HẠ XUỐNG BẬC INK Ở LIGHT ─\n * Chữ initials tuy `aria-hidden` nhưng người sáng mắt vẫn đọc, nên vẫn phải đạt AA 4,5:1. Bản đầu\n * dùng thẳng `text-teal/green/red` trên nền tint 15% và HỎNG ở light (đo theo WCAG, nền tint đã\n * trộn với `--card` trắng):\n *\n * teal #1F8A8B trên bg-teal/15 = 3,44:1 ✗\n * green #2EA56A trên bg-green/15 = 2,69:1 ✗ (dưới cả ngưỡng 3:1)\n * red #D64545 trên bg-red/15 = 3,57:1 ✗\n *\n * Sửa đúng khuôn `Badge`: dùng bậc `--x-ink` cho light, giữ token đã nâng sáng cho dark. Đo lại\n * trên đúng hex mà `getAvatarToneColors` trả ra (styles.css đã đóng băng bậc ink, xem dưới):\n *\n * teal #1A6668 trên #DDEDEE = 5,54:1 ✓ · dark #2DD4BF trên #193435 = 7,12:1 ✓\n * green #216A48 trên #E0F2E9 = 5,60:1 ✓ · dark #4ADE80 trên #1E362C = 7,44:1 ✓\n * red #9A3537 trên #F9E3E3 = 5,83:1 ✓ · dark #F87171 trên #38252A = 5,18:1 ✓\n *\n * (Bộ số 5,56 / 5,58 / 7,10 / 7,47 / 5,17 ghi ở bản trước là kết quả của chuỗi `color-mix` TRƯỚC\n * khi hex được đóng băng vào token — lệch ở chữ, không lệch ở màu.)\n *\n * Class viết thẳng `text-teal-ink` / `text-green-ink` / `text-red-ink` / `text-yellow-ink` chứ\n * KHÔNG nhắc lại chuỗi `color-mix(...)` — hai cách cho ra đúng cùng một hex (styles.css đã đóng\n * băng kết quả: #1A6668 · #216A48 · #9A3537), và ở `.dark` thì `--teal-ink` vốn trỏ về `--teal`\n * nên không cần biến thể `dark:` nào. Nhờ vậy tên trong class khớp đúng tên trong công thức hex\n * ngay bên dưới (`p.accentInk.teal`) — soi hai vế cạnh nhau là thấy lệch ngay.\n */\nexport const AVATAR_TONES: AvatarToneTable<AvatarTone> = [\n {\n classes: \"bg-gold-100 text-gold-600 dark:bg-gold-500/30 dark:text-gold-100\",\n light: (p) => ({ background: p.gold[100], foreground: p.gold[600] }),\n dark: (p) => ({ background: tintOver(p.gold[500], p.card, 0.3), foreground: p.gold[100] })\n },\n {\n classes: \"bg-teal/15 text-teal-ink\",\n light: (p) => ({ background: tintOver(p.accent.teal, p.card, 0.15), foreground: p.accentInk.teal }),\n dark: (p) => ({ background: tintOver(p.accent.teal, p.card, 0.15), foreground: p.accentInk.teal })\n },\n {\n classes: \"bg-green/15 text-green-ink\",\n light: (p) => ({ background: tintOver(p.accent.green, p.card, 0.15), foreground: p.accentInk.green }),\n dark: (p) => ({ background: tintOver(p.accent.green, p.card, 0.15), foreground: p.accentInk.green })\n },\n {\n classes: \"bg-red/15 text-red-ink\",\n light: (p) => ({ background: tintOver(p.accent.red, p.card, 0.15), foreground: p.accentInk.red }),\n dark: (p) => ({ background: tintOver(p.accent.red, p.card, 0.15), foreground: p.accentInk.red })\n },\n {\n classes: \"bg-yellow/25 text-yellow-ink dark:bg-yellow/20\",\n light: (p) => ({ background: tintOver(p.accent.yellow, p.card, 0.25), foreground: p.accentInk.yellow }),\n dark: (p) => ({ background: tintOver(p.accent.yellow, p.card, 0.2), foreground: p.accentInk.yellow })\n },\n {\n // chart-3 = navy ở light, = navy-ink (bậc sáng) ở dark — nền tint cũng đi theo đúng màu đó.\n classes: \"bg-chart-3/15 text-chart-3\",\n light: (p) => ({ background: tintOver(p.accent.navy, p.card, 0.15), foreground: p.accentInk.navy }),\n dark: (p) => ({ background: tintOver(p.accentInk.navy, p.card, 0.15), foreground: p.accentInk.navy })\n },\n {\n classes: \"bg-ash-100 text-ash-700 dark:bg-ash-500/25 dark:text-ash-100\",\n light: (p) => ({ background: p.ash[100], foreground: p.ash[700] }),\n dark: (p) => ({ background: tintOver(p.ash[500], p.card, 0.25), foreground: p.ash[100] })\n },\n {\n classes: \"bg-ink/10 text-ink-soft dark:bg-paper/15 dark:text-paper\",\n light: (p) => ({ background: tintOver(p.brandNeutral.ink, p.card, 0.1), foreground: p.brandNeutral.inkSoft }),\n dark: (p) => ({ background: tintOver(p.brandNeutral.paper, p.card, 0.15), foreground: p.brandNeutral.paper })\n }\n];\n\n/**\n * Hash chuỗi → số nguyên không âm (biến thể FNV-1a 32 bit).\n *\n * Nội bộ, KHÔNG xuất ra API: công dụng duy nhất của nó là chọn tone avatar ngay dưới đây. Xuất\n * một hàm băm ra ngoài chỉ mời app dựng lại phép chọn tone của riêng nó — đúng thứ sinh ra chuyện\n * cùng một người hai màu. Chạy trên code point (không phải byte) để tên có dấu vẫn ổn định giữa\n * server và client.\n */\nfunction hashString(input: string): number {\n let hash = 0x811c9dc5;\n for (let i = 0; i < input.length; i++) {\n hash ^= input.charCodeAt(i);\n hash = Math.imul(hash, 0x01000193);\n }\n return Math.abs(hash | 0);\n}\n\n/**\n * Chỉ số tone theo tên — phép chọn màu avatar DUY NHẤT của cả gói.\n *\n * Khoá băm là **tên hiển thị** đã `trim` + hạ chữ thường theo locale `vi-VN` (không phải\n * email/slug: chỗ nào hiển thị avatar cũng chắc chắn có tên, còn email/slug thì không — lấy khoá\n * khác nhau ở từng chỗ là cách nhanh nhất để cùng một người ra hai màu).\n */\nexport function getAvatarToneIndex(name: string): number {\n return hashString(name.trim().toLocaleLowerCase(\"vi-VN\")) % AVATAR_TONES.length;\n}\n\n/** Tone (cả class lẫn công thức hex) ứng với một tên. */\nexport function getAvatarTone(name: string): AvatarTone {\n // `noUncheckedIndexedAccess` bật → phải có nhánh lùi, dù chỉ số luôn nằm trong [0, 8).\n return AVATAR_TONES[getAvatarToneIndex(name)] ?? AVATAR_TONES[0];\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/lib/avatar-tone.ts"],"sourcesContent":["/**\n * NGUỒN SỰ THẬT DUY NHẤT của màu avatar chữ cái — cả gói chỉ có bảng này.\n *\n * File nội bộ (không nằm trong barrel nào): app không import trực tiếp được, mà đi qua đúng\n * hai cửa tuỳ theo chỗ vẽ:\n * • có Tailwind (DOM) → `getAvatarToneClasses(name)` ở `composite/letter-avatar.tsx`\n * • không có Tailwind (canvas, → `getAvatarToneColors(name, mode)` ở `tokens.ts`\n * email HTML, PDF)\n *\n * Vì sao MỘT bảng chứ không phải hai (một bảng class + một bảng hex \"ảnh chiếu\"): hai bảng viết\n * tay song song thì chỉ cần một lần thêm/xoá/đảo tone là cùng một người ra hai màu khác nhau giữa\n * web và email — mà không có gì báo. Ở đây mỗi tone là MỘT phần tử mang cả chuỗi class lẫn công\n * thức hex tương ứng, nằm sát nhau trên vài dòng liền: thêm tone là thêm cả ba, không quên được.\n * Thứ tự phần tử = chỉ số băm, nên thứ tự cũng chỉ tồn tại ở một chỗ.\n *\n * Vì sao công thức hex là HÀM nhận `palette` chứ không đọc thẳng token: `tokens.ts` là entry\n * `@comitor/ui/tokens`; nếu file này `import { accent } from \"../tokens.js\"` thì thành vòng import\n * (tokens.ts phải import ngược lại bảng này) — vòng có khởi tạo hằng ở giữa là lỗi TDZ lúc chạy.\n * Nhận bảng màu qua tham số thì phụ thuộc chỉ đi MỘT chiều: tokens.ts → file này.\n */\n\n/** Đúng 8 tone — chốt ở tầng kiểu: `getAvatarToneIndex` băm theo độ dài này. */\ntype AvatarToneTable<T> = readonly [T, T, T, T, T, T, T, T];\n\n/** Cặp nền + màu chữ (hex đục) của một tone avatar. */\nexport interface AvatarToneColors {\n background: string;\n foreground: string;\n}\n\n/**\n * Các bậc màu mà công thức hex bên dưới đọc vào — `tokens.ts` truyền xuống một bộ cho light,\n * một bộ cho dark. Khai tường minh bằng `string` (không `typeof gold`) vì bản light và bản dark\n * là hai bộ giá trị khác nhau, không cùng một kiểu literal.\n */\nexport interface AvatarTonePalette {\n /** Thang gold — cần bậc 100 · 500 · 600. */\n gold: { readonly 100: string; readonly 500: string; readonly 600: string };\n /** Thang xám ash — cần bậc 100 · 500 · 700. */\n ash: { readonly 100: string; readonly 500: string; readonly 700: string };\n /** Trung tính brand — mực và giấy. */\n brandNeutral: { readonly ink: string; readonly inkSoft: string; readonly paper: string };\n /** Màu phụ ở vai trò TÔ NỀN (`accent.light` hoặc `accent.dark`). */\n accent: {\n readonly teal: string;\n readonly green: string;\n readonly red: string;\n readonly yellow: string;\n readonly navy: string;\n };\n /** Cùng màu phụ đó ở vai trò CHỮ trên nền trang (`accentInk.light` hoặc `accentInk.dark`). */\n accentInk: {\n readonly teal: string;\n readonly green: string;\n readonly red: string;\n readonly yellow: string;\n readonly navy: string;\n };\n /** Nền mà tint hợp thành lên — `--card` của theme. */\n card: string;\n}\n\ninterface AvatarTone {\n /**\n * Tên gọi của tone — để chỗ dùng CHỌN TAY một màu thay vì để hàm băm quyết.\n *\n * ⚠ Tên là một phần của HỢP ĐỒNG: nó đi vào cột database của app (`apps.icon_color` bên\n * `comitor-account`) và vào prop `tone` của `IconAvatar`. Đổi tên một tone là làm mọi hàng đã\n * lưu trỏ vào hư không — và hỏng đó IM LẶNG, vì giá trị lạ chỉ rơi về màu suy theo tên.\n * Thêm tone thì thêm tên mới; đừng đổi tên cũ.\n */\n readonly name: AvatarToneName;\n /** Vai trò DOM: chuỗi class token tĩnh (kèm biến thể `dark:`). */\n readonly classes: string;\n /** Vai trò canvas/email/PDF: đúng những gì `classes` nói, tính ra hex đục ở light. */\n readonly light: (p: AvatarTonePalette) => AvatarToneColors;\n /** Nhánh `dark:` của `classes`. */\n readonly dark: (p: AvatarTonePalette) => AvatarToneColors;\n}\n\n/**\n * Trộn `color` phủ `alpha` lên nền `over`, trả về hex đục.\n *\n * Tái hiện đúng phép mà trình duyệt làm với `bg-teal/15`: Tailwind sinh ra màu CÓ alpha, còn nền\n * phía dưới (`--card`) mới quyết định hex cuối cùng. Nơi không có CSS thì không có gì để hợp\n * thành, nên hex phải mang sẵn kết quả đã trộn.\n */\nfunction tintOver(color: string, over: string, alpha: number): string {\n const channel = (hex: string, i: number) => Number.parseInt(hex.slice(1 + i * 2, 3 + i * 2), 16);\n const mixed = [0, 1, 2].map((i) => Math.round(channel(color, i) * alpha + channel(over, i) * (1 - alpha)));\n return `#${mixed.map((v) => v.toString(16).padStart(2, \"0\")).join(\"\")}`.toUpperCase();\n}\n\n/**\n * 8 tone deterministic. Phía class TẤT CẢ là token tĩnh — không ghép chuỗi lúc chạy (`bg-${c}-100`)\n * vì Tailwind quét tĩnh, class ghép động sẽ không có mặt trong CSS của app; cũng không dùng hex\n * inline vì như vậy là mất dark mode.\n *\n * ─ VÌ SAO LÀ MÀU PHỤ + CHART CHỨ KHÔNG PHẢI TOKEN SEMANTIC ─\n * Đây là màu ĐỊNH DANH (phân biệt người), không phải màu TRẠNG THÁI — gán `--success` cho một con\n * người là sai nghĩa. Bộ màu phụ + bảng chart là nhóm token DUY NHẤT có đủ 8 sắc khác nhau và có\n * bản riêng cho dark, nên vẫn đổi theo theme và app vẫn ghi đè được.\n *\n * ─ TƯƠNG PHẢN: VÌ SAO BA TONE PHẢI HẠ XUỐNG BẬC INK Ở LIGHT ─\n * Chữ initials tuy `aria-hidden` nhưng người sáng mắt vẫn đọc, nên vẫn phải đạt AA 4,5:1. Bản đầu\n * dùng thẳng `text-teal/green/red` trên nền tint 15% và HỎNG ở light (đo theo WCAG, nền tint đã\n * trộn với `--card` trắng):\n *\n * teal #1F8A8B trên bg-teal/15 = 3,44:1 ✗\n * green #2EA56A trên bg-green/15 = 2,69:1 ✗ (dưới cả ngưỡng 3:1)\n * red #D64545 trên bg-red/15 = 3,57:1 ✗\n *\n * Sửa đúng khuôn `Badge`: dùng bậc `--x-ink` cho light, giữ token đã nâng sáng cho dark. Đo lại\n * trên đúng hex mà `getAvatarToneColors` trả ra (styles.css đã đóng băng bậc ink, xem dưới):\n *\n * teal #1A6668 trên #DDEDEE = 5,54:1 ✓ · dark #2DD4BF trên #193435 = 7,12:1 ✓\n * green #216A48 trên #E0F2E9 = 5,60:1 ✓ · dark #4ADE80 trên #1E362C = 7,44:1 ✓\n * red #9A3537 trên #F9E3E3 = 5,83:1 ✓ · dark #F87171 trên #38252A = 5,18:1 ✓\n *\n * (Bộ số 5,56 / 5,58 / 7,10 / 7,47 / 5,17 ghi ở bản trước là kết quả của chuỗi `color-mix` TRƯỚC\n * khi hex được đóng băng vào token — lệch ở chữ, không lệch ở màu.)\n *\n * Class viết thẳng `text-teal-ink` / `text-green-ink` / `text-red-ink` / `text-yellow-ink` chứ\n * KHÔNG nhắc lại chuỗi `color-mix(...)` — hai cách cho ra đúng cùng một hex (styles.css đã đóng\n * băng kết quả: #1A6668 · #216A48 · #9A3537), và ở `.dark` thì `--teal-ink` vốn trỏ về `--teal`\n * nên không cần biến thể `dark:` nào. Nhờ vậy tên trong class khớp đúng tên trong công thức hex\n * ngay bên dưới (`p.accentInk.teal`) — soi hai vế cạnh nhau là thấy lệch ngay.\n */\n/**\n * Tên của 8 tone, ĐÚNG THỨ TỰ bảng bên dưới — thứ tự ấy cũng là chỉ số băm, nên hai thứ không\n * được trôi khỏi nhau. Đây là bảng màu mà chỗ dùng chọn tay khi không muốn để tên quyết định.\n *\n * Tên lấy theo SẮC, không theo vai trò ngữ nghĩa: đây là màu ĐỊNH DANH, không phải màu trạng\n * thái — xem lý do dài ở docblock của `AVATAR_TONES`.\n */\nexport const AVATAR_TONE_NAMES = [\"gold\", \"teal\", \"green\", \"red\", \"yellow\", \"navy\", \"ash\", \"neutral\"] as const;\n\nexport type AvatarToneName = (typeof AVATAR_TONE_NAMES)[number];\n\nexport const AVATAR_TONES: AvatarToneTable<AvatarTone> = [\n {\n name: \"gold\",\n classes: \"bg-gold-100 text-gold-600 dark:bg-gold-500/30 dark:text-gold-100\",\n light: (p) => ({ background: p.gold[100], foreground: p.gold[600] }),\n dark: (p) => ({ background: tintOver(p.gold[500], p.card, 0.3), foreground: p.gold[100] })\n },\n {\n name: \"teal\",\n classes: \"bg-teal/15 text-teal-ink\",\n light: (p) => ({ background: tintOver(p.accent.teal, p.card, 0.15), foreground: p.accentInk.teal }),\n dark: (p) => ({ background: tintOver(p.accent.teal, p.card, 0.15), foreground: p.accentInk.teal })\n },\n {\n name: \"green\",\n classes: \"bg-green/15 text-green-ink\",\n light: (p) => ({ background: tintOver(p.accent.green, p.card, 0.15), foreground: p.accentInk.green }),\n dark: (p) => ({ background: tintOver(p.accent.green, p.card, 0.15), foreground: p.accentInk.green })\n },\n {\n name: \"red\",\n classes: \"bg-red/15 text-red-ink\",\n light: (p) => ({ background: tintOver(p.accent.red, p.card, 0.15), foreground: p.accentInk.red }),\n dark: (p) => ({ background: tintOver(p.accent.red, p.card, 0.15), foreground: p.accentInk.red })\n },\n {\n name: \"yellow\",\n classes: \"bg-yellow/25 text-yellow-ink dark:bg-yellow/20\",\n light: (p) => ({ background: tintOver(p.accent.yellow, p.card, 0.25), foreground: p.accentInk.yellow }),\n dark: (p) => ({ background: tintOver(p.accent.yellow, p.card, 0.2), foreground: p.accentInk.yellow })\n },\n {\n // chart-3 = navy ở light, = navy-ink (bậc sáng) ở dark — nền tint cũng đi theo đúng màu đó.\n name: \"navy\",\n classes: \"bg-chart-3/15 text-chart-3\",\n light: (p) => ({ background: tintOver(p.accent.navy, p.card, 0.15), foreground: p.accentInk.navy }),\n dark: (p) => ({ background: tintOver(p.accentInk.navy, p.card, 0.15), foreground: p.accentInk.navy })\n },\n {\n name: \"ash\",\n classes: \"bg-ash-100 text-ash-700 dark:bg-ash-500/25 dark:text-ash-100\",\n light: (p) => ({ background: p.ash[100], foreground: p.ash[700] }),\n dark: (p) => ({ background: tintOver(p.ash[500], p.card, 0.25), foreground: p.ash[100] })\n },\n {\n name: \"neutral\",\n classes: \"bg-ink/10 text-ink-soft dark:bg-paper/15 dark:text-paper\",\n light: (p) => ({ background: tintOver(p.brandNeutral.ink, p.card, 0.1), foreground: p.brandNeutral.inkSoft }),\n dark: (p) => ({ background: tintOver(p.brandNeutral.paper, p.card, 0.15), foreground: p.brandNeutral.paper })\n }\n];\n\n/**\n * Hash chuỗi → số nguyên không âm (biến thể FNV-1a 32 bit).\n *\n * Nội bộ, KHÔNG xuất ra API: công dụng duy nhất của nó là chọn tone avatar ngay dưới đây. Xuất\n * một hàm băm ra ngoài chỉ mời app dựng lại phép chọn tone của riêng nó — đúng thứ sinh ra chuyện\n * cùng một người hai màu. Chạy trên code point (không phải byte) để tên có dấu vẫn ổn định giữa\n * server và client.\n */\nfunction hashString(input: string): number {\n let hash = 0x811c9dc5;\n for (let i = 0; i < input.length; i++) {\n hash ^= input.charCodeAt(i);\n hash = Math.imul(hash, 0x01000193);\n }\n return Math.abs(hash | 0);\n}\n\n/**\n * Chỉ số tone theo tên — phép chọn màu avatar DUY NHẤT của cả gói.\n *\n * Khoá băm là **tên hiển thị** đã `trim` + hạ chữ thường theo locale `vi-VN` (không phải\n * email/slug: chỗ nào hiển thị avatar cũng chắc chắn có tên, còn email/slug thì không — lấy khoá\n * khác nhau ở từng chỗ là cách nhanh nhất để cùng một người ra hai màu).\n */\nexport function getAvatarToneIndex(name: string): number {\n return hashString(name.trim().toLocaleLowerCase(\"vi-VN\")) % AVATAR_TONES.length;\n}\n\n/** Tone (cả class lẫn công thức hex) ứng với một tên. */\nexport function getAvatarTone(name: string): AvatarTone {\n // `noUncheckedIndexedAccess` bật → phải có nhánh lùi, dù chỉ số luôn nằm trong [0, 8).\n return AVATAR_TONES[getAvatarToneIndex(name)] ?? AVATAR_TONES[0];\n}\n\n/**\n * Tone theo TÊN — cửa cho chỗ dùng chọn tay một màu.\n *\n * Trả `undefined` cho tên lạ, và nơi gọi hiểu đó là \"rơi về màu suy theo tên\". KHÔNG ném: giá trị\n * này đến từ database (`apps.icon_color`), nơi một hàng cũ hoàn toàn có thể mang tên của một tone\n * đã bị xoá — làm sập cả dải ứng dụng vì một chữ trong một hàng là đổi sai lấy tệ hơn.\n */\nexport function getAvatarToneByName(name: string | null | undefined): AvatarTone | undefined {\n if (!name) return undefined;\n return AVATAR_TONES.find((tone) => tone.name === name);\n}\n"],"mappings":"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","names":[]}
|
|
@@ -62,7 +62,22 @@ function SidebarMenuItem({
|
|
|
62
62
|
const tileClass = cn(
|
|
63
63
|
"relative flex size-9 items-center justify-center rounded-md",
|
|
64
64
|
"transition-colors duration-150 ease-out",
|
|
65
|
-
|
|
65
|
+
/*
|
|
66
|
+
* ⚠ `text-muted-foreground` (ĐẶC), KHÔNG phải `text-sidebar-foreground/70`.
|
|
67
|
+
*
|
|
68
|
+
* Ô này KHÔNG chứa chữ — chỉ một `<Icon>` không có màu riêng, nên nó lấy màu qua
|
|
69
|
+
* `currentColor` TỪ ĐÂY. Một màu có alpha ở đây là alpha chảy thẳng xuống nét icon, và nét
|
|
70
|
+
* cắt nhau bị tô hai lần → lộ vệt nối. Xem `iconClass` ở nhánh mở rộng và
|
|
71
|
+
* `docs/hop-dong-mau.md` §7b.
|
|
72
|
+
*
|
|
73
|
+
* ⚠ Đây là ca KẾ THỪA, và nó là nửa dễ sót của luật: bản vá đầu chỉ chữa chỗ đặt màu THẲNG
|
|
74
|
+
* lên icon, còn ô này vẫn hỏng thêm một bản phát hành nữa. Đo được trên
|
|
75
|
+
* `starter.dev.comitor.ai`: thanh bên mở rộng 0/4 icon có alpha, THU GỌN thì 4/4 ở `0.7`.
|
|
76
|
+
* Nên khi soát, đừng chỉ tìm màu trên chính `<svg>` — tìm cả màu của thẻ CHA bọc nó.
|
|
77
|
+
*
|
|
78
|
+
* Ô chỉ có icon nên ngưỡng là 3:1 của WCAG 1.4.11, không phải 4,5:1 của chữ.
|
|
79
|
+
*/
|
|
80
|
+
active ? "bg-sidebar-accent text-sidebar-accent-foreground" : "text-muted-foreground",
|
|
66
81
|
!disabled && !active && "hover:bg-sidebar-accent/60 hover:text-sidebar-foreground",
|
|
67
82
|
// Cùng lý do với `rowClass` ở trên: bậc chữ mờ hơn thay cho `opacity-50`.
|
|
68
83
|
disabled && "cursor-not-allowed text-muted-foreground"
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/shell/sidebar-menu-item.tsx"],"sourcesContent":["\"use client\";\n\nimport { ChevronRight } from \"lucide-react\";\nimport { useEffect, useState } from \"react\";\nimport { Badge } from \"../components/ui/badge.js\";\nimport { Collapsible, CollapsibleContent, CollapsibleTrigger } from \"../components/ui/collapsible.js\";\nimport { Tooltip, TooltipContent, TooltipTrigger } from \"../components/ui/tooltip.js\";\nimport { cn } from \"../lib/cn.js\";\nimport { useShell } from \"./shell-context.js\";\nimport { ShellNavLink } from \"./shell-link.js\";\nimport type { NavBadgeVariant, NavItem } from \"./types.js\";\nimport { isNavBranchActive, isNavItemActive } from \"./utils.js\";\n\n/** Variant badge của NavItem → variant của primitive Badge (tên trùng nhau, map tường minh cho an toàn). */\nconst BADGE_VARIANT: Record<NavBadgeVariant, \"default\" | \"destructive\" | \"warning\" | \"success\"> = {\n default: \"default\",\n destructive: \"destructive\",\n warning: \"warning\",\n success: \"success\"\n};\n\n/**\n * Vòng focus cho mục đã khoá.\n *\n * Mục khoá vẫn nằm trong tab order (`tabIndex={0}` + `aria-disabled`) theo đúng khuyến nghị\n * của ARIA APG: người dùng bàn phím phải *biết* mục đó tồn tại và đang bị khoá, thay vì mục\n * lặng lẽ biến mất. Không có href và không có handler nên Enter/click không đi đâu cả.\n */\nconst FOCUS_RING = \"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring\";\n\nexport interface SidebarMenuItemProps {\n item: NavItem;\n /** Sidebar đang thu gọn (chỉ icon) — chỉ áp dụng cho mục cấp 0. */\n collapsed?: boolean;\n /** Cấp lồng (0 = gốc). Cấp > 0 thụt lề và không có icon vuông. */\n depth?: number;\n /**\n * Tập mục để đối chiếu khớp CHÍNH XÁC pathname — nên truyền toàn bộ mục của app\n * hiện tại. Thiếu nó, `/tasks` sẽ vẫn sáng khi đang ở `/tasks/inbox`.\n */\n siblings?: NavItem[];\n /** Gọi khi người dùng bấm vào một LINK thật (không gọi khi chỉ mở/đóng nhánh con) —\n * `MobileMenu` dùng để tự đóng. */\n onNavigate?: () => void;\n className?: string;\n}\n\n/**\n * Một mục menu sidebar: active matching thông minh, badge số, mục con `Collapsible`,\n * khi sidebar thu gọn thì hiện `Tooltip` + chấm badge.\n */\nexport function SidebarMenuItem({\n item,\n collapsed = false,\n depth = 0,\n siblings = [],\n onNavigate,\n className\n}: SidebarMenuItemProps) {\n const { pathname, searchParams } = useShell();\n\n const active = isNavItemActive(item, pathname, siblings, searchParams);\n const branchActive = isNavBranchActive(item, pathname, siblings, searchParams);\n const hasChildren = Boolean(item.children?.length);\n\n const [open, setOpen] = useState(branchActive);\n\n // Điều hướng tới một mục con → tự mở nhánh chứa nó.\n useEffect(() => {\n if (branchActive) setOpen(true);\n }, [branchActive]);\n\n const Icon = item.icon;\n const disabled = Boolean(item.disabled);\n const rowActive = active || (branchActive && !hasChildren);\n\n const rowClass = cn(\n \"group relative flex w-full items-center gap-2.5 rounded-md px-2 py-(--nav-item-py) text-sm\",\n \"transition-colors duration-150 ease-out\",\n depth > 0 && \"pl-8 text-[13px]\",\n rowActive ? \"bg-sidebar-accent font-medium text-sidebar-accent-foreground\" : \"text-sidebar-foreground/70\",\n // Mục đã khoá thì KHÔNG gắn hover — đổi màu khi rê chuột là hứa hẹn bấm được.\n !disabled && !rowActive && \"hover:bg-sidebar-accent/60 hover:text-sidebar-foreground\",\n // Mục khoá tô bằng MỘT bậc chữ mờ hơn, KHÔNG phải `opacity-50` chồng lên màu sẵn có.\n // Vì sao: mục khoá vẫn nằm trong tab order (`role=\"link\" aria-disabled tabIndex={0}` — xem\n // chú thích FOCUS_RING) nên KHÔNG được hưởng miễn trừ \"thành phần vô hiệu\" của WCAG 1.4.3,\n // mà `opacity-50` chồng lên `text-sidebar-foreground/70` cho độ mờ thực 35% → 2,28:1 (light)\n // / 3,10:1 (dark) ✗ cho chữ, và 27,5% trên icon → 1,86:1 / 2,39:1 ✗.\n // `--muted-foreground` là bậc chữ phụ đã đo cho đúng vai trò này: 5,01:1 (light) / 5,35:1\n // (dark) trên `--sidebar` ✓. Tín hiệu \"khoá\" vẫn còn nguyên ở con trỏ và ở `aria-disabled`.\n disabled && \"cursor-not-allowed text-muted-foreground\",\n className\n );\n\n /**\n * Màu icon/vạch active dùng `--sidebar-accent-foreground`, KHÔNG dùng `--app-accent`.\n *\n * Accent mặc định (= `--primary` = gold #E8B824) chỉ đạt **1,70:1** trên nền hàng active\n * (`--sidebar-accent` #FBF5DC) ở light — dưới ngưỡng 3:1 của WCAG 1.4.11 cho thành phần\n * phi-văn-bản, nên vạch chỉ mục active gần như vô hình đúng lúc nó phải nói lên trạng thái.\n * (Ở dark thì gold #F5D060 trên #1E2128 đạt 10,8:1 — chỉ light hỏng.)\n * `--sidebar-accent-foreground` đạt 17,3:1 (light) và 15,1:1 (dark), lại là token semantic\n * nên app đổi theme vẫn đúng. Cùng cách xử lý mà `stat-card.tsx` đã chọn (`text-foreground`).\n *\n * Muốn vạch active mang màu riêng của app trở lại thì phải sửa ở tầng token: cho\n * `--app-accent` một bậc đủ tối ở light (vd gold-500 #7A5800 = 6,0:1), rồi mới quay lại\n * `bg-app-accent` ở đây.\n */\n /*\n * ── ⚠ ICON KHÔNG BAO GIỜ ĐƯỢC TÔ BẰNG MÀU CÓ ALPHA ──────────────────────────────────────\n * Icon `lucide-react` là NHIỀU phần tử `<path>`/`<rect>`/`<circle>` vẽ bằng NÉT\n * (`stroke=\"currentColor\"`), và ở khá nhiều icon các nét ấy CẮT NHAU. Nét bán trong suốt chồng\n * lên nhau thì chỗ giao được tô hai lần — nó đậm hơn phần còn lại, và icon lộ ra những vệt nối\n * mà người thiết kế không vẽ.\n *\n * Đo được trên `account.dev.comitor.ai`: hàng \"Danh mục sản phẩm\" dùng icon `Boxes` — **12**\n * phần tử con, chồng nhau chằng chịt — nên vệt nối hiện rõ; `User` hay `KeyRound` (2 phần tử,\n * không cắt nhau) thì gần như không thấy, và hàng ACTIVE thì không bao giờ thấy vì nó tô đặc.\n * Đó là lý do lỗi này sống lâu: nó chỉ lộ ở đúng vài icon, ở đúng trạng thái không-active.\n *\n * Vì vậy `text-sidebar-foreground/55` (đã phẳng ra `rgb(120,122,124)`, 4,13:1) được thay bằng\n * `text-muted-foreground` — ĐẶC, `rgb(126,130,144)`, **3,67:1** trên `--sidebar`, vẫn trên\n * ngưỡng 3:1 của WCAG 1.4.11 cho thành phần phi-văn-bản. Cùng token mà nhánh `disabled` ngay\n * dưới và `workspace-switcher.tsx` đã chọn, và cùng lý do: bậc màu ĐO ĐƯỢC thay cho alpha chồng.\n *\n * ⚠ Icon của hàng thường nay TRÙNG MÀU với icon hàng khoá. Chấp nhận được: tín hiệu \"khoá\" chưa\n * bao giờ nằm ở icon — nó ở màu CHỮ (`text-muted-foreground` so với `text-sidebar-foreground/70`),\n * ở con trỏ `cursor-not-allowed`, và ở `aria-disabled`.\n *\n * ⚠ Luật này áp cho MỌI icon lucide trong gói, không riêng chỗ này: đừng dùng `/NN`,\n * `opacity-*`, hay bất kỳ màu có alpha nào lên một `<svg>` icon. Cần nhạt hơn thì lấy một BẬC\n * MÀU đặc.\n */\n const iconClass = cn(\n \"size-4 shrink-0\",\n active ? \"text-sidebar-accent-foreground\" : \"text-muted-foreground\",\n !disabled && !active && \"group-hover:text-sidebar-foreground\"\n );\n\n /*\n * KHÔNG có vạch dọc bên trái ở hàng active.\n *\n * Bản duyệt có HAI cách vẽ mục active và chúng khác nhau: `SidebarNav` của trang tài liệu dùng\n * `border-l-2 border-gold-300`, còn khung ứng dụng ở mục **Patterns** chỉ tô nền\n * (`bg-gold-300/15 text-foreground`), không vạch. Shell này là bản dựng của Patterns nên theo\n * Patterns.\n *\n * Bỏ vạch KHÔNG làm mất tín hiệu trạng thái: hàng active vẫn khác hàng thường ở nền\n * (`--sidebar-accent`), ở màu chữ (`--sidebar-accent-foreground` thay cho\n * `--sidebar-foreground/70`), ở `font-medium`, và ở `aria-current=\"page\"` cho trình đọc màn\n * hình. WCAG 1.4.11 đòi tương phản cho thông tin thị giác, không đòi một hình dạng cụ thể.\n */\n\n const badge =\n item.badge === undefined || item.badge === null || item.badge === \"\" ? null : (\n <Badge variant={BADGE_VARIANT[item.badgeVariant ?? \"default\"]} size=\"sm\" className=\"ml-auto shrink-0\">\n {item.badge}\n </Badge>\n );\n\n /* ── Sidebar thu gọn: icon vuông + tooltip ─────────────────────────────── */\n if (collapsed && depth === 0) {\n const tileClass = cn(\n \"relative flex size-9 items-center justify-center rounded-md\",\n \"transition-colors duration-150 ease-out\",\n active ? \"bg-sidebar-accent text-sidebar-accent-foreground\" : \"text-sidebar-foreground/70\",\n !disabled && !active && \"hover:bg-sidebar-accent/60 hover:text-sidebar-foreground\",\n // Cùng lý do với `rowClass` ở trên: bậc chữ mờ hơn thay cho `opacity-50`.\n disabled && \"cursor-not-allowed text-muted-foreground\"\n );\n\n const tileBody = (\n <>\n {Icon ? <Icon className=\"size-4\" /> : null}\n {/* Thu gọn không đủ chỗ cho số → chỉ chấm báo có nội dung mới */}\n {item.badge ? <span className=\"absolute right-1.5 top-1.5 size-1.5 rounded-full bg-destructive\" /> : null}\n </>\n );\n\n return (\n <Tooltip>\n <TooltipTrigger asChild>\n {disabled ? (\n // Mục khoá: KHÔNG render <a> — xem chú thích ở nhánh mục lá bên dưới.\n <span\n role=\"link\"\n aria-disabled=\"true\"\n aria-label={item.label}\n tabIndex={0}\n className={cn(tileClass, FOCUS_RING)}\n >\n {tileBody}\n </span>\n ) : (\n <ShellNavLink\n href={item.href}\n external={item.external}\n onClick={() => onNavigate?.()}\n aria-current={active ? \"page\" : undefined}\n aria-label={item.label}\n className={tileClass}\n >\n {tileBody}\n </ShellNavLink>\n )}\n </TooltipTrigger>\n <TooltipContent side=\"right\" className=\"flex items-center gap-2\">\n <span>{item.label}</span>\n {item.badge ? <span className=\"text-xs opacity-70\">{item.badge}</span> : null}\n </TooltipContent>\n </Tooltip>\n );\n }\n\n /* ── Mục lá ────────────────────────────────────────────────────────────── */\n if (!hasChildren) {\n const leafBody = (\n <>\n {Icon ? <Icon className={iconClass} /> : null}\n <span className=\"truncate\">{item.label}</span>\n {badge}\n </>\n );\n\n /*\n * Mục khoá phải dựng bằng <span role=\"link\" aria-disabled>, KHÔNG phải <a> tô mờ:\n * `pointer-events-none` chỉ chặn CHUỘT — người dùng bàn phím vẫn Tab tới thẻ <a> và\n * Enter vẫn điều hướng, lại không có tín hiệu nào cho trình đọc màn hình. Bỏ hẳn href\n * là cách duy nhất chặn được cả hai đường.\n */\n if (disabled) {\n return (\n <span role=\"link\" aria-disabled=\"true\" tabIndex={0} className={cn(rowClass, FOCUS_RING)}>\n {leafBody}\n </span>\n );\n }\n\n return (\n <ShellNavLink\n href={item.href}\n external={item.external}\n // Phải gọi `onNavigate` ở ĐÂY nữa, không chỉ ở nhánh thu gọn: `MobileMenu` truyền\n // `onNavigate={closeMenu}` cho mọi mục, thiếu nó thì bấm một mục điều hướng thường\n // trong menu mobile mà menu không đóng.\n onClick={() => onNavigate?.()}\n aria-current={active ? \"page\" : undefined}\n className={rowClass}\n >\n {leafBody}\n </ShellNavLink>\n );\n }\n\n /* ── Mục có con ────────────────────────────────────────────────────────── */\n return (\n <Collapsible open={open} onOpenChange={setOpen} disabled={disabled}>\n <CollapsibleTrigger asChild>\n {/* `disabled` thật trên <button>: bỏ khỏi tab order và chặn cả Enter/Space,\n khác hẳn `pointer-events-none` vốn chỉ chặn chuột. */}\n <button type=\"button\" disabled={disabled} className={rowClass} aria-expanded={open}>\n {Icon ? <Icon className={iconClass} /> : null}\n <span className=\"truncate\">{item.label}</span>\n {badge}\n {/* `text-muted-foreground` chứ không `text-sidebar-foreground/40`: mũi tên MANG THÔNG TIN\n (nhánh đang đóng hay mở) nên chịu ngưỡng 3:1 của WCAG 1.4.11, mà /40 chỉ được\n 2,60:1 ở light ✗ (dark 3,64:1 — lại một chỗ mỗi theme hỏng một nửa).\n `--muted-foreground`: 5,01:1 (light) · 5,35:1 (dark) trên `--sidebar` ✓ */}\n <ChevronRight\n className={cn(\n \"ml-auto size-3.5 shrink-0 text-muted-foreground transition-transform duration-150\",\n badge && \"ml-1\",\n open && \"rotate-90\"\n )}\n />\n </button>\n </CollapsibleTrigger>\n <CollapsibleContent className=\"overflow-hidden data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down\">\n <div className=\"mt-0.5 flex flex-col gap-0.5\">\n {item.children?.map((child) => (\n <SidebarMenuItem\n key={child.id}\n item={child}\n depth={depth + 1}\n siblings={siblings}\n onNavigate={onNavigate}\n />\n ))}\n </div>\n </CollapsibleContent>\n </Collapsible>\n );\n}\n"],"mappings":";AA2JM,SAiBA,UAjBA,KAiBA,YAjBA;AAzJN,SAAS,oBAAoB;AAC7B,SAAS,WAAW,gBAAgB;AACpC,SAAS,aAAa;AACtB,SAAS,aAAa,oBAAoB,0BAA0B;AACpE,SAAS,SAAS,gBAAgB,sBAAsB;AACxD,SAAS,UAAU;AACnB,SAAS,gBAAgB;AACzB,SAAS,oBAAoB;AAE7B,SAAS,mBAAmB,uBAAuB;AAGnD,MAAM,gBAA4F;AAAA,EAChG,SAAS;AAAA,EACT,aAAa;AAAA,EACb,SAAS;AAAA,EACT,SAAS;AACX;AASA,MAAM,aAAa;AAuBZ,SAAS,gBAAgB;AAAA,EAC9B;AAAA,EACA,YAAY;AAAA,EACZ,QAAQ;AAAA,EACR,WAAW,CAAC;AAAA,EACZ;AAAA,EACA;AACF,GAAyB;AACvB,QAAM,EAAE,UAAU,aAAa,IAAI,SAAS;AAE5C,QAAM,SAAS,gBAAgB,MAAM,UAAU,UAAU,YAAY;AACrE,QAAM,eAAe,kBAAkB,MAAM,UAAU,UAAU,YAAY;AAC7E,QAAM,cAAc,QAAQ,KAAK,UAAU,MAAM;AAEjD,QAAM,CAAC,MAAM,OAAO,IAAI,SAAS,YAAY;AAG7C,YAAU,MAAM;AACd,QAAI,aAAc,SAAQ,IAAI;AAAA,EAChC,GAAG,CAAC,YAAY,CAAC;AAEjB,QAAM,OAAO,KAAK;AAClB,QAAM,WAAW,QAAQ,KAAK,QAAQ;AACtC,QAAM,YAAY,UAAW,gBAAgB,CAAC;AAE9C,QAAM,WAAW;AAAA,IACf;AAAA,IACA;AAAA,IACA,QAAQ,KAAK;AAAA,IACb,YAAY,iEAAiE;AAAA;AAAA,IAE7E,CAAC,YAAY,CAAC,aAAa;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAQ3B,YAAY;AAAA,IACZ;AAAA,EACF;AAyCA,QAAM,YAAY;AAAA,IAChB;AAAA,IACA,SAAS,mCAAmC;AAAA,IAC5C,CAAC,YAAY,CAAC,UAAU;AAAA,EAC1B;AAgBA,QAAM,QACJ,KAAK,UAAU,UAAa,KAAK,UAAU,QAAQ,KAAK,UAAU,KAAK,OACrE,oBAAC,SAAM,SAAS,cAAc,KAAK,gBAAgB,SAAS,GAAG,MAAK,MAAK,WAAU,oBAChF,eAAK,OACR;AAIJ,MAAI,aAAa,UAAU,GAAG;AAC5B,UAAM,YAAY;AAAA,MAChB;AAAA,MACA;AAAA,MACA,SAAS,qDAAqD;AAAA,MAC9D,CAAC,YAAY,CAAC,UAAU;AAAA;AAAA,MAExB,YAAY;AAAA,IACd;AAEA,UAAM,WACJ,iCACG;AAAA,aAAO,oBAAC,QAAK,WAAU,UAAS,IAAK;AAAA,MAErC,KAAK,QAAQ,oBAAC,UAAK,WAAU,mEAAkE,IAAK;AAAA,OACvG;AAGF,WACE,qBAAC,WACC;AAAA,0BAAC,kBAAe,SAAO,MACpB;AAAA;AAAA,QAEC;AAAA,UAAC;AAAA;AAAA,YACC,MAAK;AAAA,YACL,iBAAc;AAAA,YACd,cAAY,KAAK;AAAA,YACjB,UAAU;AAAA,YACV,WAAW,GAAG,WAAW,UAAU;AAAA,YAElC;AAAA;AAAA,QACH;AAAA,UAEA;AAAA,QAAC;AAAA;AAAA,UACC,MAAM,KAAK;AAAA,UACX,UAAU,KAAK;AAAA,UACf,SAAS,MAAM,aAAa;AAAA,UAC5B,gBAAc,SAAS,SAAS;AAAA,UAChC,cAAY,KAAK;AAAA,UACjB,WAAW;AAAA,UAEV;AAAA;AAAA,MACH,GAEJ;AAAA,MACA,qBAAC,kBAAe,MAAK,SAAQ,WAAU,2BACrC;AAAA,4BAAC,UAAM,eAAK,OAAM;AAAA,QACjB,KAAK,QAAQ,oBAAC,UAAK,WAAU,sBAAsB,eAAK,OAAM,IAAU;AAAA,SAC3E;AAAA,OACF;AAAA,EAEJ;AAGA,MAAI,CAAC,aAAa;AAChB,UAAM,WACJ,iCACG;AAAA,aAAO,oBAAC,QAAK,WAAW,WAAW,IAAK;AAAA,MACzC,oBAAC,UAAK,WAAU,YAAY,eAAK,OAAM;AAAA,MACtC;AAAA,OACH;AASF,QAAI,UAAU;AACZ,aACE,oBAAC,UAAK,MAAK,QAAO,iBAAc,QAAO,UAAU,GAAG,WAAW,GAAG,UAAU,UAAU,GACnF,oBACH;AAAA,IAEJ;AAEA,WACE;AAAA,MAAC;AAAA;AAAA,QACC,MAAM,KAAK;AAAA,QACX,UAAU,KAAK;AAAA,QAIf,SAAS,MAAM,aAAa;AAAA,QAC5B,gBAAc,SAAS,SAAS;AAAA,QAChC,WAAW;AAAA,QAEV;AAAA;AAAA,IACH;AAAA,EAEJ;AAGA,SACE,qBAAC,eAAY,MAAY,cAAc,SAAS,UAC9C;AAAA,wBAAC,sBAAmB,SAAO,MAGzB,+BAAC,YAAO,MAAK,UAAS,UAAoB,WAAW,UAAU,iBAAe,MAC3E;AAAA,aAAO,oBAAC,QAAK,WAAW,WAAW,IAAK;AAAA,MACzC,oBAAC,UAAK,WAAU,YAAY,eAAK,OAAM;AAAA,MACtC;AAAA,MAKD;AAAA,QAAC;AAAA;AAAA,UACC,WAAW;AAAA,YACT;AAAA,YACA,SAAS;AAAA,YACT,QAAQ;AAAA,UACV;AAAA;AAAA,MACF;AAAA,OACF,GACF;AAAA,IACA,oBAAC,sBAAmB,WAAU,yGAC5B,8BAAC,SAAI,WAAU,gCACZ,eAAK,UAAU,IAAI,CAAC,UACnB;AAAA,MAAC;AAAA;AAAA,QAEC,MAAM;AAAA,QACN,OAAO,QAAQ;AAAA,QACf;AAAA,QACA;AAAA;AAAA,MAJK,MAAM;AAAA,IAKb,CACD,GACH,GACF;AAAA,KACF;AAEJ;","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/shell/sidebar-menu-item.tsx"],"sourcesContent":["\"use client\";\n\nimport { ChevronRight } from \"lucide-react\";\nimport { useEffect, useState } from \"react\";\nimport { Badge } from \"../components/ui/badge.js\";\nimport { Collapsible, CollapsibleContent, CollapsibleTrigger } from \"../components/ui/collapsible.js\";\nimport { Tooltip, TooltipContent, TooltipTrigger } from \"../components/ui/tooltip.js\";\nimport { cn } from \"../lib/cn.js\";\nimport { useShell } from \"./shell-context.js\";\nimport { ShellNavLink } from \"./shell-link.js\";\nimport type { NavBadgeVariant, NavItem } from \"./types.js\";\nimport { isNavBranchActive, isNavItemActive } from \"./utils.js\";\n\n/** Variant badge của NavItem → variant của primitive Badge (tên trùng nhau, map tường minh cho an toàn). */\nconst BADGE_VARIANT: Record<NavBadgeVariant, \"default\" | \"destructive\" | \"warning\" | \"success\"> = {\n default: \"default\",\n destructive: \"destructive\",\n warning: \"warning\",\n success: \"success\"\n};\n\n/**\n * Vòng focus cho mục đã khoá.\n *\n * Mục khoá vẫn nằm trong tab order (`tabIndex={0}` + `aria-disabled`) theo đúng khuyến nghị\n * của ARIA APG: người dùng bàn phím phải *biết* mục đó tồn tại và đang bị khoá, thay vì mục\n * lặng lẽ biến mất. Không có href và không có handler nên Enter/click không đi đâu cả.\n */\nconst FOCUS_RING = \"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring\";\n\nexport interface SidebarMenuItemProps {\n item: NavItem;\n /** Sidebar đang thu gọn (chỉ icon) — chỉ áp dụng cho mục cấp 0. */\n collapsed?: boolean;\n /** Cấp lồng (0 = gốc). Cấp > 0 thụt lề và không có icon vuông. */\n depth?: number;\n /**\n * Tập mục để đối chiếu khớp CHÍNH XÁC pathname — nên truyền toàn bộ mục của app\n * hiện tại. Thiếu nó, `/tasks` sẽ vẫn sáng khi đang ở `/tasks/inbox`.\n */\n siblings?: NavItem[];\n /** Gọi khi người dùng bấm vào một LINK thật (không gọi khi chỉ mở/đóng nhánh con) —\n * `MobileMenu` dùng để tự đóng. */\n onNavigate?: () => void;\n className?: string;\n}\n\n/**\n * Một mục menu sidebar: active matching thông minh, badge số, mục con `Collapsible`,\n * khi sidebar thu gọn thì hiện `Tooltip` + chấm badge.\n */\nexport function SidebarMenuItem({\n item,\n collapsed = false,\n depth = 0,\n siblings = [],\n onNavigate,\n className\n}: SidebarMenuItemProps) {\n const { pathname, searchParams } = useShell();\n\n const active = isNavItemActive(item, pathname, siblings, searchParams);\n const branchActive = isNavBranchActive(item, pathname, siblings, searchParams);\n const hasChildren = Boolean(item.children?.length);\n\n const [open, setOpen] = useState(branchActive);\n\n // Điều hướng tới một mục con → tự mở nhánh chứa nó.\n useEffect(() => {\n if (branchActive) setOpen(true);\n }, [branchActive]);\n\n const Icon = item.icon;\n const disabled = Boolean(item.disabled);\n const rowActive = active || (branchActive && !hasChildren);\n\n const rowClass = cn(\n \"group relative flex w-full items-center gap-2.5 rounded-md px-2 py-(--nav-item-py) text-sm\",\n \"transition-colors duration-150 ease-out\",\n depth > 0 && \"pl-8 text-[13px]\",\n rowActive ? \"bg-sidebar-accent font-medium text-sidebar-accent-foreground\" : \"text-sidebar-foreground/70\",\n // Mục đã khoá thì KHÔNG gắn hover — đổi màu khi rê chuột là hứa hẹn bấm được.\n !disabled && !rowActive && \"hover:bg-sidebar-accent/60 hover:text-sidebar-foreground\",\n // Mục khoá tô bằng MỘT bậc chữ mờ hơn, KHÔNG phải `opacity-50` chồng lên màu sẵn có.\n // Vì sao: mục khoá vẫn nằm trong tab order (`role=\"link\" aria-disabled tabIndex={0}` — xem\n // chú thích FOCUS_RING) nên KHÔNG được hưởng miễn trừ \"thành phần vô hiệu\" của WCAG 1.4.3,\n // mà `opacity-50` chồng lên `text-sidebar-foreground/70` cho độ mờ thực 35% → 2,28:1 (light)\n // / 3,10:1 (dark) ✗ cho chữ, và 27,5% trên icon → 1,86:1 / 2,39:1 ✗.\n // `--muted-foreground` là bậc chữ phụ đã đo cho đúng vai trò này: 5,01:1 (light) / 5,35:1\n // (dark) trên `--sidebar` ✓. Tín hiệu \"khoá\" vẫn còn nguyên ở con trỏ và ở `aria-disabled`.\n disabled && \"cursor-not-allowed text-muted-foreground\",\n className\n );\n\n /**\n * Màu icon/vạch active dùng `--sidebar-accent-foreground`, KHÔNG dùng `--app-accent`.\n *\n * Accent mặc định (= `--primary` = gold #E8B824) chỉ đạt **1,70:1** trên nền hàng active\n * (`--sidebar-accent` #FBF5DC) ở light — dưới ngưỡng 3:1 của WCAG 1.4.11 cho thành phần\n * phi-văn-bản, nên vạch chỉ mục active gần như vô hình đúng lúc nó phải nói lên trạng thái.\n * (Ở dark thì gold #F5D060 trên #1E2128 đạt 10,8:1 — chỉ light hỏng.)\n * `--sidebar-accent-foreground` đạt 17,3:1 (light) và 15,1:1 (dark), lại là token semantic\n * nên app đổi theme vẫn đúng. Cùng cách xử lý mà `stat-card.tsx` đã chọn (`text-foreground`).\n *\n * Muốn vạch active mang màu riêng của app trở lại thì phải sửa ở tầng token: cho\n * `--app-accent` một bậc đủ tối ở light (vd gold-500 #7A5800 = 6,0:1), rồi mới quay lại\n * `bg-app-accent` ở đây.\n */\n /*\n * ── ⚠ ICON KHÔNG BAO GIỜ ĐƯỢC TÔ BẰNG MÀU CÓ ALPHA ──────────────────────────────────────\n * Icon `lucide-react` là NHIỀU phần tử `<path>`/`<rect>`/`<circle>` vẽ bằng NÉT\n * (`stroke=\"currentColor\"`), và ở khá nhiều icon các nét ấy CẮT NHAU. Nét bán trong suốt chồng\n * lên nhau thì chỗ giao được tô hai lần — nó đậm hơn phần còn lại, và icon lộ ra những vệt nối\n * mà người thiết kế không vẽ.\n *\n * Đo được trên `account.dev.comitor.ai`: hàng \"Danh mục sản phẩm\" dùng icon `Boxes` — **12**\n * phần tử con, chồng nhau chằng chịt — nên vệt nối hiện rõ; `User` hay `KeyRound` (2 phần tử,\n * không cắt nhau) thì gần như không thấy, và hàng ACTIVE thì không bao giờ thấy vì nó tô đặc.\n * Đó là lý do lỗi này sống lâu: nó chỉ lộ ở đúng vài icon, ở đúng trạng thái không-active.\n *\n * Vì vậy `text-sidebar-foreground/55` (đã phẳng ra `rgb(120,122,124)`, 4,13:1) được thay bằng\n * `text-muted-foreground` — ĐẶC, `rgb(126,130,144)`, **3,67:1** trên `--sidebar`, vẫn trên\n * ngưỡng 3:1 của WCAG 1.4.11 cho thành phần phi-văn-bản. Cùng token mà nhánh `disabled` ngay\n * dưới và `workspace-switcher.tsx` đã chọn, và cùng lý do: bậc màu ĐO ĐƯỢC thay cho alpha chồng.\n *\n * ⚠ Icon của hàng thường nay TRÙNG MÀU với icon hàng khoá. Chấp nhận được: tín hiệu \"khoá\" chưa\n * bao giờ nằm ở icon — nó ở màu CHỮ (`text-muted-foreground` so với `text-sidebar-foreground/70`),\n * ở con trỏ `cursor-not-allowed`, và ở `aria-disabled`.\n *\n * ⚠ Luật này áp cho MỌI icon lucide trong gói, không riêng chỗ này: đừng dùng `/NN`,\n * `opacity-*`, hay bất kỳ màu có alpha nào lên một `<svg>` icon. Cần nhạt hơn thì lấy một BẬC\n * MÀU đặc.\n */\n const iconClass = cn(\n \"size-4 shrink-0\",\n active ? \"text-sidebar-accent-foreground\" : \"text-muted-foreground\",\n !disabled && !active && \"group-hover:text-sidebar-foreground\"\n );\n\n /*\n * KHÔNG có vạch dọc bên trái ở hàng active.\n *\n * Bản duyệt có HAI cách vẽ mục active và chúng khác nhau: `SidebarNav` của trang tài liệu dùng\n * `border-l-2 border-gold-300`, còn khung ứng dụng ở mục **Patterns** chỉ tô nền\n * (`bg-gold-300/15 text-foreground`), không vạch. Shell này là bản dựng của Patterns nên theo\n * Patterns.\n *\n * Bỏ vạch KHÔNG làm mất tín hiệu trạng thái: hàng active vẫn khác hàng thường ở nền\n * (`--sidebar-accent`), ở màu chữ (`--sidebar-accent-foreground` thay cho\n * `--sidebar-foreground/70`), ở `font-medium`, và ở `aria-current=\"page\"` cho trình đọc màn\n * hình. WCAG 1.4.11 đòi tương phản cho thông tin thị giác, không đòi một hình dạng cụ thể.\n */\n\n const badge =\n item.badge === undefined || item.badge === null || item.badge === \"\" ? null : (\n <Badge variant={BADGE_VARIANT[item.badgeVariant ?? \"default\"]} size=\"sm\" className=\"ml-auto shrink-0\">\n {item.badge}\n </Badge>\n );\n\n /* ── Sidebar thu gọn: icon vuông + tooltip ─────────────────────────────── */\n if (collapsed && depth === 0) {\n const tileClass = cn(\n \"relative flex size-9 items-center justify-center rounded-md\",\n \"transition-colors duration-150 ease-out\",\n /*\n * ⚠ `text-muted-foreground` (ĐẶC), KHÔNG phải `text-sidebar-foreground/70`.\n *\n * Ô này KHÔNG chứa chữ — chỉ một `<Icon>` không có màu riêng, nên nó lấy màu qua\n * `currentColor` TỪ ĐÂY. Một màu có alpha ở đây là alpha chảy thẳng xuống nét icon, và nét\n * cắt nhau bị tô hai lần → lộ vệt nối. Xem `iconClass` ở nhánh mở rộng và\n * `docs/hop-dong-mau.md` §7b.\n *\n * ⚠ Đây là ca KẾ THỪA, và nó là nửa dễ sót của luật: bản vá đầu chỉ chữa chỗ đặt màu THẲNG\n * lên icon, còn ô này vẫn hỏng thêm một bản phát hành nữa. Đo được trên\n * `starter.dev.comitor.ai`: thanh bên mở rộng 0/4 icon có alpha, THU GỌN thì 4/4 ở `0.7`.\n * Nên khi soát, đừng chỉ tìm màu trên chính `<svg>` — tìm cả màu của thẻ CHA bọc nó.\n *\n * Ô chỉ có icon nên ngưỡng là 3:1 của WCAG 1.4.11, không phải 4,5:1 của chữ.\n */\n active ? \"bg-sidebar-accent text-sidebar-accent-foreground\" : \"text-muted-foreground\",\n !disabled && !active && \"hover:bg-sidebar-accent/60 hover:text-sidebar-foreground\",\n // Cùng lý do với `rowClass` ở trên: bậc chữ mờ hơn thay cho `opacity-50`.\n disabled && \"cursor-not-allowed text-muted-foreground\"\n );\n\n const tileBody = (\n <>\n {Icon ? <Icon className=\"size-4\" /> : null}\n {/* Thu gọn không đủ chỗ cho số → chỉ chấm báo có nội dung mới */}\n {item.badge ? <span className=\"absolute right-1.5 top-1.5 size-1.5 rounded-full bg-destructive\" /> : null}\n </>\n );\n\n return (\n <Tooltip>\n <TooltipTrigger asChild>\n {disabled ? (\n // Mục khoá: KHÔNG render <a> — xem chú thích ở nhánh mục lá bên dưới.\n <span\n role=\"link\"\n aria-disabled=\"true\"\n aria-label={item.label}\n tabIndex={0}\n className={cn(tileClass, FOCUS_RING)}\n >\n {tileBody}\n </span>\n ) : (\n <ShellNavLink\n href={item.href}\n external={item.external}\n onClick={() => onNavigate?.()}\n aria-current={active ? \"page\" : undefined}\n aria-label={item.label}\n className={tileClass}\n >\n {tileBody}\n </ShellNavLink>\n )}\n </TooltipTrigger>\n <TooltipContent side=\"right\" className=\"flex items-center gap-2\">\n <span>{item.label}</span>\n {item.badge ? <span className=\"text-xs opacity-70\">{item.badge}</span> : null}\n </TooltipContent>\n </Tooltip>\n );\n }\n\n /* ── Mục lá ────────────────────────────────────────────────────────────── */\n if (!hasChildren) {\n const leafBody = (\n <>\n {Icon ? <Icon className={iconClass} /> : null}\n <span className=\"truncate\">{item.label}</span>\n {badge}\n </>\n );\n\n /*\n * Mục khoá phải dựng bằng <span role=\"link\" aria-disabled>, KHÔNG phải <a> tô mờ:\n * `pointer-events-none` chỉ chặn CHUỘT — người dùng bàn phím vẫn Tab tới thẻ <a> và\n * Enter vẫn điều hướng, lại không có tín hiệu nào cho trình đọc màn hình. Bỏ hẳn href\n * là cách duy nhất chặn được cả hai đường.\n */\n if (disabled) {\n return (\n <span role=\"link\" aria-disabled=\"true\" tabIndex={0} className={cn(rowClass, FOCUS_RING)}>\n {leafBody}\n </span>\n );\n }\n\n return (\n <ShellNavLink\n href={item.href}\n external={item.external}\n // Phải gọi `onNavigate` ở ĐÂY nữa, không chỉ ở nhánh thu gọn: `MobileMenu` truyền\n // `onNavigate={closeMenu}` cho mọi mục, thiếu nó thì bấm một mục điều hướng thường\n // trong menu mobile mà menu không đóng.\n onClick={() => onNavigate?.()}\n aria-current={active ? \"page\" : undefined}\n className={rowClass}\n >\n {leafBody}\n </ShellNavLink>\n );\n }\n\n /* ── Mục có con ────────────────────────────────────────────────────────── */\n return (\n <Collapsible open={open} onOpenChange={setOpen} disabled={disabled}>\n <CollapsibleTrigger asChild>\n {/* `disabled` thật trên <button>: bỏ khỏi tab order và chặn cả Enter/Space,\n khác hẳn `pointer-events-none` vốn chỉ chặn chuột. */}\n <button type=\"button\" disabled={disabled} className={rowClass} aria-expanded={open}>\n {Icon ? <Icon className={iconClass} /> : null}\n <span className=\"truncate\">{item.label}</span>\n {badge}\n {/* `text-muted-foreground` chứ không `text-sidebar-foreground/40`: mũi tên MANG THÔNG TIN\n (nhánh đang đóng hay mở) nên chịu ngưỡng 3:1 của WCAG 1.4.11, mà /40 chỉ được\n 2,60:1 ở light ✗ (dark 3,64:1 — lại một chỗ mỗi theme hỏng một nửa).\n `--muted-foreground`: 5,01:1 (light) · 5,35:1 (dark) trên `--sidebar` ✓ */}\n <ChevronRight\n className={cn(\n \"ml-auto size-3.5 shrink-0 text-muted-foreground transition-transform duration-150\",\n badge && \"ml-1\",\n open && \"rotate-90\"\n )}\n />\n </button>\n </CollapsibleTrigger>\n <CollapsibleContent className=\"overflow-hidden data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down\">\n <div className=\"mt-0.5 flex flex-col gap-0.5\">\n {item.children?.map((child) => (\n <SidebarMenuItem\n key={child.id}\n item={child}\n depth={depth + 1}\n siblings={siblings}\n onNavigate={onNavigate}\n />\n ))}\n </div>\n </CollapsibleContent>\n </Collapsible>\n );\n}\n"],"mappings":"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","names":[]}
|
package/dist/shell/sidebar.js
CHANGED
|
@@ -64,12 +64,12 @@ function Sidebar({
|
|
|
64
64
|
onClick: toggleSidebar,
|
|
65
65
|
"aria-label": sidebarCollapsed ? labels.expandSidebar : labels.collapseSidebar,
|
|
66
66
|
className: cn(
|
|
67
|
-
"flex items-center gap-2 rounded-md text-sidebar-foreground/60",
|
|
67
|
+
"group flex items-center gap-2 rounded-md text-sidebar-foreground/60",
|
|
68
68
|
"transition-colors duration-150 ease-out hover:bg-sidebar-accent hover:text-sidebar-foreground",
|
|
69
69
|
sidebarCollapsed ? "size-9 justify-center" : "h-8 w-full px-2 text-xs"
|
|
70
70
|
),
|
|
71
|
-
children: sidebarCollapsed ? /* @__PURE__ */ jsx(PanelLeftOpen, { className: "size-4" }) : /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
72
|
-
/* @__PURE__ */ jsx(PanelLeftClose, { className: "size-4" }),
|
|
71
|
+
children: sidebarCollapsed ? /* @__PURE__ */ jsx(PanelLeftOpen, { className: "size-4 text-muted-foreground group-hover:text-sidebar-foreground" }) : /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
72
|
+
/* @__PURE__ */ jsx(PanelLeftClose, { className: "size-4 text-muted-foreground group-hover:text-sidebar-foreground" }),
|
|
73
73
|
/* @__PURE__ */ jsx("span", { className: "truncate", children: labels.collapseSidebarShort })
|
|
74
74
|
] })
|
|
75
75
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/shell/sidebar.tsx"],"sourcesContent":["\"use client\";\n\nimport { PanelLeftClose, PanelLeftOpen } from \"lucide-react\";\nimport type { ReactNode } from \"react\";\nimport { Tooltip, TooltipContent, TooltipTrigger } from \"../components/ui/tooltip.js\";\nimport { cn } from \"../lib/cn.js\";\nimport { useShell } from \"./shell-context.js\";\nimport { SidebarGroup } from \"./sidebar-group.js\";\nimport { appAccentStyle, flattenNavItems } from \"./utils.js\";\nimport { WorkspaceSwitcher } from \"./workspace-switcher.js\";\n\nexport interface SidebarProps {\n className?: string;\n /** Slot ngay dưới WorkspaceSwitcher (vd nút \"Tạo mới\", ô tìm nhanh). */\n header?: ReactNode;\n /** Slot đáy sidebar (dung lượng gói, \"Mời thành viên\", phiên bản app…). */\n footer?: ReactNode;\n /** Ẩn WorkspaceSwitcher ở đỉnh — dùng khi app đặt bộ chuyển workspace ở header. */\n showWorkspaceSwitcher?: boolean;\n /** Ẩn nút thu gọn ở đáy (khi header đã có nút này). */\n showCollapseButton?: boolean;\n}\n\n/**\n * Sidebar desktop: 256px (`--sidebar-width`) / 56px khi thu gọn (`--sidebar-width-icon`).\n * Đỉnh: `WorkspaceSwitcher` (trục 1). Giữa: menu của app hiện tại. Đáy: slot footer.\n *\n * Ẩn hẳn dưới `md` — màn hình hẹp dùng `MobileMenu`.\n *\n * KHÔNG dùng `ui/sidebar.tsx` của shadcn (spec §7.6): primitive đó quản state bằng\n * cookie + provider riêng, sẽ thành nguồn sự thật thứ hai bên cạnh `ShellProvider`.\n */\nexport function Sidebar({\n className,\n header,\n footer,\n showWorkspaceSwitcher = true,\n showCollapseButton = true\n}: SidebarProps) {\n const { nav, currentApp, workspaces, sidebarCollapsed, toggleSidebar, labels } = useShell();\n\n // Đối chiếu khớp chính xác trên TOÀN BỘ mục của app, không chỉ mục cùng nhóm:\n // \"/tasks\" nằm nhóm A và \"/tasks/inbox\" nhóm B thì vẫn phải sáng đúng một mục.\n const allItems = flattenNavItems(nav);\n\n return (\n <aside\n data-collapsed={sidebarCollapsed || undefined}\n style={appAccentStyle(currentApp)}\n className={cn(\n \"hidden shrink-0 flex-col border-r border-sidebar-border bg-sidebar md:flex\",\n \"transition-[width] duration-200 ease-out\",\n sidebarCollapsed ? \"w-sidebar-icon\" : \"w-sidebar\",\n className\n )}\n >\n {/* Không có workspace nào (app đơn tổ chức) thì bỏ hẳn dải đỉnh, không để lại ô trống. */}\n {showWorkspaceSwitcher && workspaces.length > 0 ? (\n <div\n className={cn(\n \"flex h-header shrink-0 items-center border-b border-sidebar-border\",\n sidebarCollapsed ? \"justify-center px-1\" : \"px-2\"\n )}\n >\n <WorkspaceSwitcher collapsed={sidebarCollapsed} />\n </div>\n ) : null}\n\n {header ? <div className={cn(\"shrink-0\", sidebarCollapsed ? \"px-1.5 pt-2\" : \"px-2 pt-2\")}>{header}</div> : null}\n\n <nav\n aria-label={labels.navigationLabel}\n className={cn(\"flex-1 overflow-y-auto scrollbar-thin py-3\", sidebarCollapsed ? \"px-1.5\" : \"px-2\")}\n >\n <div className={cn(\"flex flex-col\", sidebarCollapsed ? \"gap-2\" : \"gap-4\")}>\n {nav.map((group) => (\n <SidebarGroup key={group.id} group={group} collapsed={sidebarCollapsed} siblings={allItems} />\n ))}\n </div>\n </nav>\n\n {footer || showCollapseButton ? (\n <div\n className={cn(\n \"shrink-0 border-t border-sidebar-border\",\n sidebarCollapsed ? \"flex flex-col items-center gap-1 p-1.5\" : \"p-2\"\n )}\n >\n {!sidebarCollapsed && footer ? <div className=\"mb-1\">{footer}</div> : null}\n\n {showCollapseButton ? (\n <Tooltip>\n <TooltipTrigger asChild>\n <button\n type=\"button\"\n onClick={toggleSidebar}\n aria-label={sidebarCollapsed ? labels.expandSidebar : labels.collapseSidebar}\n className={cn(\n \"flex items-center gap-2 rounded-md text-sidebar-foreground/60\",\n \"transition-colors duration-150 ease-out hover:bg-sidebar-accent hover:text-sidebar-foreground\",\n sidebarCollapsed ? \"size-9 justify-center\" : \"h-8 w-full px-2 text-xs\"\n )}\n >\n {sidebarCollapsed ? (\n <PanelLeftOpen className=\"size-4\" />\n ) : (\n <>\n <PanelLeftClose className=\"size-4\" />\n {/* Chữ NHÌN THẤY dùng bản ngắn; `aria-label` và tooltip ở trên vẫn giữ bản\n dài. Xem hợp đồng của `collapseSidebarShort` trong types.ts. */}\n <span className=\"truncate\">{labels.collapseSidebarShort}</span>\n </>\n )}\n </button>\n </TooltipTrigger>\n <TooltipContent side=\"right\">\n {sidebarCollapsed ? labels.expandSidebar : labels.collapseSidebar} · Ctrl/⌘ + B\n </TooltipContent>\n </Tooltip>\n ) : null}\n </div>\n ) : null}\n </aside>\n );\n}\n"],"mappings":";AAgEU,
|
|
1
|
+
{"version":3,"sources":["../../src/shell/sidebar.tsx"],"sourcesContent":["\"use client\";\n\nimport { PanelLeftClose, PanelLeftOpen } from \"lucide-react\";\nimport type { ReactNode } from \"react\";\nimport { Tooltip, TooltipContent, TooltipTrigger } from \"../components/ui/tooltip.js\";\nimport { cn } from \"../lib/cn.js\";\nimport { useShell } from \"./shell-context.js\";\nimport { SidebarGroup } from \"./sidebar-group.js\";\nimport { appAccentStyle, flattenNavItems } from \"./utils.js\";\nimport { WorkspaceSwitcher } from \"./workspace-switcher.js\";\n\nexport interface SidebarProps {\n className?: string;\n /** Slot ngay dưới WorkspaceSwitcher (vd nút \"Tạo mới\", ô tìm nhanh). */\n header?: ReactNode;\n /** Slot đáy sidebar (dung lượng gói, \"Mời thành viên\", phiên bản app…). */\n footer?: ReactNode;\n /** Ẩn WorkspaceSwitcher ở đỉnh — dùng khi app đặt bộ chuyển workspace ở header. */\n showWorkspaceSwitcher?: boolean;\n /** Ẩn nút thu gọn ở đáy (khi header đã có nút này). */\n showCollapseButton?: boolean;\n}\n\n/**\n * Sidebar desktop: 256px (`--sidebar-width`) / 56px khi thu gọn (`--sidebar-width-icon`).\n * Đỉnh: `WorkspaceSwitcher` (trục 1). Giữa: menu của app hiện tại. Đáy: slot footer.\n *\n * Ẩn hẳn dưới `md` — màn hình hẹp dùng `MobileMenu`.\n *\n * KHÔNG dùng `ui/sidebar.tsx` của shadcn (spec §7.6): primitive đó quản state bằng\n * cookie + provider riêng, sẽ thành nguồn sự thật thứ hai bên cạnh `ShellProvider`.\n */\nexport function Sidebar({\n className,\n header,\n footer,\n showWorkspaceSwitcher = true,\n showCollapseButton = true\n}: SidebarProps) {\n const { nav, currentApp, workspaces, sidebarCollapsed, toggleSidebar, labels } = useShell();\n\n // Đối chiếu khớp chính xác trên TOÀN BỘ mục của app, không chỉ mục cùng nhóm:\n // \"/tasks\" nằm nhóm A và \"/tasks/inbox\" nhóm B thì vẫn phải sáng đúng một mục.\n const allItems = flattenNavItems(nav);\n\n return (\n <aside\n data-collapsed={sidebarCollapsed || undefined}\n style={appAccentStyle(currentApp)}\n className={cn(\n \"hidden shrink-0 flex-col border-r border-sidebar-border bg-sidebar md:flex\",\n \"transition-[width] duration-200 ease-out\",\n sidebarCollapsed ? \"w-sidebar-icon\" : \"w-sidebar\",\n className\n )}\n >\n {/* Không có workspace nào (app đơn tổ chức) thì bỏ hẳn dải đỉnh, không để lại ô trống. */}\n {showWorkspaceSwitcher && workspaces.length > 0 ? (\n <div\n className={cn(\n \"flex h-header shrink-0 items-center border-b border-sidebar-border\",\n sidebarCollapsed ? \"justify-center px-1\" : \"px-2\"\n )}\n >\n <WorkspaceSwitcher collapsed={sidebarCollapsed} />\n </div>\n ) : null}\n\n {header ? <div className={cn(\"shrink-0\", sidebarCollapsed ? \"px-1.5 pt-2\" : \"px-2 pt-2\")}>{header}</div> : null}\n\n <nav\n aria-label={labels.navigationLabel}\n className={cn(\"flex-1 overflow-y-auto scrollbar-thin py-3\", sidebarCollapsed ? \"px-1.5\" : \"px-2\")}\n >\n <div className={cn(\"flex flex-col\", sidebarCollapsed ? \"gap-2\" : \"gap-4\")}>\n {nav.map((group) => (\n <SidebarGroup key={group.id} group={group} collapsed={sidebarCollapsed} siblings={allItems} />\n ))}\n </div>\n </nav>\n\n {footer || showCollapseButton ? (\n <div\n className={cn(\n \"shrink-0 border-t border-sidebar-border\",\n sidebarCollapsed ? \"flex flex-col items-center gap-1 p-1.5\" : \"p-2\"\n )}\n >\n {!sidebarCollapsed && footer ? <div className=\"mb-1\">{footer}</div> : null}\n\n {showCollapseButton ? (\n <Tooltip>\n <TooltipTrigger asChild>\n <button\n type=\"button\"\n onClick={toggleSidebar}\n aria-label={sidebarCollapsed ? labels.expandSidebar : labels.collapseSidebar}\n /*\n * ⚠ Màu của NÚT giữ nguyên `/60`, còn ICON được tô riêng bằng token ĐẶC. Hai\n * thứ khác nhau ở đây là CỐ Ý, và đây là chỗ duy nhất trong shell phải tách:\n *\n * · nút này chứa CẢ CHỮ khi mở rộng (`text-xs`, nhãn \"Thu gọn\"), nên màu của nó\n * chịu ngưỡng VĂN BẢN 4,5:1. `/60` phẳng ra `#6C6E71` = **4,90:1** ✓, còn\n * `--muted-foreground` chỉ **3,67:1** ✗ — đổi màu cả nút là làm rớt AA đúng\n * cái nhãn đang đạt.\n * · nhưng `/60` là màu có ALPHA, mà icon lấy màu qua `currentColor` — nét cắt\n * nhau sẽ bị tô hai lần và lộ vệt nối (xem `docs/hop-dong-mau.md` §7b, ca KẾ\n * THỪA). Icon chỉ chịu ngưỡng 3:1 nên `--muted-foreground` đủ cho nó.\n *\n * `group` + `group-hover:` là cái giá của việc tách: icon có màu riêng thì\n * `hover:text-sidebar-foreground` của nút không với tới nó nữa.\n */\n className={cn(\n \"group flex items-center gap-2 rounded-md text-sidebar-foreground/60\",\n \"transition-colors duration-150 ease-out hover:bg-sidebar-accent hover:text-sidebar-foreground\",\n sidebarCollapsed ? \"size-9 justify-center\" : \"h-8 w-full px-2 text-xs\"\n )}\n >\n {sidebarCollapsed ? (\n <PanelLeftOpen className=\"size-4 text-muted-foreground group-hover:text-sidebar-foreground\" />\n ) : (\n <>\n <PanelLeftClose className=\"size-4 text-muted-foreground group-hover:text-sidebar-foreground\" />\n {/* Chữ NHÌN THẤY dùng bản ngắn; `aria-label` và tooltip ở trên vẫn giữ bản\n dài. Xem hợp đồng của `collapseSidebarShort` trong types.ts. */}\n <span className=\"truncate\">{labels.collapseSidebarShort}</span>\n </>\n )}\n </button>\n </TooltipTrigger>\n <TooltipContent side=\"right\">\n {sidebarCollapsed ? labels.expandSidebar : labels.collapseSidebar} · Ctrl/⌘ + B\n </TooltipContent>\n </Tooltip>\n ) : null}\n </div>\n ) : null}\n </aside>\n );\n}\n"],"mappings":";AAgEU,SAyDU,UAzDV,KAyDU,YAzDV;AA9DV,SAAS,gBAAgB,qBAAqB;AAE9C,SAAS,SAAS,gBAAgB,sBAAsB;AACxD,SAAS,UAAU;AACnB,SAAS,gBAAgB;AACzB,SAAS,oBAAoB;AAC7B,SAAS,gBAAgB,uBAAuB;AAChD,SAAS,yBAAyB;AAuB3B,SAAS,QAAQ;AAAA,EACtB;AAAA,EACA;AAAA,EACA;AAAA,EACA,wBAAwB;AAAA,EACxB,qBAAqB;AACvB,GAAiB;AACf,QAAM,EAAE,KAAK,YAAY,YAAY,kBAAkB,eAAe,OAAO,IAAI,SAAS;AAI1F,QAAM,WAAW,gBAAgB,GAAG;AAEpC,SACE;AAAA,IAAC;AAAA;AAAA,MACC,kBAAgB,oBAAoB;AAAA,MACpC,OAAO,eAAe,UAAU;AAAA,MAChC,WAAW;AAAA,QACT;AAAA,QACA;AAAA,QACA,mBAAmB,mBAAmB;AAAA,QACtC;AAAA,MACF;AAAA,MAGC;AAAA,iCAAyB,WAAW,SAAS,IAC5C;AAAA,UAAC;AAAA;AAAA,YACC,WAAW;AAAA,cACT;AAAA,cACA,mBAAmB,wBAAwB;AAAA,YAC7C;AAAA,YAEA,8BAAC,qBAAkB,WAAW,kBAAkB;AAAA;AAAA,QAClD,IACE;AAAA,QAEH,SAAS,oBAAC,SAAI,WAAW,GAAG,YAAY,mBAAmB,gBAAgB,WAAW,GAAI,kBAAO,IAAS;AAAA,QAE3G;AAAA,UAAC;AAAA;AAAA,YACC,cAAY,OAAO;AAAA,YACnB,WAAW,GAAG,8CAA8C,mBAAmB,WAAW,MAAM;AAAA,YAEhG,8BAAC,SAAI,WAAW,GAAG,iBAAiB,mBAAmB,UAAU,OAAO,GACrE,cAAI,IAAI,CAAC,UACR,oBAAC,gBAA4B,OAAc,WAAW,kBAAkB,UAAU,YAA/D,MAAM,EAAmE,CAC7F,GACH;AAAA;AAAA,QACF;AAAA,QAEC,UAAU,qBACT;AAAA,UAAC;AAAA;AAAA,YACC,WAAW;AAAA,cACT;AAAA,cACA,mBAAmB,2CAA2C;AAAA,YAChE;AAAA,YAEC;AAAA,eAAC,oBAAoB,SAAS,oBAAC,SAAI,WAAU,QAAQ,kBAAO,IAAS;AAAA,cAErE,qBACC,qBAAC,WACC;AAAA,oCAAC,kBAAe,SAAO,MACrB;AAAA,kBAAC;AAAA;AAAA,oBACC,MAAK;AAAA,oBACL,SAAS;AAAA,oBACT,cAAY,mBAAmB,OAAO,gBAAgB,OAAO;AAAA,oBAgB7D,WAAW;AAAA,sBACT;AAAA,sBACA;AAAA,sBACA,mBAAmB,0BAA0B;AAAA,oBAC/C;AAAA,oBAEC,6BACC,oBAAC,iBAAc,WAAU,oEAAmE,IAE5F,iCACE;AAAA,0CAAC,kBAAe,WAAU,oEAAmE;AAAA,sBAG7F,oBAAC,UAAK,WAAU,YAAY,iBAAO,sBAAqB;AAAA,uBAC1D;AAAA;AAAA,gBAEJ,GACF;AAAA,gBACA,qBAAC,kBAAe,MAAK,SAClB;AAAA,qCAAmB,OAAO,gBAAgB,OAAO;AAAA,kBAAgB;AAAA,mBACpE;AAAA,iBACF,IACE;AAAA;AAAA;AAAA,QACN,IACE;AAAA;AAAA;AAAA,EACN;AAEJ;","names":[]}
|
package/package.json
CHANGED