---
url: https://docs.nemo12.com/design-system/ocean-world-rollout.md
description: >-
  Header www, cổng IELTS của learn, cổng contrast theo ngữ cảnh, menu avatar,
  b21 và od-root rời v0.4, marlins kính và màu tín hiệu.
---

# DS-001 · Ocean World: header, cổng IELTS, contrast và các app còn lại

Một phần của [DS-001](./index.md). Header www, cổng IELTS của learn, cổng contrast theo ngữ cảnh, menu avatar, b21 và od-root rời v0.4, marlins kính và màu tín hiệu.

### 2·1·19. www dọn bộ override header riêng sang `.n12-nav-deep` (SRC-823)

Món nợ ghi ở §2·1·18 trả ngay trong ngày: khối `.theme-www > header …` (SRC-804) — viết **ngoài `@layer`**, trước bài
học §2·1·17 — đã gỡ. Thanh của www nay gắn `n12-nav-deep` như coral/admin/mentors; bốn thứ www từng cần riêng đưa vào lớp
chung để không app nào phải viết lại: mục chưa có trang mờ 45% (`text-ocean-700/45`), ring trắng 25% của pill đang chọn
(pill so với thanh chỉ 2,5:1, ring tạo cạnh), nền hơi sáng cho nút viền (`border-border` + `bg-transparent/background/card`)
kèm hover, và `bg-sand/85` thành trong suốt. Kết quả: **không còn luật ngoài `@layer` nào ghi đè DS trong apps/web**.

Kiểm bằng cách dựng **hai bản build song song** (trước: cổng 4310, sau: 4311) và cho ba agent độc lập đối chiếu cùng
một URL, cùng thao tác: desktop (mục mặc định/hover/đang chọn/mờ, nhãn khu, nhãn lab, nút Đăng nhập), mobile (nút Menu,
panel mở — `bg-card` bị lớp chung làm trong suốt, nhưng panel nằm **trong dòng chảy** của `<header>` nên nền thanh bao
trùm; khác coral/admin nơi dropdown là `absolute` nên phải dùng token popover), và audit tĩnh từng khai báo của khối cũ.

### 2·1·20. Cổng IELTS của learn: một NavBar, một Footer, full-width (SRC-824)

Chỉ đạo chủ dự án 2026-09-18 (kèm ba ảnh `/ielts`, `/ielts/reading`, `/ielts/reading/clusters`): *"Mấy trang này, cần
có NavBar và Footer có UI tương tự nhau, còn nội dung của NavBar hay Footer thì khác nhau cũng được. Chúng đều cần
full-width."*

Cổng IELTS có **hai mạch trang dựng khung ở hai chỗ**, nên chúng trôi khỏi nhau đúng theo RISK-012 thu nhỏ: mạch khai
báo (`/ielts`, `/ielts/settings/...`) lấy thanh từ `App.tsx` — logo · đổi ngôn ngữ · tài khoản — rồi bó thân trang trong
`max-w-2xl`; mạch luyện (`/ielts/reading`, `/ielts/listening/...`) tự dựng thanh riêng — vệt đường dẫn · chip lĩnh vực ·
chip tài khoản — rồi bó trong `max-w-3xl` (`max-w-6xl` khi truyền cờ `wide`). Hai thanh khác chiều cao, khác nền, khác bề
ngang: bấm từ trang này sang trang kia đọc ra như vừa rơi sang một sản phẩm khác.

Bốn thay đổi, và cả bốn đều là **gom về một chỗ**, không phải thêm một lớp giao diện mới:

1. `apps/learn/src/ielts/Chrome.tsx` giữ HÌNH THỨC của cả hai mạch: `NavBar` (nền `n12-nav-deep` §2·1·18, sticky, cao
   tối thiểu 56px) và `PageBody`. NỘI DUNG vẫn của từng mạch qua `left` / `right` — đúng như chỉ đạo cho phép.
2. Full-width đi bằng `n12-page n12-page-full` của DS, không bằng `max-w-*` viết tay: bỏ trần bề ngang mà **giữ lề**
   (1rem, 2rem từ `sm`), nên chữ không dán vào cạnh màn hình. Cờ `wide` (SRC-786) vì thế bỏ hẳn — một cờ chọn giữa hai
   cái trần khi không còn trần nào là một tham số nói dối người đọc mã.
3. Chân trang riêng của mạch luyện (một dòng "Nemo12 · One World, One Learner") bỏ: chân trang thật của learn là
   `SiteFooter` gắn ở `main.tsx` cho MỌI nhánh route, nên mỗi trang luyện đang ký tên hai lần.
4. `SiteFooter` của learn nhận `n12-footer-deep` (§5b, SRC-574): learn mặc biển sâu ở mọi nhánh, nhưng chân trang nằm
   NGOÀI `.n12-deep` nên nó lấy token nền sáng — một dải trắng cắt ngang đúng chỗ trang đang chìm dần.

`stackOnMobile` là **cờ do trang bật**, không phải luật chung: bài học SRC-820 (phần `left` chiếm trọn một dòng trên màn
hẹp) đúng cho một vệt đường dẫn ba tầng, nhưng với một logo hai từ thì xuống dòng là ăn không một hàng của màn hình.

Kiểm chứng: typecheck + build, 160 case Playwright của learn xanh (gồm hai case gác `/ielts` và `/ielts/settings`),
`npm run check:code` và `check:docs` xanh, và chụp mắt bốn trang trên bản build thật — `/ielts` (desktop + 390px),
`/ielts/settings`, `/ielts/reading/clusters`, và trang bài đọc hai cột vốn dùng `wide`.

