---
url: https://docs.nemo12.com/design-system/layout-standard.md
description: >-
  Chuẩn UI về grid, spacing, màu và token (08.10.2026): audit chặt component xô
  lệch, luật nào cấm, đo bằng gì.
---

# Chuẩn giao diện đo được: grid, spacing, màu, token (SRC-1293)

Chủ dự án 08.10.2026: component "bị tạo loạn lên, xô lệch, không có tiêu chuẩn về Grid hay Spacing,
không có tiêu chuẩn về màu sắc, hay về token", nên cần audit cực chặt, và làm thì phải đúng chuẩn.

[DS-001](index.md) đã có luật màu (Ocean Palette §2·1·3), thang chữ (§2·1·5), radius và bóng (§4),
luật dùng token (§7). Thứ còn THIẾU là thang spacing và lưới: hai thứ đó trước nay là gu của từng
phiên, nên cùng một thẻ có chỗ `p-5`, chỗ `p-[18px]`, chỗ `p-7`. Trang này lấp chỗ thiếu, và gom
mọi luật đo được về một bảng để máy kiểm.

Máy kiểm: `npm run audit:ui` ([scripts/audit-ui.mjs](../../scripts/audit-ui.mjs)). Quy trình làm và
quy trình rà: skill `/ui-build` và `/audit-ui`.

## 1. Bảng luật máy kiểm

| Luật | Đúng | Sai | Lý do |
| --- | --- | --- | --- |
| `COLOR-RAW` | `bg-primary`, `text-muted-foreground`, `var(--n12-ocean)` | `#2C82B8`, `rgb(...)`, `oklch(...)` trong component | Màu thô là nguồn màu thứ hai; đổi palette thì nó không đổi theo |
| `COLOR-TW-PALETTE` | màu khai trong `packages/design-system/tailwind.css` | `bg-amber-100`, `text-gray-600`, `border-blue-300` | Bảng màu mặc định của Tailwind không thuộc Nemo, không qua cổng contrast |
| `ARBITRARY` | bước trong thang, token | `p-[18px]`, `text-[0.95rem]`, `rounded-[14px]`, `z-[60]` | Giá trị tuỳ ý là "gần đúng", và gần đúng là xô lệch |
| `SPACING-OFF-SCALE` | thang ở §2 | `p-7`, `gap-2.5`, `mt-14`, `px-5.5` | Hai khoảng cách khác nhau 4px trông như lỗi chứ không như ý đồ |
| `TYPE-BELOW-FLOOR` | `text-sm` (14px) trở lên | `text-xs`, `text-[11px]` | Sàn chữ 14px (`--text-floor`) cho người đọc tiếng Việt có dấu |
| `LAYOUT-WIDTH` | thang ở §3 | `max-w-4xl`, `max-w-xl`, `max-w-7xl` | Bề rộng khung tuỳ hứng trên một site làm mép trái nhảy khi chuyển trang |
| `VARIANT-OVERRIDE` | `<Button size="sm" variant="secondary">` | `<Button className="h-9 px-3 bg-sky">` | Vẽ lại component bằng className là cách một nút thành mười kiểu nút; thiếu biến thể thì thêm vào component |
| `Z-INDEX` | `z-(--z-modal)`, `z-10`..`z-50` | `z-60`, `z-[999]` | Lớp chồng ngoài thang là cuộc chạy đua số, thắng lần này thua lần sau (SRC-819) |
| `INLINE-STYLE` | class token | thuộc tính `style` gán `padding` hay `color` thẳng trên phần tử | DS-001 §7: inline chỉ cho width/height/transform/biến CSS |

Miễn trừ một dòng: chú thích `ui-audit-ignore: <lý do>` trên chính dòng đó. Không có lý do thì không
được miễn. Hình vẽ bằng thuật toán (`*Art.tsx`, `draw.ts`, `cover.ts`, `palette.ts`) được dùng màu thô:
đó là bảng màu của hình, không phải của giao diện.

## 2. Thang spacing

Bội của 4px, thêm hai bước 2px và 6px cho chi tiết nhỏ. Hết.

| Bước Tailwind | px | Dùng cho |
| --- | --- | --- |
| `0.5` · `1` · `1.5` | 2 · 4 · 6 | icon cạnh chữ, khe trong badge |
| `2` · `3` | 8 · 12 | khe giữa các phần tử trong một hàng, padding nút nhỏ |
| `4` | 16 | padding thẻ trên điện thoại, khe lưới thẻ, lề trang ở 375px |
| `5` · `6` | 20 · 24 | padding thẻ từ `sm` trở lên, khe giữa các khối trong một section |
| `8` · `10` · `12` | 32 · 40 · 48 | khe giữa nhóm lớn, padding hero |
| `16` · `20` · `24` | 64 · 80 · 96 | nhịp dọc giữa các section |

