DS-001 · Hình khối, thanh trên và khổ điện thoại
Một phần của DS-001. Radius, elevation, motion, touch; thanh trên không tràn khung; mọi trang chạy trên điện thoại; khuôn thanh trên mobile; Soft UI cho www.
4. Hình khối, elevation, motion, touch (REQ-UX-06, REQ-UX-07)
- Radius:
--radius-sm .625rem,--radius 1.25rem(card),--radius-lg 2rem(hero/modal); nút hành động = pill. - Elevation 3 mức, bóng màu ocean (không xám bẩn):
--shadow-1(card nghỉ),--shadow-2(hover/nổi),--shadow-3(dialog). - Motion:
--dur-1 150ms,--dur-2 250ms,--ease-out cubic-bezier(.22,.9,.35,1); hover card nâng 2px; fill progress có transition; tôn trọngprefers-reduced-motion. - Lưới nhiều cột chỉ nhận đúng số con nó khai (SRC-301): thêm một phần tử vào lưới hai cột mà không bọc hoặc không khai
col-spanthì phần tử đó chiếm ô (hàng 1, cột 2) và đẩy toàn bộ nội dung xuống hàng 2 cột 1 — màn hình "vỡ hết layout" mà không có lỗi nào báo ra. Thanh trạng thái, badge, chỉ báo tiến độ chèn vào một lưới có sẵn phải đi kèmcol-span-fullhoặc nằm ngoài lưới. - Touch: mọi control chính min-height 44px (
n12-btn,n12-input,n12-tab);n12-btn-sm36px chỉ cho hành động phụ trong card.
4b. Thanh trên không được tràn khung nhìn (SRC-889, 2026-09-20)
Luật: ở 375px, không thanh trên nào được làm trang cuộn ngang. Đo bằng số, không bằng mắt: document.documentElement.scrollWidth <= clientWidth, và mọi control trên thanh phải nằm TRỌN trong khung nhìn.
Vì sao đo bằng số. Một thanh tràn ngang trông vẫn bình thường trong ảnh chụp — phần thừa đơn giản là không có trong khung, nên mắt không thấy gì sai. Thanh trên trang chủ learn sống như thế cho tới 2026-09-20: hàng rộng 680px trong khung nhìn 375px, và nút avatar nằm ở x=644. Tức là công tắc VI/EN, "Khoá học", "Rủ bạn" và chính menu tài khoản — đường duy nhất tới Hồ sơ và Đăng xuất — đều nằm ngoài màn hình, chỉ tới được nếu learner cuộn ngang cả trang. Trên điện thoại hầu như không ai làm động tác ấy.
Cách chữa, theo thứ tự ưu tiên:
- Chuyển vào menu tài khoản khi màn hẹp —
AvatarMenunhậnitemsvới cờmobileOnly, và mục ấy mangsm:hidden. Nút tương ứng trên thanh manghidden sm:flex. Một lối đi, hai chỗ đứng tuỳ bề ngang; không nhân đôi trong cùng một tầm mắt. - Ẩn nhánh GIỮA của vệt đường dẫn, giữ nhánh gốc và nhánh cuối (xem SRC-886). Ẩn hết trừ nhánh cuối là lấy mất đường về nhà.
- Xuống hàng (
flex-wrap) là lựa chọn CUỐI, và chỉ cho thanh khôngsticky: mỗi hàng thêm vào một thanh dính là một dải chiếm chỗ vĩnh viễn trên đúng màn hình nhỏ nhất.
Dải TAB có cách chữa thứ tư: cuộn ngang TRONG CHÍNH NÓ (SRC-936, 2026-09-21). Ba cách ở trên đều là bớt thứ hiện ra; một dải tab thì không bớt được — nó là bản đồ của một công cụ, và giấu cả bản đồ sau một cú bấm là đổi một lỗi lấy một phiền. overflow-x-auto trên chính dải ấy giữ đủ tab, giữ một hàng, và trang không cuộn ngang — cái cuộn nằm trong thanh, không phải ở tài liệu.
Hai điều kiện để cách này đúng: dải phải là tab (người dùng quen vuốt ngang trên chúng), và control nào phải luôn ghim thì để ngoài vùng cuộn — ml-auto bên trong một hộp cuộn bị đẩy tới mép của BỀ RỘNG CUỘN, tức là nằm ngoài màn ngay từ đầu. Số tab ít (≤6) thì dropdown vẫn tốt hơn: mentors gom sáu tab vào SectionMenu dưới sm theo đúng tiền lệ SRC-649 trong chính file ấy, còn coral với mười một tab thì cuộn.
Bảng dữ liệu thì bỏ dần CỘT PHỤ, không ẩn nhánh giữa (SRC-937). Cùng cơ chế, khác hình: hàng bằng chứng ở admin/Models.tsx có bốn cột bề rộng cứng (w-16 + w-32 + w-28 + w-24 = 400px, cộng gap và lề là ~492px) trước khi cột TIÊU ĐỀ — cột duy nhất co được — được một pixel nào. Đo thật: ở 375px tiêu đề rộng 0px và trang cuộn ngang; ở 768px được 206px. Sau khi cho nguồn và dịch chuyển mastery hidden lg:block, độ tin cậy hidden sm:block: 375px → 185px, 768px → 470px, còn 1280px giữ nguyên 718px.
Thứ tự bỏ đi ngược với mức cần: cột nào trả lời "dòng này là cái gì" thì ở lại mọi bề ngang, cột nào chỉ thêm sắc thái thì đi trước. "Công cụ nội bộ dùng trên máy tính" không phải lý do để một cột biến mất — nó chỉ đổi thứ tự ưu tiên, không đổi việc phải chọn.
KHÔNG chữa bằng overflow-x: hidden trên html/body. Nó không sửa gì cả — nó cắt phần thừa đi cho khuất mắt, biến một lỗi NHÌN THẤY ĐƯỢC (trang cuộn ngang) thành một lỗi vô hình (control nằm ngoài màn và không còn cách nào tới). Tệ hơn: nó làm mọi phép gác scrollWidth <= clientWidth ở mục này xanh vĩnh viễn, tức là tháo luôn cái cổng. Chỗ chật phải chữa ở chỗ chật.
Ẩn bằng CSS, không bằng cờ JavaScript đọc bề ngang. Đổi hướng máy hay chia đôi màn hình là bề ngang đổi giữa chừng, và một matchMedia giữ trong state thì phải nhớ gỡ listener; một lớp sm:hidden thì không có gì để nhớ.
Cổng kiểm: apps/learn/e2e/headerMobile.spec.ts — gác cả hai vế (375px không tràn và mọi lối đi vẫn bấm tới được; màn rộng thì nút ở trên thanh và không lặp lại trong menu).
Kiểu hỏng thứ hai: nhãn CO VỀ 0 thay vì tràn ra (SRC-900, 2026-09-20)
Một thanh chật không phải lúc nào cũng tràn. Nếu trong hàng có đúng một phần tử co được, nó gánh trọn phần thiếu và biến mất, còn trang thì vẫn vừa khít — không tràn ngang, không lỗi, chỉ là chữ không còn ở đó. Kiểu này khó thấy hơn kiểu tràn, vì cả ảnh chụp lẫn scrollWidth đều nói rằng mọi thứ ổn.
Công thức sinh ra nó: vài phần tử anh em mang shrink-0 (hoặc w-* cố định), cạnh một phần tử mang truncate. overflow-hidden của truncate gỡ bỏ kích thước tối thiểu tự động của một flex item (min-width: auto → 0), nên phần tử ấy là chỗ duy nhất còn co được, và nó co tới 0.
Thanh trên màn Học sống như thế: vệt Nemo12 / <School> / <môn> cho hai nhánh tổ tiên shrink-0, còn nhánh môn — nhánh duy nhất nói learner đang ở đâu — mang truncate. Đo thật: ở 320px và 375px nhãn môn rộng 0px, ở 414px được 15px (một chữ cái rồi dấu ba chấm), phải tới 640px mới đủ 32px. Vệt ấy hy sinh đúng nhánh quan trọng nhất để giữ trọn hai nhánh learner đã đi qua.
Luật: trong một hàng không xuống dòng, đừng để phần tử quan trọng nhất là phần tử co được duy nhất. Chữa bằng luật 2 ở trên (ẩn nhánh giữa dưới sm), không bằng cách thêm shrink-0 cho nốt nhánh cuối — làm thế là đổi một nhãn biến mất lấy một thanh tràn ngang.
Đo bằng hai con số, không bằng toBeVisible(): một nút rộng 0px vẫn có thể đọc là "visible" với vài selector. Phép đo đúng là boundingBox().width > 0 và scrollWidth === clientWidth (hiện đủ chữ, không bị cắt).
Đo phải giả lập MÀN CẢM ỨNG, và n12-switch-sm không làm hẹp được gì (SRC-905, 2026-09-20)
.n12-switch-option có min-width: 44px, và dưới @media (pointer: coarse) biến thể n12-switch-sm quay về đúng sàn ấy. Nghĩa là trên điện thoại thật, công tắc VI/EN rộng ~96px dù khai biến thể nào. Hai hệ quả, cả hai đều đã suýt dẫn tới việc làm sai:
- "Đổi sang switch nhỏ cho đỡ chật" là một cú sửa không có tác dụng nào trên máy thật. Chỗ chật phải chữa bằng bớt chữ (ẩn nhánh giữa, nhãn nút lùi về icon), không bằng bớt kích thước control.
- Đo bằng con trỏ chuột là đo một cái thanh hẹp hơn thanh người dùng nhìn thấy. Mọi phép đo thanh trên phải chạy với
hasTouch: true(Playwright) — đó là điều kiện thật của cái máy mà luật này sinh ra để bảo vệ.isMobilethì không cần: nó đổi cả user agent và cách đọc thẻ viewport, nhiều hơn thứ đang hỏi.
Trường hợp đã chữa theo luật này: /student-portrait — đo ở 375px có cảm ứng, hàng thanh trên rộng 388px trong khung nhìn 375px, nửa "EN" của công tắc nằm ngoài màn, và thanh cao 85px thay vì 71px vì nhãn vắt ba dòng. Trong hàng ấy không ai nhường được: nút Back mang shrink-0 từ buttonVariants, công tắc có sàn 44px. Chữa bằng luật 2 (dưới sm giữ logo và tên trang, bỏ "Nemo12" và "/ Hải đăng ·") cộng nhãn nút Back lùi về mũi tên với aria-label giữ nguyên nghĩa. Sau khi chữa: 375/375, thanh 71px, ở 640px trở lên không đổi một chữ nào.
4c. Mọi trang chạy được trên điện thoại, và hai cổng giữ điều đó (SRC-1194, 2026-10-03)
Chỉ đạo chủ dự án 03.10.2026: "Đảm bảo là site hiển thị và chạy tốt trên mobile. Và đảm bảo mọi trang đều như vậy." §4b gác thanh trên; mục này mở phép đo ra mọi trang của hai app công khai.
Luật đo (giống nhau ở cả hai app, cùng một hàm inspect):
| Phép đo | Ngưỡng | Chặn CI? |
|---|---|---|
Trang cuộn ngang (scrollWidth > innerWidth) | 0 trang | Có, cả hai app |
| Phần tử thò ra ngoài khung nhìn, không nằm trong hộp tự cuộn/cắt | 0 | Có, cả hai app |
| Nút và link trên thanh điều hướng cao dưới 40px | 0 | Có ở learn, báo cáo ở www |
| Chữ đoạn văn (từ 30 ký tự) dưới 14px | 0 | Có ở learn |
| Chữ bất kỳ dưới 12px, thanh cố định cao quá 25% màn | Báo cáo ở www |
Hai cổng:
- www:
apps/web/scripts/mobile-audit.mjs(npm run test:mobilesaunpm run build) phục vụdist/bằng một server tĩnh tại chỗ, mở mọi file HTML dựng sẵn (~630 trang, cả trang nội dung) trên một Chromium ở 375x812, 8 tab song song, khoảng 50 giây. Mọi request ra ngoài bị chặn nên không cần mạng và không đụng production. Thêm--width 390,--only /ielts,--report out.jsonkhi cần soi. CI chạy nó trong job e2e, cờ phạm viweb_mobile(đụngapps/web/hoặc gói nó dựng từ đó). - learn:
apps/learn/e2e/mobileAudit.spec.ts(SRC-1135) nay phủ thêm mọi chương trình ngoài IELTS/Speak/Essay:/programs,/sat/**,/math/**,/ap/**,/grammar/**,/ai-teen/**,/books/**,/nemo-walk, ở 375 và 320px. API giả của SAT và AI Teen nằm ởmockPrograms(e2e/mobileMocks.ts).
Kết quả lượt rà 03.10.2026:
| Trước | Sau | |
|---|---|---|
| www, trang cuộn ngang ở 375px (631 trang) | 1 (/ielts/goal-examples), thêm /ai-teen ở 320px | 0 (cả 320, 390, 414) |
| www, trang có nút/link điều hướng dưới 40px | 630 (logo trên SiteNav) | 60 (nút phụ trong nội dung) |
| www, trang có chữ dưới 12px | 4 | 2 (nhãn trong hình minh hoạ band IELTS) |
| learn, URL chương trình mới đỏ (28 URL x 2 khổ) | 15 | 0 |
Ba nguyên nhân gốc, đáng nhớ hơn từng chỗ sửa:
- Nhãn
sr-onlytrong hộpoverflow-x-autokhông cópositionthoát khỏi hộp và đẩy cả trang rộng ra (/ielts/goal-examples: 479px trong khung 375px). Phần tửabsolutechỉ bị hộp cuộn giữ lại khi hộp ấy là khối chứa của nó. Hộp cuộn ngang bọc bảng thì thêmrelative. - Nút shadcn mang
whitespace-nowrap shrink-0từbuttonVariants: nhãn dài ("Reading and Writing · 27 câu · 32 phút") không xuống dòng được nên thò ra ngoài. Nhãn dài thì thêmh-auto min-h-10 max-w-full whitespace-normal; hàng nút thìflex-wrap. - Vùng bấm nhỏ hơn hình: logo, crumb, nút tăng giảm, thanh tiến độ. Không đổi kích thước hình (giữ desktop y nguyên) mà mở vùng bấm:
after:absolute after:-inset-*cho phần tử không cắt tràn,-my-2.5 inline-block py-2.5cho linktruncate(pseudo-element bịoverflow: hiddencắt mất),min-h-10 sm:min-h-0cho pill chỉ cần cao hơn ở khổ điện thoại.
Còn lại, có chủ ý: 60 trang www còn nút phụ cao 16 đến 38px (pill lọc, chọn ngôn ngữ trên trang nội dung, link quay lại chữ nhỏ, nút tăng giảm 36px). Cổng www chỉ chặn cuộn ngang vì đó là lỗi không cần ai phán xét; vùng bấm thì phải nhìn từng chỗ, nên in thành báo cáo.
4d. Thanh trên khổ điện thoại: một khuôn cho www và learn (SRC-1213, 2026-10-03)
Chủ dự án 03.10.2026: "NavBar trên Mobile cứ bị loạn, không đồng nhất, và thiếu Hamburger menu icon." Trước đó www có nút chữ "Menu", còn learn vắt thanh thành hai hàng (chip tài khoản, rồi pill chương trình cùng dải tab cuộn ngang) và mỗi chương trình trông một kiểu.
Luật (dưới lg; từ lg trở lên thanh giữ nguyên):
| Vị trí | www (SiteNav) | learn (NavBar ở ielts/Chrome.tsx) |
|---|---|---|
| Trái | Nút hamburger (lucide Menu, 44px, aria-label "Mở menu") | Cùng nút; "Open menu" ở /ielts, /walk, /style |
| Giữa | Tên chương trình (hoặc "Nemo12"), chỉ chữ, không icon cá | Tên chương trình, hoặc left của trang với icon ẩn |
| Phải | Nút "Vào học" vào thẳng learn.nemo12.com/<chương trình> ("Tham gia" ở /walk) | Nút lưới các chương trình (/programs) |
Ngăn kéo là MobileNavSheet (canonical packages/design-system/ui/MobileNavSheet.tsx, mỗi app một bản sao y hệt như Motion.tsx): Radix Dialog lo focus trap, Esc, bấm nền để đóng; m của Motion lo cú trượt. Thứ tự trong ngăn: tên chương trình (về Tổng quan) và sáu mục, cùng nhãn cùng đích với màn rộng, mục đang đứng nền đặc; rồi vệt đường dẫn nếu trang có; rồi tài khoản, trợ giúp và nút học thử.
Logo về Tổng quan của chương trình (đảo lại SRC-776): trên trang chương trình hay trong khu kỳ thi, bấm tên thương hiệu ("NEMO IELTS") về /ielts, không về /. Chỉ trang ngoài chương trình mới về /. Lý do: khối thương hiệu ở đó đọc là tên chương trình, nên bấm tên nào về đúng chỗ của tên ấy.
Gác: apps/learn/e2e/headerMobile.spec.ts (năm chương trình ở 320px: một hàng dưới 72px, không cuộn ngang, nút 44px, ngăn có bảy link và đúng một mục đang đứng, Esc đóng) và apps/web/src/site/siteNav.test.ts (đích của tên thương hiệu, nút hamburger, nút Vào học).
4e. www dùng hình khối Soft UI (SRC-1301, 08.10.2026)
Chủ dự án yêu cầu chỉnh www theo một theme của bộ ui-ux-pro-max-skill. Đã chọn Soft UI Evolution, lấy Accessible & Ethical làm nền. Claymorphism (bộ ấy gợi ý cho app giáo dục) bị loại vì nó nhắm vào trẻ nhỏ, va với luật tối giản §5b, và người đọc www là phụ huynh.
Chỉ mượn hình khối, không mượn màu: bo góc thẻ 18px / hero 24px, bóng mềm hai lớp ánh navy, thẻ bấm được nổi 2px khi rê chuột, nút lún nhẹ khi nhấn (200ms), vòng focus 3px. Bảng màu navy + coral giữ nguyên. Toàn bộ nằm trong .theme-www ở apps/web/src/index.css; token chung không đổi, nên learn, marlins, coral không bị reskin. Muốn mở rộng ra cả hệ thì đó là một quyết định riêng.
SRC-1302 (09.10.2026): learn dùng chung. Phần chung tách ra packages/design-system/soft.css, kích hoạt bằng lớp .theme-soft (www đặt ở gốc App, learn đặt trên <body>). Selector bám data-slot của shadcn chứ không bám chuỗi utility. Vùng tối (.n12-deep, .n12-band-dark) giữ viền sáng và dùng bóng đen. Màn làm bài (data-immersive="1") tắt nổi và lún. Kế hoạch chuẩn hoá learn chia đợt: (1) theme dùng chung, (2) learn chuyển nền sáng, (3) khung chung: thanh trên, chân trang, khung học, (4) hub và roadmap, (5) bài học và lab, (6) hồ sơ và lịch, (7) cụm /style, kèm cổng trần cho hex và style={{}}. Màu trong hình bài học (engine phân số, trục số, biểu đồ lab) giữ nguyên vì mang nghĩa sư phạm.
Đợt 2 (09.10.2026): learn sang nền sáng. Gốc của mọi màn learn đổi n12-deep thành n12-light (nền foam như www, token sáng gốc của DS, không remap). Thanh trên giữ n12-nav-deep navy và chân trang giữ n12-footer-deep, đúng nhịp sáng giữa hai mép tối của www. Body learn đè nền #EDF5FA vì deep.css (dùng chung với gull) đặt body tối. Popover và sheet tìm vùng chứa theo .n12-light, .n12-deep. Cụm /style giữ công tắc sáng/tối. Bẫy đã gặp: tên đầu tiên định dùng là n12-page, nhưng đó đã là lớp BỐ CỤC của DS (giới hạn bề ngang --measure-app), gắn lên gốc là bóp nội dung xuống 896px và làm đỏ bốn ca e2e.
Đợt 3-7 (09.10.2026): khung, nội dung, cổng.
- Thanh trên: tám màn hub cũ (school, roadmap, whale, orca, lighthouse, portrait) bỏ thanh trắng tự dựng, dùng
n12-nav-deepnhư sân IELTS và journey shell. Một thanh cho cả learn. - Màu chỉ hợp nền tối được remap ở
soft.csstrên nền sáng: chữtext-accentlấy--accent-deep(luật SRC-1163 của www nay dùng chung),text-sun/text-warninglấy--n12-sun-ink#8A5A00 (5,9:1),text-ocean-300lấyocean-700. Vùng tối (n12-nav-deep,n12-band-dark,n12-deep) được loại trừ tường minh vì luật nằm ngoài@layer: thử@layer utilities+:where()thì Lightning CSS dựng ra selector rỗng:is() …, luật chết lặng lẽ mà build vẫn xanh. - Kính trắng (
bg-white/15,hover:bg-white/10), chipbg-sun text-background,bg-ocean-500chữ trắng được đổi sang cặp đạt AA trên nền sáng. Vùng chạm dưới 40px ở hub cũ dùng pattern có sẵnafter:absolute after:-inset-*(giữ kích thước nhìn thấy) hoặcmin-h-10. - Cổng tương phản LÚC CHẠY:
apps/learn/e2e/contrastInspect.ts, gọi trong bộ audit mobile ở 375px, đo chữ trên nền đã sơn thật theo WCAG AA. Lần chạy đầu bắt 96 chỗ. Bộ audit thêm 14 màn hub cũ. - Cổng tĩnh AS-09.3.1 (
contrast-check.mjs) chấm learn ở ngữ cảnh:rootthay cho.n12-deep, và file của thanh trên (AvatarMenu) chấm ởn12-nav-deepnhư mọi app. - Cổng trần nợ giao diện
scripts/check-learn-ui-debt.mjstrongnpm run check:code: hex viết cứng,style={{}}, kính trắng,<button>thô. Chỉ được giảm, như SRC-570. - Cụm
/stylemở ở nền sáng.n12-light, công tắc biển sâu vẫn giữ vì component DS còn sống ở gull và tutors; trang Surfaces có thêm mẫu Soft UI.