**Ba vòng tiếp theo cùng ngày, trên chính bốn trang ấy** (chỉ đạo chủ dự án 2026-09-18, xem ảnh chụp `/ielts/reading`):

1. **Status Card thay một dòng chữ.** *"Cụm này cần đưa ra một card riêng, kiểu như Status Card."* Bốn con số
   (`SkillStatusCard` trong `ielts/Hub.tsx`) trước đó là MỘT dòng ngay dưới tên kỹ năng — cùng cỡ chữ, cùng màu mờ với
   câu mô tả, nên việc learner đã làm đọc ra như chú thích của tiêu đề. Nay là một khối có ranh giới, bốn ô hai cột trên
   điện thoại / bốn cột từ `sm`, mỗi ô có nhãn riêng thay vì cách nhau bằng dấu chấm giữa. Luật cũ giữ nguyên: chưa có
   lịch sử thì không vẽ gì, vì một card toàn số 0 là bản kê những việc learner chưa làm.
2. **Tag trên card: nền da cam đặc, chữ trắng.** *"Mấy cái text da cam (KEEP GOING, BROWSE...), cần đổi các card đó
   thành nền da cam, chữ trắng."* `bg-accent/15` + chữ accent gần như tan vào nền biển sâu, nên cái "cục màu" mắt phải
   bắt trước tiên (SRC-818) chỉ còn là một dòng chữ hơi ấm. Trắng trên `--accent` đạt 4,5:1.
3. **Đường tiến bộ lexis theo NGÀY.** *"Trong đây có khu vực nào đó, vẽ biểu đồ, thể hiện sự tiến bộ trong khía cạnh
   lexis, theo từng ngày."* Chỗ ấy là chính Status Card: bốn con số nói learner đang Ở ĐÂU, đường này nói nó ĐI TỚI ĐÓ
   thế nào. Server thêm `lexis_by_day` (14 ngày, `workers/api/src/modules/ieltsSkills/progress.ts`) — **cộng dồn** tới
   hết mỗi ngày, vì một cột "từ mới mỗi ngày" thì ngày nghỉ nào cũng tụt về 0 và biểu đồ sẽ nói sai về trí nhớ. Hai
   đường (`met` đã gặp · `solid` đã gặp lại ở bài khác) vì bỏ một đường là mất đúng phân biệt "nhiều hơn / chắc hơn".
   Vẽ bằng **SVG viết tay**, không kéo thư viện chart — DS-001 §0 cấm gói thứ tư, và một đường 14 điểm không đáng một
   dependency (cùng cách đã dùng cho Mastery Continuum Bar).

Một bẫy đã chắn ngay: client khai `lexis_by_day` **không bắt buộc**. App tĩnh lên Pages trước, worker API lên sau, nên
có một khoảng thời gian trang mới hỏi một API chưa biết trường ấy — khai bắt buộc thì TypeScript yên lặng còn trình
duyệt đọc `undefined.length` và cả thẻ trắng. Kiểm thêm: test `buildProgress` gác tính cộng dồn và gác `solid ≤ met`
từng ngày (40 case module `ieltsSkills` xanh), 160 case Playwright xanh, và chụp mắt card + biểu đồ ở desktop và 390px.

**Hai lần đỏ trên đường đi, cả hai đều đáng ghi:**

* `text-white` trên `bg-accent` chỉ **4,36:1**, dưới ngưỡng 4,5 cho chữ nhỏ — cổng contrast (AS-09.3.1, vừa mở rộng ở
  SRC-827) bắt đúng lúc. Nền tag vì thế là `--accent-deep`, bậc DS dựng sẵn cho việc "nền coral mang chữ trắng", chứ
  không phải một sắc pha riêng. Bài học: **đừng viết tỉ số tương phản vào chú thích theo trí nhớ** — chạy cổng rồi hãy
  viết, vì một con số sai trong chú thích còn sống lâu hơn cái sai nó mô tả.
* Trường mới trong `SkillProgress` làm một fixture test khác thiếu thuộc tính, và `vitest` của module **vẫn xanh** vì
  vitest không typecheck. Chỉ `tsc -b` của `workers/api` bắt được. Bài học: đổi một type dùng chung thì chạy **cả hai**
  trước khi push, không chỉ bộ test của đúng module mình sửa.

### 2·1·21. Cổng contrast chấm trong đúng ngữ cảnh — điểm mù `.n12-deep` (SRC-827)

Phiên khác báo (và tự vấp): `npm run contrast` chỉ đo token ở gốc và `.theme-*`, **không đo trong `.n12-deep`** — nơi
DS lật nghĩa token (`--accent-ink` ở gốc là nền sẫm `#8F2A0F`, trong deep là **chữ sáng** `#EAF4FF`). Hậu quả thật: nút
`bg-accent-ink text-white` được cổng chấm 17:1 mà trên production là 1,11:1 (đã vá ở `b09b40ea`). Toàn bộ mạch IELTS đứng
trong vùng ấy, nên đây là điểm mù lớn nhất của cổng.

**Sửa ở cổng:**

1. **Ngữ cảnh.** Đọc thêm token theo scope (`.n12-v1`, `.n12-deep` của learn, `.theme-marlins.od-root`, `.n12-band-dark`
   của www) và dựng ngữ cảnh chồng đúng cascade. Cặp auto-scan chấm trong ngữ cảnh của **app chứa nó** (learn → deep vì
   mọi `<main>` đều `n12-deep` từ SRC-809; marlins → od-root; www → v1, thêm band-dark cho file có dải tối; file chỉ render
   trong thanh (AvatarMenu, SiteNav, LangSwitch) → nav-deep); component DS khai tay chấm ở gốc, v1 và deep.
