---
url: https://docs.nemo12.com/design-system/stack-and-packages.md
description: >-
  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.
---

# DS-001 · Bộ ba bắt buộc và kiến trúc package

Một phần của [DS-001](./index.md). 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:

1. **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 i` một gói. Hiệu ứng chưa có
   thì **thêm preset vào `Motion.tsx`**, chỗ đó là nơi để nó ở.
2. **Không dùng gói `framer-motion` cũ.** Framer Motion đã đổi tên npm thành `motion`; 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/react`
   và `motion/react-m` (bản `m` + `LazyMotion` — lý do ở §5g·1).
3. **"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"`/`"*"` trong `package.json` khiến hai lần cài cùng một commit ra hai cây phụ thuộc
   khác nhau — cổng `check-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:

1. **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/quiz` chấm được thật.
2. **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ẽ đã đưa `ieltsPractice` lê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.
3. **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ì `body` của learn luôn là màu biển sâu.
4. **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](./guided-journey/index.md) (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)

```text
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ới `schoolsInfo.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-deep` của sân `learn.nemo12.com/ielts` chuyển nguyên văn từ `apps/learn/src/index.css` vào DS để gull.nemo12.com mặc đúng bộ áo ấy; learn và gull cùng `@import "@nemo12/design-system/deep.css"`. Khung `NavBar`/`PageBody` đi kèm có canonical `ui/DeepChrome.tsx`, bản sao y hệt ở `apps/learn/src/ielts/Chrome.tsx` và `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](./audit-2026-10-04.md)) đ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:

1. **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.
2. **Primitive shadcn mới vào `shadcn/` trước**, rồi mới chép vào app. Một
   `apps/*/src/components/ui/<x>.tsx` không có canonical là cổng đỏ.
3. **Fork phải khai lý do** trong `forks` của `copies.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.
