PRD · exam.flyer.vn · frontend apps/exam

Theme Version 2 — hệ thống giao diện có token cho exam.flyer.vn

Giao 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.

Sản phẩm
exam.flyer.vn · exam.flyer.us
Phạm vi mã
flyer-webs/apps/exam
Trạng thái
Draft v0.1
Ngày
06 · 09 · 2026
Nguồn kiểm kê
tailwind.config.js, src/styles/globals.css @ a5a7da116
Hiện trạng đo được 41 biến thể 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)

1Bối cảnh và vấn đề

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.

Ba nhóm lớp viết tay đang tồn tại song song

Nhóm lớpBiến thểCách khai báoVí dụ
.btn-solid[data-color]8gradient 180° + viền + inset 0 -3px bóng đáy, mỗi màu tự chọn 3 hexpurple, purple-2, red, green, blue, blue-1, indigo
.btn-question-type[data-color]15gradient + viền + bóng + radius + chữ, tách theo cấp độ thistarter-default, movers-default, toefl-primary-default, pet-default, movers-selected
.btn-polish[data-color]8màu phẳng đơn + bóng chungpurple, ocean-green, orange-soda, dark-orchid, teal
Lớp lẻ khác10bố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
Hệ quả trực tiếpCùng một ý niệm "nút xác nhận màu tím" hiện có ít nhất bốn cách viết khác nhau (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 đủ.

Tại sao phải xử lý bây giờ

2Mục tiêu · Không mục tiêu

Mục tiêu

  1. Đưa toàn bộ màu sắc, chữ, khoảng cách, bo góc và bóng đổ về một tệp token duy nhất, dùng chung bởi Tailwind và CSS thuần.
  2. Thay 41 biến thể nút viết tay bằng một công thức nút bevel duy nhất, nhận màu từ token.
  3. Giữ nguyên cảm giác trò chơi hiện tại đến mức người dùng không nhận ra thay đổi ở giai đoạn 1.
  4. Mở đường cho theme đối tác và chế độ trợ năng mà không phải nhân bản config.

Không mục tiêu

3Nguyên tắc thiết kế v2

Nguyên tắcÝ nghĩa cụ thể
Token trước, thành phần sauKhô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ơiBevel, 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ượcCá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.

4Hệ thống token đề xuất

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ả.

Màu ngữ nghĩa (semantic)

primary--c-primary · #5B4CF0nút hành động chính, tiến trình
primary-deep--c-primary-deep · #3D34B8viền và bóng đáy của primary
danger--c-danger · #E4574Fhủy, thoát, sai
success--c-success · #1DB954đúng, nộp bài, hoàn thành
warning--c-warning · #F2A826hết giờ sắp, cần chú ý
info--c-info · #12A0F2gợi ý, liên kết phụ

Bề mặt và chữ

surface-world--s-world · #56167Dnền thế giới trò chơi (giữ từ hiện tại)
surface-world-edge--s-world-edge · #7627A8viền khung nền
surface-card--s-card · #FFFFFFthẻ câu hỏi, hộp thoại
surface-card-tint--s-card-tint · #F3EEFFthẻ được chọn
ink--ink · #1C1A45chữ chính, viền nút (giữ từ hiện tại)
ink-muted--ink-muted · #6B6F8Achữ phụ, nhãn

Accent theo cấp độ thi

Mỗ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ừ đó.

level-starters--c-level · #61C858
level-movers--c-level · #FFA15C
level-flyers--c-level · #3FA3E6
level-toefl-primary--c-level · #1E5F37
level-ielts-junior--c-level · #7046C9

Chữ

Giả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.

--font-displayBaloo 2 · tiêu đề, số điểm, tên cấp độ
--font-bodyOpen Sans · nội dung câu hỏi, hướng dẫn, nhãn nút
--text-display-xl2 rem / 1.1
--text-display-lg1.5 rem / 1.15
--text-body1 rem / 1.55
--text-label0.8125 rem / 1.3 · đậm

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.

Khoảng cách, bo góc, bóng

TokenGiá trịThay cho
--radius-btn12 pxhỗn hợp 7 / 12 / 16 px đang rải trong config
--radius-card16 px.home-bottom-bg 16 px và các thẻ tự khai
--bevel-depth3 pxinset 0 -3px viết lặp ở 8 nơi; một chỗ là 2.4 px do lỗi gõ
--shadow-lift0 2px 0 rgb(0 0 0 / .25)bóng ngoài của nút xanh blue, blue-1
--space-1 … --space-84 · 8 · 12 · 16 · 24 · 32 · 48 · 64 pxgiá trị tùy ý đang dùng qua lớp tiện ích Tailwind

5Công thức nút bevel dùng chung

Đâ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ố.

Hiện tại · 3 lớp riêng, 9 hex, không liên hệ

Bắt đầu Thoát Nộp bài

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ò.

v2 · 1 công thức, 3 giá trị --c

Bắt đầu Thoát Nộp bài Sắp hết giờ

Gradient sáng ở đỉnh, viền và bóng đáy đều suy ra từ --c--c-deep. Thêm màu mới chỉ cần hai dòng.

Đặc tả công thức

.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); }
Ghi chú kỹ thuậtcolor-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.