2. **Màu mờ.** Lần chạy đầu báo 20 lỗi, 18 là giả: trong deep `--muted` là trắng 10%, cổng bỏ alpha nên coi là trắng đặc.
   Nay màu mờ được **hoà lên `--card` của ngữ cảnh** (đo trên learn thật: khớp pixel ±0,12; hoà lên nền trang thì lạc quan
   1,6–2,4 bậc). `transparent` là alpha 0, `color-mix` trộn theo alpha, `bg-x/NN`, `bg-(--token)` và `text-[#hex]` đều đọc được.
3. **Remap utility.** Ba vùng tối đổi màu chữ bằng CSS chứ không bằng token (`.n12-nav-deep`, `.theme-marlins.od-root`,
   `.n12-band-dark`, và bốn màu chữ trong `.n12-deep`); cổng giữ bảng `REMAPS` **soi gương** các luật ấy — đổi một bên
   phải đổi bên kia. Remap theo **cặp** (`PAIR_REMAPS`) cho pill đang chọn của thanh: remap `text-white` toàn cục từng làm
   mọi chữ trắng khác trong header bị chấm sai.

**Lỗi thật cổng mới bắt được, đã sửa cùng commit:** learn — avatar dự phòng và ngôi sao Focus (`bg-ocean-700 text-white`,
1,34:1 vì `--n12-ocean-700` lật thành xanh nhạt), `.n12-badge-review` (1,40:1), thẻ school wash trên `#fff` (1,0:1), chữ
`text-[#4a3a10]` trên nền mờ (1,24:1), `text-destructive/success/coral-deep` trên thẻ tối (2,3–2,9:1), `bg-accent
text-white` (3,29:1) và `text-accent` trên wash coral (3,93:1) ở IELTS; marlins — `bg-sun-soft text-ocean-900` (1,01:1); www —
Initials `bg-ocean-100 text-ocean-700` trong dải tối (1,87:1), nhãn lab coral trên thanh (4,02:1 → peach), `text-accent`
trên wash coral (2,76:1); VitePress footer (1,54:1). Với marlins và www, cách sửa là biến bốn nền "mềm" (sun-soft ·
seafoam · ocean-100 · muted) thành màu mờ **bằng token** trong chính vùng tối — đúng cách learn đã làm ở `.n12-deep`.

**Nợ có tên, không có nợ ngầm.** `contrast-baseline.json` theo luật các cổng trần khác: khoản nợ cũ phải ghi tên, khoản
không tên là đỏ, trả xong phải xoá tên (còn tên là đỏ). Baseline đầu tiên ghi 8 cặp cổng **chưa phân định được thật hay
giả** vì cần mắt xem: marlins ngoài/trong `od-root` (màn đăng nhập, `bg-sand`/`bg-card` với chữ remap sáng — 4 cặp), chữ
trắng trên ảnh ở mentors (`bg-black/45`, `/50`), b21 (`bg-white/5` trên hero tối, app chưa v1), một `bg-card` trong file có
dải tối của www. Mỗi dòng baseline là một việc phải mở màn thật để kết luận.

**Giới hạn còn lại, ghi rõ:** cổng chỉ ghép cặp `bg-*` + `text-*` đứng cùng một `className`; màu đặt qua lớp CSS
(`.n12-tab`…), chữ kế thừa từ cha, className động (`cn()`, biến, 9,5% số className) và `opacity-NN` không-disabled thì không
thấy — đó là việc của một cổng đo DOM thật, chưa có.

**Bài học vận hành, lần thứ hai trong ngày:** toàn bộ việc này từng bị xoá sạch khỏi cây làm việc chung bởi một
`git reset --hard` của phiên khác, phải áp lại từ transcript. Việc dài, nhiều file thì làm trong worktree riêng ngay từ đầu.

### 2·1·22. Trang môn IELTS: "Chẩn đoán" và "Học phí" thành dải tối, hai cột (SRC-830)

Chủ dự án 2026-09-18, kèm ảnh `/parents/university/ielts`: *"Sửa section Chẩn đoán, để màu nền tối màu, và chỉnh hết
lại layout của riêng section đó"*, rồi *"chỉnh nốt cả section Học phí nữa cho đồng bộ"*.

**Trước:** hai thẻ trắng ngang trên nền sáng, mỗi thẻ một tiêu đề, một câu, một nút — trông như hai dòng thông báo hơn
là hai lối đi, và đứng ngay sau thang CEFR (cũng sáng) nên không có ranh giới nào cho mắt.

**Sau:** một `CtaBand` (`GoalPages.tsx`): dải `n12-band-dark` — cùng mặt nước với phần còn lại của mạch IELTS — mỗi khối
một thẻ `Card` (trong dải tối tự thành mặt đặc `--card-deep`, §2·1·16): nhãn nhỏ, tiêu đề to, một câu dẫn, **ba ý có chấm
coral** (đúng ngôn ngữ của trang chẩn đoán mà chủ dự án đã chốt ở §2·1·16), và một nút `accent`. Với bố mẹ, hai khối vốn
đứng cạnh nhau nên gộp thành **một dải hai cột** — hai dải tối liền nhau là phá nhịp section (§2·1·6), còn một dải hai cột
đọc ra là "hai việc kế tiếp". Học sinh thấy hai dải riêng vì Học phí đứng ở cuối trang với họ.

Không khai màu tay trong JSX: `text-ocean-900` / `text-muted-foreground` được `.n12-band-dark` kéo về bản sáng, nút dùng
`accent` vì `primary` (deep-ocean) chìm trên nền tối. Đo trên build: nền dải = `--surface-deep`, thẻ `#1D2533`, tiêu đề
`#F3F7FC`, chấm `#F45B32`, nút `#C13A17`. Cổng contrast: 0 nợ mới.

