apps/examGiao diện hiện tại của phòng thi ảo được dựng từ hàng chục lớp màu viết tay, không có lớp token chung nào. Tài liệu này kiểm kê đúng hiện trạng từ mã nguồn, đề xuất một hệ thống design token thay thế và lộ trình chuyển đổi không làm đứt trải nghiệm đang chạy.
data-color viết tay
105 mã màu hex khác nhau trong 1 file config
0 biến CSS trong :root
3 họ font (Baloo 2, Montserrat, Open Sans)
exam.flyer.vn là phòng thi ảo cho trẻ em luyện Cambridge YLE, TOEFL Primary và các kỳ thi tiếng Anh. Giao diện mang phong cách trò chơi 2D: nút bấm có độ nổi ba chiều, gradient bão hòa, chữ hiển thị bằng Baloo 2. Phong cách này phù hợp đối tượng và cần được giữ nguyên về cảm giác.
Vấn đề không nằm ở thẩm mỹ mà ở cách mã hóa thẩm mỹ đó. Toàn bộ màu sắc được viết trực tiếp vào từng lớp thành phần trong tailwind.config.js, mỗi biến thể tự khai báo gradient, viền và bóng đổ riêng, không tham chiếu lẫn nhau.
| Nhóm lớp | Biến thể | Cách khai báo | Ví dụ |
|---|---|---|---|
.btn-solid[data-color] | 8 | gradient 180° + viền + inset 0 -3px bóng đáy, mỗi màu tự chọn 3 hex | purple, purple-2, red, green, blue, blue-1, indigo |
.btn-question-type[data-color] | 15 | gradient + viền + bóng + radius + chữ, tách theo cấp độ thi | starter-default, movers-default, toefl-primary-default, pet-default, movers-selected |
.btn-polish[data-color] | 8 | màu phẳng đơn + bóng chung | purple, ocean-green, orange-soda, dark-orchid, teal |
| Lớp lẻ khác | 10 | bối cảnh, thẻ lịch sử, hiệu ứng chữ, nút hình thoi | .home-bottom-bg, .btn-alien, .bg-yellow, .text-shadow-red-filter |
btn-solid purple, btn-solid purple-2, btn-solid indigo, btn-polish purple) với bốn bộ hex không trùng nhau. Đổi một sắc thái thương hiệu đồng nghĩa với sửa hàng chục dòng rải rác và không có cách nào kiểm chứng đã sửa đủ.btn-question-type riêng, tức thêm 5 đến 7 hex mới thay vì đổi một token accent.vb-*, text-vb) đang bảo vệ bố cục trên máy tính bảng.| Nguyên tắc | Ý nghĩa cụ thể |
|---|---|
| Token trước, thành phần sau | Không lớp CSS nào được chứa mã hex trực tiếp. Mọi màu đi qua var(--*) hoặc key trong theme.extend.colors. |
| Ngữ nghĩa thay vì sắc màu | Đặt tên theo vai trò (primary, danger, surface), không theo màu (purple-2, blue-1). Đổi thương hiệu chỉ đổi giá trị, không đổi tên. |
| Một công thức, nhiều giá trị | Độ nổi ba chiều là một mixin duy nhất tính gradient và bóng từ một màu gốc. Cấp độ thi chỉ ghi đè một token accent. |
| Giữ chất trò chơi | Bevel, gradient, bóng đáy được giữ nguyên hình thức. Tiêu chuẩn thay đổi ở tầng mã, không ở tầng cảm nhận. |
| Đọc được cho trẻ | Chữ trên nút đạt tương phản tối thiểu 4.5:1 theo WCAG AA. Vùng chạm tối thiểu 44×44 px trên máy tính bảng. |
| Tương thích ngược | Các lớp cũ vẫn hoạt động qua lớp alias trong giai đoạn chuyển đổi, gỡ dần theo từng màn hình. |
Bảng màu bên dưới là đề xuất khởi điểm, lấy sắc thái từ chính các giá trị đang được dùng nhiều nhất trong mã (tím indigo của nút chính, đỏ san hô của nút hủy, xanh lá của nút đúng), rồi quy về một dải có bậc rõ ràng. Đây là phần duy nhất trong tài liệu hiển thị màu thật, vì màu chính là đối tượng được đặc tả.
--c-primary · #5B4CF0nút hành động chính, tiến trình--c-primary-deep · #3D34B8viền và bóng đáy của primary--c-danger · #E4574Fhủy, thoát, sai--c-success · #1DB954đúng, nộp bài, hoàn thành--c-warning · #F2A826hết giờ sắp, cần chú ý--c-info · #12A0F2gợi ý, liên kết phụ--s-world · #56167Dnền thế giới trò chơi (giữ từ hiện tại)--s-world-edge · #7627A8viền khung nền--s-card · #FFFFFFthẻ câu hỏi, hộp thoại--s-card-tint · #F3EEFFthẻ được chọn--ink · #1C1A45chữ chính, viền nút (giữ từ hiện tại)--ink-muted · #6B6F8Achữ phụ, nhãnMỗi cấp độ hiện đang sở hữu một bộ btn-question-type riêng gồm 5 đến 7 hex. Trong v2, cấp độ chỉ ghi đè một token accent; toàn bộ gradient, viền và bóng được suy ra từ đó.
--c-level · #61C858--c-level · #FFA15C--c-level · #3FA3E6--c-level · #1E5F37--c-level · #7046C9Giảm từ ba họ font xuống hai. Montserrat chỉ xuất hiện ở một tiện ích lẻ (.font-montserrat) và không có vai trò riêng; gỡ để tiết kiệm một lượt tải font trên thiết bị yếu.
Hệ co giãn text-vb theo tỷ lệ 16:9 được giữ nguyên và ánh xạ lên các bậc trên, không thay bằng rem cố định.
| Token | Giá trị | Thay cho |
|---|---|---|
--radius-btn | 12 px | hỗn hợp 7 / 12 / 16 px đang rải trong config |
--radius-card | 16 px | .home-bottom-bg 16 px và các thẻ tự khai |
--bevel-depth | 3 px | inset 0 -3px viết lặp ở 8 nơi; một chỗ là 2.4 px do lỗi gõ |
--shadow-lift | 0 2px 0 rgb(0 0 0 / .25) | bóng ngoài của nút xanh blue, blue-1 |
--space-1 … --space-8 | 4 · 8 · 12 · 16 · 24 · 32 · 48 · 64 px | giá trị tùy ý đang dùng qua lớp tiện ích Tailwind |
Đây là thay đổi mang lại nhiều giá trị nhất trên một đơn vị công sức. Toàn bộ 41 biến thể hiện tại đều là cùng một hình dạng với các số khác nhau; v2 gói hình dạng đó thành một công thức và để token quyết định số.
Sao chép nguyên văn từ .btn-solid[data-color="purple|red|green"]. Ba viền dùng ba hex khác nhau cho cùng vai trò.
--cGradient sáng ở đỉnh, viền và bóng đáy đều suy ra từ --c và --c-deep. Thêm màu mới chỉ cần hai dòng.
.btn {
--c: var(--c-primary);
--c-deep: var(--c-primary-deep);
font: 700 var(--text-label) var(--font-body);
color: #fff;
border-radius: var(--radius-btn);
border: 1px solid var(--c-deep);
background: linear-gradient(180deg,
color-mix(in srgb, var(--c) 80%, #fff) 0%,
var(--c) 100%);
box-shadow: inset 0 calc(var(--bevel-depth) * -1) 0 var(--c-deep);
min-height: 44px; /* vùng chạm trẻ em */
}
.btn:active { transform: translateY(var(--bevel-depth)); box-shadow: none; }
.btn[data-intent="danger"] { --c: var(--c-danger); --c-deep: color-mix(in srgb, var(--c-danger) 70%, #000); }
.btn[data-intent="success"] { --c: var(--c-success); --c-deep: color-mix(in srgb, var(--c-success) 70%, #000); }
.btn[data-level] { --c: var(--c-level); --c-deep: color-mix(in srgb, var(--c-level) 70%, #000); }
color-mix() có mặt trên mọi trình duyệt mục tiêu của exam từ 2023 (Chrome 111, Safari 16.2). Nếu cần hỗ trợ WebView cũ hơn trên máy tính bảng trường học, tệp token sinh sẵn giá trị --c-deep ở bước build thay vì tính trên trình duyệt; công thức không đổi.Mỗi lớp cũ được ánh xạ về một tổ hợp .btn + thuộc tính dữ liệu. Lớp alias giữ tên cũ hoạt động trong giai đoạn chuyển đổi để không phải sửa toàn bộ JSX cùng lúc.
| Lớp hiện tại | Thay bằng | Ghi chú |
|---|---|---|
.btn-solid[data-color="purple"], purple-2, indigo, .btn-polish purple | .btn | Bốn cách viết gộp về một; sắc tím lấy trung vị |
.btn-solid[data-color="red"], .btn-polish orange-soda | .btn[data-intent="danger"] | |
.btn-solid[data-color="green"], .btn-polish green, .btn-polish ocean-green | .btn[data-intent="success"] | ocean-green (#47C199) không còn vai trò riêng |
.btn-solid[data-color="blue"], blue-1, .btn-polish blue | .btn[data-intent="info"] | |
.btn-question-type[data-color="starter-default"] | .btn.btn-choice[data-level="starters"] | nền sáng, chữ màu accent: biến thể btn-choice đảo sáng/tối |
movers-default, toefl-primary-default, pet-default | .btn.btn-choice[data-level="…"] | chỉ đổi --c-level |
movers-selected, white, white2, white-mix | .btn-choice[aria-pressed="true"] | trạng thái chọn là thuộc tính, không phải màu riêng |
gray, disable-green | .btn:disabled | trạng thái vô hiệu đi qua thuộc tính HTML |
.btn-alien | giữ nguyên, đổi hex sang token | hình thoi là thành phần đặc thù của màn Alien Attack, không phải nút chung |
.bg-yellow, .bg-red, .btn-default | --gradient-reward, --gradient-alert | hai gradient nền trang trí, đặt tên theo nơi dùng |
Ước tính sau chuyển đổi: 105 hex trong config giảm về khoảng 22 token màu; 41 biến thể data-color giảm về 1 công thức + 3 thuộc tính (data-intent, data-level, aria-pressed).
| Giai đoạn | Nội dung | Đầu ra | Điều kiện qua cửa |
|---|---|---|---|
| 0 · Nền | Tạo src/styles/tokens.css với toàn bộ biến ở mục 4; nối vào theme.extend của Tailwind qua var(). Chưa đụng lớp cũ. | 1 tệp token, 0 thay đổi hiển thị | Kiểm tra hồi quy hình ảnh (Playwright) trên 12 màn chính đạt 100 % không đổi pixel |
| 1 · Nút | Viết .btn theo mục 5. Thêm lớp alias để 41 tên cũ trỏ về công thức mới. Bật sau cờ NEXT_PUBLIC_THEME_V2=1. | Công thức nút + alias | Người thử nghiệm nội bộ không phân biệt được hai bản qua ảnh chụp mù |
| 2 · Cấp độ thi | Chuyển btn-question-type sang .btn-choice[data-level]. Thêm accent cho Flyers và IELTS Junior đang thiếu. | 5 cấp độ, 5 token | Đủ tương phản 4.5:1 cho chữ trên mọi cấp độ, đo bằng axe |
| 3 · Bề mặt | Đưa nền thế giới, thẻ, hộp thoại về --s-*. Gỡ các background: viết tay trong globals.css. | globals.css không còn hex | Lint chặn hex trong CSS (stylelint color-no-hex ngoại trừ tokens.css) |
| 4 · Gỡ alias | Thay tên lớp cũ trong JSX theo từng thư mục màn hình; xóa lớp alias khi grep về 0. | Config gọn, lớp cũ biến mất | grep -c data-color tailwind.config.js trả về 0 |
| Chỉ số | Hiện tại | Mục tiêu v2 | Cách đo |
|---|---|---|---|
Mã hex khác nhau trong tailwind.config.js | 105 | ≤ 24 | grep -oE '#[0-9A-Fa-f]{6}' | sort -u | wc -l |
Biến thể data-color | 41 | 0 | grep -c 'data-color=' tailwind.config.js |
Biến CSS trong :root | 0 | ≥ 40 | đếm dòng -- trong tokens.css |
| Họ font tải về | 3 | 2 | tab Network, lọc font |
Kích thước CSS đã nén của apps/exam | đo ở giai đoạn 0 | giảm ≥ 15 % | next build báo cáo |
| Chữ trên nút đạt WCAG AA | chưa đo | 100 % | axe-core trong Playwright |
| Thời gian thêm một cấp độ thi mới vào giao diện | ~ 1 ngày | < 1 giờ | một dòng token accent + ảnh chụp kiểm tra |
| Rủi ro | Mức | Giảm thiểu |
|---|---|---|
| Sắc thái "trung vị" của tím không trùng bất kỳ bản nào cũ, đội thiết kế không duyệt | Trung bình | Lấy giá trị từ bảng token Figma đã lưu thay vì tự tính; đưa 3 lựa chọn cho thiết kế chốt trước giai đoạn 1 |
WebView cũ trên máy tính bảng trường không hỗ trợ color-mix() | Trung bình | Sinh sẵn --c-deep lúc build; kiểm tra trên danh sách thiết bị đối tác |
| Lớp alias tồn tại quá lâu, hai hệ song song thành vĩnh viễn | Cao | Gắn hạn gỡ alias vào định nghĩa hoàn thành của giai đoạn 4; lint chặn thêm lớp data-color mới ngay từ giai đoạn 1 |
| Hồi quy hình ảnh phát hiện lệch 1 px do làm tròn gradient | Thấp | Ngưỡng so sánh ảnh 0.1 %; chấp nhận lệch dưới ngưỡng |
--c-primary hay chỉ đổi logo và nền? Câu trả lời quyết định token nào là công khai..btn-alien làm thành phần riêng hay đưa Alien Attack về nút chung với hình thoi là trang trí giả?Các con số trong tài liệu được lấy trực tiếp từ mã nguồn tại commit a5a7da116 (nhánh main của flyer/webs, kéo về ngày 06 · 09 · 2026), không ước lượng.
| Bằng chứng | Vị trí | Lệnh kiểm chứng |
|---|---|---|
41 biến thể data-color | apps/exam/tailwind.config.js | grep -c 'data-color=' tailwind.config.js |
| 105 mã hex khác nhau | cùng tệp | grep -oE '#[0-9A-Fa-f]{6}' tailwind.config.js | sort -fu | wc -l |
Không có :root token | apps/exam/src/styles/globals.css | grep -c ':root' globals.css → 0; thay vào đó là chuỗi background: #hex rời |
| Ba họ font | tailwind.config.js, globals.css | Baloo 2, Montserrat, Open Sans; chỉ Open Sans được @import |
| Công thức bevel lặp | .btn-solid[data-color=*] | 8 lần inset 0px -3px 0px, một lần -2.4px |
| Palette riêng theo cấp độ | .btn-question-type[data-color=*] | 15 biến thể, mỗi biến thể 4 đến 5 thuộc tính màu |
| Hệ co giãn 16:9 | writingPlugin trong config | vRatio: 16/9, tiện ích vb-w, vb-h, text-vb, h-vb |
Tài liệu nội bộ FLYER Product. Bản nháp v0.1, chờ đội thiết kế và đội frontend exam góp ý trước khi chuyển sang v1.0.