diff --git a/.gitignore b/.gitignore index ad3bd67..8a71ee2 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,5 @@ web-client/dist/ web-server/uploads/ docs/* +output/ +tmp/ \ No newline at end of file diff --git a/design-language.md b/design-language.md new file mode 100644 index 0000000..d01a11c --- /dev/null +++ b/design-language.md @@ -0,0 +1,770 @@ +--- +type: foundation-layer +layer: design-language +status: draft +version: 0.4 +phase: icon-subphase +last-bump: 2026-06-01 +source-priority: + - foundation-4-layer + - research-method-d + - em-propose +tags: + - brand + - design-language + - foundation +--- + +> [!WARNING] **[DRAFT — chưa ratify, KHÔNG adopt cho production deliverable]** +> File này đang xây (Phase 1-3 xong, còn Phase 4-5). Status sẽ chuyển `ratified` khi Phase 5 close + audit cycle pass. Trong khi đang xây: tham khảo OK, KHÔNG dùng làm authority binding cho output Samsung/Nichirin/ODM. + +# Design Language Phenikaa-X v0.4 + +> [!NOTE] **Errata mới nhất: v0.4.4 (2026-06-10)** — xem [Lịch sử phiên bản](#lịch-sử-phiên-bản). Nhãn H1 + frontmatter giữ `v0.4` theo chính sách errata-no-bump; patch level thực ở Lịch sử. + +Hệ quy chiếu thiết kế universal cross-surface (Office/Web/HMI/Physical). Layer 1 = nguyên tắc + token + bố cục cơ bản, vendor-agnostic. Layer 2 (Implementation per platform) defer team dev tự build. + +## 1. Nguyên tắc nền tảng + +> [!NOTE] Thuật ngữ đoạn dưới: **token** = đơn vị thiết kế nguyên tử dùng lại được (màu, cỡ chữ, khoảng cách...) định nghĩa một lần, áp dụng mọi nơi · **thứ bậc thị giác** = cách bố cục dẫn mắt người đọc theo mức độ quan trọng · **WCAG 2.2 AA** = chuẩn quốc tế về khả năng tiếp cận nội dung số. + +Năm nguyên tắc định hình mọi quyết định thiết kế Phenikaa-X trên mọi bề mặt (tài liệu, web, giao diện máy, biển hiệu). Ba nguyên tắc đầu truy nguyên giá trị cốt lõi của tổ chức — giữ bản sắc. Hai nguyên tắc sau bảo đảm phạm vi dùng chung của Layer 1. Mỗi nguyên tắc gồm: định nghĩa + cách áp dụng + nguồn. + +### 1.1 Minh bạch + +Thiết kế nói thật. Thông tin quan trọng hiện rõ, không trang trí gây nhiễu, thứ bậc thị giác phản ánh đúng mức độ quan trọng của nội dung. + +**Áp dụng:** Dashboard cycle-time → số liệu chính nổi bật, bỏ trang trí thừa trong biểu đồ. Báo cáo FAT → kết quả đạt/không-đạt rõ, không màu mè che dữ liệu. Slide → một thông điệp chính mỗi trang. + +**Nguồn:** `identity-from-raw.md` §11.2 (Trung thực và minh bạch) + +### 1.2 Rõ ràng + +Mỗi yếu tố thiết kế phải có mục đích. Ưu tiên đọc-hiểu nhanh, súc tích, đúng trọng tâm — không thêm yếu tố không phục vụ thông điệp. + +**Áp dụng:** Proposal → tiêu đề phân cấp rõ, đoạn ngắn. Bảng số liệu → chỉ giữ cột thật cần. Loại bỏ chi tiết trang trí không mang nghĩa. + +**Nguồn:** `applied-from-raw.md` §1 (giọng viết: rõ ràng + súc tích + đúng trọng tâm) + `[fact]` Fluent 2 "Coherent" + Carbon clarity — [Fluent 2 design principles](https://fluent2.microsoft.design/design-principles) + +### 1.3 Trách nhiệm + +Thiết kế nhất quán, chuẩn mực, đáng tin qua mọi sản phẩm. Cùng một token (màu, font, khoảng cách) áp dụng nhất quán — người nhận nhận ra "đây là Phenikaa-X" trước cả khi thấy logo. + +**Áp dụng:** Mọi output dùng chung bảng màu + thang chữ + thang khoảng cách. Không tùy biến tự phát mỗi tài liệu. + +**Nguồn:** `identity-from-raw.md` §11.3 (Trách nhiệm cam kết đến cùng) + §13 (6 Hexagon văn hóa — Kỷ luật hexagon #2) + `[fact]` Carbon "builds consistency" — [Carbon — what is Carbon](https://carbondesignsystem.com/all-about-carbon/what-is-carbon/) + +### 1.4 Có thể truy cập + +Thiết kế cho mọi người dùng, không loại trừ ai. Tuân chuẩn WCAG 2.2 AA: đủ tương phản màu, cỡ chữ đọc được, không chỉ dựa vào màu để truyền thông tin. + +**Áp dụng:** Cặp màu chữ trên nền đạt tỷ lệ tương phản ≥ 4.5:1 (chữ thường). Trạng thái phân biệt thêm bằng icon hoặc chữ, không chỉ bằng màu. Chữ thân bài không nhỏ hơn ngưỡng đọc được. + +**Nguồn:** `identity-from-raw.md` §11.7 (Tôn trọng và trao quyền) + `[fact]` Carbon "Carbon is inclusive" — [Carbon — what is Carbon](https://carbondesignsystem.com/all-about-carbon/what-is-carbon/) + +### 1.5 Đồng bộ đa nền tảng + +Một hệ token chung phục vụ mọi bề mặt: tài liệu Office, web, giao diện máy (HMI), biển hiệu vật lý. Layer 1 định nghĩa token độc lập nền tảng; Layer 2 (team dev) ánh xạ sang định dạng từng nền tảng. + +**Áp dụng:** Màu, font, khoảng cách định nghĩa một lần ở Layer 1 → xuất sang `.thmx` (Office), biến CSS (web), theme giao diện máy. Không định nghĩa lại token riêng cho mỗi nền tảng. + +**Nguồn:** `[fact]` Fluent 2 token "common language" đồng bộ đa nền tảng — [Fluent 2 design tokens](https://fluent2.microsoft.design/design-tokens) + phạm vi Layer 1 (xem `spec-design-language-stream.md`) + +## 2. Token + +### 2.1 Color + +> [!NOTE] Thuật ngữ: **token** = đơn vị màu định nghĩa một lần dùng mọi nơi · **scale/thang** = các bậc đậm-nhạt của một màu · **semantic alias** = tên theo công dụng (vd `text.primary`) trỏ tới một bậc màu cụ thể · **tỷ lệ tương phản** = chỉ số WCAG đo độ đọc được của chữ trên nền. + +Hệ màu canonical Layer 1 — nguồn duy nhất cho mọi bề mặt. Mô hình 3 tầng (kế thừa concept tốt từ prior art cycle-time, sửa màu + chuẩn scope): + +| Tầng | Trách nhiệm | Sống ở đâu | +|---|---|---| +| **L1 — token universal** | Brand scale + neutral + semantic + theme alias (file này) | `design-language.md` §2.1 | +| **L2 — platform** | Ánh xạ token sang `.thmx`/CSS/HMI theme + bộ data-viz dashboard | Team dev / `40-knowledge/cycle-time-dashboard/` | +| **L3 — project** | Override white-label ODM/khách | `30-projects//.../dashboard-config` | + +Thang xanh + cam **neo trên màu chính thức Tập đoàn** (Trang 18-19 guideline). Neutral + semantic **thiết kế mới** (palette Tập đoàn không có xám/semantic) — hài hoà brand, đạt WCAG. + +#### 2.1.1 Brand color + +**Thang Xanh** (neo Deep Blue chủ đạo + Dark Blue Phenikaa-X): + +| Token | Hex | Nguồn | Vai trò | +|---|---|---|---| +| `blue-50` | `#EAEDF5` | tint mới | Nền hover/selected (light) | +| `blue-100` | `#C5CCE0` | tint mới | Nền nhấn nhẹ | +| `blue-200` | `#8C9AC4` | tint mới | Link/accent trên nền tối | +| `blue-300` | `#6E7CA8` | tint mới | UI lớn trên nền tối | +| `blue-500` | `#223771` | **official Deep Blue** | **Chủ đạo** — heading, link, nút chính | +| `blue-700` | `#1A2A57` | shade mới | Nhấn đậm | +| `blue-900` | `#1e2243` | **official Dark Blue** | Đậm nhất — footer, nền dark mode | + +> Màu xanh official ngả cyan của Tập đoàn (`#03428E`, `#004E96`, `#00599D` — Trang 18) giữ làm **xanh accent đặc biệt** (sự kiện, đồ hoạ môi trường). Không đưa vào thang chính vì hue lệch sang cyan. + +**Thang Cam** (4 bậc official + tint/shade): + +| Token | Hex | Nguồn | Vai trò | +|---|---|---|---| +| `orange-100` | `#FCD9C4` | tint mới | Nền cảnh báo nhẹ | +| `orange-200` | `#F7941E` | **official** cam nhạt | Accent sáng (nền tối) | +| `orange-300` | `#F58220` | **official** | Accent | +| `orange-400` | `#F37021` | **official** | Accent đậm | +| `orange-500` | `#f26522` | **official Sun Orange** | **Accent chủ đạo** — highlight, CTA | +| `orange-700` | `#C44E14` | shade mới | Nhấn đậm | + +#### 2.1.2 Neutral (thiết kế mới — xám ngả xanh hài hoà brand) + +| Token | Hex | Vai trò chính | +|---|---|---| +| `neutral-0` | `#FFFFFF` | Nền cơ sở (light) | +| `neutral-50` | `#F7F8FB` | Nền nâng nhẹ | +| `neutral-100` | `#EEEDF6` | = Brilliant White brand tint | +| `neutral-200` | `#DDDFEA` | Nền phân vùng | +| `neutral-300` | `#C5C8D8` | Viền trang trí (divider) | +| `neutral-400` | `#9FA3B8` | Icon phụ, disabled | +| `neutral-450` | `#8C90A8` | **Viền thiết yếu** (ô input — đạt 3:1) | +| `neutral-500` | `#757A91` | Chữ cấp 3 (caption, large) | +| `neutral-550` | `#6A6F88` | Viền thiết yếu dark mode (= `border.strong` dark) | +| `neutral-600` | `#565B70` | Chữ phụ (đạt AA) | +| `neutral-700` | `#3C4054` | Viền thiết yếu (dark mode area) | +| `neutral-800` | `#272A3A` | Nền thẻ (dark) | +| `neutral-900` | `#161824` | Chữ chính (light) · nền cơ sở (dark) | + +#### 2.1.3 Semantic (thiết kế mới — căn theo IEC 60204-1 + ISO 3864) + +Màu trạng thái tuân quy ước an toàn công nghiệp (xanh lá = an toàn/chạy, đỏ = dừng/cấm, vàng = cảnh báo, xanh dương = thông tin) để không mâu thuẫn HMI/biển báo. Mỗi màu có biến thể light + dark. + +| Token | Light | Dark | Ý nghĩa | +|---|---|---|---| +| `success` | `#2E7D32` | `#66BB6A` | An toàn / đạt / chạy | +| `warning` | `#9A5B00` | `#FFB74D` | Cảnh báo / cần để ý | +| `error` | `#C62828` | `#EF9A9A` | Lỗi / dừng / vượt ngưỡng | +| `info` | `#1565C0` | `#90CAF9` | Thông tin / bắt buộc | + +> Quy tắc: trạng thái KHÔNG chỉ phân biệt bằng màu — luôn kèm icon/chữ (per WCAG 1.4.1 + color-blindness, research D §5.3). + +**Biến thể `error.text-sm`** (v0.2 — chỉ cho chữ/nhãn đỏ cỡ nhỏ, KHÔNG phải status mới): + +| Token | Light | Dark | Dùng cho | +|---|---|---|---| +| `error.text-sm` | `#A4262C` | `#F2B8B5` | Chữ/số đỏ cỡ nhỏ (9–14px) **trên nền sáng** — đỏ trầm chống nhoè viền do tán sắc | + +> **Khi nào cần `error.text-sm`:** đỏ no `error` `#C62828` chỉ **nhoè viền** khi HỘI ĐỦ — **cỡ nhỏ (9–14px) + nét mảnh + bão hoà cao + trên nền sáng/trắng**; nguyên nhân là **tán sắc ngang của mắt (TCA — chromatic aberration)**: bước sóng đỏ lấy nét lệch so với bước sóng khác nên viền nhoè, rõ nhất trên nền trắng `[fact]`. → dùng `error.text-sm` trong đúng điều kiện đó. **KHÔNG cần** đỏ trầm khi thiếu một điều kiện: chữ lớn/đậm · fill/pill/icon/khối đặc (mép nhoè không đáng kể so với diện tích) · **nền tối** — tán sắc gần như **không thấy trên nền đen** `[fact]`, nên dark-mode giữ đỏ sáng `#F2B8B5` (không cần trầm). Đây là biến thể công dụng, KHÔNG đổi nghĩa `error`. + +**Biến thể `warning.icon`** (v0.4 — chỉ cho icon/tiêu đề cảnh báo CỠ LỚN, KHÔNG phải status mới): + +| Token | Light | Dark | Dùng cho | +|---|---|---|---| +| `warning.icon` | `#CB6119` | `#FFB74D` (= `warning` dark) | Icon + tiêu đề callout cảnh báo cỡ lớn (≥18px / ≥14px đậm) — cam ISO tươi hợp hue an toàn | + +> Ngược chiều `error.text-sm`: `warning` `#9A5B00` cố tình **trầm** để chữ/nhãn cỡ nhỏ vẫn đạt AA 4.5:1. Nhưng icon + tiêu đề callout **cỡ lớn** chỉ cần ngưỡng 3:1 (WCAG 1.4.11 / 1.4.3 large) → dùng cam ISO tươi `#CB6119` (3.99:1 / white · 3.76:1 / `neutral-50` — `[fact]` công thức W3C) cho hợp hue cảnh báo ANSI/ISO + đẹp hơn nâu trầm. Nền tối: `warning` dark `#FFB74D` đã đủ sáng, không cần biến thể. Đây là biến thể công dụng, KHÔNG đổi nghĩa `warning`. + +**Biến thể `caution.fill`** (v0.4.4 — nền vàng callout mức "Thận trọng" ANSI Y2 / ISO 3864 signal yellow, KHÔNG phải status mới): + +| Token | Light | Dark | Dùng cho | +|---|---|---|---| +| `caution.fill` | `#F9A900` | `#FFB74D` (= `warning` dark) | Nền callout "Thận trọng" (mức nhẹ hơn "Cảnh báo") — **buộc chữ đen `neutral-900`** | + +> Phân biệt 3 cấp cảnh báo (theo ANSI Z535 / ISO 3864): **đỏ `error`** = Nguy hiểm/Cấm · **cam `warning.icon` `#CB6119`** = Cảnh báo (W2, icon/tiêu đề cỡ lớn) · **vàng `caution.fill` `#F9A900`** = Thận trọng (Y2, nền callout). Vàng `#F9A900` quá sáng → chữ trắng **FAIL** (1.96:1 — `[fact]` công thức W3C), nên **buộc chữ đen `neutral-900`** (8.99:1 AAA). Khoá ở `research-status-colors-office.md` §6 (D202, owner ratify S56). Đây là biến thể công dụng nền, KHÔNG đổi nghĩa `warning`. + +#### 2.1.4 Theme alias (light + dark mode) + +Tên theo công dụng — đây là lớp mà output thật tham chiếu, KHÔNG hardcode hex. + +| Alias | Light → token | Dark → token | +|---|---|---| +| `surface.base` | `neutral-0` | `neutral-900` | +| `surface.raised` | `neutral-50` | `neutral-800` | +| `surface.brand-tint` | `neutral-100` | `blue-900` | +| `text.primary` | `neutral-900` | `neutral-100` | +| `text.secondary` | `neutral-600` | `neutral-400` | +| `text.tertiary` | `neutral-500` | `neutral-500` | +| `border.subtle` | `neutral-300` | `neutral-700` | +| `border.strong` | `neutral-450` | `neutral-550` | +| `brand.primary` | `blue-500` | `blue-200` | +| `brand.accent` | `orange-500` | `orange-300` | +| `link` | `blue-500` | `blue-200` | + +#### 2.1.5 AMR body paint + decal (bề mặt vật lý) + +| Token | Spec | Khớp brand | Trạng thái | +|---|---|---|---| +| `paint.body.primary` | **RAL 5011 Steel Blue** | ≈ Deep Blue `#223771` | `[inference]` cần mẫu RAL vật lý chốt | +| `paint.body.accent` | **RAL 2008 Bright Red Orange** | ≈ Sun Orange `#f26522` | `[inference]` cần mẫu RAL vật lý chốt | +| `paint.body.dark` | **RAL 5022 Night Blue** | ≈ Dark Blue `#1e2243` | `[inference]` cần mẫu RAL vật lý chốt | +| `paint.body.light` | **RAL 9003 Signal White** | ≈ Brilliant White `#eeedf6` | `[inference]` cần mẫu RAL vật lý chốt | +| `decal.logo` | Pantone Solid Coated | logo decal trên thân AMR | GAP — chưa có Pantone official (defer M2.5) | + +> 4 màu sơn khớp đủ 4 màu chính thức Phenikaa-X (Deep Blue + Sun Orange + Dark Blue + Brilliant White, per `visual-from-raw.md` §11). + +> Màu nền thân robot (xám máy móc nền — vd RAL 7035) là quyết định product-spec per model, không khoá ở Layer 1. + +#### 2.1.6 WCAG 2.2 AA verify (tính bằng `[fact]` công thức relative luminance W3C) + +| Cặp | Tỷ lệ | Kết quả | +|---|---|---| +| text.primary / surface.base (light) | 17.6:1 | AAA | +| text.secondary `neutral-600` / white | 6.7:1 | AA | +| brand.primary `blue-500` / white | 11.3:1 | AAA | +| chữ trắng / nền `blue-500` | 11.3:1 | AAA | +| **chữ trắng / nền `orange-500`** | **3.15:1** | FAIL body · **PASS chữ lớn** (≥3:1) — xem quy tắc phân tầng | +| chữ tối `neutral-900` / `orange-500` | 5.6:1 | AA mọi cỡ ✅ | +| border.strong `neutral-450` / white | 3.15:1 | AA non-text | +| success/warning/error/info / white | ≥5.1 / 5.4 / 5.6 / 5.8 | AA | +| text.primary `neutral-100` / surface.base (dark) | 15.2:1 | AAA | +| link `blue-200` / nền tối | 6.3:1 | AA | +| semantic dark variant / nền tối | ≥7.4 | AAA | +| `error.text-sm` `#A4262C` / white | 7.26:1 | AAA | +| `error.text-sm` `#F2B8B5` / nền tối `#1e2243` | 9.01:1 | AAA | +| `warning.icon` `#CB6119` / white | 3.99:1 | AA chữ lớn/icon (≥3:1) · FAIL body | +| `warning.icon` `#CB6119` / `neutral-50` | 3.76:1 | AA chữ lớn/icon (≥3:1) | +| `caution.fill` `#F9A900` / `neutral-900` (chữ đen) | 8.99:1 | AAA | +| `caution.fill` `#F9A900` / white (chữ trắng) | 1.96:1 | FAIL — buộc chữ đen | + +> [!IMPORTANT] **Quy tắc dùng chữ trên nền cam (binding — theo bậc cam × cỡ chữ):** +> Tính `[fact]` bằng công thức W3C. Cam càng nhạt, chữ trắng càng fail — KHÔNG áp dụng "chữ lớn trắng OK" cho mọi bậc cam: +> | Nền | Chữ trắng | Chữ tối `neutral-900` | Khuyến nghị | +> |---|---|---|---| +> | `orange-200/300/400` (nhạt) | FAIL (≤2.94:1, kể cả chữ lớn) | AA mọi cỡ (6.0–7.7) | **Luôn chữ tối** | +> | `orange-500` Sun Orange | chữ lớn OK (3.15:1), body FAIL | AA mọi cỡ (5.6) | Logo + chữ lớn/CTA = trắng; body = tối | +> | `orange-700` (đậm) | AA mọi cỡ (4.72) | chỉ chữ lớn (3.74) | Trắng OK mọi cỡ | +> +> **Hệ quả thực tế:** nút cam **light mode** (`brand.accent`=orange-500) CTA lớn → trắng OK, nút nhỏ → tối. Nút cam **dark mode** (`brand.accent`=orange-300, nhạt) → **luôn chữ tối**. Logo/wordmark: trắng luôn được (logotype miễn trừ WCAG `[fact]`). +> +> **Quy tắc khác:** +> 1. **30% rule** (truyền thông): ấn phẩm marketing ≥30% diện tích màu chủ đạo (Deep Blue/Sun Orange) — per `research-method-d-standard.md` §5.1 (quy tắc 60-30-10). +> 2. Trạng thái kèm icon/chữ, không chỉ màu (WCAG 1.4.1). + +> [!NOTE] **Hai thước đo tương phản (dual-metric):** Bảng trên tính bằng **WCAG 2.x** (chuẩn pháp lý hiện hành). WCAG 2.x có lỗi đã ghi nhận với màu cam ("orange problem") — chấm chữ trắng/cam ~3:1 fail dù thực tế đọc rõ. **APCA** (thuật toán cảm nhận thị giác, sẽ vào WCAG 3) phản ánh chính xác hơn. Quy tắc phân tầng cam ở trên dựa trên cả hai. Test harness `50-plans/phenikaa-x-design-system/color-test-harness.html` tính song song WCAG + APCA + mô phỏng mù màu. + +**Nguồn:** `visual-from-raw.md` §11 (4 màu chính + parent 8 tone) + parent guideline Trang 18-19 (crop `color-palette-aux.jpg` 400 DPI verified) + `research-method-d-standard.md` §5.2 (WCAG 2.2) + §9.1 (RAL/Pantone AMR) + `[fact]` [W3C WCAG 2.2](https://www.w3.org/TR/WCAG22/) · [WCAG 1.4.3 logotype exemption](https://www.w3.org/WAI/WCAG21/Understanding/contrast-minimum.html) · [APCA contrast](https://git.apcacontrast.com/documentation/APCAeasyIntro.html) · [Carbon color tokens](https://carbondesignsystem.com/elements/color/tokens/) · RAL `[inference]` [RALcolorchart](https://www.ralcolorchart.com/ral-classic) + +#### 2.1.7 Data-viz — 4 họ màu theo ý đồ + +> [!NOTE] Thuật ngữ: **categorical** = màu phân loại nhóm rời rạc · **sequential** = thang 1 màu cho độ lớn · **diverging** = 2 màu quanh mốc giữa · **ΔE** = chỉ số khoảng cách 2 màu (CIEDE2000; >15 phân biệt rõ, 10–15 ổn, <10 dễ lẫn). + +Màu data-viz chọn theo **ý đồ truyền đạt**, KHÔNG phải 1 bộ cố định (`[fact]` Few "Show Me the Numbers" + Knaflic "Storytelling with Data": màu = ý đồ, phần lớn biểu đồ chỉ cần 1–2 màu). Bốn họ: + +| Họ | Dùng khi | Alias | +|---|---|---| +| **Categorical** | Phân loại series rời rạc (≤6) | `dataviz.cat-1…6` | +| **Sequential** | Độ lớn 1 chiều (mật độ, throughput) | `dataviz.seq-50…900` | +| **Diverging** | Lệch quanh mốc (sai số ±, trên/dưới target) | `dataviz.diverge-neg/mid/pos` | +| **Status** | Ngưỡng an toàn | đã khoá §2.1.3 | + +> Categorical **cố tình chừa hue xanh-lá/đỏ/vàng** — để dành cho status. Đây là quyết định đo được (khoảng cách tới màu status ΔE 20.1, không đụng nghĩa "đạt/cảnh báo/lỗi"), không phải sở thích. +> +> **Khi nào dùng họ nào + luật status-override** (lúc data vượt ngưỡng an toàn → đổi sang màu status): xem `_foundation/patterns/pattern-dashboard.md` §2. + +##### 2.1.7.1 Categorical + +Bộ **4 màu** (`cat-1…4`) = dùng nhiều nhất (~90% biểu đồ). Thêm `cat-5, cat-6` chỉ khi buộc 5–6 series. Trên 6 series → đổi **small-multiples** (chia nhiều biểu đồ nhỏ), KHÔNG thêm màu thứ 7 (`[fact]` Cleveland-McGill: màu là kênh tri giác kém chính xác nhất). + +| Token | Light | Dark | +|---|---|---| +| `dataviz.cat-1` | `#223771` | `#7AA2F7` | +| `dataviz.cat-2` | `#f26522` | `#F7941E` | +| `dataviz.cat-3` | `#8E6CF0` | `#B79CF5` | +| `dataviz.cat-4` | `#22A0A8` | `#56C7D6` | +| `dataviz.cat-5` *(chỉ khi cần 5–6)* | `#3E89C0` | `#4FB0DD` | +| `dataviz.cat-6` *(chỉ khi cần 5–6)* | `#B05CA0` | `#E07BB0` | + +##### 2.1.7.2 Sequential + +Thang đơn sắc (single-hue) xanh thương hiệu, **độ sáng đều cảm nhận** — dựng bằng OKLab (cố định hue 266° của Deep Blue + chroma ~0.10, chia độ sáng đều), **neo cứng 2 đầu** vào màu chính thức Tập đoàn. Thay thang mượn v0.2 (trả "nợ" perceptual-uniform). + +| Token | Hex | L (OKLab) | Vai trò | +|---|---|---|---| +| `dataviz.seq-50` | `#ECF2FF` | 0.960 | mật độ thấp nhất (sáng nhất) | +| `dataviz.seq-100` | `#C3D5FF` | 0.873 | | +| `dataviz.seq-200` | `#9CB8FA` | 0.786 | | +| `dataviz.seq-300` | `#819DDD` | 0.700 | | +| `dataviz.seq-400` | `#6882C1` | 0.613 | | +| `dataviz.seq-500` | `#5068A5` | 0.526 | | +| `dataviz.seq-600` | `#394F8A` | 0.440 | | +| `dataviz.seq-700` | `#23376F` | 0.353 | ≈ **Deep Blue official** `#223771` | +| `dataviz.seq-900` | `#1e2243` | 0.267 | mật độ cao nhất = **Dark Blue official** (neo cứng) | + +> **Bước độ sáng** mọi bậc ~**0.087** (đều — không nhảy vọt, không dồn cục; đo bằng `50-plans/phenikaa-x-design-system/` script OKLab). Số hiệu token là *nhãn quy ước* (bỏ `800` để giữ `seq-900` neo Dark Blue ở đáy) — bước *độ sáng* vẫn đều bất kể nhãn. +> +> **Dark mode:** đảo nhạt↔đậm theo nền — `seq-900` thành đầu nhạt, `seq-50` thành đầu đậm (giữ cơ chế v0.2). `[inference]` verify khi render thật. + +##### 2.1.7.3 Diverging + +Hai màu brand quanh mốc trung tính (không đỏ/xanh-lá → không đụng status). Cân độ sáng 2 cánh để không tạo bất đối xứng giả. + +| Token | Light | Dark | +|---|---|---| +| `dataviz.diverge-neg` (cánh âm) | `#5B9BD0` | `#6FAEDD` | +| `dataviz.diverge-mid` (mốc 0) | `#EEEDF6` | `#2C3158` | +| `dataviz.diverge-pos` (cánh dương) | `#F58220` | `#F7941E` | + +> Giá trị `[inference]` — 2 cánh cân độ sáng ~L*62–70; owner verify khi xem render thật. + +> [!NOTE] **Bằng chứng + rủi ro tồn dư** (đo bằng `50-plans/phenikaa-x-design-system/palette_eval.py`): Categorical sáng ΔE phân biệt 28.8 (4 màu) / 17.8 (6 màu), khoảng cách status 20.1 ✅. Categorical tối 15.0 (4 màu) / 11.9 (6 màu) — **khoảng cách status 9.6 ⚠️** (`cat-2` cam gần warning-dark): dark mode ưu tiên 4 màu + status luôn kèm icon để không lẫn. + +**Nguồn:** `spec-pattern-dashboard.md` (quyết định D135–D138 + bằng chứng) + `spec-phase-3.md` (sequential brand-anchored D147) + `research-method-d-standard.md` §7.3 (Cleveland-McGill `[fact]`) + `[fact]` Few "Show Me the Numbers" + Knaflic "Storytelling with Data" + OKLab (Ottosson 2020) + [ColorBrewer](https://colorbrewer2.org) + [viridis](https://bids.github.io/colormap/) + `palette_eval.py` / `seq_anchor.py` (số đo CIEDE2000 + OKLab L + WCAG W3C). + +### 2.2 Typography + +> [!NOTE] Thuật ngữ: **type scale** = bộ cỡ chữ chuẩn (tiêu đề → chữ thường → chú thích) · **vai trò (role)** = tên theo công dụng (Body, Heading...) thay vì cỡ cụ thể · **độ cao dòng (line-height)** = khoảng cách giữa 2 dòng · **px** = đơn vị màn hình, **pt** = đơn vị Word/in (1pt ≈ 1.33px). + +#### 2.2.1 Font family + +- **Body + Heading:** **Montserrat** — Phenikaa-X UNIQUE retain (owner ratify S12, sub-brand style), per `visual-from-raw.md` §12. Montserrat có bộ ký tự tiếng Việt đầy đủ (Google Fonts Vietnamese subset) `[fact]`. +- **Độ đậm dùng:** Regular 400 · Medium 500 · SemiBold 600 · Bold 700. +- **Fallback:** `system-ui, -apple-system, "Segoe UI", sans-serif` (khi Montserrat chưa load). +- Lưu ý: font trong **logo** ≠ font body (logo có style riêng, per `visual-from-raw.md` §2 Logo). +- **UTM Avo** là font của tập đoàn Phenikaa Group — chỉ là tham chiếu, **KHÔNG dùng** cho Phenikaa-X (owner ratify S12, sub-brand giữ style riêng, per `visual-from-raw.md` §16.1 #2). + +> [!NOTE] **Ngoại lệ phạm vi — văn bản hành chính gửi cơ quan nhà nước** +> Montserrat áp dụng cho **mọi output do Phenikaa-X kiểm soát** (proposal, slide, brochure, HMI, web, tài liệu kỹ thuật). **Ngoại lệ:** văn bản hành chính gửi cơ quan nhà nước (công văn, tờ trình, hồ sơ thầu dự án nhà nước) → dùng **Times New Roman cỡ 13–14**, mã Unicode TCVN 6909:2001, theo Nghị định 30/2020/NĐ-CP `[fact]`. +> +> Đây là **format pháp lý bên ngoài, brand không override** — cùng nguyên tắc với màu an toàn IEC 60204-1 / ISO 3864 (đỏ–vàng dừng khẩn không được đổi sang xanh brand). +> +> Phenikaa-X là công ty cổ phần tư nhân → **không thuộc đối tượng bắt buộc** của Nghị định 30 (đối tượng = cơ quan/tổ chức nhà nước + doanh nghiệp nhà nước) `[fact]`. Nhưng **nên tuân theo** khi tài liệu vào quy trình văn thư nhà nước, để bên nhận chấp nhận `[inference]`. + +#### 2.2.2 Type scale (Major Third 1.25 — neo Body theo thực tế Word 11pt) + +Thang theo tỷ lệ 1.25 (Bringhurst `[fact]`). Body neo **11pt** (cỡ Word A4 đang dùng) → Web 16px. 2 cột đơn vị cho 2 môi trường: + +| Vai trò | Web/HMI (px) | Word/In (pt) | Độ đậm | Line-height | Dùng cho | +|---|---|---|---|---|---| +| Display | 39 | 28 | Bold 700 | 1.1× | Bìa, tiêu đề slide lớn | +| Heading 1 | 31 | 22 | Bold 700 | 1.15× | Tiêu đề chính tài liệu | +| Heading 2 | 25 | 18 | SemiBold 600 | 1.2× | Tiêu đề mục | +| Heading 3 | 20 | 14 | SemiBold 600 | 1.3× | Tiêu đề mục con | +| Heading 4 | 18 | 12 | Bold 700 | 1.3× | Tiêu đề phụ (hơi lớn hơn body) | +| Heading 5 | 16 | 11 | SemiBold 600 | 1.4× | Tiêu đề ngang cỡ body, nhạt hơn H4 (weight phân biệt) | +| Heading 6 | 16 | 11 | SemiBold 600 · CHỮ HOA | 1.4× | Tiêu đề nhỏ nhất (phân biệt bằng chữ hoa) | +| **Body** | 16 | **11** | Regular 400 | **1.5×** | Chữ thường (đoạn văn) | +| Label | 13 | 9 | Medium 500 | 1.5× | Chú thích, nhãn KPI, trục biểu đồ | + +> Web hơi lớn hơn In (16px ≈ 12pt vs 11pt) — bình thường: màn hình đọc xa hơn giấy. Cùng vai trò Body, mỗi môi trường neo cỡ phù hợp. +> +> **6 tầng heading** (map thẳng Word Heading 1-6 / HTML h1-h6). H1-H3 phân biệt bằng **cỡ**; H4-H6 cụm gần body, phân biệt bằng **độ đậm + chữ hoa** (mắt không phân biệt rõ 6 cỡ chữ khác nhau). Thang H4-H6: **H4 Bold 700 → H5 SemiBold 600 → H6 SemiBold 600 + CHỮ HOA** (mỗi cấp đổi đúng 1 tín hiệu so với cấp trên; KHÔNG in nghiêng — bảo toàn dấu tiếng Việt ở cỡ nhỏ + sống sót khi in đen trắng). `[fact]` Word built-in cũng dùng italic ở cấp sâu; brand chọn weight+caps thay italic vì 2 lý do trên. +> +> **Cỡ theo thiết bị KHÔNG khoá ở L1:** L1 định *tỷ lệ* (1.25) + *vai trò* + cỡ tham chiếu; co giãn responsive theo màn hình (web/HMI/in) là việc **L2** per platform. L1 = hệ, không phải cỡ chết per device. +> +> **Cột "Line-height" = giá trị web/CSS.** Setting Word-native (Single / Multiple) KHÁC — Word "Multiple N" = N × line-unit font (Montserrat 1.219×), không phải N × cỡ chữ. Xem §2.2.4 bảng ánh xạ Office (body Word = Multiple 1.15, heading lớn = Single). + +#### 2.2.3 Role × surface mapping (áp dụng đa nền tảng) + +| Vai trò | Office (Word/Excel) | Web (Dashboard) | HMI (màn hình máy) | In ấn | +|---|---|---|---|---| +| Display | (hiếm dùng) | hero/h1 trang | tiêu đề splash | tiêu đề poster | +| Heading 1-6 | h1-h6 tài liệu | h1-h6 dashboard | tiêu đề màn hình | tiêu đề mục/tiểu mục | +| Body | đoạn văn (11pt) | nhãn dữ liệu, đoạn | chữ nút, giá trị | đoạn văn | +| Label | caption | nhãn KPI, nhãn trục | chỉ báo | chú thích chân | + +#### 2.2.4 Line-height tiếng Việt + ánh xạ Office-native + +**An toàn dấu chồng đến từ CHẾ ĐỘ cách dòng, KHÔNG từ một bội số.** `[fact]` (M1.7 §4.1 + Microsoft Support f25ed208): mọi chế độ tự nới (Single / Multiple / At least / Double) đều nới đủ chứa dấu chồng tiếng Việt (ầ ẫ ễ ộ ỡ ữ); **CHỈ "Exactly"** (cách dòng cứng) cắt đỉnh dấu. → Quy tắc an toàn = **dùng chế độ auto, tránh "Exactly"** (nếu buộc Exactly: ≥ ~1.25× cỡ chữ + render-test). Ngay cả Word "Single" (Montserrat = 1.219×) đã an toàn dấu — **"1.5×" KHÔNG phải sàn an toàn**, nó là mục tiêu đọc thoải mái cho web. + +**Mục tiêu đọc thoải mái (tách khỏi an toàn dấu):** +- Body: vùng dễ đọc **120-145%** `[fact]` (Butterick). Web/HMI dùng 1.5× (màn hình đọc xa); Office body khác — xem ánh xạ dưới. +- Heading: chặt hơn body (chữ to cần ít leading). + +**Ánh xạ Office-native — Word "Multiple N" ≠ CSS N** `[fact]` (M1.7 §1): Word nhân N với *line-unit của font*, mà Montserrat line-unit = **1.219×** (do cờ OpenType USE_TYPO_METRICS), KHÔNG phải 1.0. Hệ quả cho `.dotx`: + +| Vai trò | Web/CSS (pitch) | Word (Montserrat) | Pitch thực | +|---|---|---|---| +| Body | line-height 1.5 | **Multiple 1.15** | 1.40× | +| Heading lớn (Title / H1-H4) | 1.1-1.3 | **Single** | 1.219× | +| Heading nhỏ (H5/H6 = cỡ body) | 1.4 | **Multiple 1.15** | 1.40× | + +> Web giữ 1.5× (không đổi); Office body = 1.40× (Multiple 1.15). **CẤM Word "1.5 lines" cho body** (= 1.829× Montserrat, quá rộng — đây là bug S44). Nguồn: `50-plans/phenikaa-x-office-templates/research-office-typography.md` §1-§2 (đo OS/2 + ECMA-376) + owner ratify S45. + +`[fact]` Latin body 1.4-1.5 (Bringhurst). `[inference]` Buffer riêng cho tiếng Việt KHÔNG cần khi dùng chế độ auto (engine tự nới) — chỉ cần tránh "Exactly" (`research-method-d-standard.md` §4.2). + +#### 2.2.5 Quy tắc dùng (binding) + +1. Heading + body **cùng family Montserrat** (an toàn, per `research-method-d-standard.md` §4.3). KHÔNG trộn 2 font sans khác nhau. +2. Phân cấp bằng **cỡ + độ đậm**, không bằng màu đơn thuần. +3. Viết hoa chữ đầu câu tiêu đề; KHÔNG drop-shadow/viền chữ (per `visual-from-raw.md` §13). + +**Nguồn:** `visual-from-raw.md` §12 (Montserrat) + §13 (headline Bold 1.1× / body Regular 1.3×, owner ratify) + `research-method-d-standard.md` §4.1 (modular scale Bringhurst `[fact]`) + §4.2 (VN line-height) + §4.3 (font pairing Hoefler `[fact]`) + thực tế owner Word 11pt body. Endorsed ref: Carbon Productive + Fluent 2 ramp `[fact]`. (Material 3 de-based per `research-record-phase-1.md` §P1.2 — KHÔNG dùng.) + +### 2.3 Spacing — lưới 8px + +> [!NOTE] Thuật ngữ: **lưới 8px (8px grid)** = mọi khoảng cách là bội số của 8 · **token** = tên biến cho 1 giá trị · **macro whitespace** = khoảng nghỉ giữa các khối lớn · **micro whitespace** = khe hẹp bên trong 1 thành phần. + +Đơn vị gốc = **8px**. Bước nửa 4px cho phép trong khe hẹp của component. 2 cột đơn vị (Web/HMI px · Office xấp xỉ pt, 1pt ≈ 1.33px): + +| Token | Web/HMI (px) | Office (≈pt) | Dùng cho | +|-------|------|------|----------| +| `space.0` | 0 | 0 | Không cách | +| `space.xxs` | 4 | 3 | Khe hẹp trong component (icon↔nhãn, padding chip) — nửa bước | +| `space.xs` | 8 | 6 | Padding nhỏ, gap giữa 2 item liền kề | +| `space.sm` | 12 | 9 | Gap trong nhóm field | +| `space.md` | 16 | 12 | Padding thẻ mặc định, gap giữa đoạn | +| `space.lg` | 24 | 18 | Gap giữa nhóm / section nhỏ | +| `space.xl` | 32 | 24 | Padding trang, gap giữa section lớn | +| `space.2xl` | 48 | 36 | Khoảng nghỉ lớn (hero, page block) | +| `space.3xl` | 64 | 48 | Tách biệt khối chính (landing, dashboard sheet) | + +**Quy tắc (binding):** +1. Mọi margin/padding/gap = bội số **8px** (ngoại lệ duy nhất: `space.xxs` 4px cho khe component). +2. Macro whitespace (giữa section) ≥ 2× micro whitespace (trong component) — bảo toàn phân cấp thị giác. +3. Macro spacing co theo breakpoint (web/HMI); Word/print KHÔNG co (trang vật lý cố định). + +**Nguồn:** `research-method-d-standard.md` §6.2 (chọn lưới 8pt cross-deliverable) + 8pt baseline Material/Fluent `[fact]`. + +> [!NOTE] **Office-native:** "Lưới 8px" trong Word = **lưới 6pt** (8px × 0.75 = 6pt; 1pt = 20 twip). Cột "Office ≈pt" ở bảng trên đã đúng đơn vị (8→6, 16→12, 24→18…) — convert sạch, không cần tính lại. `[fact]` (M1.7 §5.1). + +#### 2.3.1 Núm 1 — Mật độ (density), 3 mức + +> [!NOTE] Thuật ngữ: **density mode** = mức nén bố cục. **comfortable/compact** = tên chuẩn ngành. + +Cùng thang gốc, đổi *bậc* dùng cho padding/gap component (KHÔNG thêm token — chỉ preset chọn bậc). Giá trị gán per mức = `[inference]` (snap lưới 8px, verify reality-test khi dựng template): + +| Mức | Dùng cho | Padding thẻ | Gap nhóm | +|---|---|---|---| +| **Thoáng** (comfortable) | Tài liệu đọc kỹ, ít dữ liệu (Manual, training, proposal) | `space.lg` 24px | `space.md` 16px | +| **Vừa** (default) | Mặc định cân bằng (FAT/SAT, slide) | `space.md` 16px | `space.sm` 12px | +| **Dày** (compact) | Bảng dữ liệu đặc (Datasheet, BOM, data-table) | `space.xs` 8px | `space.xxs` 4px | + +> **Nguồn `[fact]`:** Material Design 3 (default/comfortable/compact) + Cloudscape (comfortable/compact data table). + +#### 2.3.2 Núm 2 — Kích thước màn hình (responsive) + +> [!NOTE] Thuật ngữ: **responsive** = bố cục tự điều chỉnh theo bề rộng màn hình. **macro/micro whitespace** = khoảng nghỉ giữa khối lớn / khe trong 1 component. + +Spacing **macro** (giữa khối lớn) co 1 bậc khi màn hẹp dần; **micro** (trong component) giữ nguyên: + +| Breakpoint (§3.4) | Gutter/gap macro | +|---|---| +| `desktop` >1024px | `space.xl` 32px | +| `tablet` 640-1024px | `space.lg` 24px | +| `mobile`/HMI nhỏ <640px | `space.md` 16px | + +> **Nguồn `[fact]`:** Utopia (fluid spacing). **CHỈ áp web/HMI** (màn co giãn). **Word/print KHÔNG co** (trang A4 cố định — xem §2.3.3). L1 định *quy tắc*; *cách* co (clamp, gộp cột) = **L2** team dev (per §3.4). + +#### 2.3.3 Núm 3 — Môi trường (Word pt cố định vs web/HMI px co giãn) + +Word đo spacing dọc bằng "space before/after paragraph" + "line spacing" (paragraph model), KHÔNG bằng box padding như web: + +| Môi trường | Đơn vị | Co giãn (núm 2)? | Token | +|---|---|:---:|---| +| Web / Dashboard | px | ✅ | `space.*` (px) | +| HMI | px | ✅ | `space.*` (px) | +| Word / In ấn | pt | ❌ (A4 cố định) | `space.*` (cột pt) **+ token Word-native** | + +**Token Word-native:** + +| Token | Giá trị | Vai trò | Nguồn | +|---|---|---|---| +| `space.word.para-gap` | 6pt (= `space.xs` Office) | Khoảng sau mỗi đoạn (space after paragraph) | `[fact]` Word default 8pt / business 6-12pt → snap 6pt `[inference]` | +| `space.word.section-gap` | 12pt (= `space.md`) | Khoảng trước heading mục | `[inference]` neo lưới + nhịp dọc §2.3.4 | +| `space.word.line` | Multiple 1.15 (1.40× Montserrat) | Giãn dòng body | `[fact]` §2.2.4 ánh xạ Office — Word "Multiple 1.15", KHÔNG "1.5 lines" | + +> Density (núm 1) áp Word qua các token này: thoáng = section 18pt/para 9pt · vừa = 12/6 · dày = 9/3 `[inference]` (verify template Phase 4). + +#### 2.3.4 Nhịp dọc (vertical rhythm) + ngoại lệ nhà nước + +**Nhịp dọc:** gap giữa section gắn line-height body — 2 tầng độc lập (KHÔNG hạ cấp nguồn research-grade): +- **Sàn cứng (binding):** macro ≥ **2×** micro (ngưỡng tối thiểu giữ phân cấp). `[inference]` +- **Mục tiêu khuyến nghị:** macro = **4-8×** micro `[fact]` NNGroup — áp density mặc định/thoáng. +- **Ngoại lệ compact:** density Dày tiệm cận sàn 2× — precedent Material 3 compact `[fact]` (owner ratify S36). + +**Ngoại lệ văn bản hành chính nhà nước** (cùng nhóm "format pháp lý bên ngoài, brand KHÔNG override" với font Times New Roman §2.2.1): công văn/tờ trình/hồ sơ thầu nhà nước → theo **NĐ 30/2020/NĐ-CP** `[fact]`: font Times New Roman, mã TCVN 6909:2001, cỡ 13-14, lề trên/dưới 20-25mm/trái 30-35mm/phải 15-20mm, số trang giữa chân trang. **NĐ 30 KHÔNG quy định giãn dòng** `[fact]` — "1.5 dòng" là thông lệ kế thừa từ TT 01/2011/TT-BNV (**đã hết hiệu lực 15/6/2020**), không ràng buộc pháp lý hiện hành. Phenikaa-X (cổ phần tư nhân) không thuộc đối tượng bắt buộc `[fact]`, nên tuân khi tài liệu vào quy trình văn thư nhà nước `[inference]`. (M1.7 §4.2) + +### 2.4 Elevation + shadow — 3 mức tối giản + +Phenikaa-X **nghiêng phẳng**: logo + chữ **CẤM** đổ bóng (per `visual-from-raw.md` §13 + don't #6). Elevation chỉ áp dụng cho **bề mặt UI** (thẻ, popup), KHÔNG cho logo/chữ. Bản in (print) = luôn phẳng (giấy không có lớp). + +| Token | Mức | Shadow (CSS) | Dùng cho | +|-------|-----|--------------|----------| +| `elevation.0` | Phẳng | `none` | Mặc định · nền · print · HMI phẳng | +| `elevation.1` | Nổi nhẹ | `0 1px 3px rgba(22,24,36,.12)` | Thẻ, KPI tile, panel | +| `elevation.2` | Lớp phủ | `0 4px 12px rgba(22,24,36,.18)` | Modal, dropdown, tooltip, popover | + +> [!NOTE] **Dark mode:** shadow gần như vô hình trên nền tối → dùng **viền 1px `neutral-700`** thay shadow để tách lớp (pattern Fluent dark) `[inference]`. + +**Nguồn:** Fluent depth = shadow-only, KHÔNG tint overlay (khác Material 3 — đã de-base `research-record-phase-1.md` §P1.2) `[fact]`; nghiêng phẳng của brand `visual-from-raw.md` §13. + +### 2.5 Border + radius + +#### Border (viền) + +| Token | px | Dùng cho | +|-------|----|----| +| `border.0` | 0 | Không viền — **ưu tiên** (nguyên tắc closure: gợi khối bằng nền + khoảng trắng, không cần viền đầy) | +| `border.1` | 1px | Mặc định: field, ô bảng, divider | +| `border.2` | 2px | Nhấn: trạng thái active | +| `border.accent` | 4px (1 cạnh) | Dải nhấn 1 bên (KPI tile mép trái) — gợi container mà không cần viền 4 cạnh | +| `border.focus` | 2px (offset 2px) | Vòng focus bàn phím — màu `blue-500` (light) / `blue-200` (dark), tương phản ≥3:1 với nền kề (WCAG 2.2 §2.4.13) `[fact]` | + +**Quy tắc:** tránh viền đầy 4 cạnh dư thừa (anti-pattern "chartjunk"). Ưu tiên khoảng trắng + nền phân tách. + +#### Radius (bo góc) + +| Token | px | Dùng cho | +|-------|----|----| +| `radius.none` | 0 | Mặc định công nghiệp/HMI — thẩm mỹ AMR sắc cạnh | +| `radius.sm` | 2 | Bo nhẹ: chip, badge, input | +| `radius.md` | 4 | Thẻ, panel (Office/dashboard) | +| `radius.lg` | 8 | Modal, dialog lớn | + +> [!NOTE] **Bo góc tùy bề mặt (L2 quyết định cuối):** HMI/biển công nghiệp nghiêng `radius.none` (sắc cạnh, kỹ thuật); tài liệu Office/dashboard nghiêng `radius.md` 4px (thẻ mềm, thân thiện). L1 cung cấp thang; L2 chọn mặc định theo từng nền tảng. +> +> **Office-native:** trong Word chỉ **shape / text-box** bo góc được (qua `a:prstGeom roundRect`); **đoạn văn thường + ô bảng KHÔNG bo** (= `radius.none`). Radius chỉ áp cho KPI tile/khối đồ hoạ, không áp body/table. `[fact]` (M1.7 §5.3). + +**Quy tắc tương tác (hover/active/disabled):** chỉ định nghĩa **token nền** ở đây (`border.focus`); ma trận trạng thái đầy đủ per-component thuộc **L2** (platform implementation). Consensus viền mặc định 1px (Carbon/Material/Fluent) `[fact]`. + +### 2.6 Bộ icon + +> [!NOTE] Thuật ngữ: **bộ icon (icon set)** = một thư viện icon thiết kế đồng bộ một phong cách · **Regular** = kiểu viền rỗng (nét vẽ đường viền) · **Filled** = kiểu tô đặc (khối đặc) — đây là tên Fluent dùng · **currentColor** = SVG kế thừa màu chữ của phần tử cha (đổi màu qua token, không sửa file) · **tương phản non-text** = chỉ số WCAG đo độ rõ của đồ hoạ mang nghĩa (icon, viền) trên nền. + +Bộ icon canonical Layer 1 = **Fluent UI System Icons** (Microsoft, giấy phép MIT) — nguồn icon duy nhất cho mọi bề mặt. Chọn vì: phong cách hình học bo nhẹ thân thiện (cùng gia đình Fluent 2 — chuẩn chính của hệ, `research-record-phase-1.md §P1.2`) · có sẵn **đầy đủ cả Regular + Filled** nên không phải trộn thư viện · gói NuGet chính chủ Microsoft hợp stack dev (MudBlazor) · bên bảo trì bền vững. Tên icon cụ thể (`bot-24-regular`, `warning-24-filled`...) là **L2** — không khoá ở đây (xem §4 cơ chế áp dụng). + +**Thang cỡ icon** — neo thẳng cột pt vai trò type scale §2.2 (cùng *neo per-môi-trường*, KHÔNG theo tỷ lệ ÷1.33 cứng của spacing §2.3): + +| Token | Web/HMI (px) | Office (pt) | Khớp vai trò §2.2 | Dùng cho | +|---|---|---|---|---| +| `icon.sm` | 16 | 11 | Body | Inline cạnh chữ body · nhãn · chip | +| `icon.md` | 20 | 14 | Heading 3 | **Mặc định UI** — nút, menu, KPI tile | +| `icon.lg` | 24 | 18 | ≈ Heading 2 (25px) | Tiêu đề · mục tiêu chạm (touch) · HMI nhìn xa | + +> Icon **inline** (chèn giữa dòng chữ) lấy cỡ bằng chữ kế bên, không cố định bậc. Cỡ theo thiết bị co giãn = **L2** (giống §2.2 type scale). + +**Quy tắc (binding) — 6 luật cốt lõi:** + +1. **Một nguồn duy nhất** — mọi icon từ Fluent UI System Icons, KHÔNG trộn bộ khác. +2. **Hai kiểu, chọn theo tình huống** (KHÔNG khoá mặc định cứng): **Regular** (viền rỗng) cho icon thường/phẳng · **Filled** (tô đặc) cho nhấn mạnh / trạng thái active / HMI nhìn xa / nơi cần lắp êm vào MudBlazor. Trong cùng một ngữ cảnh, giữ một kiểu nhất quán. +3. **Cỡ icon** theo thang trên, neo thẳng cột pt vai trò §2.2 (icon.sm=Body 11pt · icon.md=H3 14pt · icon.lg≈H2 18pt) — KHÔNG dùng tỷ lệ ÷1.33 của spacing. +4. **Màu icon = token**, KHÔNG hardcode hex: dùng `text.*` / `brand.*` / semantic theo ngữ cảnh; icon phụ (trang trí, ít quan trọng) = `neutral-400`. SVG dùng `currentColor` để kế thừa. +5. **Icon mang chức năng/trạng thái luôn kèm nhãn** (chữ hoặc tooltip) — theo WCAG 1.4.1 (use of color); kế thừa §2.1.3 (semantic, file này) + nguyên tắc "không chỉ dựa vào màu" §1.4. Khe icon↔nhãn = `space.xxs` 4px. +6. **Tương phản non-text ≥ 3:1** cho icon mang nghĩa (WCAG 2.2 §1.4.11). Icon trang trí thuần (không mang thông tin) miễn trừ. + +> Lưu ý DRY: 6 luật **tái dùng** token đã có (theme alias §2.1.4 + type scale §2.2 + `space.xxs` §2.3 + luật WCAG §1.4 / §2.1.3) — chỉ thêm họ token cỡ icon. Danh sách "khái niệm → tên icon Fluent" là tài liệu tra cứu riêng (L2), dựng khi deliverable thật cần (D125 kích hoạt theo thực tế). + +**Nguồn:** `spec-icon-subphase.md` (D150-D154 — chọn Fluent + 6 luật, owner ratify S37) + `research-method-d-amr-hmi-web.md` §3.5 (rubric Q10 icon-set-consistent) + `research-method-d-standard.md` §5.2 (WCAG non-text 3:1) + `[fact]` [Fluent UI System Icons (GitHub, MIT)](https://github.com/microsoft/fluentui-system-icons) · [WCAG 2.2 §1.4.11 non-text contrast](https://www.w3.org/WAI/WCAG22/Understanding/non-text-contrast.html) · [WCAG 1.4.1 use of color](https://www.w3.org/WAI/WCAG22/Understanding/use-of-color.html). + +## 3. Bố cục cơ bản + +> Bố cục L1 đứng trên **3 trụ nghiên cứu**: (1) nguyên tắc tri giác Gestalt, (2) phân cấp thị giác, (3) hệ lưới — đều có nguồn học thuật. KHÔNG dựa trên bản phác ý tưởng in ấn (xem §3.3 cảnh báo). + +### 3.1 Nguyên tắc bố cục — Gestalt + phân cấp thị giác + +> [!NOTE] Thuật ngữ: **Gestalt** = nhóm nguyên tắc tâm lý mô tả cách não gom element rời thành nhóm · **phân cấp thị giác (visual hierarchy)** = thứ tự mắt nhìn element. + +**Gestalt — não nhóm element thế nào** `[fact]` (Wertheimer 1923): + +| Nguyên tắc | Nghĩa | Áp dụng bố cục Phenikaa-X | +|-----------|-------|----------------------------| +| Gần gũi (proximity) | Element gần nhau = cùng nhóm | Gom cụm KPI bằng khoảng cách (token §2.3), không cần viền | +| Tương tự (similarity) | Cùng màu/shape/size = cùng loại | Series chart cùng màu = cùng nhóm đọc | +| Đóng kín (closure) | Não tự khép hình thiếu | Dải nhấn `border.accent` 4px gợi khối — không cần viền 4 cạnh | +| Hình–nền (figure-ground) | Element nổi trên nền | Khối `blue-500` + chữ trắng = tương phản rõ | + +**Phân cấp thị giác — mắt đọc theo đường nào** `[fact]` (Nielsen Norman Group): +- **F-pattern** — dashboard, báo cáo, trang nhiều chữ (quét trái→phải dòng trên → xuống → dọc lề trái). +- **Z-pattern** — slide bìa, proposal, marketing ít chữ (4 góc). `[unverified]` bằng chứng yếu hơn F — coi là guideline. +- **Phân cấp bằng:** cỡ (tiêu đề/thân ≥ 2.5×) + tương phản màu + khoảng cách. KHÔNG phân cấp bằng màu đơn thuần. + +**Nguồn:** `research-method-d-standard.md` §2 (Gestalt, Wertheimer 1923 `[fact]`) + §3 (F/Z-pattern, NNGroup `[fact]`). + +### 3.2 Hệ lưới — chọn loại theo ngữ cảnh + lưới 12 cột + +> [!NOTE] Thuật ngữ: **cột (column)** = dải dọc chia bố cục · **gutter** = khe giữa 2 cột. + +**Chọn loại lưới theo ngữ cảnh** `[fact]` (Müller-Brockmann 1981): + +| Loại lưới | Dùng cho | +|-----------|----------| +| Manuscript (1 cột) | Tài liệu văn xuôi: proposal, báo cáo thân bài | +| Column (nhiều cột) | Slide, newsletter, brochure | +| Modular (lưới 2D) | Dashboard, gallery thẻ, web card | +| Hierarchical (bất quy tắc) | Hero section, trang điều hướng (wayfinding) | + +**Lưới số mặc định cho digital = 12 cột** (chia hết 2/3/4/6 → 2 nửa, 3 phần ba, 4 phần tư). Gutter neo token §2.3: + +| Thuộc tính | Giá trị | +|------------|---------| +| Số cột | 12 (Fluent + Material `[fact]`) | +| Gutter desktop | `space.lg` 24px | +| Gutter mobile/HMI | `space.md` 16px | +| Bề rộng nội dung tối đa | ~1200px (tránh dòng quá dài) | + +**Ví dụ:** 3 ô KPI = 4+4+4 cột · nội dung 8 cột + sidebar 4 cột · 2 nửa = 6+6. + +**Nguồn:** Müller-Brockmann grid taxonomy `[fact]` + Fluent/Material 12-col `[fact]` (KHÔNG dùng Carbon 16-col — Fluent chuẩn chính `research-record-phase-1.md` §P1.2). + +### 3.3 Khối bố cục (layout primitives) — digital + +4–6 khối **composable** (ghép lồng nhau), mỗi khối tự co giãn theo nội dung. Mọi khoảng cách neo token §2.3 (DRY): + +| Primitive | Vai trò | Khoảng cách | +|-----------|---------|-------------| +| **Container** | Khung giới hạn bề rộng + padding ngoài | `space.xl` 32px (desktop) | +| **Stack** | Xếp dọc, đều khoảng cách | gap = token §2.3 | +| **Inline** | Xếp ngang, tự xuống dòng (nút, chip, tag) | gap = `space.sm`/`space.md` | +| **Grid** | Lưới 2D (thẻ dashboard, gallery) | gap = gutter §3.2 | + +**Nguồn:** composable layout primitives = chuẩn ngành `[fact]` ([Every Layout](https://every-layout.dev/rudiments/composition/) · [Atlassian primitives](https://atlassian.design/components/primitives/overview)). + +> [!WARNING] **Bản in KHÔNG phải nguồn L1.** `applied-from-raw.md` §2 ("5 khối bố cục") là **bản phác ý tưởng in ấn trích từ guideline tập đoàn** (dùng motif 2:5 + font UTM Avo — cả hai Phenikaa-X KHÔNG dùng), ngôn ngữ "gợi ý / có thể". Đây là **điểm khởi đầu cho template in ở Phase 2**, KHÔNG phải primitive L1 ràng buộc. Khi Phase 2 dựng template in → re-research + chuyển motif sang LiDAR + font sang Montserrat, owner ratify. + +### 3.4 Breakpoint — điểm tham chiếu (hành vi co giãn → L2) + +> [!IMPORTANT] L1 chỉ định nghĩa **bộ điểm tham chiếu** (từ vựng chung). **Hành vi co giãn thật** (cột nào gộp, font scale theo màn hình) thuộc **L2** — nhất quán quyết định §2.2 "kích thước theo thiết bị = L2". + +| Tên | Bề rộng màn hình | Ngữ cảnh | +|-----|------------------|----------| +| `mobile` | < 640px | Điện thoại, HMI nhỏ | +| `tablet` | 640–1024px | Tablet, HMI tầm trung | +| `desktop` | > 1024px | PC, dashboard lớn | + +**Quy tắc:** L1 cấp 3 mốc làm vocabulary chung; L1 KHÔNG quy định "mobile lưới 12 thành mấy cột" — việc đó L2 (per nền tảng). `[inference]` + +## 4. Cơ chế áp dụng + +2 cơ chế kết hợp, phủ ~80% trường hợp dùng (theo spec §8.7, quyết định D130): + +### 4.1 Cơ chế 1 — Tự nạp thụ động (passive) + +`@_foundation/design-language.md` đã thêm vào `70-company/CLAUDE.md` (cùng kiểu với `@index.md`). + +- **Kích hoạt:** Claude tự nạp tài liệu này khi làm việc trong `70-company/`. +- **Phạm vi:** output thương hiệu trong 70-company (asset logo, template thương hiệu, rà soát governance). +- **Chi phí:** ~744 dòng nạp mỗi phiên làm việc trong 70-company (đã tăng ~1.7× so với ~430 dòng Phase 1 sau khi thêm §2.1.7 data-viz + §2.3.1-4 spacing + §2.6 icon + các errata) — vẫn chấp nhận được, nhưng là yếu tố cân nhắc cho §4.3 (vault rule paths-glob) nếu tiếp tục phình. + +### 4.2 Cơ chế 2 — Skill gọi tay (active) + +Skill `phenikaa-x-design-apply` đặt tại `.claude/skills/phenikaa-x-design-apply/SKILL.md`. + +- **Kích hoạt:** anh gõ `/phenikaa-x-design-apply` khi dựng deliverable mới. +- **Đầu ra:** thiết kế đã áp dụng + bảng tự kiểm 5 câu + trích nguồn quy tắc. +- **Phạm vi:** Dashboard, báo cáo FAT, slide, email, brochure (mọi nơi ngoài 70-company). + +### 4.3 Vault rule paths-glob — HOÃN + +Theo D125 "kích hoạt theo thực tế": chỉ thêm vault rule nếu Phase 5 pilot cho thấy anh quên gõ skill nhiều lần. + +## 5. Cross-references + +### Nguồn căn cứ (per spec §6.1) + +Foundation 4 tầng (đã ratify): +- `visual-from-raw.md` — spec màu / typography / logo / motif +- `identity-from-raw.md` — 6 giá trị cốt lõi (truy nguyên nguyên tắc) +- `applied-from-raw.md` — giọng viết (truy nguyên nguyên tắc §1) +- `governance-from-raw.md` — 5 giá trị vận hành (dùng cho phạm vi rà soát governance §4; chưa cite ở §1-§3, reserved Phase 2+) + +Nghiên cứu method D (ship S19): +- `50-plans/phenikaa-x-design-system/research-method-d-standard.md` — chuẩn ngành (Gestalt, grid taxonomy, lưới 8pt) +- `50-plans/phenikaa-x-design-system/research-method-d-community.md` — Carbon / Material 3 / Fluent 2 / Atlassian / Salesforce +- `50-plans/phenikaa-x-design-system/research-method-d-amr-hmi-web.md` — đặc thù AMR / HMI / Web +- `50-plans/phenikaa-x-design-system/research-method-d-rubric.md` — rubric kiểm Q1-Q5 + +### Nguồn spec + +- `50-plans/phenikaa-x-design-system/spec-design-language-stream.md` — spec brainstorm ratify 2026-05-31 +- `50-plans/phenikaa-x-design-system/plan.md` — plan stream 5 Phase +- `50-plans/phenikaa-x-design-system/research-record-phase-1.md` — nhật ký audit Phase 1 + +### Cơ chế áp dụng + +- `70-company/CLAUDE.md` — `@_foundation/design-language.md` tự nạp thụ động +- `.claude/skills/phenikaa-x-design-apply/SKILL.md` — gọi tay chủ động + +## Lịch sử phiên bản + +### v0.4.4 (2026-06-10) — Errata: hấp thụ token Caution + dọn nhất quán (audit workflow S59) + +**Thêm (errata additive — KHÔNG đổi token cũ):** +- §2.1.2 — thêm bậc `neutral-550` `#6A6F88` (giữa `neutral-500` và `neutral-600`); `border.strong` dark nay trỏ token này thay vì hardcode hex `#6A6F88` — khôi phục nguyên tắc §2.1.4 "alias trỏ token, KHÔNG hardcode hex". +- §2.1.3 — thêm biến thể `caution.fill` `#F9A900` (nền vàng callout "Thận trọng" ANSI Y2 / ISO 3864 signal yellow), buộc chữ đen `neutral-900`. Đối xứng tiền lệ `warning.icon` (v0.4.2): trước chỉ nhắc trong changelog prose, nay thành token chính thức §2.1.3 + WCAG §2.1.6. +- §2.1.6 — thêm 2 dòng WCAG cho `caution.fill` (8.99:1 / `neutral-900` AAA · 1.96:1 / white FAIL → buộc chữ đen). + +**Sửa (errata — số liệu lỗi thời, KHÔNG đổi token):** +- §2.1.6 — đồng bộ cặp chữ trắng / `orange-500` + `border.strong` `neutral-450` / white: bảng ghi `3.2:1` → `3.15:1` (giá trị thực công thức W3C; làm tròn lên 3.2 che mức sát ngưỡng 3:1, đã khớp callout phân tầng cam vốn ghi 3.15). +- §4.1 — "~430 dòng" → "~744 dòng" (số dòng thực sau Phase 2/3 + icon + errata; chi phí auto-load eager tăng ~1.7×). + +**Nguồn:** workflow kiểm chứng độc lập 8 chiều (S59 2026-06-10 — 10/21 phát hiện qua phản biện được xác nhận) + `research-status-colors-office.md` §6 (#F9A900 = 8.99:1 / chữ đen, D202) + `[fact]` công thức relative luminance W3C (tự tính lại #F9A900 + neutral-550) + owner ratify S59 2026-06-10. Nhãn H1/frontmatter giữ `v0.4` theo chính sách errata-no-bump; thêm dòng trỏ dưới H1. + +### v0.4.3 (2026-06-10) — Errata: sửa thuật ngữ + điều kiện `error.text-sm` + +**Sửa (errata — KHÔNG đổi token/giá trị màu; KHÔNG bump frontmatter, đúng tiền lệ v0.4.1/v0.4.2):** +- §2.1.3 — thuật ngữ nguyên nhân nhoè đỏ nhỏ: **"chromostereopsis" → "tán sắc ngang (TCA — chromatic aberration)"**. Chromostereopsis là ảo giác chiều sâu đỏ/xanh (cần màu đối nghịch kế bên), KHÔNG phải cơ chế nhoè chữ đỏ trên nền sáng. Đồng bộ `research-status-colors-office.md` §3 (đã ghi TCA từ S56). +- §2.1.3 — làm rõ **điều kiện cần `error.text-sm`**: chỉ khi HỘI ĐỦ cỡ nhỏ + nét mảnh + bão hoà cao + nền sáng; nêu rõ **nền tối gần như không bị** (tán sắc không thấy trên nền đen) → dark-mode giữ đỏ sáng, không cần trầm. + +**Nguồn:** `[fact]` [UXmatters — color theory digital displays](https://www.uxmatters.com/mt/archives/2007/01/applying-color-theory-to-digital-displays.php) · [MIT 6.813 Reading 16 Color](https://web.mit.edu/6.813/www/sp16/classes/16-color/) · [W3C WCAG3 visual contrast of text](https://www.w3.org/WAI/GL/WCAG3/2021/how-tos/visual-contrast-of-text/) (đỏ/xanh tán sắc, nhoè bão hoà rõ nhất nền trắng — không thấy nền đen) + `research-status-colors-office.md` §3 + owner ratify S58 2026-06-10. + +### v0.4.2 (2026-06-08) — Errata: token `warning.icon` cho callout office + +**Thêm (errata additive — KHÔNG đổi token cũ):** +- §2.1.3 — thêm biến thể `warning.icon` `#CB6119` (cam ISO, cho icon + tiêu đề callout cảnh báo cỡ lớn); giữ `warning #9A5B00` cho chữ/nhãn nhỏ. Đối xứng tiền lệ `error`/`error.text-sm`. +- §2.1.6 — thêm 2 dòng WCAG cho `warning.icon` (3.99:1 / white · 3.76:1 / `neutral-50` — đạt ngưỡng 3:1 chữ lớn/icon, `[fact]` công thức W3C). + +**Nguồn:** `50-plans/phenikaa-x-office-templates/research-status-colors-office.md` (§6 quyết định cuối + owner ratify S56 2026-06-08). Callout office dùng `warning.icon` cho Cảnh báo; Thận trọng dùng nền vàng `#F9A900` + chữ đen (Y2 ANSI). + +### v0.4.1 (2026-06-04) — Errata typography Office-native (M1.7) + +**Sửa (errata — KHÔNG đổi token, chỉ làm rõ Office mapping + sửa cite pháp lý):** +- §2.2.4 — viết lại: an toàn dấu chồng VN đến từ **chế độ auto** (tránh "Exactly"), KHÔNG từ bội số 1.5×; thêm bảng **ánh xạ Office** (Word "Multiple N" ≠ CSS N vì Montserrat line-unit = 1.219×, cờ USE_TYPO_METRICS). Body Word = **Multiple 1.15** (1.40×); heading lớn = **Single** (1.219×). +- §2.2.2 — note cột line-height = giá trị web/CSS; Word-native xem §2.2.4. +- §2.3 — note "lưới 8px" Office = lưới 6pt (cột pt đã đúng). +- §2.3.3 — `space.word.line` 1.5 → Multiple 1.15; bỏ cite TT 01/2011 sai. +- §2.3.4 — **NĐ 30 KHÔNG quy định giãn dòng**; "1.5 dòng" kế thừa TT 01/2011 (**đã hết hiệu lực 15/6/2020**). +- §2.5 — note radius: Word chỉ shape/text-box bo được, body+bảng KHÔNG bo. + +**Nguồn:** `50-plans/phenikaa-x-office-templates/research-office-typography.md` (M1.7 — đo OS/2 binary + ECMA-376 + NĐ30 Phụ lục I) + owner ratify S45 2026-06-04. + +### v0.4 (2026-06-01) — Sub-phase icon: chọn bộ icon Fluent + 6 luật cốt lõi + +**Ship (additive — Minor bump, KHÔNG đụng token cũ):** +- §2.6 mới — Bộ icon canonical = **Fluent UI System Icons** (Microsoft, MIT). Thêm thang cỡ `icon.sm/md/lg` (16/20/24px) + 6 luật cốt lõi (một nguồn · 2 kiểu Regular/Filled theo tình huống · cỡ neo type scale · màu = token · icon kèm nhãn WCAG 1.4.1 · non-text ≥3:1). Tái dùng token sẵn có, không tạo token màu/spacing mới. + +**Defer reality-activated:** icon AMR đặc thù (AMR/AGV/LiDAR/dock/mission) · bảng tra "khái niệm → tên Fluent" (L2) · luật nâng cao (keyline grid, ma trận state). + +**Reconcile nhãn version:** icon ship trước Phase 4 → icon nhận **v0.4**; Phase 4 (Pattern FAT report) rebase **v0.5** khi ship (per `spec-icon-subphase.md` §4 + reviewer S37 MAJOR-1). Đã sync `plan.md` Phase 4 (v0.4→v0.5). + +**Quyết định nguồn:** `50-plans/phenikaa-x-design-system/spec-icon-subphase.md` D150-D154 (brainstorm S37 + owner ratify Fluent + kiểu theo tình huống). + +### v0.3 (2026-06-01) — Phase 3: thang màu sequential đều cảm nhận + mô hình spacing 3 núm + +**Ship (additive — Minor bump):** +- §2.1.7.2 — Sequential ĐỔI từ "mượn thang brand" → brand-anchored đơn sắc đều L (OKLab), neo cứng `seq-700`≈Deep Blue + `seq-900`=Dark Blue. Trả "nợ" perceptual-uniform v0.2. +- §2.3 — Thêm mô hình spacing "1 thang gốc + 3 núm" (§2.3.1 density 3 mức · §2.3.2 responsive · §2.3.3 môi trường + token `space.word.*` · §2.3.4 nhịp dọc + ngoại lệ nhà nước). Bảng `space.0…3xl` GIỮ nguyên. + +**Tách Phase sau (D146):** icon set → sub-phase riêng (thẩm mỹ risk cao, cần phiên tập trung). + +**Quyết định nguồn:** `50-plans/phenikaa-x-design-system/spec-phase-3.md` D146-D149 (reviewer-clean iter1 + owner ratify S36). + +### v0.2 (2026-06-01) — Phase 2: bảng màu data-viz + chữ đỏ nhỏ + +**Ship (additive — Minor bump, KHÔNG đổi token v0.1 nào):** +- §2.1.7 mới — Data-viz 4 họ màu (categorical 4/6 + sequential + diverging) + alias `dataviz.*` +- §2.1.3 thêm biến thể `error.text-sm` (chữ đỏ nhỏ chống rung viền) — không đổi nghĩa `error` +- §2.1.6 thêm 2 dòng WCAG cho `error.text-sm` (7.26:1 sáng / 9.01:1 tối — AAA) + +**Hoãn Phase 3 (v0.3):** +- Sequential perceptual-uniform (thang đều cảm nhận) (→ shipped v0.3) +- Spacing formal scale (→ shipped v0.3, mô hình 3 núm) +- Icon set (→ tách sub-phase riêng per D146) + +**Quyết định nguồn:** `50-plans/phenikaa-x-design-system/spec-pattern-dashboard.md` D134–D142 (reviewer agent duyệt 2 vòng) + `palette_eval.py` (số đo khách quan). + +### v0.1 (2026-05-31) — Phase 1 bản nháp đầu + +**Ship:** +- §1 Nguyên tắc nền tảng — 5 nguyên tắc (3 truy nguyên giá trị cốt lõi + 2 bảo đảm phạm vi Layer 1) +- §2 Token — màu (thang sắc độ + alias semantic + verify WCAG) / chữ (thang cỡ + role mapping + verify dấu tiếng Việt) / spacing lưới 8px / elevation 3 mức / border + radius +- §3 Bố cục — lưới 12 cột + 4 khối bố cục (Container/Stack/Inline/Grid) + 4 breakpoint +- §4 Cơ chế áp dụng — @file thụ động 70-company + skill gọi tay + +**Hoãn Phase 2+:** +- Component (mảnh Lego) +- Pattern Dashboard (Phase 2) +- Data viz (Phase 3) +- Pattern báo cáo FAT (Phase 4) + +**Cờ bất định thẩm mỹ (rà lại Phase 5 audit):** +1. §1 cách đặt tên nguyên tắc — chờ feedback pilot +2. §2.1 số bậc thang sắc độ — rà lại theo cycle-time S24 +3. §2.2 cặp trọng lượng font — chờ feedback pilot +4. §2.4 thẩm mỹ elevation — chờ feedback pilot +5. §3.3 cách đặt tên khối bố cục — quy ước chủ quan diff --git a/web-server/database/04_notifications.sql b/web-server/database/04_notifications.sql new file mode 100644 index 0000000..53c3e54 --- /dev/null +++ b/web-server/database/04_notifications.sql @@ -0,0 +1,73 @@ +USE [RobotInstaller]; +GO + +SET ANSI_NULLS ON; +SET QUOTED_IDENTIFIER ON; +GO + +-- Additive/idempotent migration. Safe to run more than once. +IF OBJECT_ID(N'dbo.Notifications', N'U') IS NULL +BEGIN + CREATE TABLE dbo.Notifications + ( + Id UNIQUEIDENTIFIER NOT NULL + CONSTRAINT PK_Notifications PRIMARY KEY CLUSTERED + CONSTRAINT DF_Notifications_Id DEFAULT NEWSEQUENTIALID(), + RecipientUserId UNIQUEIDENTIFIER NOT NULL, + ActorUserId UNIQUEIDENTIFIER NULL, + ActorName NVARCHAR(200) NULL, + BroadcastId UNIQUEIDENTIFIER NULL, + EventType NVARCHAR(100) NOT NULL, + Severity NVARCHAR(20) NOT NULL + CONSTRAINT DF_Notifications_Severity DEFAULT N'info', + Title NVARCHAR(200) NOT NULL, + Message NVARCHAR(1000) NOT NULL, + EntityType NVARCHAR(50) NULL, + EntityId NVARCHAR(100) NULL, + ActionUrl NVARCHAR(1000) NULL, + ReadAt DATETIME2(3) NULL, + CreatedAt DATETIME2(3) NOT NULL + CONSTRAINT DF_Notifications_CreatedAt DEFAULT SYSUTCDATETIME(), + ExpiresAt DATETIME2(3) NULL, + CONSTRAINT FK_Notifications_RecipientUser + FOREIGN KEY (RecipientUserId) REFERENCES dbo.Users(Id) ON DELETE CASCADE, + CONSTRAINT CK_Notifications_Severity + CHECK (Severity IN (N'info', N'success', N'warning', N'error')), + CONSTRAINT CK_Notifications_EventType_NotBlank + CHECK (LEN(LTRIM(RTRIM(EventType))) > 0), + CONSTRAINT CK_Notifications_Title_NotBlank + CHECK (LEN(LTRIM(RTRIM(Title))) > 0), + CONSTRAINT CK_Notifications_Message_NotBlank + CHECK (LEN(LTRIM(RTRIM(Message))) > 0) + ); +END; +GO + +IF NOT EXISTS ( + SELECT 1 + FROM sys.indexes + WHERE object_id = OBJECT_ID(N'dbo.Notifications') + AND name = N'IX_Notifications_Recipient_Read_Created' +) +BEGIN + CREATE INDEX IX_Notifications_Recipient_Read_Created + ON dbo.Notifications(RecipientUserId, ReadAt, CreatedAt DESC) + INCLUDE (Severity, Title, ActionUrl, ExpiresAt); +END; +GO + +IF NOT EXISTS ( + SELECT 1 + FROM sys.indexes + WHERE object_id = OBJECT_ID(N'dbo.Notifications') + AND name = N'IX_Notifications_BroadcastId' +) +BEGIN + CREATE INDEX IX_Notifications_BroadcastId + ON dbo.Notifications(BroadcastId) + WHERE BroadcastId IS NOT NULL; +END; +GO + +PRINT N'RobotInstaller notification schema is ready.'; +GO diff --git a/web-server/database/README.md b/web-server/database/README.md index 7b48e26..4b6e2e4 100644 --- a/web-server/database/README.md +++ b/web-server/database/README.md @@ -28,6 +28,7 @@ Không lưu mật khẩu thật vào file cấu hình. Khi chạy local, tạo f | `dbo.PackageVersions` | Các version của từng package | | `dbo.Applications` | App được đóng gói từ nhiều package | | `dbo.ApplicationPackages` | Liên kết app-package, có thể chọn version cụ thể | +| `dbo.Notifications` | Thông báo riêng cho từng user và thông báo hệ thống do Admin đăng | ## Ràng buộc quan trọng @@ -63,12 +64,15 @@ $env:SQLCMDPASSWORD = '' sqlcmd -S 172.20.235.176 -U sa -b -i .\database\01_create_database.sql sqlcmd -S 172.20.235.176 -U sa -d RobotInstaller -b -i .\database\02_schema.sql sqlcmd -S 172.20.235.176 -U sa -d RobotInstaller -b -i .\database\03_views.sql +sqlcmd -S 172.20.235.176 -U sa -d RobotInstaller -b -i .\database\04_notifications.sql ``` Chạy các lệnh trên từ thư mục `web-server`. Khi dùng `sqlcmd` để seed/test dữ liệu, thêm `-I` hoặc bật `SET QUOTED_IDENTIFIER ON` vì schema có filtered index cho ràng buộc một latest version trên mỗi package. +`04_notifications.sql` là migration chỉ bổ sung bảng/index và có thể chạy lặp lại. Với database đang hoạt động, chỉ chạy file này; không chạy lại `02_schema.sql` vì script schema gốc chủ động dừng khi phát hiện bảng đã tồn tại. + ## Luồng dữ liệu đề xuất 1. Upload package mới: diff --git a/web-server/public/css/styles.css b/web-server/public/css/styles.css index c303f81..6d32f8c 100644 --- a/web-server/public/css/styles.css +++ b/web-server/public/css/styles.css @@ -225,6 +225,8 @@ button:disabled { height: 56px; justify-content: space-between; padding: 0 24px; + position: relative; + z-index: 60; } .topbar-left, @@ -240,6 +242,256 @@ button:disabled { margin: 0; } +.notification-center { + position: relative; +} + +.notification-button { + position: relative; +} + +.notification-badge { + align-items: center; + background: #d92d20; + border: 2px solid #f8fafc; + border-radius: 999px; + color: #ffffff; + display: inline-flex; + font-size: 9px; + font-weight: 800; + height: 18px; + justify-content: center; + line-height: 1; + min-width: 18px; + padding: 0 4px; + position: absolute; + right: -7px; + top: -7px; +} + +.notification-panel { + background: #ffffff; + border: 1px solid #dbe3ea; + border-radius: var(--radius); + box-shadow: var(--shadow-lg); + overflow: hidden; + position: absolute; + right: 0; + top: calc(100% + 10px); + width: min(400px, calc(100vw - 28px)); + z-index: 120; +} + +.notification-panel[hidden], +.notification-badge[hidden], +.notification-empty[hidden] { + display: none; +} + +.notification-panel-header { + align-items: center; + border-bottom: 1px solid #eef2f7; + display: flex; + justify-content: space-between; + padding: 14px 16px 10px; +} + +.notification-panel-header > div { + display: flex; + flex-direction: column; + gap: 2px; +} + +.notification-panel-header strong { + color: #111827; + font-family: "Manrope", Arial, sans-serif; + font-size: 15px; +} + +.notification-panel-header span { + color: #64748b; + font-size: 11px; +} + +.text-button { + background: transparent; + border: 0; + color: var(--primary); + font-size: 11px; + font-weight: 800; + padding: 5px; +} + +.text-button:hover, +.text-button:focus-visible { + text-decoration: underline; +} + +.notification-tabs { + border-bottom: 1px solid #eef2f7; + display: flex; + gap: 16px; + padding: 0 16px; +} + +.notification-tabs button { + background: transparent; + border: 0; + border-bottom: 2px solid transparent; + color: #64748b; + font-size: 11px; + font-weight: 800; + padding: 9px 0 7px; +} + +.notification-tabs button.active { + border-bottom-color: var(--primary); + color: var(--primary); +} + +.notification-list { + max-height: min(440px, calc(100vh - 190px)); + overflow-y: auto; +} + +.notification-item { + align-items: flex-start; + background: #ffffff; + border: 0; + border-bottom: 1px solid #f0f3f7; + color: inherit; + display: grid; + gap: 10px; + grid-template-columns: 34px minmax(0, 1fr) 7px; + padding: 12px 14px; + text-align: left; + width: 100%; +} + +.notification-item:hover, +.notification-item:focus-visible { + background: #f8fafc; + outline: 0; +} + +.notification-item.unread { + background: #f7f8ff; +} + +.notification-item.unread:hover, +.notification-item.unread:focus-visible { + background: #eef1ff; +} + +.notification-item-icon { + align-items: center; + background: var(--info-bg); + border-radius: 50%; + color: var(--info); + display: inline-flex; + height: 34px; + justify-content: center; + width: 34px; +} + +.notification-item-icon .material-symbols-outlined { + font-size: 19px; +} + +.notification-item.tone-success .notification-item-icon { + background: var(--success-bg); + color: var(--success); +} + +.notification-item.tone-warning .notification-item-icon { + background: var(--warning-bg); + color: var(--warning); +} + +.notification-item.tone-error .notification-item-icon { + background: var(--danger-bg); + color: var(--danger); +} + +.notification-item-copy { + min-width: 0; +} + +.notification-item-copy strong, +.notification-item-copy span, +.notification-item-copy time { + display: block; +} + +.notification-item-copy strong { + color: #172033; + font-size: 12px; + line-height: 1.4; +} + +.notification-item-copy span { + color: #52606d; + font-size: 11px; + line-height: 1.45; + margin-top: 3px; + overflow-wrap: anywhere; +} + +.notification-item-copy time { + color: #84909a; + font-size: 10px; + margin-top: 5px; +} + +.notification-unread-dot { + align-self: center; + background: var(--primary); + border-radius: 50%; + height: 7px; + opacity: 0; + width: 7px; +} + +.notification-item.unread .notification-unread-dot { + opacity: 1; +} + +.notification-empty { + align-items: center; + color: #64748b; + display: flex; + flex-direction: column; + gap: 5px; + min-height: 180px; + justify-content: center; + padding: 24px; + text-align: center; +} + +.notification-empty .material-symbols-outlined { + color: #94a3b8; + font-size: 32px; +} + +.notification-empty strong { + color: #334155; + font-size: 12px; +} + +.notification-empty span:last-child { + font-size: 11px; +} + +.notification-panel-footer { + background: #f8fafc; + border-top: 1px solid #eef2f7; + padding: 10px 14px; +} + +.notification-panel-footer .btn { + width: 100%; +} + .profile-chip { align-items: center; background: transparent; @@ -419,6 +671,7 @@ button:disabled { color: var(--danger); } + .dashboard-stats { display: grid; flex-shrink: 0; @@ -1060,6 +1313,13 @@ tbody tr:hover td.action-col { font-weight: 800; } +.modal-header p { + color: #64748b; + font-size: 11px; + line-height: 1.45; + margin-top: 3px; +} + .modal-form { flex: 1; min-height: 0; @@ -1464,6 +1724,15 @@ tbody tr:hover td.action-col { padding: 0 14px; } + .notification-panel { + left: 8px; + max-height: calc(100dvh - 72px); + position: fixed; + right: 8px; + top: 64px; + width: auto; + } + .profile-meta { display: none; } diff --git a/web-server/public/js/app.js b/web-server/public/js/app.js index 7a8d65b..b644ad6 100644 --- a/web-server/public/js/app.js +++ b/web-server/public/js/app.js @@ -596,11 +596,343 @@ }); } + function formatNotificationTime(value) { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return ''; + + const elapsedSeconds = Math.max(0, Math.floor((Date.now() - date.getTime()) / 1000)); + if (elapsedSeconds < 60) return 'Vừa xong'; + if (elapsedSeconds < 3600) return `${Math.floor(elapsedSeconds / 60)} phút trước`; + if (elapsedSeconds < 86400) return `${Math.floor(elapsedSeconds / 3600)} giờ trước`; + if (elapsedSeconds < 604800) return `${Math.floor(elapsedSeconds / 86400)} ngày trước`; + + return new Intl.DateTimeFormat('vi-VN', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit' + }).format(date); + } + + function getNotificationIcon(notification) { + if (notification.isSystem) return 'campaign'; + if (notification.severity === 'success') return 'check_circle'; + if (notification.severity === 'warning') return 'warning'; + if (notification.severity === 'error') return 'error'; + if (notification.entityType === 'package') return 'inventory_2'; + if (notification.entityType === 'application') return 'apps'; + return 'notifications'; + } + + async function notificationRequest(url, options = {}) { + const response = await fetch(url, { + cache: 'no-store', + credentials: 'same-origin', + ...options, + headers: { + Accept: 'application/json', + ...(options.body ? { 'Content-Type': 'application/json' } : {}), + ...(options.headers || {}) + } + }); + const data = await response.json().catch(() => ({})); + + if (!response.ok) { + throw new Error(data.error || 'Không thể xử lý thông báo.'); + } + + return data; + } + + function initNotificationCenter() { + const center = document.querySelector('[data-notification-center]'); + if (!center) return; + + const toggle = center.querySelector('[data-notification-toggle]'); + const panel = center.querySelector('[data-notification-panel]'); + const badge = center.querySelector('[data-notification-badge]'); + const summary = center.querySelector('[data-notification-summary]'); + const list = center.querySelector('[data-notification-list]'); + const empty = center.querySelector('[data-notification-empty]'); + const readAllButton = center.querySelector('[data-notification-read-all]'); + const filterButtons = center.querySelectorAll('[data-notification-filter]'); + const knownNotificationIds = new Set(); + let currentFilter = 'all'; + let initialized = false; + let loading = false; + let reloadPending = false; + + function setPanelOpen(open) { + panel.hidden = !open; + toggle.setAttribute('aria-expanded', open ? 'true' : 'false'); + } + + function setEmptyState(title, message, visible) { + const titleElement = empty.querySelector('strong'); + const messageElement = empty.querySelector('span:last-child'); + if (titleElement) titleElement.textContent = title; + if (messageElement) messageElement.textContent = message; + empty.hidden = !visible; + } + + function updateUnreadCount(count) { + const unreadCount = Math.max(0, Number(count) || 0); + badge.textContent = unreadCount > 99 ? '99+' : String(unreadCount); + badge.hidden = unreadCount === 0; + summary.textContent = unreadCount > 0 ? `${unreadCount} thông báo chưa đọc` : 'Không có thông báo chưa đọc'; + readAllButton.disabled = unreadCount === 0; + } + + function renderNotifications(notifications) { + list.replaceChildren(); + setEmptyState( + currentFilter === 'unread' ? 'Không có thông báo chưa đọc' : 'Chưa có thông báo', + 'Các cập nhật quan trọng sẽ xuất hiện tại đây.', + notifications.length === 0 + ); + + notifications.forEach((notification) => { + const item = document.createElement('button'); + item.type = 'button'; + item.className = `notification-item tone-${notification.severity || 'info'}${notification.isRead ? '' : ' unread'}`; + item.dataset.notificationId = notification.id; + + const icon = document.createElement('span'); + icon.className = 'notification-item-icon'; + const iconGlyph = document.createElement('span'); + iconGlyph.className = 'material-symbols-outlined'; + iconGlyph.setAttribute('aria-hidden', 'true'); + iconGlyph.textContent = getNotificationIcon(notification); + icon.appendChild(iconGlyph); + + const copy = document.createElement('span'); + copy.className = 'notification-item-copy'; + const title = document.createElement('strong'); + title.textContent = notification.title; + const message = document.createElement('span'); + message.textContent = notification.message; + const time = document.createElement('time'); + time.dateTime = notification.createdAt || ''; + time.textContent = formatNotificationTime(notification.createdAt); + copy.append(title, message, time); + + const unreadDot = document.createElement('span'); + unreadDot.className = 'notification-unread-dot'; + unreadDot.setAttribute('aria-label', notification.isRead ? 'Đã đọc' : 'Chưa đọc'); + + item.append(icon, copy, unreadDot); + item.addEventListener('click', async () => { + try { + if (!notification.isRead) { + await notificationRequest(`/api/notifications/${encodeURIComponent(notification.id)}/read`, { + method: 'POST' + }); + notification.isRead = true; + item.classList.remove('unread'); + await loadNotifications(false); + } + } catch (error) { + notify('warning', error.message); + } + + if ( + notification.actionUrl + && notification.actionUrl.startsWith('/') + && !notification.actionUrl.startsWith('//') + ) { + window.location.assign(notification.actionUrl); + } + }); + + list.appendChild(item); + }); + } + + async function loadNotifications(announceNew = true) { + if (loading) { + reloadPending = true; + return; + } + loading = true; + + try { + const data = await notificationRequest( + `/api/notifications?limit=20&unreadOnly=${currentFilter === 'unread' ? 'true' : 'false'}` + ); + + if (!data.available) { + badge.hidden = true; + summary.textContent = 'Chưa kích hoạt trong database'; + readAllButton.disabled = true; + list.replaceChildren(); + setEmptyState( + 'Thông báo chưa khả dụng', + 'Admin cần chạy migration 04_notifications.sql.', + true + ); + return; + } + + const notifications = Array.isArray(data.notifications) ? data.notifications : []; + if (initialized && announceNew) { + const importantNotification = notifications.find((notification) => ( + !knownNotificationIds.has(notification.id) + && !notification.isRead + && ['warning', 'error'].includes(notification.severity) + )); + + if (importantNotification) { + notify( + importantNotification.severity === 'error' ? 'failure' : 'warning', + importantNotification.title + ); + } + } + + notifications.forEach((notification) => knownNotificationIds.add(notification.id)); + initialized = true; + updateUnreadCount(data.unreadCount); + renderNotifications(notifications); + } catch (error) { + summary.textContent = 'Không thể tải thông báo'; + setEmptyState('Không thể tải thông báo', 'Vui lòng thử lại sau.', true); + } finally { + loading = false; + if (reloadPending) { + reloadPending = false; + loadNotifications(false); + } + } + } + + async function refreshUnreadCount() { + try { + const data = await notificationRequest('/api/notifications?countOnly=true'); + if (!data.available) { + badge.hidden = true; + summary.textContent = 'Chưa kích hoạt trong database'; + readAllButton.disabled = true; + return; + } + + updateUnreadCount(data.unreadCount); + } catch (error) { + console.info('Cannot refresh notification count:', error); + } + } + + toggle.addEventListener('click', () => { + const open = panel.hidden; + setPanelOpen(open); + if (open) loadNotifications(false); + }); + + readAllButton.addEventListener('click', async () => { + readAllButton.disabled = true; + try { + await notificationRequest('/api/notifications/read-all', { method: 'POST' }); + await loadNotifications(false); + } catch (error) { + notify('failure', error.message); + } + }); + + filterButtons.forEach((button) => { + button.addEventListener('click', () => { + currentFilter = button.dataset.notificationFilter; + filterButtons.forEach((candidate) => { + const active = candidate === button; + candidate.classList.toggle('active', active); + candidate.setAttribute('aria-selected', active ? 'true' : 'false'); + }); + loadNotifications(false); + }); + }); + + document.addEventListener('click', (event) => { + if (!panel.hidden && !center.contains(event.target)) { + setPanelOpen(false); + } + }); + + document.addEventListener('keydown', (event) => { + if (event.key === 'Escape' && !panel.hidden) { + setPanelOpen(false); + toggle.focus(); + } + }); + + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible') { + if (panel.hidden) refreshUnreadCount(); + else loadNotifications(true); + } + }); + + document.addEventListener('notifications:refresh', () => { + if (panel.hidden) refreshUnreadCount(); + else loadNotifications(false); + }); + + const broadcastButton = center.querySelector('[data-modal-open="systemNotificationModal"]'); + if (broadcastButton) { + broadcastButton.addEventListener('click', () => setPanelOpen(false)); + } + + window.setInterval(() => { + if (document.visibilityState !== 'visible') return; + if (panel.hidden) refreshUnreadCount(); + else loadNotifications(true); + }, 30000); + + refreshUnreadCount(); + } + + function initSystemNotificationForm() { + const form = document.querySelector('[data-system-notification-form]'); + if (!form) return; + + form.addEventListener('submit', (event) => { + event.preventDefault(); + if (!form.reportValidity()) return; + + confirmAction('Gửi thông báo này tới tất cả tài khoản đang hoạt động?', async () => { + const submitButton = form.querySelector('[data-system-notification-submit]'); + const formData = new FormData(form); + submitButton.disabled = true; + + try { + const result = await notificationRequest('/api/notifications/broadcasts', { + method: 'POST', + body: JSON.stringify({ + title: formData.get('title'), + severity: formData.get('severity'), + message: formData.get('message'), + actionUrl: formData.get('actionUrl') + }) + }); + + closeModal(form.closest('.modal-backdrop')); + form.reset(); + notify('success', `Đã gửi thông báo tới ${result.recipientCount || 0} người dùng.`); + document.dispatchEvent(new CustomEvent('notifications:refresh')); + } catch (error) { + notify('failure', error.message); + } finally { + submitButton.disabled = false; + } + }); + }); + } + initNotiflix(); initFileDropzones(); initRegistrationUniqueChecks(); initProfileForms(); initEditAppForms(); + initNotificationCenter(); + initSystemNotificationForm(); if (body.dataset.notice) { notify(body.dataset.noticeType || 'info', body.dataset.notice); diff --git a/web-server/server.js b/web-server/server.js index 3c7220c..589eb8c 100644 --- a/web-server/server.js +++ b/web-server/server.js @@ -8,6 +8,7 @@ const path = require('path'); const express = require('express'); const multer = require('multer'); const repository = require('./src/repository'); +const notificationRepository = require('./src/notification-repository'); const mailer = require('./src/mailer'); const { closePool, getPool } = require('./src/db'); const notiflixVersion = require('notiflix/package.json').version; @@ -123,6 +124,7 @@ app.set('view engine', 'ejs'); app.set('views', path.join(__dirname, 'views')); app.use(express.urlencoded({ extended: true })); +app.use(express.json({ limit: '32kb' })); app.use(express.static(path.join(__dirname, 'public'))); app.use('/vendor/notiflix', express.static(path.join(__dirname, 'node_modules/notiflix/dist'))); app.use(applyPublicApiCors); @@ -524,6 +526,55 @@ function sanitizeReturnTo(value) { return value; } +function sanitizeNotificationActionUrl(value) { + const text = String(value || '').trim(); + if (!text) return ''; + if ( + text.length > 1000 + || !text.startsWith('/') + || text.startsWith('//') + || text.includes('\\') + || /[\u0000-\u001F\u007F]/.test(text) + ) { + return null; + } + + return text; +} + +function normalizeNotificationSeverity(value) { + const severity = String(value || 'info').trim().toLowerCase(); + return ['info', 'success', 'warning', 'error'].includes(severity) ? severity : null; +} + +function isUuid(value) { + return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test( + String(value || '').trim() + ); +} + +function getNotificationActorName(user) { + return String(user?.name || user?.username || '').trim(); +} + +async function safelyCreateNotification(label, callback) { + try { + return await callback(); + } catch (error) { + console.error(`Cannot create notification (${label}):`, error); + return null; + } +} + +async function safelyLoadNotificationContext(label, callback) { + try { + return await callback(); + } catch (error) { + console.error(`Cannot load notification context (${label}):`, error); + return null; + } +} + function getBaseUrl(req) { const requestBaseUrl = getRequestBaseUrl(req); if (process.env.APP_BASE_URL) { @@ -851,6 +902,15 @@ function requireAdmin(req, res, next) { redirectWithNotice(res, '/', 'failure', 'Bạn cần quyền Admin để quản lý user.'); } +function requireAdminApi(req, res, next) { + if (req.currentUser && req.currentUser.role === 'Admin') { + next(); + return; + } + + res.status(403).json({ error: 'Bạn cần quyền Admin để thực hiện thao tác này.' }); +} + function normalizePackageType(value) { const packageType = String(value || 'deb').toLowerCase(); return ['deb', 'apt', 'docker'].includes(packageType) ? packageType : 'deb'; @@ -1497,6 +1557,92 @@ exit 1 app.use(requireAuthenticated); app.use('/uploads', express.static(path.join(__dirname, 'uploads'))); +app.get('/api/notifications', asyncRoute(async (req, res) => { + const countOnly = String(req.query.countOnly || '').toLowerCase() === 'true'; + const result = countOnly + ? await notificationRepository.getUnreadCount(req.currentUser.id) + : await notificationRepository.listNotifications(req.currentUser.id, { + limit: req.query.limit, + unreadOnly: String(req.query.unreadOnly || '').toLowerCase() === 'true' + }); + + res.setHeader('Cache-Control', 'no-store'); + res.json(result); +})); + +app.post('/api/notifications/read-all', asyncRoute(async (req, res) => { + const result = await notificationRepository.markAllNotificationsRead(req.currentUser.id); + if (!result.available) { + res.status(503).json({ error: 'Tính năng thông báo chưa được kích hoạt trong database.' }); + return; + } + + res.json(result); +})); + +app.post('/api/notifications/:id/read', asyncRoute(async (req, res) => { + if (!isUuid(req.params.id)) { + res.status(400).json({ error: 'Notification id không hợp lệ.' }); + return; + } + + const result = await notificationRepository.markNotificationRead(req.currentUser.id, req.params.id); + if (!result.available) { + res.status(503).json({ error: 'Tính năng thông báo chưa được kích hoạt trong database.' }); + return; + } + + if (!result.updated) { + res.status(404).json({ error: 'Không tìm thấy thông báo.' }); + return; + } + + res.json(result); +})); + +app.post('/api/notifications/broadcasts', requireAdminApi, asyncRoute(async (req, res) => { + const title = String(req.body.title || '').trim(); + const message = String(req.body.message || '').trim(); + const severity = normalizeNotificationSeverity(req.body.severity); + const actionUrl = sanitizeNotificationActionUrl(req.body.actionUrl); + + if (!title || title.length > 200) { + res.status(400).json({ error: 'Tiêu đề bắt buộc và không được vượt quá 200 ký tự.' }); + return; + } + + if (!message || message.length > 1000) { + res.status(400).json({ error: 'Nội dung bắt buộc và không được vượt quá 1000 ký tự.' }); + return; + } + + if (!severity) { + res.status(400).json({ error: 'Mức độ thông báo không hợp lệ.' }); + return; + } + + if (actionUrl === null) { + res.status(400).json({ error: 'Đường dẫn phải là đường dẫn nội bộ bắt đầu bằng /.' }); + return; + } + + const result = await notificationRepository.createSystemBroadcast({ + actorUserId: req.currentUser.id, + actorName: getNotificationActorName(req.currentUser), + severity, + title, + message, + actionUrl + }); + + if (!result.available) { + res.status(503).json({ error: 'Chưa có bảng dbo.Notifications. Hãy chạy migration 04_notifications.sql trước.' }); + return; + } + + res.status(201).json(result); +})); + app.get('/agent', requireAdmin, asyncRoute(async (req, res) => { const pageData = await repository.getPageData(req.currentUser); const agentPackages = await listAgentPackages(); @@ -1824,6 +1970,14 @@ app.post('/package-versions', upload.single('packageFile'), asyncRoute(async (re }; await repository.addPackageVersion(versionInput); + await safelyCreateNotification('package version available', () => ( + notificationRepository.notifyPackageVersionAvailable({ + packageId: versionInput.packageId, + version: versionInput.version, + actorUserId: req.currentUser.id, + actorName: getNotificationActorName(req.currentUser) + }) + )); redirectWithNotice(res, `/packages/${req.body.packageId}`, 'success', 'Đã cập nhật version mới và đặt làm latest.'); } catch (error) { @@ -1833,6 +1987,15 @@ app.post('/package-versions', upload.single('packageFile'), asyncRoute(async (re const replacedVersionId = await repository.replacePackageVersionArtifact(versionInput); if (replacedVersionId) { + await safelyCreateNotification('package version artifact updated', () => ( + notificationRepository.notifyPackageVersionAvailable({ + packageId: versionInput.packageId, + version: versionInput.version, + isArtifactUpdate: true, + actorUserId: req.currentUser.id, + actorName: getNotificationActorName(req.currentUser) + }) + )); redirectWithNotice( res, `/packages/${versionInput.packageId}`, @@ -1857,8 +2020,28 @@ app.post('/package-versions', upload.single('packageFile'), asyncRoute(async (re })); app.post('/packages/:id/delete', asyncRoute(async (req, res) => { + const notificationImpact = await safelyLoadNotificationContext('package delete', () => ( + notificationRepository.getPackageImpact(req.params.id) + )); const deleted = await repository.deletePackage(req.params.id); + if (deleted && notificationImpact) { + await safelyCreateNotification('package deleted', () => ( + notificationRepository.createNotificationsForUsers({ + recipientUserIds: notificationImpact.recipientUserIds, + actorUserId: req.currentUser.id, + actorName: getNotificationActorName(req.currentUser), + eventType: 'package.deleted', + severity: 'error', + title: `Package ${notificationImpact.packageCode} đã bị xóa`, + message: 'Các liên kết application sử dụng package này đã bị gỡ. Hãy kiểm tra lại cấu hình application liên quan.', + actionUrl: '/applications', + entityType: 'package', + entityId: notificationImpact.packageId + }) + )); + } + redirectWithNotice( res, '/packages', @@ -1876,8 +2059,28 @@ app.post('/package-versions/:id/latest', asyncRoute(async (req, res) => { app.post('/package-versions/:id/delete', asyncRoute(async (req, res) => { const returnTo = sanitizeReturnTo(req.body.returnTo || '/packages'); + const notificationImpact = await safelyLoadNotificationContext('package version delete', () => ( + notificationRepository.getPackageVersionImpact(req.params.id) + )); const result = await repository.deletePackageVersion(req.params.id); + if (result.deleted && notificationImpact) { + await safelyCreateNotification('package version deleted', () => ( + notificationRepository.createNotificationsForUsers({ + recipientUserIds: notificationImpact.recipientUserIds, + actorUserId: req.currentUser.id, + actorName: getNotificationActorName(req.currentUser), + eventType: 'package.version_deleted', + severity: 'warning', + title: `Version ${notificationImpact.version} của ${notificationImpact.packageCode} đã bị xóa`, + message: 'Các liên kết application sử dụng chính version này đã bị gỡ. Hãy kiểm tra lại application liên quan.', + actionUrl: '/applications', + entityType: 'package', + entityId: notificationImpact.packageId + }) + )); + } + redirectWithNotice( res, result.packageId ? `/packages/${result.packageId}` : returnTo, @@ -2037,6 +2240,16 @@ app.post('/applications/:id/release', asyncRoute(async (req, res) => { const returnTo = sanitizeReturnTo(req.body.returnTo || `/applications/${applicationId}`); const updated = await repository.updateApplicationStatus(applicationId, 'Released'); + if (updated) { + await safelyCreateNotification('application released', () => ( + notificationRepository.notifyApplicationReleased({ + applicationId, + actorUserId: req.currentUser.id, + actorName: getNotificationActorName(req.currentUser) + }) + )); + } + redirectWithNotice( res, returnTo, @@ -2237,6 +2450,13 @@ app.get('/builder', asyncRoute(async (req, res) => { app.use(async (error, req, res, next) => { console.error(error); + if (req.path.startsWith('/api/notifications')) { + res.status(500).json({ + error: 'Không thể xử lý thông báo lúc này. Vui lòng thử lại sau.' + }); + return; + } + const pageData = await repository.getPageData(req.currentUser).catch(() => ({ currentUser: req.currentUser || { name: 'Guest', role: 'Guest', email: '' }, stats: { diff --git a/web-server/src/notification-repository.js b/web-server/src/notification-repository.js new file mode 100644 index 0000000..5f0fbdd --- /dev/null +++ b/web-server/src/notification-repository.js @@ -0,0 +1,458 @@ +const crypto = require('crypto'); +const { sql, getPool } = require('./db'); + +const SCHEMA_CHECK_TTL_MS = 30 * 1000; +const ALLOWED_SEVERITIES = new Set(['info', 'success', 'warning', 'error']); +let notificationSchemaState = { + available: false, + checkedAt: 0 +}; + +function normalizeSeverity(value) { + const severity = String(value || '').trim().toLowerCase(); + return ALLOWED_SEVERITIES.has(severity) ? severity : 'info'; +} + +function toIsoString(value) { + if (!value) return null; + const date = value instanceof Date ? value : new Date(value); + return Number.isNaN(date.getTime()) ? null : date.toISOString(); +} + +function mapNotificationRow(row) { + return { + id: String(row.Id), + eventType: row.EventType, + isSystem: row.EventType === 'system.announcement', + severity: normalizeSeverity(row.Severity), + title: row.Title, + message: row.Message, + entityType: row.EntityType || '', + entityId: row.EntityId || '', + actionUrl: row.ActionUrl || '', + actorName: row.ActorName || '', + isRead: Boolean(row.ReadAt), + readAt: toIsoString(row.ReadAt), + createdAt: toIsoString(row.CreatedAt) + }; +} + +async function checkNotificationSchema(force = false) { + const now = Date.now(); + if (!force && now - notificationSchemaState.checkedAt < SCHEMA_CHECK_TTL_MS) { + return notificationSchemaState.available; + } + + const pool = await getPool(); + const result = await pool.request().query(` + SELECT CASE WHEN OBJECT_ID(N'dbo.Notifications', N'U') IS NULL THEN 0 ELSE 1 END AS IsAvailable; + `); + + notificationSchemaState = { + available: Boolean(result.recordset[0] && result.recordset[0].IsAvailable), + checkedAt: now + }; + return notificationSchemaState.available; +} + +async function listNotifications(userId, options = {}) { + const available = await checkNotificationSchema(); + if (!available) { + return { + available: false, + notifications: [], + unreadCount: 0 + }; + } + + const limit = Math.min(Math.max(Math.trunc(Number(options.limit)) || 20, 1), 50); + const unreadOnly = Boolean(options.unreadOnly); + const pool = await getPool(); + const result = await pool.request() + .input('RecipientUserId', sql.UniqueIdentifier, userId) + .input('Limit', sql.Int, limit) + .input('UnreadOnly', sql.Bit, unreadOnly ? 1 : 0) + .query(` + SELECT COUNT_BIG(*) AS UnreadCount + FROM dbo.Notifications + WHERE RecipientUserId = @RecipientUserId + AND ReadAt IS NULL + AND (ExpiresAt IS NULL OR ExpiresAt > SYSUTCDATETIME()); + + SELECT TOP (@Limit) + Id, EventType, Severity, Title, Message, EntityType, EntityId, + ActionUrl, ActorName, ReadAt, CreatedAt + FROM dbo.Notifications + WHERE RecipientUserId = @RecipientUserId + AND (@UnreadOnly = 0 OR ReadAt IS NULL) + AND (ExpiresAt IS NULL OR ExpiresAt > SYSUTCDATETIME()) + ORDER BY CreatedAt DESC, Id DESC; + `); + + return { + available: true, + unreadCount: Number(result.recordsets[0][0].UnreadCount || 0), + notifications: result.recordsets[1].map(mapNotificationRow) + }; +} + +async function getUnreadCount(userId) { + const available = await checkNotificationSchema(); + if (!available) { + return { available: false, unreadCount: 0 }; + } + + const pool = await getPool(); + const result = await pool.request() + .input('RecipientUserId', sql.UniqueIdentifier, userId) + .query(` + SELECT COUNT_BIG(*) AS UnreadCount + FROM dbo.Notifications + WHERE RecipientUserId = @RecipientUserId + AND ReadAt IS NULL + AND (ExpiresAt IS NULL OR ExpiresAt > SYSUTCDATETIME()); + `); + + return { + available: true, + unreadCount: Number(result.recordset[0].UnreadCount || 0) + }; +} + +async function markNotificationRead(notificationId, userId) { + if (!await checkNotificationSchema()) { + return { available: false, updated: false }; + } + + const pool = await getPool(); + const result = await pool.request() + .input('Id', sql.UniqueIdentifier, notificationId) + .input('RecipientUserId', sql.UniqueIdentifier, userId) + .query(` + UPDATE dbo.Notifications + SET ReadAt = COALESCE(ReadAt, SYSUTCDATETIME()) + OUTPUT inserted.Id + WHERE Id = @Id + AND RecipientUserId = @RecipientUserId; + `); + + return { + available: true, + updated: result.recordset.length > 0 + }; +} + +async function markAllNotificationsRead(userId) { + if (!await checkNotificationSchema()) { + return { available: false, updatedCount: 0 }; + } + + const pool = await getPool(); + const result = await pool.request() + .input('RecipientUserId', sql.UniqueIdentifier, userId) + .query(` + UPDATE dbo.Notifications + SET ReadAt = SYSUTCDATETIME() + OUTPUT inserted.Id + WHERE RecipientUserId = @RecipientUserId + AND ReadAt IS NULL + AND (ExpiresAt IS NULL OR ExpiresAt > SYSUTCDATETIME()); + `); + + return { + available: true, + updatedCount: result.recordset.length + }; +} + +async function createSystemBroadcast(input) { + if (!await checkNotificationSchema()) { + return { available: false, broadcastId: null, recipientCount: 0 }; + } + + const pool = await getPool(); + const broadcastId = crypto.randomUUID(); + const result = await pool.request() + .input('ActorUserId', sql.UniqueIdentifier, input.actorUserId) + .input('ActorName', sql.NVarChar(200), input.actorName || null) + .input('BroadcastId', sql.UniqueIdentifier, broadcastId) + .input('Severity', sql.NVarChar(20), normalizeSeverity(input.severity)) + .input('Title', sql.NVarChar(200), input.title) + .input('Message', sql.NVarChar(1000), input.message) + .input('ActionUrl', sql.NVarChar(1000), input.actionUrl || null) + .query(` + INSERT dbo.Notifications ( + RecipientUserId, ActorUserId, ActorName, BroadcastId, EventType, + Severity, Title, Message, EntityType, ActionUrl + ) + SELECT + u.Id, @ActorUserId, @ActorName, @BroadcastId, N'system.announcement', + @Severity, @Title, @Message, N'system', @ActionUrl + FROM dbo.Users AS u + WHERE u.IsActive = 1; + + DECLARE @RecipientCount INT = @@ROWCOUNT; + SELECT @RecipientCount AS RecipientCount; + `); + + return { + available: true, + broadcastId, + recipientCount: Number(result.recordset[0].RecipientCount || 0) + }; +} + +async function createNotificationsForUsers(input) { + if (!await checkNotificationSchema()) { + return { available: false, recipientCount: 0 }; + } + + const uniqueRecipientIds = Array.from(new Set((input.recipientUserIds || []).filter(Boolean))).slice(0, 1000); + if (uniqueRecipientIds.length === 0) { + return { available: true, recipientCount: 0 }; + } + + const pool = await getPool(); + const request = pool.request() + .input('ActorUserId', sql.UniqueIdentifier, input.actorUserId || null) + .input('ActorName', sql.NVarChar(200), input.actorName || null) + .input('EventType', sql.NVarChar(100), input.eventType) + .input('Severity', sql.NVarChar(20), normalizeSeverity(input.severity)) + .input('Title', sql.NVarChar(200), input.title) + .input('Message', sql.NVarChar(1000), input.message) + .input('EntityType', sql.NVarChar(50), input.entityType || null) + .input('EntityId', sql.NVarChar(100), input.entityId || null) + .input('ActionUrl', sql.NVarChar(1000), input.actionUrl || null); + const recipientRows = uniqueRecipientIds.map((userId, index) => { + const parameterName = `RecipientUserId${index}`; + request.input(parameterName, sql.UniqueIdentifier, userId); + return `SELECT @${parameterName} AS Id`; + }).join('\nUNION ALL\n'); + + const result = await request.query(` + WITH RequestedRecipients AS ( + ${recipientRows} + ) + INSERT dbo.Notifications ( + RecipientUserId, ActorUserId, ActorName, EventType, Severity, + Title, Message, EntityType, EntityId, ActionUrl + ) + SELECT DISTINCT + u.Id, @ActorUserId, @ActorName, @EventType, @Severity, + @Title, @Message, @EntityType, @EntityId, @ActionUrl + FROM dbo.Users AS u + INNER JOIN RequestedRecipients AS requested ON requested.Id = u.Id + WHERE u.IsActive = 1 + AND (@ActorUserId IS NULL OR u.Id <> @ActorUserId); + + DECLARE @RecipientCount INT = @@ROWCOUNT; + SELECT @RecipientCount AS RecipientCount; + `); + + return { + available: true, + recipientCount: Number(result.recordset[0].RecipientCount || 0) + }; +} + +async function getPackageVersionAudience(packageId) { + if (!await checkNotificationSchema()) return []; + + const pool = await getPool(); + const result = await pool.request() + .input('PackageId', sql.UniqueIdentifier, packageId) + .query(` + SELECT DISTINCT a.CreatedByUserId AS UserId + FROM dbo.ApplicationPackages AS ap + INNER JOIN dbo.Applications AS a ON a.Id = ap.ApplicationId + WHERE ap.PackageId = @PackageId; + `); + + return result.recordset.map((row) => String(row.UserId)); +} + +async function notifyPackageVersionAvailable(input) { + if (!await checkNotificationSchema()) { + return { available: false, recipientCount: 0 }; + } + + const pool = await getPool(); + const packageResult = await pool.request() + .input('PackageId', sql.UniqueIdentifier, input.packageId) + .query(` + SELECT TOP (1) Id, PackageCode, PackageName + FROM dbo.Packages + WHERE Id = @PackageId; + `); + const packageRow = packageResult.recordset[0]; + if (!packageRow) { + return { available: true, recipientCount: 0 }; + } + + const recipientUserIds = await getPackageVersionAudience(input.packageId); + const isArtifactUpdate = Boolean(input.isArtifactUpdate); + return createNotificationsForUsers({ + recipientUserIds, + actorUserId: input.actorUserId, + actorName: input.actorName, + eventType: isArtifactUpdate ? 'package.version_artifact_updated' : 'package.version_available', + severity: 'info', + title: isArtifactUpdate + ? `Artifact ${packageRow.PackageCode} ${input.version} đã được cập nhật` + : `Package ${packageRow.PackageCode} có version ${input.version} mới`, + message: isArtifactUpdate + ? 'Artifact của version hiện tại đã được upload lại. Hãy kiểm tra trước khi triển khai tiếp.' + : 'Application của bạn đang sử dụng package này. Hãy kiểm tra và chọn version mới khi phù hợp.', + entityType: 'package', + entityId: String(packageRow.Id), + actionUrl: `/packages/${packageRow.Id}` + }); +} + +async function getPackageDeletionContext(packageId) { + if (!await checkNotificationSchema()) return null; + + const pool = await getPool(); + const result = await pool.request() + .input('PackageId', sql.UniqueIdentifier, packageId) + .query(` + SELECT TOP (1) Id, PackageCode, PackageName, CreatedByUserId + FROM dbo.Packages + WHERE Id = @PackageId; + + SELECT DISTINCT recipients.UserId + FROM ( + SELECT a.CreatedByUserId AS UserId + FROM dbo.ApplicationPackages AS ap + INNER JOIN dbo.Applications AS a ON a.Id = ap.ApplicationId + WHERE ap.PackageId = @PackageId + UNION + SELECT CreatedByUserId FROM dbo.Packages WHERE Id = @PackageId + UNION + SELECT Id FROM dbo.Users WHERE Role = N'Admin' + ) AS recipients; + `); + const packageRow = result.recordsets[0][0]; + if (!packageRow) return null; + + return { + packageId: String(packageRow.Id), + packageCode: packageRow.PackageCode, + packageName: packageRow.PackageName, + recipientUserIds: result.recordsets[1].map((row) => String(row.UserId)) + }; +} + +async function getPackageVersionDeletionContext(packageVersionId) { + if (!await checkNotificationSchema()) return null; + + const pool = await getPool(); + const result = await pool.request() + .input('PackageVersionId', sql.UniqueIdentifier, packageVersionId) + .query(` + SELECT TOP (1) + pv.Id, pv.PackageId, pv.Version, p.PackageCode, p.PackageName, p.CreatedByUserId + FROM dbo.PackageVersions AS pv + INNER JOIN dbo.Packages AS p ON p.Id = pv.PackageId + WHERE pv.Id = @PackageVersionId; + + SELECT DISTINCT recipients.UserId + FROM ( + SELECT a.CreatedByUserId AS UserId + FROM dbo.ApplicationPackages AS ap + INNER JOIN dbo.Applications AS a ON a.Id = ap.ApplicationId + WHERE ap.SelectedVersionId = @PackageVersionId + UNION + SELECT p.CreatedByUserId + FROM dbo.PackageVersions AS pv + INNER JOIN dbo.Packages AS p ON p.Id = pv.PackageId + WHERE pv.Id = @PackageVersionId + UNION + SELECT Id FROM dbo.Users WHERE Role = N'Admin' + ) AS recipients; + `); + const versionRow = result.recordsets[0][0]; + if (!versionRow) return null; + + return { + packageVersionId: String(versionRow.Id), + packageId: String(versionRow.PackageId), + packageCode: versionRow.PackageCode, + packageName: versionRow.PackageName, + version: versionRow.Version, + recipientUserIds: result.recordsets[1].map((row) => String(row.UserId)) + }; +} + +async function getApplicationReleaseContext(applicationId) { + if (!await checkNotificationSchema()) return null; + + const pool = await getPool(); + const result = await pool.request() + .input('ApplicationId', sql.UniqueIdentifier, applicationId) + .query(` + SELECT TOP (1) Id, AppCode, AppName, AppVersion, CreatedByUserId + FROM dbo.Applications + WHERE Id = @ApplicationId; + + SELECT DISTINCT recipients.UserId + FROM ( + SELECT CreatedByUserId AS UserId + FROM dbo.Applications + WHERE Id = @ApplicationId + UNION + SELECT Id FROM dbo.Users WHERE Role = N'Admin' + ) AS recipients; + `); + const applicationRow = result.recordsets[0][0]; + if (!applicationRow) return null; + + return { + applicationId: String(applicationRow.Id), + appCode: applicationRow.AppCode, + appName: applicationRow.AppName, + appVersion: applicationRow.AppVersion, + recipientUserIds: result.recordsets[1].map((row) => String(row.UserId)) + }; +} + +async function notifyApplicationReleased(input) { + const context = await getApplicationReleaseContext(input.applicationId); + if (!context) { + return { + available: await checkNotificationSchema(), + recipientCount: 0 + }; + } + + return createNotificationsForUsers({ + recipientUserIds: context.recipientUserIds, + actorUserId: input.actorUserId, + actorName: input.actorName, + eventType: 'application.released', + severity: 'success', + title: `Application ${context.appCode} đã được release`, + message: `Version ${context.appVersion} đã chuyển sang trạng thái Released.`, + entityType: 'application', + entityId: context.applicationId, + actionUrl: `/applications/${context.applicationId}` + }); +} + +module.exports = { + checkNotificationSchema, + createNotificationsForUsers, + createSystemBroadcast, + getApplicationReleaseContext, + getPackageImpact: getPackageDeletionContext, + getPackageDeletionContext, + getPackageVersionAudience, + getPackageVersionImpact: getPackageVersionDeletionContext, + getPackageVersionDeletionContext, + getUnreadCount, + listNotifications, + markAllNotificationsRead, + markNotificationRead: (userId, notificationId) => markNotificationRead(notificationId, userId), + notifyApplicationReleased, + notifyPackageVersionAvailable +}; diff --git a/web-server/views/partials/page-end.ejs b/web-server/views/partials/page-end.ejs index 7c7c0b4..2060a4b 100644 --- a/web-server/views/partials/page-end.ejs +++ b/web-server/views/partials/page-end.ejs @@ -55,6 +55,56 @@ <% } %> + <% if (currentUser && currentUser.role === 'Admin') { %> + + <% } %> + diff --git a/web-server/views/partials/page-start.ejs b/web-server/views/partials/page-start.ejs index d4c5ee3..6245b56 100644 --- a/web-server/views/partials/page-start.ejs +++ b/web-server/views/partials/page-start.ejs @@ -52,9 +52,52 @@ - +
+ + + +
<% if (currentUser.role === 'User') { %>