**Còn lại, nói rõ:** ngay dưới dải này là dải tối "Bạn cần bao nhiêu" (`IeltsTargetPicker`), nên trên trang bố mẹ hai dải
tối đứng liền nhau — đúng thứ §2·1·6 cấm. Chưa xử lý vì chỉ đạo chỉ nói tới hai section này; cách hợp lý nhất là kéo dải
"Bạn cần bao nhiêu" sang nền sáng hoặc đổi chỗ, là một quyết định riêng.

### 2·1·23. Tám khoản nợ contrast phân định bằng màn thật; nhịp section trang môn IELTS (SRC-831)

Tiếp §2·1·21 và §2·1·22 (chủ dự án: *"tiếp đi"*). Bốn agent mở màn thật (marlins/mentors qua build + mock API, www/b21
prerender tĩnh) cho tám khoản trong `contrast-baseline.json`; một agent sửa nhịp section.

**Kết quả tám khoản — 4 thật, 4 giả; baseline về rỗng:**

* **Thật, đã sửa:** marlins `text-[#4a3a10]` trên `bg-sun-soft` trong `od-root` (1,56:1 → `text-ocean-900`, 6,34:1);
  marlins "Ai là ai trong phim?" `bg-sand text-ink` (1,07:1 — kem đặc dưới chữ đã đổi sáng) và `Beliefs` cùng khuôn (1,69:1)
  → `bg-muted`; mentors nhãn "Đổi ảnh" và nút ‹ › / bộ đếm lightbox `bg-black/45–50` trên ảnh sáng (3,4–4,0:1) → `/60`
  (5,7–6,4:1 trên ảnh trắng).
* **Giả — cổng gán sai ngữ cảnh, sửa ở cổng:** marlins `App.tsx` "Đang tải" và `GoogleButton` nằm **ngoài** `od-root`
  (5,6–5,9:1 thật); www `Kit.tsx` chỉ *định nghĩa* lớp `n12-band-dark` trong template string, không render gì trong dải
  (6,1:1 thật); b21 `bg-white/5 text-white` nằm trong `.od-card` trên gradient biển (4,75–5,8:1 thật, điểm tệ nhất 4,35 ở
  mép trái mobile — sát ngưỡng, ghi lại).
* **Cổng học được gì:** ngữ cảnh gán theo **vị trí**, không chỉ theo file — `<main>` không mang `od-root` → v1, `<header>`
  con của `od-root` → nav-deep, file đăng nhập/lỗi của marlins → v1 (`LIGHT_FILES`); www chỉ tính "có dải tối" khi
  `n12-band-dark` đứng trong một `className` literal; b21 có ngữ cảnh `:root .od-root` với nền hoà là od-card trên stop sáng
  nhất của gradient; khi một cặp KHÔNG ĐẠT, in **mọi** `file:dòng` cùng cặp — cùng key `bg-sand text-muted-foreground` vừa
  giả ở `App.tsx` vừa thật ở `Beliefs.tsx`, chỉ in chỗ đầu là kết luận sai.
* **Token:** `.theme-marlins.od-root` thêm `--n12-sand-50` mờ (cùng nhóm bốn nền mềm ở §2·1·21) — `bg-sand` còn ở
  Portrait/Roadmap/PlanPage với chữ kế thừa từ Card, cổng không thấy; token sửa một lần cho tất cả.

**Nhịp section trang môn IELTS.** Thứ tự thật trên production (cả hai vai): CEFR (sáng) → Chẩn đoán + Học phí (tối) →
Mentors (**tối** — chỉ hiện khi API mentors về) → "Bạn cần bao nhiêu" (tối) → Đọc tiếp (sáng): **ba** dải tối liền nhau,
không phải hai như §2·1·22 ghi (bản prerender không có Mentors nên chỉ thấy hai). Sửa: "Bạn cần bao nhiêu" thành
`n12-band-sky` (sky chứ không foam, vì nền trang đã là foam) — gỡ mọi lớp sống nhờ remap của band-dark (`bg-white/10`,
`ring-white/*`, `hover:text-accent`), ô thang trong vùng coral đổi sang `coral-600` cho chữ trắng (4,80:1); và **đổi chỗ**
lên trước Mentors. Đổi chỗ đi xa hơn chữ đề bài, có lý do: chỉ đổi màu mà giữ chỗ thì Chẩn đoán (tối) → Mentors (tối)
vẫn liền nhau ở cả hai vai; đường còn lại là kéo Mentors sang sáng, tức đụng một dải chủ dự án chưa nói tới. Sau sửa:
sáng → tối → sáng → tối → sáng ở cả hai vai.

### 2·1·24. Menu avatar ba app về một kiểu: phần kéo dài của thanh sẫm (SRC-837)

Món nợ ghi ở §2·1·18: sau SRC-822, dropdown dưới avatar ở coral/admin là **mặt trắng** (token popover) còn mentors là
**phần kéo dài của thanh sẫm** — cả ba đều đạt tương phản, chỉ là ba cách cho một việc. Chủ dự án: *"tiếp đi"*.