Ba nhịp cố định, đừng chọn lại mỗi lần:

* **Section:** `py-12 sm:py-16 lg:py-20`.
* **Thẻ:** `p-4 sm:p-6` (DS-001 §2·1·7 nói 24-32px cho thẻ thật sự cần đựng: `sm:p-6` hoặc `lg:p-8`).
* **Chồng dọc trong thẻ:** `gap-2` (nhãn với giá trị), `gap-4` (khối với khối).

Khoảng cách giữa anh em dùng `gap-*` trên cha (flex/grid), không dùng `mt-*` rải trên từng con: margin
rải từng con là nguồn số một của "lệch một chút" khi thêm hay bỏ một phần tử.

## 3. Lưới và bề rộng

**Lề trang:** `px-4 sm:px-6 lg:px-8`. Một mép trái cho mọi section của một trang.

**Bề rộng khung, sáu giá trị:**

| Class | px | Dùng cho |
| --- | --- | --- |
| `max-w-sm` | 384 | popover, thẻ đứng một mình |
| `max-w-md` | 448 | form, dialog, màn đăng nhập |
| `max-w-2xl` · `max-w-prose` | 672 · 65ch | khối đọc hẹp: câu hỏi, bài đọc |
| `max-w-3xl` | 768 | trang đọc, trang chi tiết |
| `max-w-5xl` | 1024 | khung gọn: trang có hai cột nhưng chủ dự án muốn chặt (trang Speaking, SRC-1293 giữ nguyên 33 chỗ) |
| `max-w-6xl` | 1152 | khung trang có lưới nhiều cột |

Luật tối giản SRC-048 nói "full-width": nền section chạy hết bề ngang, NỘI DUNG nằm trong một trong
sáu khung trên, căn giữa bằng `mx-auto`.

**Lưới thẻ:** mobile-first, một cột ở 375px; `sm:grid-cols-2`, rồi `lg:grid-cols-3` (hoặc 4 với
thẻ số liệu nhỏ). Khe `gap-4`, `lg:gap-6` khi thẻ có padding `p-6`. Breakpoint chỉ `sm` và `lg`;
`md` chỉ khi có lý do ghi lại trong code.

**Lưới nhận đúng số con nó khai** (DS-001 §4, SRC-301): phần tử chen vào lưới phải `col-span-full`
hoặc nằm ngoài lưới. Mọi thẻ cùng hàng cao bằng nhau: dùng `grid` (không phải `flex-wrap`) và
`h-full` trên thẻ; nút cuối thẻ đẩy xuống đáy bằng `mt-auto` trong thẻ `flex flex-col`.

## 4. Màu, chữ, hình khối: trỏ về DS-001

* Màu: chỉ token semantic (`background`, `foreground`, `primary`, `muted`, `accent`, `accent-deep`,
  `border`, `destructive`, `success`, `warning`) và Ocean Palette (DS-001 §2·1·3). Tỉ lệ 70/20/7/3,
  coral hiếm. Chữ trên nền coral dùng `accent-deep`. Đỏ không dùng cho trạng thái học tập (REQ-UX-03).
* Chữ: thang DS-001 §2·1·5 (`text-display-l` ... `text-body-l`, `text-sm` 14px là sàn).
* Radius: `rounded-sm` · `rounded` (thẻ) · `rounded-lg` (hero, dialog) · `rounded-full` (nút pill).
  Bóng: `--shadow-1/2/3`.
* Chuyển động: chỉ preset trong `packages/design-system/ui/Motion.tsx`, thời lượng `--dur-*`.
* Component: shadcn trong `apps/<app>/src/components/ui/` hoặc `packages/design-system/ui/`. Thiếu
  biến thể thì thêm biến thể vào component đó, không dựng component song song.

## 5. Nợ đã trả hết, cổng chặn CI (08.10.2026)

Lượt đo đầu tiên trên cả repo (571 file) ra 2.458 vi phạm, phần lớn là `text-xs` (960), `text-[11px]`
(123), cỡ chữ tuỳ ý và bước spacing ngoài thang ở `apps/web` và `apps/learn`. Chủ dự án yêu cầu xử
lý toàn bộ ngay trong ngày:

* **Codemod** đổi những gì có ánh xạ một nghĩa: `text-xs` thành `text-sm`, cỡ chữ tuỳ ý về bậc gần
  nhất, bước spacing về bước gần nhất của thang, bề rộng khung về khung gần nhất, màu mặc định của
  Tailwind về token cùng vai (vàng thành `sun`, xanh lá thành `success`/`seafoam`, đỏ thành
  `destructive`, xanh dương thành `ocean-brand`/`sky`/`sea`, xám thành `muted`/`border`).
* **Sửa tay** phần cần phán đoán: màu thô, style inline, nút và badge vẽ lại bằng className.

