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. 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:
apps/learn/src/ielts/Chrome.tsxgiữ HÌNH THỨC của cả hai mạch:NavBar(nềnn12-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 qualeft/right— đúng như chỉ đạo cho phép.- Full-width đi bằng
n12-page n12-page-fullcủa DS, không bằngmax-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ã. - 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à
SiteFootergắn ởmain.tsxcho MỌI nhánh route, nên mỗi trang luyện đang ký tên hai lần. SiteFootercủa learn nhậnn12-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-deepnê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):
- 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ố (
SkillStatusCardtrongielts/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. - 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. - Đườ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-whitetrênbg-accentchỉ 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
SkillProgresslàm một fixture test khác thiếu thuộc tính, vàvitestcủa module vẫn xanh vì vitest không typecheck. Chỉtsc -bcủaworkers/apibắ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:
- Ngữ cảnh. Đọc thêm token theo scope (
.n12-v1,.n12-deepcủa learn,.theme-marlins.od-root,.n12-band-darkcủ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>đềun12-deeptừ 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. - Màu mờ. Lần chạy đầu báo 20 lỗi, 18 là giả: trong deep
--mutedlà trắng 10%, cổng bỏ alpha nên coi là trắng đặc. Nay màu mờ được hoà lên--cardcủ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).transparentlà alpha 0,color-mixtrộn theo alpha,bg-x/NN,bg-(--token)vàtext-[#hex]đều đọc được. - 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ảngREMAPSsoi 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: remaptext-whitetoà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ênbg-sun-softtrongod-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àBeliefscùng khuôn (1,69:1) →bg-muted; mentors nhãn "Đổi ảnh" và nút ‹ › / bộ đếm lightboxbg-black/45–50trê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àGoogleButtonnằm ngoàiod-root(5,6–5,9:1 thật); wwwKit.tsxchỉ định nghĩa lớpn12-band-darktrong template string, không render gì trong dải (6,1:1 thật); b21bg-white/5 text-whitenằm trong.od-cardtrê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 mangod-root→ v1,<header>con củaod-root→ nav-deep, file đăng nhập/lỗi của marlins → v1 (LIGHT_FILES); www chỉ tính "có dải tối" khin12-band-darkđứng trong mộtclassNameliteral; b21 có ngữ cảnh:root .od-rootvớ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ọifile:dòngcùng cặp — cùng keybg-sand text-muted-foregroundvừa giả ởApp.tsxvừa thật ởBeliefs.tsx, chỉ in chỗ đầu là kết luận sai. - Token:
.theme-marlins.od-rootthêm--n12-sand-50mờ (cùng nhóm bốn nền mềm ở §2·1·21) —bg-sandcò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.