**Chuẩn là mentors, vì hai lý do.** Một, dropdown gắn vào nút avatar và nằm **trong** `<header>`: nó là con của thanh, nên
mang màu thanh thì mắt đọc ra "mở từ đây"; một tấm trắng rơi xuống từ thanh sẫm trông như thứ của trang chứ không phải của
thanh. Hai, www đã làm đúng thế cho panel menu điện thoại (§2·1·19). **Hộp thoại** Profile/Settings thì ngược lại: nó là màn
đọc thông tin, sáng — và phải ra khỏi cây DOM của thanh bằng `createPortal`, vì `.n12-nav-deep` remap chữ/nền của mọi
thứ bên trong, và header có `backdrop-filter` nên là containing block của `fixed`: overlay không portal bị **giam trong
khung header** (đo được ở coral trước SRC-822, và admin còn nguyên tới đợt này).

Khuôn (đúng file `apps/mentors/src/AvatarMenu.tsx`): dropdown `Card` `bg-(--nav-surface) text-ink` — chữ được nav-deep
kéo về `--nav-ink`; mục Logout/Sign out `text-ocean-900 hover:bg-muted` kèm dấu ↪ (không `text-destructive`: 1,99:1 trên
`--nav-surface`); hộp thoại `Card` sáng trong `createPortal(…, document.body)`. Cổng contrast chấm `AvatarMenu.tsx` ở ngữ
cảnh nav-deep (§2·1·21) nên khuôn nào lệch sẽ đỏ ngay.

### 2·1·25. b21 bật hệ màu chung — app React cuối cùng rời v0.4 (SRC-838)

Chủ dự án 2026-09-18: *"bật màu mới cho b21 luôn đi."* b21 là khung năng lực công khai (không đăng nhập), mọi route bọc
trong `od-root` — biển sâu có sẵn từ SRC-556 — chữ nằm trong `.od-card`. Hai việc như coral/admin/mentors (§2·1·18):
`<html class="n12-v1">` và header gắn `n12-nav-deep`. Khác ba app kia, nội dung b21 vốn tối, nên phần đổi thật sự là
**token hành động** (`--accent` v1, `--accent-deep` cho nút, radius v1) và **thanh** — từ một vạch trong suốt trên gradient
thành thanh sẫm cùng token với mọi cổng.

Cổng contrast theo ngay: ngữ cảnh b21 từ `:root .od-root` thành `.n12-v1 .od-root` (nền hoà vẫn là od-card trên stop sáng
nhất của gradient), header của b21 chấm ở nav-deep như marlins. Kiểm như §2·1·18: build, preview, chụp và đo pixel; b21
công khai nên đo được mọi trang, không cần mock.

### 2·1·26. Nợ .od-dim trả ở tầng DS — od-root là mặt nước tự pha cuối cùng (SRC-842)

Chủ dự án 2026-09-19: *"sửa nợ .od-dim ở tầng DS luôn đi."* Khoản nợ nêu ở §2·1·25/SRC-838: `.od-dim` của b21 đo được
2,6–3,9:1 tuỳ vị trí trên gradient — cổng không thấy vì nó mô hình các stop tuyến tính, còn chỗ tệ nhất là vầng lagoon ở
góc trên. Hai cần gạt đã nhắc tới (bớt vầng sáng, tăng độ mờ chữ) đều **không đủ**, và làm phép thì thấy ngay tại sao.

**Nguyên nhân thật: nước, không phải chữ.** `od-root` là mặt tối **cuối cùng** còn tự pha — stop đỉnh `--n12-ocean`
\#12639A cộng vầng lagoon 50%. Trên đỉnh ấy, ngay cả **trắng đặc** trong một `.od-card` 8% cũng chỉ 3,1–3,4:1; `.od-dim`
72% là 2,3–2,5:1; ở stop đỉnh trơn có card, trắng 72% = 3,6:1 và trắng 100% = 5,4:1. Tức là **không có độ mờ nào** của
`.od-dim` chạm 4,5:1 chừng nào stop đỉnh còn là `--n12-ocean` — vá ở `.od-dim` là vá sai chỗ; mặt nước phải hạ xuống.