Từ đó `npm run check:code` chạy `audit-ui.mjs --strict`: **một vi phạm mới ở bất kỳ đâu là CI đỏ**.

**Vị trí và kích thước là hình học, không phải nhịp.** `top-[69px]` neo dưới một thanh cao 69px,
`w-[...]` khớp một ảnh: đổi chúng về "bước gần nhất" làm lệch thứ chúng bám theo. Vì vậy cổng không
bắt `top/left/right/bottom/inset`, `w/h/min/max`, `size`, `grid-cols-[...]`.

## 6. Token cấu trúc (SRC-1293)

Phép thử "đủ token": **mọi giá trị nhìn thấy được trên màn hình đều có tên**. Màu có tên từ lâu
(DS-001 §2·1·3); các nhóm dưới đây là phần trước 08.10.2026 mỗi phiên tự chọn một con số. Giá trị ở
`packages/design-system/tokens/base.css`, tên Tailwind ở `packages/design-system/tailwind.css`.

Ba tầng: **nguyên liệu** (thang bội 4px, Ocean Palette) · **ngữ nghĩa** (tên theo vai, bảng dưới) ·
**component** (biến thể `cva` của shadcn). Component chỉ đọc tầng ngữ nghĩa.

| Nhóm | Token CSS | Class Tailwind | Giá trị |
| --- | --- | --- | --- |
| Control | `--control-h-sm/md/lg` | `h-control-sm/md/lg` | 36 · 44 · 52px |
| Icon | `--icon-sm/md/lg` | `size-icon-sm/md/lg` | 16 · 20 · 24px |
| Chồng dọc | `--space-stack-sm`, `--space-stack` | `gap-stack-sm`, `gap-stack` | 8 · 16px |
| Thẻ | `--space-card-sm`, `--space-card` | `p-card-sm sm:p-card` | 16 · 24px |
| Lưới | `--gutter`, `--gutter-lg` | `gap-gutter lg:gap-gutter-lg` | 16 · 24px |
| Lề trang | `--page-x-sm/-/-lg` | `px-page-x-sm sm:px-page-x lg:px-page-x-lg` | 16 · 24 · 32px |
| Nhịp section | `--section-y-sm/-/-lg` | `py-section-sm sm:py-section lg:py-section-lg` | 48 · 64 · 80px |
| Khung | `--n12-container-*` | `max-w-popover/form/read-narrow/read/wide/page` | 384 · 448 · 672 · 768 · 1024 · 1152px |
| Lớp chồng | `--z-raised/sticky/dropdown/overlay/modal/toast/tour` | `z-(--z-modal)` | 10 · 20 · 30 · 40 · 50 · 60 · 70 |
| Kiểu chữ | `--lh-*`, `--fw-*` | `text-h1` ... tự mang dòng và độ đậm | dòng 1,2 · 1,35 · 1,6 |
| Viền, focus | `--border-hairline/strong`, `--ring-width`, `--ring-offset` | | 1 · 2 · 2 · 2px |
| Trạng thái | `--opacity-disabled`, `--overlay` | | 0,5 · deep-ocean 55% |

`text-h2` nay mang sẵn `line-height` và `font-weight`: viết `text-h2` là đủ, không thêm
`leading-*` hay `font-*` cạnh nó.

**Shadcn không thay được bảng này.** Shadcn chuẩn hoá HÀNH VI (bàn phím, focus, aria) và khoảng 20
token màu cộng một `--radius`. Spacing, cỡ chữ, khung, lớp chồng nó để nguyên thang mặc định của
Tailwind, một thang rộng tới mức `p-7`, `text-xs`, `max-w-4xl` đều hợp lệ. Dùng đúng shadcn mà vẫn
xô lệch là chuyện bình thường nếu không có tầng ngữ nghĩa.

**Còn để dành:** màu trạng thái đủ bộ (info, success, warning kèm nền nhạt và chữ trên nền) cần một
lượt đo contrast riêng; gộp hai bộ radius v0.4 và v1; bỏ `--dur-1/2` cũ. Khi làm thì thêm vào bảng
này, không mở bảng thứ hai.

## 7. Bốn khung bố cục

`packages/design-system/ui/Layout.tsx`: `Section` (vai nền + nhịp dọc), `Container` (một trong sáu
khung + lề trang), `CardGrid` (lưới thẻ mobile-first, con cao bằng nhau), `Stack` (chồng dọc bằng
`gap`). App dùng thì chép bản sao y hệt như `Motion.tsx` (DS-001 §1).

```tsx
<Section band="foam">
  <Container>
    <Stack>
      <h2 className="text-h2">...</h2>
      <CardGrid cols={3}>...</CardGrid>
    </Stack>
  </Container>
</Section>
```

Màn mới dựng bằng bốn khung này. Màn cũ chuyển dần khi có người sửa, cùng luật "file mình chạm thì
phải sạch" ở §5.
