DS-001 · Component React, shadcn, Motion và pattern theo cổng
Một phần của DS-001. Component React dùng chung, shadcn/ui không thành hệ token thứ hai, màn chờ cá bơi, chuyển n12-* sang shadcn, Motion, pattern từng cổng.
5c. Component React dùng chung packages/design-system/ui/ (SRC-118; REQ-ONB-09, REQ-BRD-06)
Hai component không diễn tả được bằng class CSS (cần state + đo đạc DOM) nên nằm ở ui/, canonical giống art/N12Art.tsx — sửa ở packages/design-system/ui/ rồi copy y hệt sang app, không viết bản riêng cho từng app.
Tour.tsx — dẫn tham quan có đèn rọi. Dùng: gắn data-tour="tên" lên element cần rọi, khai TourStep[], gọi useTour(key, steps, ready), render {tour.active && <Tour {...tour.props} />}.
| Điểm thiết kế | Vì sao |
|---|---|
Vùng tối vẽ bằng box-shadow spread 9999px của chính ô đèn rọi (.n12-tour-spot), không ghép 4 tấm che | 4 tấm che hở đường chỉ 1px khi zoom; cách này phần sáng luôn khít element |
Danh sách bước lọc trong useLayoutEffect sau commit, không lọc lúc render | Lọc lúc render thì element anh em chưa vào DOM, mọi bước có target sẽ rụng sạch |
| Bước không tìm thấy element thì bỏ hẳn | Nội dung màn phụ thuộc trạng thái (chưa có con, hôm nay không có bài ôn) — không được rọi vào chỗ trống |
scrollIntoView tức thì (không smooth) rồi mới đo | Đo giữa animation ra toạ độ của một khung hình dở dang |
Thẻ nội dung đặt dưới đèn rọi, tự lật lên trên khi không đủ chỗ; chưa đo xong thì opacity: 0 | Không để thẻ "bay" từ ngoài màn hình vào |
Nút Tiếp dùng n12-btn-primary, không n12-btn-accent | Màn bên dưới đã có nút coral của nó (§7.4) |
Esc · bấm nền · "Bỏ qua hướng dẫn" đều thoát; ← → đi lại giữa các bước | Tour không bao giờ được chặn đường người dùng |
Đã xem ghi localStorage n12_tour_<key>_v1; useTour nhớ "đã tự mở" theo key chứ không theo lần mount | Một component sống xuyên nhiều màn (Dashboard của marlins) vẫn phải tự mở tour màn kế tiếp |
Tour đang chạy: learn learn-home (5 bước) · learn-cockpit (3) · marlins marlins-family (5) · marlins-child (2). Đường xem lại: menu avatar → "🧭 Xem hướng dẫn" (prop onTour của AvatarMenu).
LangSwitch.tsx — công tắc đổi ngôn ngữ (SRC-146). Dùng: <LangSwitch value={lang} options={[{value:"vi",short:"VI",label:"Tiếng Việt"},{value:"en",short:"EN",label:"English"}]} onChange={setLang} />. Trong app learn đã bọc sẵn thành LangToggle của lang.tsx — mọi chỗ đổi ngôn ngữ đều gọi component này, không ai dựng nút riêng.
| Điểm thiết kế | Vì sao |
|---|---|
| Hiện cả hai lựa chọn cùng lúc, ô đang chọn có con trượt nền | Nút cũ chỉ hiện ngôn ngữ ĐANG dùng ("VI") nên không đoán được bấm vào ra gì — nhiều người tưởng đó là nhãn |
role="radiogroup" + 2 role="radio", không role="switch" | Switch là bật/tắt một thứ; đây là chọn một trong hai giá trị ngang hàng, trình đọc màn hình phải đọc "Tiếng Việt, đã chọn" |
Con trượt là một <span> riêng chạy bằng translateX, vị trí lấy từ data-index trên khung | Không tô nền từng nút thì chuyển động liền mạch; prefers-reduced-motion tắt hẳn transition |
| Mỗi ô tối thiểu 44×38px, nền đục | Ngón tay trẻ con; header sticky có backdrop-blur nên nền trong sẽ lẫn chữ bên dưới |
Bấm vào ô nào cũng chọn ô đó; ←/→/Home/End đi lại, Space/Enter đảo | Bàn phím phải làm được đúng việc chuột làm |
Có mặt ở: navbar trang gốc · Lighthouse · Kế hoạch học · Student Portrait · Lộ trình · Orca · Whale · HOME của school. Smoke test khoá luật "đổi qua lại được" và "trang khác cũng có".
FindingNemo.tsx — đề nghị xem phim. Card nhỏ trên nemo12.com (dưới lưới school) và marlins (trang Gia đình, dismissible → nhớ ✕ trong localStorage). Chỉ ánh xạ nhân vật có thật trong phim và có mascot trong DS (Nemo, Marlin, Crush→Turtle, Bruce→Shark, cá voi→Whale, Dory→Ôn nhanh); không bịa vai cho đủ bộ school. Không gắn link tới dịch vụ phát trực tuyến nào — Nemo12 không phát hành phim và không điều hướng người dùng tới một nhà cung cấp cụ thể.
5d. shadcn/ui — lớp component có sẵn, KHÔNG phải hệ token thứ hai (SRC-566)
Chủ dự án 2026-08-24: dùng shadcn/ui cho learn.nemo12.com và nemo12.com, Tailwind bản mới nhất; ngay sau đó mở rộng cho marlins, coral, admin, dolphin (= app mentors), pearl, compass.
Sáu app React có shadcn (SRC-567 mở rộng từ learn/web sang marlins · coral · admin · mentors): learn · web · marlins · coral · admin · mentors. Pearl, Compass và Docs thì KHÔNG — chúng là VitePress (Vue), mà shadcn là React + Radix nên không có đường cài; điều làm được và đã làm là nạp cùng shadcn-bridge.css để token (radius, popover, màu biểu đồ) không lệch khỏi phần còn lại của hệ.
Rủi ro rõ ràng của việc này là RISK-012 quay lại: cách cài mặc định của shadcn dán cả bảng màu của nó vào index.css, và Nemo12 lập tức có hai nguồn màu song song — đúng thứ DS-001 §1 sinh ra để chặn. Nemo12 tránh được vì một may mắn có thật: DS-001 đã đặt tên token theo đúng quy ước shadcn (--background, --foreground, --primary, --muted, --border, --ring, --destructive, --card), nên phần lớn khớp sẵn.
Cách cài đã chọn:
packages/design-system/shadcn-bridge.csskhai NỐT những biến shadcn cần mà DS-001 chưa có (--popover,--input,--sidebar-*,--chart-*, bậc radius) và khai bằng cách trỏ về token DS-001 — không có một giá trị màu thô nào trong file đó. Đổi màu vẫn chỉ sửatokens/base.css.--chart-1..5trỏ về bốn màu trạng thái học đã có nghĩa (chưa biết · đang học · cần ôn · đã vững) cộng điểm màu hành động. Bịa năm màu biểu đồ mới là để một màu mang hai nghĩa khác nhau trên cùng một hệ.- Component sinh ra ở
src/components/ui/của từng app (đúng mô hình shadcn: code thuộc về repo, không phải dependency),cn()ởsrc/lib/utils.ts, alias@/. - Quy tắc 1 và 2 của §7 vẫn nguyên: shadcn cho hành vi (Radix: bàn phím, focus trap, aria),
n12-*và token cho hình thức. Component shadcn nào cần đổi diện mạo thì sửa bằng class token, không thêm màu mới. - Chốt chặn tự động:
scripts/check-shadcn-tokens.mjs(trongnpm run check:docs) đỏ nếu app khai lại bảng màu shadcn — tức là nếu ai đó chạyshadcn inittheo lối mặc định.
Nợ ghi rõ: components/ui/ là mã sinh ra và chưa đi qua rule "không fork component" của §7·2 — nó là bản sao trong từng app, giống ngoại lệ art/ và ui/ ở §1. Nếu learn và web bắt đầu sửa cùng một component theo hai hướng, phải kéo về packages/design-system/ui/ như Tour.tsx.
5e. Màn chờ có cá bơi — và luật về việc CỐ TÌNH làm chậm (SRC-568)
Chủ dự án 2026-08-24: "tất cả mọi chỗ loading thì có hình con cá bơi bơi và mấy bong bóng nổi nổi lên mặt nước. Thậm chí cố tình có thêm những đoạn loading đó, để learner chờ tầm 3 tới 5 giây, cho vui."
SwimmingLoader (canonical ở packages/design-system/ui/SwimmingLoader.tsx, mỗi app một bản sao y hệt như Tour.tsx) vẽ cá + 6 bong bóng bằng SVG và CSS thuần — không bitmap (luật cũ), không khung hình JavaScript nào trong lúc trang đang bận tải. prefers-reduced-motion thì cá đứng yên, bong bóng tắt.
| Loại chờ | Dùng gì |
|---|---|
| Chờ cả màn (đường học, Toàn cảnh, mở Lab, mở Experience, bài khám) | SwimmingLoader |
| Chờ một ô trong bố cục đã dựng | giữ n12-skeleton — hình dạng của ô nói được "sắp có gì ở đây", con cá thì không |
Sàn chờ (useMinDelay) — phần cần đọc kỹ. Làm chậm mọi thứ 3-5 giây là cách nhanh nhất giết một app học: learner mở app mỗi ngày, mỗi ngày vài chục lần chuyển màn, và cái vui của lần thứ nhất thành cái bực của lần thứ hai mươi. Bản này giữ đúng ý "cho vui" nhưng đóng khung lại bằng ba luật:
- Sàn, không phải cộng thêm. Đồng hồ chạy từ lúc bắt đầu tải; mạng trả về sau 4 giây thì không chờ thêm giây nào. Chỉ lấp phần còn thiếu.
- Chỉ ở chuyển cảnh lớn — vào một Experience, mở một Lab, bắt đầu bài khám: chỗ learner vừa quyết định một việc và đang chờ một thế giới mới mở ra. Các màn mặc định (Hôm nay, Toàn cảnh) và fetch nền không có sàn; thêm 3 giây vào đó là thêm 3 giây vào mọi ngày học.
- Tắt được:
prefers-reduced-motionhoặc?nowait=1. Playwright bậtreducedMotion: "reduce"nên bộ e2e không dài thêm hàng phút — và đó là đúng công tắc người dùng có, không phải một cờ riêng cho test.
Sàn mặc định là 3s, đầu khoảng chủ dự án nói chứ không phải 5s: mỗi 100ms chờ thêm là một cơ hội để trẻ chuyển sang việc khác.
5f. Chuyển lớp n12-* sang component shadcn (SRC-570)
Chủ dự án 2026-08-25: "bắt buộc mọi page nhỏ đều cần dùng Shadcn (100% giao diện và các components)".
Cách làm: codemod, không sửa tay (SRC-569 dựng codemod, SRC-570 chạy trên 6 app). ~85 file .tsx và hàng trăm điểm chạm — sửa tay thì vừa lâu vừa chắc chắn sót. scripts/codemod-shadcn.mjs biến mỗi phép đổi thành một luật đọc được và chạy lại được; chỗ nào không khớp luật thì để nguyên chứ không đoán. Đợt đầu chuyển 592 chỗ trong 68 file trên 6 app.
Biến thể Nemo12 nằm trong chính components/ui/button.tsx, không dựng file thứ hai: accent (điểm màu duy nhất của màn), school, outline, ghost — màu vẫn đọc từ token DS-001. Kích thước cũng đè: nút Nemo12 cao 44px và bo tròn hoàn toàn, khác h-9 rounded-md mặc định của shadcn.
Ba lỗi codemod đã mắc và cách chữa — ghi lại vì cả ba đều là bẫy chung của loại việc này:
- Regex
[^>]*?dừng ở mũi tên củaonClick={() => …}nên bỏ sót phần lớn nút thật. Phải quét thẻ bằng tay, đếm ngoặc và bỏ qua dấu>trong chuỗi. - Gộp thẻ nhiều dòng thành một dòng làm comment
// …nuốt luôn phần còn lại của biểu thức. Không nén khoảng trắng. <section aria-label>thành<div>làm mất landmark — e2e bắt được ở trang Hồ sơ. Đổi kèmrole="region".
Chưa xong, và đo được: scripts/check-shadcn-adoption.mjs (trong npm run check:docs) chốt số chỗ còn dùng lớp cũ làm trần chỉ đi một chiều — thêm một chỗ là CI đỏ, dọn bớt thì hạ trần trong cùng commit. Phần còn lại chủ yếu là className động và các thẻ hiếm; luật xử lý template literal đã viết nhưng tạm khoá vì bộ đếm thẻ đóng còn lệch khi trong khối đã có <Card> xen <div> (ghi trong chính file codemod).
5g. Chuyển động — Motion cho React (SRC-596)
Chủ dự án 2026-08-26: "cần dùng Motion React gì đó, để các hình ảnh trên mọi trang có thể di chuyển mượt mà, hiệu ứng thú vị".
Thư viện: motion (tên mới của Framer Motion). Năm preset dùng chung ở packages/design-system/ui/Motion.tsx, mỗi app một bản sao y hệt như Tour.tsx.
| Preset | Dùng cho | Hiệu ứng |
|---|---|---|
MotionRoot | bọc gốc cây React, một lần mỗi app | nạp engine + luật trợ năng |
FadeIn | khối nội dung | mờ → rõ, nhích lên 10px khi cuộn tới, chạy một lần |
Liftable | thẻ bấm được | nhấc 3px khi rê chuột, lún 1,5% khi bấm |
Stagger + StaggerItem | danh sách, lưới thẻ | hiện lần lượt, mỗi mục trễ 60ms |
Floating | mascot, hình trang trí | trôi bồng bềnh ±6px, lặp vô hạn |
Ba quyết định, tra tài liệu Motion trước khi viết:
LazyMotion+mthay chomotionđầy đủ. Bản đầy đủ kéo cả engine vào bundle đầu tiên;LazyMotion features={domAnimation}chỉ nạp phần cần, nạp muộn. Nemo12 chạy trên máy học sinh, mỗi KB tải về là thời gian trẻ ngồi nhìn màn trống.MotionConfig reducedMotion="user"bọc toàn app. Motion tự tắt animation biến hình khi hệ điều hành bật "giảm chuyển động" — không phải kiểm tay ở từng chỗ. Đây là luật trợ năng, không phải tuỳ chọn.- Biên độ nhỏ, thời lượng ngắn: tối đa 12px, dưới 0,4s. Nemo12 là chỗ học chứ không phải trang giới thiệu sản phẩm — chuyển động ở đây để mắt biết cái gì vừa đổi, không phải để gây ấn tượng. Luật tối giản (§5b) vẫn cầm trịch.
Một hồi quy lộ ra nhờ việc này. Bọc Floating quanh mascot làm mọi mascot biến mất. Truy ra: lớp .n12-swim của màn chờ (SRC-568) va chạm tên với một lớp trang trí đã dùng ở 23 chỗ từ trước; width: 100% của màn chờ làm mascot rộng 0px khi thẻ cha không có bề ngang xác định. Lỗi nằm im từ SRC-568 cho tới hôm nay. Đã đổi tiền tố màn chờ thành n12-loader-sea*.
Luật rút ra: đặt tên lớp CSS mới thì
greptên đó trước. Một tên nghe có vẻ mới không có nghĩa là chưa ai dùng.
6. Patterns per portal (REQ-UX-01/09/10)
- learn (Nemo): header sticky trắng + tab lớn; màn "Hôm nay" xếp cấp: (1) khối hành động chính có đúng 1 nút coral, (2) readiness, (3) phần còn lại. Diagnostic: 1 câu/màn, progress trên đầu, không đếm ngược gây áp lực. Empty/error: giọng khích lệ, có nút thử lại — không đổ lỗi cho learner.
- marlins/dolphin/coral (người lớn): cùng token + component, mật độ cao hơn (bảng, stat tiles
n12-cardgọn), mọi con số kèm câu giải thích; theme marlins/coral cố định, không đổi theo school đang xem trừ khối "view mode". - Theme phủ HẾT một trang, không phủ nửa trang (SRC-574): trang báo cáo của marlins từng có nền trắng trong khi lớp chữ đã đổi sang theme biển sâu — chữ trắng trên nền trắng, mất hẳn chữ. Trang nào mang theme tối thì vỏ toàn màn hình, chân trang (biến thể đáy biển) và mọi bề mặt nổi lên trên nó (§4h) đều phải cùng theme đó; đổi màu chữ mà không đổi màu vỏ là cách hỏng im lặng nhất.
- Bộ chọn độ sâu bản đồ (Gói · Chủ đề · Từng bài) là ba icon ở góc trên trái, dùng chung một cụm với learn (SRC-574) — ba nút chữ ngang hàng chiếm cả một dòng của màn cho một việc phụ, và hai portal đặt cùng một bộ chọn ở hai chỗ khác nhau thì người lớn phải học lại giao diện khi đi từ báo cáo sang màn học của con.
- Information Hierarchy và Visual Hierarchy (SRC-1294): bốn bậc thông tin (em đang ở đâu, làm gì bây giờ, hỗ trợ, tra cứu), một h1, tối đa ba cấp tiêu đề, thang năm cấp chữ, một kicker, một nút tô đầy trong tầm nhìn, card lồng tối đa một cấp, luật mật độ thẻ bên. Chuẩn đo được: Information Hierarchy và Visual Hierarchy; áp cho mọi màn AI Teen.
- Focus Order và Information Load (SRC-1303): một nút tô đầy trong tầm nhìn đầu, thứ tự đọc h1 → nút chính → mục gập, vị trí hiện tại khớp màn, thẻ trạng thái không ở màn luyện; tối đa 5 khối trong tầm nhìn đầu ở 375px, hiện dần, không lặp. Thang chấm 25 yếu tố của ui.nemo12.com và bảng điểm theo màn: Focus Order và Information Load, skill
/ui-audit-25. - Card patterns (SRC-1298): 18 pattern card (intro, outcomes, output, concepts, prose, example, chat, compare, steps, framework, prompt, callout, misconception, reflection, checklist, quiz, result, next), mỗi loại thông tin đúng một pattern, một mapping có kiểu từ khối bài học sang pattern, khoảng cách
gap-6/gap-8giữa card. Chuẩn: Card patterns. - Guided Journey (SRC-1253): mẫu "learner trả lời một chuỗi câu ngắn, Nemo tính phần còn lại" (hero câu hỏi, thanh tiến độ dính, danh sách câu hỏi đánh số, chân dung một CTA) dùng ở
/ielts,/ielts/considervà hub learn/ielts. Mô tả đầy đủ: Guided Journey pattern. - School world: hero
n12-hero(icon lớn + tên + tagline), phần thân vẫn nền sand — accent chỉ xuất hiện ở nút school, chip active, fill progress.
6b. KHUNG HỌC dùng chung cho mọi màn đọc-bài (SRC-677)
Chỉ đạo 2026-09-04: chế độ học của khoá bố mẹ (marlins/parent-courses/.../u/N/l/M) phải trông đúng như màn học của learn.nemo12.com. Từ nay đó là một khung chung, không phải hai bản dựng riêng:
- Không có thanh trên của portal. Logo, công tắc ngôn ngữ và đường ra đều nằm trong panel trái. Để cả hai thì có hai đường ra nằm cạnh nhau và mắt phải chọn.
- Panel trái tối, cố định, rộng 17rem từ
lg; màn hẹp thì nó trượt ra sau một nút ☰. Ba khối theo đúng thứ tự: logo + ✕ → dropdown chọn Unit/bài → mục lục "Trong bài này". - Cây khoá là DROPDOWN, mục lục bài thì trải phẳng. Đổi bài là việc hiếm, đổi cụm là việc thường — thứ bấm nhiều nhất phải nằm trong tầm mắt mà không cần bấm thêm.
- Thân bài hiện MỘT cụm một lúc. Mục lục chỉ có nghĩa khi nó thay nội dung; nếu chỉ nhảy chỗ cuộn thì nó là mục lục trang trí, và người đọc lại không có điểm dừng nào.
- Cuối cụm là một VIÊN nút nhỏ ("Đọc xong cụm này" → "Tôi đã hoàn thành" ở cụm cuối), bấm lại là bỏ đánh dấu. Nút to bằng một khối nội dung ở cuối quãng cuộn đọc thành "còn phải đọc tiếp".
Giới hạn còn lại của bản marlins: mốc "đã đọc" từng cụm nằm trong localStorage của trình duyệt, vì máy chủ mới chỉ ghi "đã mở chỗ học" và điểm quiz (SRC-671). Nó không theo bố mẹ sang máy khác — mở API rồi thì đổi đúng hai hàm readDone/writeDone trong apps/marlins/src/ParentLesson.tsx.
Cùng chỉ đạo, cùng ngày: nút "Bắt đầu học" ở cột phải trang khoá đổi sang viên variant="accent" một dòng chữ, điều kiện "miễn phí chỗ học đầu tiên" tụt ra ngoài nút. Một hộp vuông màu nền tối ôm hai dòng chữ trên nền biển sâu không đọc ra là thứ để bấm, và điều kiện không phải hành động.
6c. Thanh Journey dính trên cùng (SRC-1251)
Chủ dự án 06.10.2026: thanh Journey là một thành phần UX cốt lõi của Nemo12, không chỉ của AI Teen. Bản đầu tiên ở aiTeen/JourneyBar.tsx (SDD-043 §9); từ SRC-1263 nó là NavBar + thanh bước có tên của apps/learn/src/components/JourneyShell.tsx, dùng chung cho AI Teen và learn /ielts (Guided Journey §9).
- Dính
top-0, luôn thấy khi cuộn: learner biết đang ở đâu, đã qua đâu, còn gì phía trước. - Mỗi chặng một chấm có số: xong là chấm đặc có dấu ✓, đang ở có vòng nhấn, phía trước khoá thật (
disabled+ biểu tượng khoá). Không cho bấm tuỳ ý: progressive disclosure là chủ ý. - Chặng có bước con (Goal 5 bước, Where am I? 5 bước): từ SRC-1263 các bước ấy là chính thanh bước, mỗi bước có tên; dải mảnh
n/5 · tên bướccũ đã bỏ. - Thay chỗ thanh menu, không chồng lên nó: hai thanh
sticky top-0thì thanh này đè lên thanh kia. - Nguyên tắc thiết kế đi kèm: a Journey is not a page, a Journey is a learner state transition. Chặng không có URL riêng; URL chỉ nói learner đang ở đâu trong nội dung.
Trang khác muốn dùng thì nhấc component này lên packages/design-system/ui/, không chép bản thứ hai.