**Sửa chỉ ở DS, không app nào phải đổi file.** `.od-root` nay `background: var(--surface-deep)` (mặt nước chung của
§2·1·11) + `background-attachment: fixed` (cùng lý do với `.n12-surface-deep`: trang dài không được thành một stop đỉnh
sáng kéo dài), `color: --n12-deep-ink`; `.od-card` dùng `--surface-glass` + `--n12-deep-ink` (viền 26% và hover giữ
nguyên); `.od-dim` = `--n12-deep-ink-muted` — **một** màu mực phụ cho mọi thế giới tối (learn `.n12-deep`, marlins,
od-root). Tia nắng (`::before`) và bong bóng (`.od-bubble`) giữ lại: đó là dấu hiệu chất liệu của od-root, không phải phần
gây nợ. Số sau sửa: đỉnh `--surface-deep` với vầng 18% đủ alpha ≈ rgb(23,50,86) (#173256); kính 7% trên đó ≈ rgb(39,64,98);
\#A9C6E6 trên kính ấy = 5,97:1. Kèm theo: `--muted` của `.theme-marlins.od-root` 10% → 8%, vì cổng nay hoà nền mờ lên kính
ở đỉnh (`bg-muted text-muted-foreground` 10% chỉ 4,41:1, 8% được 4,70:1).

**Cổng học được gì (contrast-check.mjs).** Đỉnh gradient suy từ token thật (`--surface-deep-glow` hoà lên
`--n12-deep-surface`), không gõ tay; `.od-dim`/`.od-card` **đọc từ index.css** (sự thật, không phải bản soi gương); có
khoá gương — cổng từ chối chạy nếu `.od-root` rời `--surface-deep`; nền hoà cho b21 **và** marlins = kính trên đỉnh (nền
`--card-deep` cũ của marlins là lạc quan); auto-scan nhận `od-dim`/`od-card`; thêm 8 cặp (od-root ×5, marlins ×2, learn ×1
— cùng điểm mù đỉnh gradient đóng luôn cho learn). Kết quả: exit 0, 213 cặp, 0 fail. Đột biến để kiểm cổng: đổi `.od-root`
về gradient cũ → đỏ; nâng alpha `--surface-deep-glow` 0,18 → 0,5 → đỏ; `--n12-deep-ink-muted` → #7F9BBF → đỏ. Ba đột biến
còn **xanh** và phải nói rõ: `.od-dim` → trắng 72% (7,48:1 trên nước mới — nay thật sự đạt), `.od-card` → trắng 30%
(kính sáng hơn thì chữ sáng vẫn đạt), và thêm một `<p class="od-card od-dim">` vào b21 (cặp ấy đạt thật). Xanh vì đúng,
không phải vì cổng mù — riêng đột biến `.od-card` thì vòng hai chứng minh câu ấy chỉ đúng một nửa, xem bên dưới.

**Đo pixel trên bản build (Playwright 1280 và 375).** Mọi mẫu `.od-dim` / `text-muted-foreground` / `text-white/60–75`
nằm trên nước hoặc trên kính od-card ≥ 5,49:1 (tệ nhất: viên đếm "Bậc" trong card đầu ở `/f/nemo/c/ELA.1`, đậu ngay dưới
header = nước sáng nhất). Dải nước trơn dưới header 7,86–8,06:1 với #A9C6E6 (đo #1d2b44..#1f2c44, tối hơn dự đoán một
chút). Theo trang: b21 home 5,94 · detail 5,02 · deeper 5,49 · marlins harness 4,98 (hai số 4,98–5,02 là viền 26% của card
rơi vào dải đo, không phải nền chữ). Mắt thường: tia nắng còn rõ, card vẫn đọc ra kính (#1e2734 trên #0c1524 + viền), header
nav-deep cao hơn nước một bậc, không dải sáng hay card trắng ở viewport nào.

**Nợ CŨ lộ ra khi đổi nước — vòng một chỉ ghi, vòng hai trả (cùng commit).** Rà tay các màu tự pha trong ba app od-world
(chỉ đọc) sau vòng một: **fail** — marlins `.n12-badge-solid` chữ #171C22 trên `--n12-seafoam-200` 18% = 2,51:1 (đo pixel
1,96; nước cũ 4,20 nên đổi nước làm nó lộ ra) và `.n12-badge-review` #7A5A14 trên `--n12-sun-100` 22% = 1,03:1 (đo 1,34; đã
\~1,2–1,5 trên nước cũ) — cổng in 14,56 và 5,66 vì hai cặp này chỉ được chấm ở `:root`/`.n12-v1`/`.n12-deep`, chưa bao giờ ở
`.theme-marlins.od-root`; track `--n12-ocean-100` 18% ở Roadmap = 1,47:1. **Rủi ro**: `--n12-sand-50` 10% với #A9C6E6
4,45:1 (3,60 dưới tia nắng); b21 `od-dim` 12px trong bậc mở 4,21 dưới tia; marlins chip `bg-ocean-900/40`, `bg-[#0e3c68]` ở
Courses và panel `bg-ocean-900/x` ở ParentLesson pha theo nước xanh cũ, nay thành đảo xanh trên navy; `opacity-70` trong hộp
`bg-white/8` ở MarlinPains 4,00 dưới tia; vòng focus trên `a.od-card` cạnh nước 2,86. www `/login` đo 2,73 — ở ngoài chữ phụ.

**Vòng hai — phản biện đo trên build thật.** Một lượt đối kháng đo lại đúng các màn od-world ấy, trên bản build với DS của
cây làm việc (không phải DS của cây chung — bẫy symlink `node_modules`, xem ghi chú build), và tìm ra **bốn khoản nợ có sẵn
mà cổng không nhìn thấy**: `.n12-badge-solid` 1,96:1 và `.n12-badge-review` 1,34:1 (Portrait/ChildReport); nút shadcn
`variant="outline"` khi hover 1,76:1 (`hover:bg-card` = trắng của theme sáng, thành một ô trắng chữ trắng trên navy); và
vòng focus của od-root 2,86–2,93:1 (< 3:1 — `.od-root` chưa có mặt trong danh sách `--ring-on-dark`, nên outline vẫn là
`--ring` ocean trên nước ocean); cộng chữ phụ trên `bg-sand` trong card ở đỉnh 4,45:1. Sửa **chỉ ở DS, cùng commit**:
`.od-root :focus-visible` vào danh sách `--ring-on-dark` (`@layer base`); trong `.theme-marlins.od-root`: `--n12-sand-50`
10% → 8% (cùng lý do với `--muted`), `.n12-badge-solid` mực → `--n12-deep-ink`, `.n12-badge-review` mực → #FFD180 (đúng
cách learn làm cho `.n12-deep`, SRC-827; **không** remap token `--n12-ocean-900` vì marlins còn dùng nó làm nền chip/panel),
`.hover\:bg-card:hover` → trắng 10% + `--n12-deep-ink` (như `.n12-nav-deep` đã làm cho nút outline trên thanh).

