DS-001 · Bộ ba bắt buộc và kiến trúc package
Một phần của DS-001. Luật bộ ba Motion, shadcn/ui, Tailwind; cụm trang /style; câu hỏi mở đầu trang www; kiến trúc package và bản sao có máy kiểm.
0. Bộ ba bắt buộc — Motion · shadcn/ui · Tailwind (SRC-650) — LUẬT ĐỨNG TRÊN MỌI MỤC DƯỚI
Chủ dự án 2026-08-31: "Design System & UI của tất cả các website liên quan tới nemo12.com và các sub-domain trong nó, cần dùng Framer Motion và Shadcn, Tailwind. Tất cả đều dùng bản mới nhất."
Mục này không mở đường mới — nó chốt lại thứ §5d, §5f, §5g đã dựng, và nâng từ "cách làm đang theo" lên "ràng buộc của hệ". Khác biệt nằm ở chỗ: từ nay một app mới, hay một trang mới trong app cũ, không được chọn khác, và có cổng máy kiểm.
| Lớp | Gói duy nhất | Ranh giới |
|---|---|---|
| Chuyển động | motion (tên npm hiện tại của Framer Motion) | qua preset ở packages/design-system/ui/Motion.tsx — §5g |
| Component | shadcn/ui trên Radix | hành vi thôi: bàn phím, focus trap, aria — §5d |
| Style | Tailwind v4 (CSS-first @theme) | hình thức, và chỉ đọc token DS-001 — §1 |
Ba điều luật này cấm, kèm lý do:
- Không có thư viện UI hoặc animation thứ hai. MUI, Chakra, Bootstrap, GSAP, react-spring, AOS — không cái nào. Đây chính là RISK-012 nói bằng lời khác: legacy chuyenchon + sutucon có 6+ hệ token song song, và mỗi hệ mới vào đều vào theo cùng một cách — một trang cần một hiệu ứng, ai đó
npm imột gói. Hiệu ứng chưa có thì thêm preset vàoMotion.tsx, chỗ đó là nơi để nó ở. - Không dùng gói
framer-motioncũ. Framer Motion đã đổi tên npm thànhmotion; cài cả hai là hai bản engine cùng nằm trong một bundle mà không ai để ý, vì cả hai đều "chạy được". Import đúng:motion/reactvàmotion/react-m(bảnm+LazyMotion— lý do ở §5g·1). - "Bản mới nhất" = nâng lên rồi ghim số, không phải khai
"latest". Hai luật này nghe như ngược nhau nhưng không:"latest"/"*"trongpackage.jsonkhiến hai lần cài cùng một commit ra hai cây phụ thuộc khác nhau — cổngcheck-deps(SRC-591) đỏ đúng chuỗi đó, sau một tai nạn có thật. Nâng bản là một việc có commit, có test, có người nhìn; dải phiên bản mở là để mặc cho registry quyết định lúc CI chạy.
Ai theo luật này: bảy app React — web · learn · marlins · coral · admin · mentors (dolphin) · b21. Tất cả hiện đều có đủ ba lớp.
Ai đứng ngoài, và vì sao: docs · pearl · compass · playbooks là VitePress (Vue). shadcn là React + Radix nên không có đường cài — đây là giới hạn kỹ thuật chứ không phải chỗ được tuỳ nghi. Chúng nạp chung shadcn-bridge.css để radius, màu biểu đồ, popover không lệch khỏi phần còn lại (§5d).
Hai cổng, hai việc khác nhau:
| Cổng | Kiểm gì | Chạy ở đâu |
|---|---|---|
scripts/check-design-stack.mjs | Có mặt: app React nào thiếu motion, thiếu Tailwind, thiếu src/components/ui/, hay có framer-motion cũ / thư viện UI-animation thứ hai | trong npm run check:docs, đỏ là chặn deploy |
scripts/check-ui-latest.mjs (SRC-653) | Độ mới: dải trong package.json đã tụt lại so với bản mới nhất trên registry chưa | npm run check:ui-latest, chạy khi cần |
Cổng thứ hai cố ý không chắn CI: nó gọi registry npm, và một cổng phụ thuộc mạng mà đứng chắn deploy thì đến ngày npm chậm là mọi phiên đứng — repo này đã trả giá đúng kiểu đó (CLAUDE.md, mục cổng chất lượng). Mất mạng thì nó báo "không tra được" và thoát 0: không biết thì không được giả vờ là đạt.
Lượt nâng gần nhất (SRC-1204, 03.10.2026): motion 13.4 lên 14.0 (major duy nhất, không đổi API ta dùng; e2e learn 535/535 xanh), Tailwind 4.3.3, Vite 8.3.2, Vitest 5.0.3, wrangler 4.147, @hono/zod-openapi 1.6.3. Bản 1.6 suy kiểu handler chặt hơn và lộ 12 chỗ lệch thật giữa schema và code (cast as never, z.record một tham số, SessionPatch giao kiểu ra mode không nhận null); đã sửa ở code, không ghim lùi gói.
Nó so major và minor, bỏ qua patch — ^4.3.0 tự nhận 4.3.3 khi cài, nên báo động vì một số patch là báo động giả, đúng thứ làm người ta thôi đọc cảnh báo.
Cả hai cổng kiểm được sự có mặt và con số, không kiểm được "trang mới có thật sự dùng chúng không" — phần đó vẫn là việc của người review, và §7 là chỗ ghi tiêu chí.
0·1. Cụm trang /style — bộ ba ấy trông như thế nào, chạy thật (SRC-942)
Chủ dự án 21.09.2026: "Tạo trang style ở link learn.nemo12.com/style, trong đó là một cụm gồm nhiều trang con, mỗi trang chứa các components, color pallets, buttons, các tables… ưu tiên dùng các component tiêu chuẩn của Shadcn. Sau khi các trang này được chuẩn hóa, thì dùng các components và màu sắc chung trong trang này, để áp dụng cho mọi trang khác trong hệ thống."
§0 ở trên nói được dùng gì. Mục này thêm chỗ nhìn thấy chúng: learn.nemo12.com/style và mười trang con của nó (sections.ts là mục lục duy nhất — thanh điều hướng, bộ định tuyến và test đều đọc từ đó).
Bốn quyết định, và lý do:
- Mẫu CHẠY THẬT, không phải ảnh chụp. Một câu hỏi trắc nghiệm là hành vi chứ không phải hình ảnh: ảnh chụp không cho biết một cú bấm nhầm có làm mất câu hay không. Trang
/style/quizchấm được thật. - Không đăng nhập, không đọc dữ liệu learner. Cụm này đứng trước cả ba cổng của
App(đang tải · cổng ra mắt · màn Join), cùng lý lẽ đã đưaieltsPracticelên trước chúng: nó chẳng có gì để canh giữ, còn cổng ra mắt thì sẽ chặn cả chính người đang sửa giao diện. Dữ liệu trong mọi mẫu là số viết cứng. - Nút đổi nền không phải đồ trang trí. Learn chạy trên nền sáng và nền biển sâu; cách duy nhất biết chắc một component đọc được ở cả hai là nhìn thấy cả hai. Đây từng hỏng thật: một màu nền nhạt của theme sáng đặt lên nền nước thành mảng trắng đục che mất chữ (SRC-355). Bản xem nền sáng phải tự sơn nền của nó, vì
bodycủa learn luôn là màu biển sâu. - Chữ người đọc là PROP, không viết cứng trong component. Learn nói tiếng Việt, sân luyện
/ielts/**nói tiếng Anh. Một component dùng chung hai bên mà giữ sẵn chữ tiếng Việt là kéo tiếng Việt vào phòng thi.
Nguồn của hình, không phải bản sao của hình. Cụm này chỉ có giá trị khi các màn thật dùng chính những component nó trưng. Vì vậy nó đi kèm một lớp component dựng sẵn ở apps/learn/src/components/kit/, và các màn thật được nối vào đó chứ không chép lại:
| Thứ | Ở đâu | Ai đang dùng |
|---|---|---|
| Ba trạng thái màn (đang tải · rỗng · lỗi) | kit/States.tsx | states.tsx → hơn 20 màn gọi LoadError |
| Lựa chọn trắc nghiệm, năm trạng thái | kit/Quiz.tsx | ielts/MicroExercise.tsx |
| Ô số liệu, hàng đo | kit/Stat.tsx | các màn kiểu dashboard |
| Tiêu đề trang / khối | kit/Section.tsx | các màn kiểu dashboard |
Primitive shadcn bổ sung trong đợt này (table, alert, select, checkbox, radio-group, switch, tooltip, avatar, breadcrumb, slider, separator, skeleton, textarea) không kéo thêm gói nào: chúng dựng trên radix-ui — gói ô đã có sẵn trong package.json của learn — đúng như §0 đòi.
Cụm này viết bằng TIẾNG ANH (SRC-947). Chủ dự án 22.09.2026: "Chuyển các trang này sang tiếng Anh toàn bộ." Đây là ngoại lệ thứ hai của luật chữ tiếng Việt ở learn, đứng cạnh sân luyện /ielts/** — bảng ranh giới ngôn ngữ trong CLAUDE.md giữ đủ bốn dòng để không ai phải đoán, và câu dặn cũng giống hệt: đừng "sửa lại cho đúng luật" bằng cách dịch ngược.
Lý do không phải là gu: người đọc cụm này không phải learner mà là người dựng giao diện, và mọi thứ họ cầm trên tay khi dựng đều đã bằng tiếng Anh — tên component (ChoiceButton), tên biến thể (accent, destructive), tên token (--muted-foreground), tài liệu shadcn và Radix. Một trang tiếng Việt bọc quanh những cái tên ấy bắt người đọc dịch qua dịch lại giữa hai từ vựng cho cùng một thứ. Đây đúng lý lẽ đã đưa sân luyện sang tiếng Anh ở SRC-834, chỉ khác người đọc.
Hệ quả cần nói rõ: chữ mẫu trong cụm này không phải mẫu chữ cho sản phẩm. Tên learner, tên đề, câu hỏi trưng bày ở đây là tiếng Anh vì trang này là tiếng Anh; màn thật của learn vẫn nói tiếng Việt, và chép một câu từ /style sang một màn học là mang sai ngôn ngữ vào đó.
Cổng: apps/learn/e2e/style.spec.ts sinh một ca cho mỗi mục khai trong mục lục, và mỗi ca đòi ít nhất một mẫu hiện ra. Chỉ kiểm tiêu đề thì một trang con mất sạch nội dung vẫn xanh — đã hỏng đúng như vậy một lần lúc dựng, vì /style trần trả về mục rỗng.
0·2. Mọi trang www mở đầu bằng MỘT câu hỏi của người đọc, căn giữa (SRC-1190, SRC-1198)
Đảo một phần ngày 03.10.2026 (SRC-1198). Chủ dự án: "Trong mỗi trang, cần bỏ 'NEMO IELTS · Tài nguyên' đi. Đưa câu hỏi ra giữa, bỏ các title cũ đi." Nay đầu trang chỉ còn MỘT phần tử: câu hỏi
h1, căn giữa. Dòng tên trang và tiêu đề cũ (h2đọc như câu trả lời) đều đã gỡ, kể cả eyebrow/badge tên chương trình đứng trên tiêu đề cũ. Đoạn dẫn (lead), nút và nội dung giữ nguyên; lead căn giữa dưới câu hỏi. Tiêu đề cũ mang thông tin riêng thì giữ dưới dạng nội dung: tên người (hồ sơ mentor), tên trường, tên câu chuyện, tên bài viết trong danh sách, tên sách, và dòng "phân môn · mảng" của khoá toán.<title>không đổi. Bảng dưới là luật SRC-1190 gốc; hàng "Bố cục" và "Thẻ" đã thay bằng đoạn này.
Chủ dự án 02.10.2026: mỗi trang có tên của trang, và luôn có một câu hỏi quan trọng nhất, từ góc nhìn người dùng, xuất hiện ngay ở phía trên cùng. Áp cho mọi trang của nemo12.com: trang app (bảng route), trang cũ ngoài bảng (LEGACY_META), trang theo slug trong D1/API (câu chuyện, mentor, trường), họ trang nội dung IELTS/SAT/CELTA/TESOL/TEFL, và cụm bài viết D1 do worker dựng.
| Luật | |
|---|---|
| Bố cục | Hai phần tử, không thêm: dòng tên trang chữ nhỏ, không in hoa vì có dấu (§2·1·5) (NEMO SAT · Chương trình), rồi câu hỏi làm tiêu đề. Tiêu đề cũ của trang đứng ngay dưới và đọc như câu trả lời. |
| Thẻ | Câu hỏi là h1 DUY NHẤT của trang; tiêu đề cũ thành h2. <title> và JSON-LD giữ nguyên (SEO không đổi). Ngoại lệ: trang chủ chọn vai đã hỏi "Bạn muốn học gì?", và trang 404. |
| Giọng | Theo người đọc: trang bố mẹ nói "con tôi", trang học sinh nói "mình", trang giáo viên nói "tôi". Tiếng Việt, ngắn, một câu, riêng cho trang đó; cấm câu chung chung kiểu "Trang này nói gì?". Trang khuyến mãi bản tiếng Anh, Indonesia, Thái hỏi bằng tiếng của trang. |
| Một nguồn | Trang app: apps/web/src/site/pageQuestions.ts (bảng tay + mẫu theo loại trang cho khoá toán, lớp học, khung năng lực, ghi chú tuần). Trang nội dung: apps/web/content/seo/questions.ts. Bài viết D1: apps/web/worker/articles/render.ts. Một component vẽ: PageQuestion trong site/Kit.tsx (khung tĩnh prerender dùng đúng lớp ấy). |
| Cổng | apps/web/content/seo/pageQuestions.test.ts: mọi route, trang cũ, trang nội dung có câu hỏi kết thúc bằng "?", không lặp tiêu đề, trang bố mẹ có "con"/"tôi". apps/web/scripts/prerender.mjs: HTML dựng sẵn của từng trang có đúng một h1 và đó là câu hỏi, sai là build dừng. apps/web/worker/articles/questions.test.ts: mọi danh mục trong seed có câu hỏi viết tay. |
Vì sao câu hỏi chứ không phải khẩu hiệu: người mở một trang mang theo một câu hỏi của chính họ. Câu hỏi đặt đầu trang cho họ biết ngay trang này có trả lời đúng điều họ cần hay không, và buộc người viết trang chọn ra một việc trang ấy phải làm cho xong. Nguồn câu hỏi đầu tiên là Page Briefs SRC-1185 (apps/ui/src/briefs, mục Core Questions) cho các trang đã có brief.
0·3. Thanh chương trình có mục Học phí (SRC-1198)
Chủ dự án 03.10.2026: "NavBar của mọi program đang thiếu link tới trang Học phí." Thanh chương trình trên www (apps/web/src/site/programNavData.ts, SiteNav.tsx, cả menu mobile) và trên learn (apps/learn/src/programNav.ts, ProgramMenu.tsx) nay là: tên chương trình · Lộ trình · Chương trình · Cách học · Kết quả · Tài nguyên · Học phí.
learn không còn bảy mục (SRC-1290, chủ dự án 07.10.2026). learn là cụm đóng, không link nào sang www, nên menu của learn chỉ giữ tên chương trình và các mục có trang THẬT trên learn; Học phí và các mục khác chỉ có trên www đã biến mất khỏi learn. Luật đầy đủ: Guided Journey §9 (learn là một cụm đóng). Bảng dưới chỉ còn đúng cho www.
| Chương trình | Học phí trỏ tới |
|---|---|
| IELTS, Grammar, Essay, AP | trang học phí có sẵn, bản học sinh /<id>/tuition, bản bố mẹ /parents/<id>/tuition |
| SAT | /sat/tuition cho cả hai vai (chỉ có một trang) |
| SPEAK | /speak/tuition, trang mới dựng từ tabs/speaking.ts; giá chép từ learn SPEAK_COURSES, test đối chiếu |
| MATH, AI TEEN | /math/tuition, /ai-teen/tuition: trang mới nói thẳng chưa có học phí và hỏi qua hello@nemo12.com. Không bịa giá. |
Cổng: apps/web/src/site/programNav.test.ts kiểm mọi mục của mọi chương trình, cả hai vai, là một route có thật, mục ấy sáng đúng trên trang của nó. Từ SRC-1290 cổng ấy kiểm ngược lại: menu của learn chỉ trỏ vào đường dẫn learn; apps/learn/src/programNav.test.ts kiểm mỗi chương trình chỉ còn các mục có trang trên learn.
1. Kiến trúc package (REQ-PLT-06, REQ-UX-05)
packages/design-system/
├── tokens/base.css # màu, radius, elevation, motion, typography — nguồn sự thật duy nhất
├── tailwind.css # @theme mapping cho Tailwind v4 (CSS-first)
├── index.css # base styles + lớp component n12-* (button/card/chip/tab/input/progress/skeleton/hero…)
├── art/ # N12Art, NemoLogo, OrcaLogo — canonical, app giữ bản sao y hệt (§2c)
└── ui/ # Tour, FindingNemo, DeepChrome — canonical, app giữ bản sao y hệt (§5c)- Phân phối CSS = npm workspace package
@nemo12/design-system. Cấm mỗi app tự chế lại một pattern đã có trong package. - Ngoại lệ duy nhất cho luật "một nguồn":
art/vàui/là component React nên không phân phối qua CSS được — mỗi app giữ bản sao y hệt file canonical (cùng cách vớischoolsInfo.ts, §2d). Sửa ởpackages/design-system/rồi copy sang app; cấm sửa thẳng bản trong app rồi để hai bên lệch nhau. deep.css(SRC-1094, 27.09.2026): nền biển sâu.n12-deepcủa sânlearn.nemo12.com/ieltschuyển nguyên văn từapps/learn/src/index.cssvào DS để gull.nemo12.com mặc đúng bộ áo ấy; learn và gull cùng@import "@nemo12/design-system/deep.css". KhungNavBar/PageBodyđi kèm có canonicalui/DeepChrome.tsx, bản sao y hệt ởapps/learn/src/ielts/Chrome.tsxvàapps/gull/src/Chrome.tsx.- School/portal theme là lớp mỏng: từ v0.4 chỉ còn đặt một biến
--school-mark(vạch nhận diện 2px). Không đè màu hành động, không fork component — xem §2·0.
1·1. Bản sao Y HỆT có máy kiểm: copies.json + check-ds-copies (SRC-1227)
Từ SRC-118, component dùng chung sống ở packages/design-system/{art,ui}/ và mỗi app giữ một bản sao y hệt. Luật đúng, nhưng 46 ngày không có máy kiểm, và audit 04.10.2026 (report) đo ra 46 bản sao đã lệch: bản sửa đi vào bản sao của MỘT app rồi dừng ở đó. Ba lỗi đã sửa xong ở chỗ này (hình vẽ mờ SRC-182, popover trong suốt SRC-878, Tour StrictMode) vẫn sống ở chỗ khác.
| Thứ | Ở đâu |
|---|---|
| Canonical component Nemo12 | packages/design-system/{art,ui}/ |
| Canonical primitive shadcn | packages/design-system/shadcn/ (24 file, bản của cụm /style; sheet.tsx thêm ở SRC-1264) |
| Ai là bản sao của ai, fork nào được phép | packages/design-system/copies.json |
| Cổng | scripts/check-ds-copies.mjs trong npm run check:code, đỏ là chặn deploy |
| Đồng bộ | npm run ds:sync |
Ba luật:
- Sửa ở canonical, rồi
npm run ds:sync. Sửa thẳng bản sao trong app thì cổng đỏ. Đây là điểm chính: người sửa luôn sửa chỗ họ đang đứng, nên chỗ ấy phải là nguồn. - Primitive shadcn mới vào
shadcn/trước, rồi mới chép vào app. Mộtapps/*/src/components/ui/<x>.tsxkhông có canonical là cổng đỏ. - Fork phải khai lý do trong
forkscủacopies.json. Fork không lý do, hoặc fork của file đã không còn, đều đỏ. Fork là NỢ thì ghi chữ NỢ và cách trả.
Preset Appear (hiện ngay khi dựng, cho nội dung đổi do người dùng bấm) vào Motion.tsx canonical trong đợt này: gull và web đã tự viết nó hai lần dưới hai cái tên.