6Bản đồ chuyển đổ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ạiThay bằngGhi chú
.btn-solid[data-color="purple"], purple-2, indigo, .btn-polish purple.btnBố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:disabledtrạng thái vô hiệu đi qua thuộc tính HTML
.btn-aliengiữ nguyên, đổi hex sang tokenhì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-alerthai 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).

7Lộ trình triển khai

Giai đoạnNội dungĐầu raĐiều kiện qua cửa
0 · NềnTạ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útViế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 + aliasNgườ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 độ thiChuyể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 hexLint chặn hex trong CSS (stylelint color-no-hex ngoại trừ tokens.css)
4 · Gỡ aliasThay 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ấtgrep -c data-color tailwind.config.js trả về 0
Không phát hành cùng lúc với thay đổi luồng thiĐợt phát hành theme phải là đợt riêng, không kèm tính năng thi mới, để nếu có lỗi hiển thị trên một dòng máy tính bảng cụ thể thì lùi được toàn bộ bằng một cờ mà không mất tính năng.

8Chỉ số đo lường

Chỉ sốHiện tạiMục tiêu v2Cách đo
Mã hex khác nhau trong tailwind.config.js105≤ 24grep -oE '#[0-9A-Fa-f]{6}' | sort -u | wc -l
Biến thể data-color410grep -c 'data-color=' tailwind.config.js
Biến CSS trong :root0≥ 40đếm dòng -- trong tokens.css
Họ font tải về32tab Network, lọc font
Kích thước CSS đã nén của apps/examđo ở giai đoạn 0giảm ≥ 15 %next build báo cáo
Chữ trên nút đạt WCAG AAchưa đo100 %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

9Rủi ro và câu hỏi mở

Rủi ro

Rủi roMứcGiả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ệtTrung bìnhLấ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ìnhSinh 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ễnCaoGắ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 gradientThấpNgưỡng so sánh ảnh 0.1 %; chấp nhận lệch dưới ngưỡng

Câu hỏi mở

10Phụ lục: bằng chứng kiểm kê

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ứngVị tríLệnh kiểm chứng
41 biến thể data-colorapps/exam/tailwind.config.jsgrep -c 'data-color=' tailwind.config.js
105 mã hex khác nhaucùng tệpgrep -oE '#[0-9A-Fa-f]{6}' tailwind.config.js | sort -fu | wc -l
Không có :root tokenapps/exam/src/styles/globals.cssgrep -c ':root' globals.css → 0; thay vào đó là chuỗi background: #hex rời
Ba họ fonttailwind.config.js, globals.cssBaloo 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:9writingPlugin trong configvRatio: 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.