**Cổng vòng hai.** `OD_CARD_PEAK` nay **suy từ `.od-card { background }` đã parse** — đột biến `.od-card` → trắng 30% ở
vòng một "xanh" không phải vì đúng mà vì các cặp cố định vẫn ôm token `--surface-glass` chứ không đọc class; `OD_GLASS_PEAK`
từ `--surface-glass` cho marlins. Thêm 7 cặp: badge-solid 6,11 · badge-review 4,56 · bg-sand 4,67 · bg-muted 4,67 · outline
hover 6,98 · vòng focus trên nước 11,09 / trên card 8,93. Vòng ba (dưới) thêm 3 cặp cho `--ring` của od-root: vòng shadcn `ring-ring/50` trên nước 3,98 / trên kính 3,53, outline `n12-input` 8,93 (`.od-root` tự khai token nên cổng đọc scope `.od-root` từ index.css, gộp vào cả hai ngữ cảnh od-root). Tổng **225 cặp, 0 fail**, exit 0. Đột biến lại `.od-card` → trắng
30%: nay **đỏ** — `2.87:1 (od-root · .od-dim trong od-card trên đỉnh, od-dim vs #5d7089)`, EXIT=1, 220/221. Nhưng phản biện
bác được cổng ở một chỗ và phải ghi thẳng: **xoá** dòng `.theme-marlins.od-root .n12-badge-review { color: #FFD180 }` rồi chạy
cổng → vẫn 4,56 ĐẠT, 221/221, EXIT=0, trong khi màu thật lúc ấy là #7A5A14 trên sun 22% trên kính #274162 = 1,02:1. Ba dòng
badge-solid / badge-review / outline-hover là **gương viết tay** trong PAIRS, không đọc từ index.css (đột biến 1 và 3 chứng
minh cổng có đọc `.od-card` và `--n12-sand-50`). Đây là giới hạn gương đã biết của cổng, nói rõ để không ai tin nhầm, không
phải lỗi sửa trong SRC này.

**Đo pixel vòng hai (Playwright trên build thật, ba app).** Chữ tệ nhất 5,44:1 (ghost Button hover, `hover:bg-ocean-100` =
\#8CD2FF 18%); badge-solid 7,71 · badge-review 5,76 · badge-soft 6,90 · chữ phụ trên `bg-sand` trong card 6,23 · trên
`bg-muted` 6,49 · outline Button 8,28, hover 9,57 · ghost 8,30. Vòng focus trên `a.od-card` (b21 home và www `/login`):
outline tính được rgb(221,241,250) = `--ring-on-dark` sky, 3px lệch 2px, đo 15,76–16,13:1 cạnh nước và 13,08–13,60:1 phía
trong card. Nút bấm trơn và LangSwitch cùng đường outline: 7,5–15:1. Vòng ba (harness marlins, build với DS của worktree): vòng `ring-ring/50` của shadcn Button đo 4,15–4,42:1 cạnh nước, outline `<select class="n12-input">` ≈ 8:1 (trước đó 1,48 và 2,45).

**Việc còn lại — nói rõ cái gì đóng, cái gì chưa.** Vòng focus của od-root đóng theo ba lớp: đường *outline*
(`a.od-card`, nút trơn, LangSwitch — danh sách `--ring-on-dark`, vòng hai); rồi **vòng ba**: mọi `<Button>`/`<Input>` shadcn
(marlins 94 + 38 chỗ trong od-root) mang `outline-none` ở `@layer utilities`, thắng `.od-root :focus-visible` ở `@layer base`,
nên outline sky không bao giờ vẽ trên chúng — thứ vẽ là `box-shadow` `ring-ring/50` từ token `--ring` (ocean, 1,22–1,50:1 cạnh
kính); `<select class="n12-input">` (12 chỗ) cũng outline `--ring` (2,45:1). Sửa đúng như learn đã làm ở `.n12-deep`
(`--ring: #7FD4FF`): `.od-root { --ring: var(--ring-on-dark) }` — một token trong phạm vi mặt tối, mọi vòng focus theo cùng;
đổi `outline-color` riêng (vòng hai) là chưa đủ vì nó chỉ đổi màu của một outline không được vẽ. Còn lại, mỗi khoản một SRC
khác: viền nút outline khi hover 1,67:1 (`hover:border-ocean-300` bị `.border-border` remap trắng 16% đè — trang trí, chữ
bên trong 8+:1); gương PAIRS cho mực badge marlins (ở trên). Track `bg-ocean-100` ở Roadmap.tsx:79 (~1,4:1) **đã trả ở SRC-848**
(chủ dự án 2026-09-19: "sửa luôn"): thanh epic là đồ hoạ có nghĩa (độ dài = thời gian), nay `bg-white/45` — 3,47:1 ngay
trên kính ở đỉnh gradient, trung tính để chấm mốc màu trạng thái vẫn là thứ mang nghĩa; epic *tạm dừng* không mờ 45% nữa
(mờ là về ~2:1, lại mất thanh) mà thành thanh rỗng viền đứt trắng 70% (6,0:1 trên điểm đứt) — "có kế hoạch nhưng không chạy" đọc bằng
hình. Cổng: hai cặp non-text (3:1) trong ngữ cảnh marlins soi gương Roadmap.tsx. Cùng khuôn, thanh phần trăm phiếu ở
`EventRoom.tsx` (~1,5:1) **trả ở SRC-851** (chủ dự án 2026-09-19: "sửa luôn"), nhưng khác một điểm quyết định cách sửa:
thanh ấy phủ cả chiều cao nút, **dưới chữ** — làm nó sáng lên để đạt 3:1 thì chữ phụ `#A9C6E6` nằm trên nó rơi xuống
\~1,7:1. Nên thanh dời xuống mép dưới nút (h-1.5, trắng 45% như Roadmap): tỷ lệ phiếu đọc bằng một thanh không nằm dưới
chữ, và số `N · pct%` vẫn in cạnh. Bỏ `opacity-70` khi poll đóng: kết quả poll đã đóng là lúc cần đọc nhất, badge
"Đã đóng" + nút disabled đã nói đủ, còn mờ đi chỉ đưa chữ phụ (3,7:1) và thanh (2,4:1) về dưới ngưỡng. Luật rút ra:
**đồ hoạ có nghĩa không nằm dưới chữ** — một lớp vừa là nền chữ vừa là thanh đo thì không thể cùng lúc đủ tương phản cho
cả hai. Chỗ cuối cùng cùng khuôn, `EventLive.tsx` (Live Stage — màn chiếu đọc từ cuối phòng) thanh `bg-white/20` phủ cả
chiều cao dưới chữ (đo thật 1,9:1), **trả ở SRC-854** (chủ dự án 2026-09-19: "sửa luôn"): vạch mép dưới ô dày **12px**, trắng
**55%** — dày hơn và đậm hơn 8px/45% của hai chỗ trước vì màn chiếu: 12px trên máy chiếu 3 m ở 1080p ≈ 8 phút cung từ 8 m,
và máy chiếu nâng mức đen 5% thì 55% còn 3,6:1 trong khi 45% đã tụt 2,8. Nền thật của màn chiếu là `bg-ocean-900` đặc
(#171C22) tô lên od-root chứ không phải kính, nên cặp trong cổng soi gương đúng nền ấy (5,6:1; đo thật 5,5). Giữ transition
để vạch trượt khi phiếu đổi lúc đang chiếu. Với SRC-848/851/854, marlins không còn thanh đo nào dưới 3:1 mà cổng biết.

**Luật cho lần sau.** Một mặt tối **là `--surface-deep`**, hoặc nó là một SRC mới — không tự pha stop nữa, vì mọi phép đo
của cổng đứng trên đỉnh của đúng token ấy. Cổng đọc class của DS **từ CSS**, nên một màu mực `od-*` mới phải là một class có
màu mà parser đọc được (token hoặc `rgb()`), không phải một `calc`/`color-mix` giấu số. Một component DS mà **token mực của
nó cũng được dùng làm nền ở chỗ khác** (như `--n12-ocean-900` của marlins) thì sửa bằng **một rule class có phạm vi**
(`.theme-marlins.od-root .n12-badge-review`, đúng khuôn learn `.n12-deep .n12-badge-review`), không remap token — remap thì
chữ đọc được mà chip đổi màu. Và `.n12-hero-ocean` còn giữ đúng chuỗi gradient cũ của od-root: sửa gì ở đây thì bám vào
selector, đừng tìm theo chuỗi gradient.

### 2·1·27. marlins: tấm nội dung thành kính (SRC-844) và hai màu tín hiệu của mặt tối thành token (SRC-845) — ghi hộ

Chỉ đạo chủ dự án 2026-09-19: chỉnh toàn bộ màu sắc và giao diện marlins. Hai việc đã làm ở hai commit của
phiên khác, ghi lại đây vì cổng SRC-596 đỏ trên `main` khi chưa có mô tả; phiên làm hai SRC ấy sửa lời nếu cần.

**SRC-844 (commit 887993d8) — 25 khối nội dung thành `.n12-panel`.** Marlins vốn là cổng biển sâu (`od-root`,
SRC-571), nhưng 25 khối trong 18 file lại mang `n12-hero-ocean` — chính dải nền vẽ lại lần hai *bên trong*
trang — và vài khối mang `n12-hero` (#083B66 đặc, một sắc xanh không có ở đâu khác trong cổng). Đo được: tấm
gradient nằm trên nền gradient cùng hướng nên mép tấm chỗ biến mất chỗ thành vệt, màu "nhảy" giữa các màn.
`.n12-panel` (khai một chỗ ở `apps/marlins/src/index.css`) là một tấm **kính** lấy nguyên token DS
(`--surface-glass`, `--n12-deep-line`, `--n12-deep-ink`) — đúng bề mặt mà tầng theme đã định cho thẻ ở
`.theme-marlins.od-root [data-slot="card"]`; nhờ vậy nó tự theo mặt nước mới của SRC-842 (§2·1·26) mà không
phải sửa gì. Màn đăng nhập đổi từ `n12-hero` sang `od-root` vì đó là màn đầu tiên bố mẹ thấy và là màn duy
nhất mang sắc xanh khác. Là một CLASS chứ không đổi từng khối sang `Card`: 25 khối nằm trong 18 file đang có
phiên khác sửa dở, đổi `<section>` thành `<Card>` là đụng cấu trúc JSX của từng file.

**SRC-845 (commit 07c55080) — `--n12-solid-deep` #7EE0A8 ("đang chắc") và `--n12-risk-deep` #FF9D8B ("nên ôn
lại").** Hai trạng thái mà learner/bố mẹ đọc bằng màu trước khi đọc chữ, nay là token trong `tokens/base.css`
(map `--color-solid-deep` / `--color-risk-deep` ở `tailwind.css`); 13 chỗ viết tay trong marlins (Courses,
ParentLesson, Metrics) đổi sang `text-solid-deep`, `bg-solid-deep/15`, `border-solid-deep/50`, `text-risk-deep`,
`bg-risk-deep`. KHÔNG dùng lại `--success` / `--destructive` vì hai biến ấy sinh ra cho nền sáng và trên mặt
nước tối thì chìm hẳn. Đo sau khi đổi: không một điểm ảnh nào khác — đây là refactor, không phải đổi màu. Mã
gửi thư (`workers/api/src/modules/email/theme.ts`) vẫn giữ hằng SOLID riêng vì thư chạy trong hộp mail của người nhận,
không stylesheet nào tới được; đổi màu thì phải đổi cả hai, ghi chú hai chiều đã có ở cả hai bên.
