Design Tokens: Để AI Agent Viết UI Đúng Design System
Posted on: 10/26/2026 3:00:28 PM
Table of contents
- 1. Vì sao AI agent hay "vẽ lệch" design system
- 2. Ba tầng token: primitive, semantic, component
- 3. Giải phẫu chuẩn DTCG 2025.10
- 4. Theming không bùng nổ tổ hợp: Resolver module
- 5. Pipeline: từ Figma, Penpot tới mọi nền tảng
- 6. Đưa design system vào tầm với của AI agent
- 7. Hàng rào kỹ thuật: để codebase tự chặn UI lệch
- 8. Accessibility nằm ngay trong token
- 9. Lộ trình áp dụng trong 4 tuần
- 10. Những sai lầm thường gặp
- Kết luận
Một đội sản phẩm cho AI coding agent viết giao diện được ba tháng. Tốc độ ra màn hình mới tăng gấp ba, nhưng khi designer mở lại codebase thì thấy 47 sắc xanh khác nhau, nút "Lưu" có bốn kiểu bo góc, còn chế độ tối thì mỗi trang tối theo một cách. Agent không làm gì sai theo nghĩa thông thường: nó học theo code xung quanh, mà code xung quanh đầy mã hex viết tay. Thấy #3b82f6 ở ba chỗ, nó tự tin viết thêm chỗ thứ tư — hoặc #3a7ff0 vì "trông gần giống".
Gốc rễ của vấn đề không nằm ở model mà ở chỗ quyết định thiết kế không tồn tại dưới dạng máy đọc được. Chúng nằm trong file Figma, trong đầu designer, trong một trang wiki ít ai mở. Design token chính là cách biến những quyết định đó thành dữ liệu có tên, có kiểu, có quan hệ — thứ mà cả trình build, ứng dụng web, mobile lẫn AI agent đều đọc được. Tháng 10/2025, chuẩn Design Tokens của W3C Community Group (DTCG) ra bản ổn định đầu tiên; một năm sau, hệ sinh thái công cụ đã đủ chín để dùng trong production. Bài viết này đi từ cấu trúc chuẩn DTCG 2025.10, cơ chế theming bằng Resolver, pipeline build với Terrazzo, đến cách đưa design system vào ngữ cảnh của AI agent và dựng hàng rào kỹ thuật để agent không thể "vẽ lệch". Mọi đoạn code trong bài đều đã được chạy thử.
1. Vì sao AI agent hay "vẽ lệch" design system
Một coding agent viết UI dựa trên ba nguồn: chỉ dẫn bạn đưa vào (prompt, AGENTS.md, CLAUDE.md), code nó đọc được trong repo, và kiến thức chung từ lúc huấn luyện. Khi design system chỉ tồn tại trong Figma, hai nguồn đầu gần như trống, agent sẽ lấp chỗ trống bằng nguồn thứ ba — tức là bằng "giao diện trung bình của internet". Kết quả là UI chạy được, trông ổn, nhưng không phải UI của bạn. Hiện tượng này thường được gọi là UI drift: mỗi màn hình lệch một chút, cộng dồn lại thành một sản phẩm không còn nhất quán.
Mã hex là thủ phạm khó thấy nhất. #165dfc không nói gì về ý định: nó là màu nhấn, màu link, màu nền nút hay màu viền focus? Con người có thể đoán từ ngữ cảnh, agent thì không chắc. Ngược lại, var(--color-action-primary) nói rõ: "màu cho hành động chính". Khi đổi thương hiệu hay bật chế độ tối, giá trị thay đổi nhưng ý nghĩa giữ nguyên, và mọi nơi dùng token đó đổi theo.
Token là hợp đồng, không chỉ là biến màu
Một design token là một quyết định thiết kế có tên: tên mô tả ý nghĩa, giá trị có kiểu rõ ràng, và có thể trỏ tới token khác. Biến CSS chỉ là một trong nhiều "đầu ra" của token. Cùng một file token có thể sinh ra CSS cho web, Swift cho iOS, file cho Android, cấu hình Tailwind — và ngữ cảnh cho AI agent.
2. Ba tầng token: primitive, semantic, component
Hầu hết design system trưởng thành tổ chức token thành ba tầng. Tách tầng không phải để cho "đẹp sơ đồ", mà để mỗi loại thay đổi chỉ chạm vào đúng một chỗ.
| Tầng | Ví dụ | Trả lời câu hỏi | Thay đổi khi nào |
|---|---|---|---|
| Primitive (nguyên thủy) | color.brand.600, space.4 | Có những giá trị nào? | Đổi bảng màu, đổi thang khoảng cách |
| Semantic (ngữ nghĩa) | color.action.primary, color.text.muted | Giá trị này dùng để làm gì? | Đổi theme, đổi brand, chế độ tương phản cao |
| Component (thành phần) | button.primary.background | Component này dùng gì? | Tinh chỉnh riêng một component |
flowchart LR
subgraph P["Primitive: giá trị thô"]
P1["color.brand.600 · oklch 0.546 0.245 262.9"]
P2["color.neutral.0 · trắng"]
P3["space.4 · 1rem"]
end
subgraph S["Semantic: ý nghĩa"]
S1["color.action.primary"]
S2["color.action.on-primary"]
S3["space.inset.md"]
end
subgraph C["Component: phạm vi hẹp"]
C1["button.primary.background"]
C2["button.primary.text"]
C3["button.padding"]
end
P1 --> S1 --> C1
P2 --> S2 --> C2
P3 --> S3 --> C3
C1 --> UI["Nút Thanh toán · web, iOS, Android"]
C2 --> UI
C3 --> UI
T["Đổi brand hoặc theme"] -.->|"chỉ nối lại primitive với semantic"| S
classDef hot fill:#e94560,stroke:#fff,color:#fff
classDef light fill:#f8f9fa,stroke:#e94560,color:#2c3e50
classDef dark fill:#2c3e50,stroke:#fff,color:#fff
class P1,P2,P3 light
class S1,S2,S3 hot
class C1,C2,C3 light
class UI,T dark
Hình 1: Component không bao giờ trỏ thẳng vào primitive. Đổi theme chỉ là nối lại mũi tên giữa tầng primitive và semantic.
Quy tắc quan trọng nhất: component và code ứng dụng chỉ dùng token semantic (hoặc component), không dùng primitive. Nếu nút bấm trỏ thẳng vào color.blue.600, ngày đổi thương hiệu sang màu cam bạn sẽ có một token tên "blue" mang giá trị màu cam — và một agent đọc tên đó sẽ hiểu sai hoàn toàn. Với AI agent, tầng semantic là tầng có giá trị nhất: nó mang ý định thiết kế, thứ mà một mã hex không bao giờ mang được.
3. Giải phẫu chuẩn DTCG 2025.10
Ngày 28/10/2025, Design Tokens Community Group công bố bản ổn định đầu tiên, gồm ba module: Format (cấu trúc file và các kiểu token), Color (màu sắc với không gian màu hiện đại) và Resolver (theming và các tổ hợp ngữ cảnh). Lưu ý đây là báo cáo của Community Group, không phải W3C Recommendation và không nằm trên Standards Track — nhưng nó là định dạng chung mà Figma, Adobe, Penpot, Sketch, Framer, Style Dictionary, Tokens Studio, Terrazzo, zeroheight, Supernova và nhiều công cụ khác cùng tham gia xây dựng.
3.1. Token, group và các thuộc tính $
Một object có thuộc tính $value là một token; object không có $value là một group. Mọi thuộc tính do chuẩn định nghĩa đều bắt đầu bằng $, nên tên token do bạn đặt không được bắt đầu bằng $ và không được chứa {, } hay dấu chấm.
$value(bắt buộc): giá trị của token, hoặc một alias trỏ tới token khác.$type: kiểu của token. Nếu bỏ trống, token kế thừa$typetừ group cha gần nhất. Chuẩn cấm công cụ "đoán" kiểu từ giá trị.$description: mô tả dạng văn bản thuần — với AI agent, đây là chỗ đặt ý định thiết kế.$deprecated:truehoặc một chuỗi giải thích nên dùng gì thay thế.$extensions: dữ liệu riêng của từng công cụ, khóa đặt theo kiểu tên miền đảo ngược; công cụ bắt buộc giữ nguyên dữ liệu mà nó không hiểu.
Ở cấp group có thêm $extends để kế thừa toàn bộ token của một group khác (gộp sâu, token cục bộ ghi đè token kế thừa cùng đường dẫn), và tên dành riêng $root cho token "gốc" của một group, ví dụ color.accent.$root bên cạnh color.accent.subtle. Ví dụ dưới đây gói gọn gần hết các khái niệm trên:
{
"radius": {
"$type": "dimension",
"md": { "$value": { "value": 6, "unit": "px" } }
},
"button": {
"primary": {
"$description": "Nút hành động chính, tối đa một nút trên mỗi màn hình",
"background": {
"$type": "color",
"$value": "{color.action.primary}",
"$extensions": { "com.figma": { "scopes": ["FRAME_FILL", "SHAPE_FILL"] } }
},
"text": { "$type": "color", "$value": "{color.action.on-primary}" },
"radius": { "$type": "dimension", "$value": "{radius.md}" },
"elevation": {
"$type": "shadow",
"$value": {
"color": { "colorSpace": "oklch", "components": [0.2, 0.04, 265], "alpha": 0.18 },
"offsetX": { "value": 0, "unit": "px" },
"offsetY": { "value": 1, "unit": "px" },
"blur": { "value": 2, "unit": "px" },
"spread": { "value": 0, "unit": "px" }
}
}
},
"secondary": {
"$extends": "{button.primary}",
"background": { "$type": "color", "$value": "{color.surface.default}" },
"text": { "$type": "color", "$value": "{color.text.default}" }
},
"cta": {
"$extends": "{button.primary}",
"$deprecated": "Dùng button.primary, nhóm này sẽ bị xóa ở v4"
}
}
}
button.secondary kế thừa radius và elevation từ button.primary, chỉ ghi đè nền và chữ. button.cta vẫn chạy nhưng mọi công cụ (và agent) đọc file đều biết nó đã bị khai tử và nên dùng gì thay thế.
3.2. Alias: dấu ngoặc nhọn và JSON Pointer
Chuẩn có hai cú pháp tham chiếu. Cú pháp ngoặc nhọn {color.brand.600} luôn trỏ tới toàn bộ $value của một token và là cách bạn sẽ dùng 95% thời gian. Cú pháp $ref theo JSON Pointer (RFC 6901) với hai điểm khác biệt: phải ghi đủ đường dẫn kèm /$value, và trỏ được vào bên trong một giá trị. Ví dụ, tạo một màu nền rất nhạt nhưng giữ đúng hue của màu thương hiệu (trong cùng tài liệu):
{
"color": {
"tint": {
"brand-subtle": {
"$type": "color",
"$description": "Nền rất nhạt nhưng giữ đúng hue của thương hiệu",
"$value": {
"colorSpace": "oklch",
"components": [0.97, 0.02, { "$ref": "#/color/brand/600/$value/components/2" }]
}
}
}
}
}
Alias có thể nối chuỗi (token A trỏ B, B trỏ C) và công cụ sẽ đi theo cho tới khi gặp giá trị thật; tham chiếu vòng là lỗi bắt buộc phải báo.
3.3. Các kiểu token và định dạng giá trị
| Nhóm | Kiểu | Ghi chú |
|---|---|---|
| Nguyên thủy | color, dimension, fontFamily, fontWeight, duration, cubicBezier, number | dimension và duration là object { value, unit }: đơn vị px/rem và ms/s, bắt buộc có đơn vị kể cả khi giá trị bằng 0 |
| Tổ hợp | strokeStyle, border, transition, shadow, gradient, typography | Mỗi thuộc tính con có thể là giá trị hoặc alias, như shadow.color ở ví dụ trên |
| Màu (Color module) | colorSpace + components | 14 không gian: srgb, srgb-linear, hsl, hwb, lab, lch, oklab, oklch, display-p3, a98-rgb, prophoto-rgb, rec2020, xyz-d65, xyz-d50; thêm alpha và hex dự phòng |
Thay đổi đáng giá nhất của bản 2025.10 là màu không còn là một chuỗi hex. Một màu được mô tả bằng không gian màu và các thành phần, ví dụ { "colorSpace": "oklch", "components": [0.546, 0.245, 262.9], "hex": "#165dfc" }. OKLCH là lựa chọn tốt cho design system vì độ sáng L tuyến tính theo cảm nhận: hai màu cùng L trông sáng như nhau, nên việc dựng thang màu và dự đoán độ tương phản dễ hơn nhiều so với HSL. Thành phần còn có thể mang giá trị none (khác với 0 khi nội suy màu). File token dùng đuôi .tokens hoặc .tokens.json, media type application/design-tokens+json.
4. Theming không bùng nổ tổ hợp: Resolver module
Một sản phẩm SaaS thực tế hiếm khi chỉ có một bộ giao diện. Ba thương hiệu (white-label) × sáng/tối × mật độ thường/gọn × tương phản thường/cao đã là 24 tổ hợp. Cách cũ là sao chép file theo quy ước tên như acme-dark-compact.json, rồi cầu mong không ai quên sửa một trong 24 bản. Resolver module giải bài toán này bằng một file mô tả quan hệ giữa các bộ token thay vì liệt kê từng tổ hợp:
sets: các bộ token luôn được nạp (ví dụ primitive dùng chung), mỗi set có mảngsourcesgồm$reftới file hoặc token viết trực tiếp.modifiers: các "công tắc" nhưbrandhaytheme, mỗi công tắc cócontexts(các lựa chọn) vàdefaulttùy chọn.resolutionOrder(bắt buộc): thứ tự áp dụng; mục đứng sau ghi đè mục đứng trước khi trùng token.
{
"name": "Acme Design System",
"version": "2025.10",
"sets": {
"foundation": { "sources": [{ "$ref": "foundation/neutral.tokens.json" }] }
},
"modifiers": {
"brand": {
"contexts": {
"acme": [{ "$ref": "brand/acme.tokens.json" }],
"nova": [{ "$ref": "brand/nova.tokens.json" }]
},
"default": "acme"
},
"theme": {
"contexts": {
"light": [{ "$ref": "theme/light.tokens.json" }],
"dark": [{ "$ref": "theme/dark.tokens.json" }]
},
"default": "light"
}
},
"resolutionOrder": [
{ "$ref": "#/sets/foundation" },
{ "$ref": "#/modifiers/brand" },
{ "$ref": "#/modifiers/theme" }
]
}
Lưu ý trường version
Bản báo cáo final của Resolver module ghi không thống nhất về giá trị của version: bảng thuộc tính ghi 2025-10-01, phần văn bản lại ghi 2025-11-01. Terrazzo, công cụ dùng trong bài, chỉ chấp nhận "2025.10". Hãy theo đúng công cụ bạn dùng và kiểm tra lại khi spec có bản sửa.
Khi nhận input như {"brand": "nova", "theme": "dark"}, resolver chạy bốn bước: kiểm tra input có khớp modifier và context không, đi theo resolutionOrder để gộp các nguồn thành một cây token, giải alias, rồi xuất bộ token cuối cùng. Số tổ hợp có thể sinh ra bằng tích số context của mọi modifier — ở đây là 2 × 2 = 4.
flowchart TB IN["Input: brand = nova, theme = dark"] --> V["Bước 1: Kiểm tra input khớp modifier và context"] V --> O["Bước 2: Đi theo resolutionOrder"] O --> F["set foundation · neutral.tokens.json"] O --> B["modifier brand chọn nova · nova.tokens.json"] O --> TH["modifier theme chọn dark · dark.tokens.json"] F --> M["Gộp thành một cây token · nguồn sau ghi đè nguồn trước"] B --> M TH --> M M --> A["Bước 3: Giải alias action.primary trỏ brand.400 của nova"] A --> R["Bước 4: Bộ token cuối cho tổ hợp nova + dark"] classDef hot fill:#e94560,stroke:#fff,color:#fff classDef light fill:#f8f9fa,stroke:#e94560,color:#2c3e50 classDef dark fill:#2c3e50,stroke:#fff,color:#fff class IN,R dark class V,O,M,A hot class F,B,TH light
Hình 2: Bốn bước của resolver. File light.tokens.json và acme.tokens.json không hề được nạp cho tổ hợp này.
Điểm tinh tế nằm ở bước 3: alias được giải sau khi đã gộp. Nhờ vậy file theme chỉ cần viết "{color.brand.600}" mà không cần biết đang ở brand nào; modifier brand quyết định brand.600 là xanh hay cam. Đây là file theme sáng ở phiên bản đầu tiên, và file palette của brand nova:
{
"color": {
"$type": "color",
"surface": { "default": { "$value": "{color.neutral.0}" } },
"text": {
"default": { "$value": "{color.neutral.900}" },
"muted": { "$value": "{color.neutral.600}" }
},
"action": {
"primary": { "$value": "{color.brand.600}", "$description": "Nền nút hành động chính" },
"on-primary": { "$value": "{color.neutral.0}", "$description": "Chữ và icon đặt trên action.primary" }
},
"border": { "focus": { "$value": "{color.brand.500}" } }
}
}
{
"color": {
"$type": "color",
"brand": {
"400": { "$value": { "colorSpace": "oklch", "components": [0.75, 0.183, 55.9], "hex": "#ff8b1f" } },
"500": { "$value": { "colorSpace": "oklch", "components": [0.705, 0.213, 47.6], "hex": "#fc7100" } },
"600": { "$value": { "colorSpace": "oklch", "components": [0.646, 0.222, 41.1], "hex": "#ec5600" } },
"700": { "$value": { "colorSpace": "oklch", "components": [0.553, 0.195, 38.4], "hex": "#c43e00" } }
}
}
}
Trông hoàn toàn hợp lý. Nhưng phiên bản này có lỗi accessibility mà mắt thường khó phát hiện — ta sẽ bắt nó ở phần 7.
5. Pipeline: từ Figma, Penpot tới mọi nền tảng
Nguyên tắc nền tảng: nguồn sự thật là file JSON trong Git, không phải file thiết kế và cũng không phải CSS. Thay đổi token đi qua pull request, có review, có diff, có version — giống hệt code. Công cụ thiết kế là nơi designer soạn token, CI là nơi sinh ra đầu ra cho từng nền tảng.
flowchart TB
subgraph D["Thiết kế"]
direction LR
FG["Figma Variables · qua Tokens Studio"]
PP["Penpot · token gốc theo DTCG"]
end
subgraph G["Nguồn sự thật: Git"]
direction LR
PR["Pull request · review và diff"]
TK[("tokens/*.tokens.json · ds.resolver.json")]
end
subgraph CI["CI"]
direction LR
VA["Validate · kiểu, alias, vòng lặp"]
CT["Kiểm tra tương phản · mọi tổ hợp"]
BU["tz build"]
end
subgraph OUT["Đầu ra"]
direction LR
CSS["tokens.css"]
TW["Tailwind v4"]
MOB["Swift, JS/TS, Sass"]
AG["Ngữ cảnh cho agent"]
end
FG --> PR
PP --> PR
PR --> TK --> VA --> CT --> BU
BU --> CSS
BU --> TW
BU --> MOB
BU --> AG
classDef hot fill:#e94560,stroke:#fff,color:#fff
classDef light fill:#f8f9fa,stroke:#e94560,color:#2c3e50
classDef dark fill:#2c3e50,stroke:#fff,color:#fff
class FG,PP light
class PR,TK dark
class VA,CT,BU hot
class CSS,TW,MOB,AG light
Hình 3: Token được soạn trong công cụ thiết kế, review trong Git, kiểm tra và build trong CI.
Về phía công cụ thiết kế, Penpot giới thiệu mình là công cụ thiết kế đầu tiên có design token gốc (từ bản 2.6, xây cùng Tokens Studio), theo định dạng DTCG, có theme và set, import và export JSON. Với Figma, designer làm việc trên Variables; việc đồng bộ ra định dạng DTCG thường đi qua plugin Tokens Studio (có tùy chọn định dạng W3C DTCG) hoặc Variables REST API. Về phía build, có hai lựa chọn phổ biến:
| Công cụ | Vai trò | Hỗ trợ DTCG 2025.10 | Phù hợp khi |
|---|---|---|---|
| Terrazzo (2.x) | Parser, linter, build đa nền tảng | Đầy đủ từ 2.0, gồm cả Resolver | Bắt đầu mới, cần theming nhiều chiều, màu OKLCH |
| Style Dictionary (5.x) | Build đa nền tảng, hệ sinh thái lớn nhất | Có DTCG từ v4; tài liệu ghi hỗ trợ đầy đủ 2025.10 vẫn đang hoàn thiện ở v5 | Đã có pipeline cũ, cần nhiều transform tùy biến, Android |
| Tokens Studio | Plugin Figma, đồng bộ Git | Có định dạng W3C DTCG | Designer làm việc trên Figma |
| Penpot | Công cụ thiết kế mã nguồn mở | Token gốc theo DTCG | Muốn token là tính năng lõi, tự host |
Cấu hình Terrazzo cho resolver ở trên rất gọn. Mỗi phần tử permutations là một tổ hợp input, kèm hàm bọc selector CSS:
import { defineConfig } from "@terrazzo/cli";
import css from "@terrazzo/plugin-css";
export default defineConfig({
tokens: ["./tokens/ds.resolver.json"],
outDir: "./dist/",
plugins: [
css({
filename: "tokens.css",
permutations: [
{ input: { brand: "acme", theme: "light" }, prepare: (css) => `:root {\n ${css}\n}` },
{ input: { brand: "acme", theme: "dark" }, prepare: (css) => `[data-theme="dark"] {\n ${css}\n}` },
{ input: { brand: "nova", theme: "light" }, prepare: (css) => `[data-brand="nova"] {\n ${css}\n}` },
{ input: { brand: "nova", theme: "dark" }, prepare: (css) => `[data-brand="nova"][data-theme="dark"] {\n ${css}\n}` },
],
}),
],
});
Chạy npx tz build sinh ra 21 token cho bốn tổ hợp. Trích kết quả:
/* dist/tokens.css (trích rút gọn từ kết quả tz build) */
:root {
/* Nền nút hành động chính */
--color-action-primary: var(--color-brand-strong);
--color-brand-400: oklch(70.4% 0.1584 253.5);
--color-brand-600: oklch(54.6% 0.245 262.9);
--color-brand-strong: var(--color-brand-600);
--color-surface-default: var(--color-neutral-0);
--color-text-muted: var(--color-neutral-600);
--space-4: 1rem;
}
@media (color-gamut: p3) {
:root {
--color-brand-400: oklch(70.7% 0.165 254.6);
}
}
[data-theme="dark"] {
--color-action-primary: var(--color-brand-400);
--color-surface-default: var(--color-neutral-950);
--color-text-muted: var(--color-neutral-400);
}
[data-brand="nova"] {
--color-brand-600: oklch(64.76% 0.217 36.96);
--color-brand-strong: var(--color-brand-700);
}
Có hai chi tiết đáng chú ý. Thứ nhất, alias được giữ thành chuỗi var() thay vì chép giá trị, nên DevTools cho thấy rõ action.primary lấy từ đâu. Thứ hai, một số màu OKLCH nằm ngoài gamut sRGB: Terrazzo tự gamut-map về sRGB cho giá trị mặc định, rồi sinh khối @media (color-gamut: p3) trả lại sắc độ gốc cho màn hình rộng gamut. Bạn khai báo màu một lần, người dùng iPhone hay MacBook thấy màu rực hơn, màn hình sRGB vẫn hiển thị đúng.
6. Đưa design system vào tầm với của AI agent
Có token rồi, bước tiếp theo là để agent thấy và dùng chúng. Kinh nghiệm thực tế cho thấy nhồi thật nhiều chỉ dẫn vào một file rules không hiệu quả: file càng dài, agent càng bỏ sót. Cách tốt hơn là chia ngữ cảnh thành nhiều lớp, mỗi lớp được nạp đúng lúc cần.
| Lớp ngữ cảnh | Cung cấp gì | Khi nào agent đọc |
|---|---|---|
AGENTS.md / CLAUDE.md | Bản đồ ngắn: quy tắc cứng, chỗ tìm token, mẫu chuẩn, lệnh kiểm tra | Đầu mỗi phiên |
Token đã build (tokens.css) | Danh sách tên token hợp lệ, kèm $description thành comment | Khi viết style |
DESIGN.md | Lý do thiết kế, nên và không nên, bằng văn xuôi | Khi làm màn hình mới |
| Storybook MCP | Danh sách component, props, ví dụ, chạy test | Trước khi tạo hoặc sửa component |
| Figma MCP | Cấu trúc frame và các variable đang dùng | Khi làm theo một thiết kế cụ thể |
6.1. AGENTS.md: bản đồ, không phải giáo trình
File gốc nên ngắn và chỉ đường, vì nó được nạp vào mọi phiên làm việc:
## Giao diện (UI)
- Màu, khoảng cách, bo góc: CHỈ dùng biến trong `dist/tokens.css`
(sinh tự động từ `tokens/`, không sửa tay). Cấm hex và px tự đặt.
- Trước khi tạo component mới: gọi Storybook MCP `docs-list`, `docs-show`
để tìm và dùng lại component có sẵn.
- Làm theo mẫu chuẩn trong `src/examples/` (settings-form, data-table, detail-page).
- Lý do thiết kế, nên và không nên: xem `DESIGN.md`.
- Xong việc chạy `npm run verify:ui`. Không thêm eslint-disable,
không xóa hay nới lỏng test để cho qua.
6.2. DESIGN.md: phần mà token không nói được
Token cho biết giá trị, nhưng không cho biết khi nào không được dùng. Google Labs đã mở mã nguồn DESIGN.md (giấy phép Apache-2.0, hiện ở trạng thái alpha): một file Markdown có front matter YAML chứa token (màu, typography, bo góc, spacing, component) và phần văn xuôi giải thích cách áp dụng. Bộ CLI @google/design.md có lệnh lint (kiểm cấu trúc, tham chiếu và tương phản WCAG), diff và export sang Tailwind hoặc định dạng DTCG.
---
version: alpha
name: Acme
colors:
primary: "#165dfc"
on-primary: "#ffffff"
surface: "#ffffff"
text: "#0f172b"
rounded:
md: 6px
spacing:
md: 16px
components:
button-primary:
backgroundColor: "{colors.primary}"
textColor: "{colors.on-primary}"
rounded: "{rounded.md}"
---
## Overview
Giao diện tài chính cho kế toán doanh nghiệp: dày thông tin, ít trang trí.
## Colors
`primary` chỉ dành cho MỘT hành động chính trên mỗi màn hình.
Thao tác xóa không dùng `primary`.
## Do's and Don'ts
- Nên: căn phải cột số tiền trong bảng dữ liệu.
- Không nên: đặt hai nút primary cạnh nhau.
Đừng để có hai nguồn sự thật
Nếu đã có file DTCG trong Git, đừng chép tay giá trị sang DESIGN.md rồi để hai bên trôi dạt. Hãy coi file DTCG là nơi giữ giá trị, còn DESIGN.md tập trung vào lý do và các quy tắc "nên và không nên". Với dự án nhỏ bắt đầu từ con số không, có thể làm ngược lại: soạn DESIGN.md trước rồi dùng export sang DTCG.
6.3. MCP: để agent tự tra cứu thay vì đoán
Storybook MCP (@storybook/addon-mcp, hiện ở chế độ preview) biến thư viện component thành công cụ mà agent gọi được: docs-list liệt kê component, docs-show trả props và ví dụ, get-storybook-story-instructions hướng dẫn cách viết story theo đúng quy ước của dự án, test-run chạy test component để agent tự kiểm tra kết quả. Server chạy ngay trong dev server Storybook tại http://localhost:6006/mcp.
Figma MCP server (bản remote tại https://mcp.figma.com/mcp) cung cấp ngữ cảnh thiết kế: cấu trúc frame đang chọn, các variable và component được dùng; kết hợp Code Connect để agent dùng lại đúng component trong codebase thay vì viết mới. Bản mới còn cho agent ghi ngược lên canvas — dựng component, variable và màn hình từ thư viện design system có sẵn.
sequenceDiagram participant Dev as Developer participant AG as AI coding agent participant FM as Figma MCP participant SB as Storybook MCP participant CI as verify:ui Dev->>AG: Làm màn hình Cài đặt thanh toán theo frame Figma AG->>AG: Đọc AGENTS.md, nắm quy tắc token AG->>FM: Lấy design context và variable của frame FM-->>AG: Cấu trúc layout, variable đang dùng AG->>SB: docs-list, docs-show cho Button và FormField SB-->>AG: Props, ví dụ, story có sẵn AG->>AG: Viết code chỉ dùng component và token có thật AG->>CI: npm run verify:ui CI-->>AG: Báo token color-focus-ring không tồn tại AG->>AG: Đổi sang color-border-focus AG->>CI: npm run verify:ui CI-->>AG: Pass AG-->>Dev: Pull request kèm ảnh chụp story
Hình 4: Agent tra cứu qua MCP trước khi viết, rồi để bộ kiểm tra tự động phản hồi cho tới khi pass.
7. Hàng rào kỹ thuật: để codebase tự chặn UI lệch
Chỉ dẫn chỉ là lời khuyên; công cụ kiểm tra mới là luật. Bài học từ các đội đã đưa agent vào phát triển frontend khá thống nhất: thay vì viết rules dài hơn, hãy làm cho code sai không thể vượt qua CI. Hãy xem một đoạn CSS điển hình mà agent viết khi chưa có hàng rào:
.btn-primary {
background-color: var(--color-action-primary);
color: #fff;
border-radius: 6px;
padding: var(--space-2) var(--space-4);
outline-color: var(--color-focus-ring);
}
.btn-primary:hover { background-color: var(--color-action-primary-hover); }
Có bốn lỗi: một mã hex, một giá trị px tự đặt, và hai token "bịa" — tên nghe rất hợp lý nhưng không tồn tại. Lỗi thứ tư nguy hiểm nhất vì CSS không báo lỗi: biến không tồn tại chỉ làm thuộc tính rơi về giá trị mặc định, nút hover không đổi màu và viền focus biến mất trong im lặng.
7.1. Stylelint: cấm giá trị thô
Rule color-no-hex có sẵn của Stylelint chặn mã hex, còn plugin stylelint-declaration-strict-value bắt buộc các thuộc tính được chọn phải dùng biến, hàm hoặc từ khóa. Nên bắt đầu với màu và bo góc, chỉ mở rộng sang spacing và typography khi bộ token đã phủ đủ — nếu không agent sẽ bịa tên token để lách luật.
export default {
plugins: ["stylelint-declaration-strict-value"],
rules: {
"color-no-hex": true,
"color-named": "never",
"scale-unlimited/declaration-strict-value": [
["/color$/", "fill", "stroke", "border-radius"],
{ ignoreValues: ["transparent", "inherit", "currentColor", "0"] },
],
},
};
$ npx stylelint "src/**/*.css"
src/button.css
3:3 ✖ Expected variable, function or keyword for "#fff" of "color" scale-unlimited/declaration-strict-value
3:10 ✖ Unexpected hex color "#fff" color-no-hex
4:3 ✖ Expected variable, function or keyword for "6px" of "border-radius" scale-unlimited/declaration-strict-value
✖ 3 problems (3 errors, 0 warnings)
7.2. Bắt token "bịa"
Stylelint không biết token nào tồn tại. Một script nhỏ đối chiếu mọi var(--color-…), var(--space-…) trong mã nguồn với danh sách biến trong tokens.css đã build (cần Node 22 trở lên cho globSync):
// Bắt token "bịa": var(--color-...) không có trong file CSS sinh từ design tokens
import { readFileSync, globSync } from "node:fs";
const defined = new Set(readFileSync("dist/tokens.css", "utf8").match(/--[\w-]+(?=\s*:)/g));
const USE = /var\((--(?:color|space|radius|font|shadow)-[\w-]+)/g;
let bad = 0;
for (const file of globSync("src/**/*.{css,vue,tsx}")) {
for (const [, name] of readFileSync(file, "utf8").matchAll(USE)) {
if (!defined.has(name)) {
bad++;
console.error(`${file}: ${name} không tồn tại trong design tokens`);
}
}
}
process.exit(bad ? 1 : 0);
$ node scripts/check-unknown-tokens.mjs
src/button.css: --color-focus-ring không tồn tại trong design tokens
src/button.css: --color-action-primary-hover không tồn tại trong design tokens
7.3. Một lệnh kiểm tra duy nhất
Gom mọi thứ vào một lệnh để agent chạy sau mỗi thay đổi. Lỗi trả về chính là phản hồi để agent tự sửa, giống vòng lặp ở Hình 4:
{
"scripts": {
"tokens:build": "tz build",
"tokens:check": "node scripts/check-contrast.mjs",
"lint:css": "stylelint \"src/**/*.css\"",
"lint:tokens": "node scripts/check-unknown-tokens.mjs",
"verify:ui": "npm run tokens:build && npm run tokens:check && tsc --noEmit && eslint . && npm run lint:css && npm run lint:tokens && vitest run --project storybook"
}
}
- Type hóa trạng thái không hợp lệ: dùng discriminated union trong TypeScript để những tổ hợp props sai (ví dụ card thông tin nhưng có
onClick) thành lỗi biên dịch. - Thư mục mẫu chuẩn: vài màn hình mẫu chạy được (form cài đặt, bảng dữ liệu, trang chi tiết) nằm trong CI; đổi tên prop là build hỏng ngay, mẫu không bao giờ lỗi thời.
- Chặn "lách luật": agent từng thêm
eslint-disable, ép kiểuas any, thậm chí xóa test đang fail. Cấm comment tắt rule trong file thay đổi và gắn cờ review cho mọi test bị xóa hoặc nới lỏng.
8. Accessibility nằm ngay trong token
WCAG 2.2 yêu cầu tỉ lệ tương phản tối thiểu 4.5:1 cho chữ thường (tiêu chí 1.4.3, chữ lớn 3:1) và 3:1 cho thành phần giao diện như viền ô nhập, chỉ báo focus (tiêu chí 1.4.11). Khi màu nằm trong token, ta kiểm tra được tương phản trước khi có một dòng UI nào, và quan trọng hơn: kiểm tra mọi tổ hợp mà resolver sinh ra, không chỉ theme mặc định mà designer hay xem.
Script dưới đây tự cài đặt lại bốn bước của resolver ở dạng tối giản (đủ cho ví dụ: chỉ $ref tới file và màu oklch/srgb), sinh mọi tổ hợp brand × theme, rồi đo các cặp màu quan trọng bằng thư viện culori:
// Kiểm tra tương phản WCAG cho MỌI tổ hợp brand × theme mà resolver sinh ra
import { readFileSync } from "node:fs";
import { wcagContrast } from "culori";
const load = (p) => JSON.parse(readFileSync(new URL(`../tokens/${p}`, import.meta.url), "utf8"));
const resolver = load("ds.resolver.json");
// [chữ/viền, nền, ngưỡng] — 4.5:1 cho chữ thường, 3:1 cho thành phần UI (WCAG 2.2: 1.4.3, 1.4.11)
const PAIRS = [
["color.text.default", "color.surface.default", 4.5],
["color.text.muted", "color.surface.default", 4.5],
["color.action.on-primary", "color.action.primary", 4.5],
["color.border.focus", "color.surface.default", 3],
];
const isGroup = (x) => x && typeof x === "object" && !("$value" in x);
const merge = (a, b) => {
for (const [k, v] of Object.entries(b)) a[k] = isGroup(v) && isGroup(a[k]) ? merge(a[k], v) : v;
return a;
};
// Bước 1–2 của resolver: đi theo resolutionOrder, gộp set và context được chọn
function build(input) {
const tree = {};
for (const { $ref } of resolver.resolutionOrder) {
const [, kind, name] = $ref.split("/"); // "#/modifiers/theme" -> modifiers, theme
const sources = kind === "sets" ? resolver.sets[name].sources : resolver.modifiers[name].contexts[input[name]];
for (const src of sources) merge(tree, structuredClone(src.$ref ? load(src.$ref) : src));
}
return tree;
}
// Bước 3: giải alias {a.b.c} sau khi đã gộp — nhờ vậy theme trỏ được tới palette của brand
function color(tree, path, seen = new Set()) {
if (seen.has(path)) throw new Error(`Alias vòng lặp tại ${path}`);
seen.add(path);
const v = path.split(".").reduce((n, k) => n?.[k], tree)?.$value;
if (v === undefined) throw new Error(`Không có token ${path}`);
if (typeof v === "string") return color(tree, v.slice(1, -1), seen);
const [a, b, c] = v.components;
return v.colorSpace === "oklch" ? { mode: "oklch", l: a, c: b, h: c } : { mode: "rgb", r: a, g: b, b: c };
}
// Sinh mọi tổ hợp context: brand(acme, nova) × theme(light, dark) = 4
const inputs = Object.entries(resolver.modifiers).reduce(
(acc, [name, m]) => acc.flatMap((i) => Object.keys(m.contexts).map((ctx) => ({ ...i, [name]: ctx }))),
[{}],
);
let failed = 0;
for (const input of inputs) {
const tree = build(input);
const label = Object.entries(input).map(([k, v]) => `${k}=${v}`).join(" ");
for (const [fg, bg, min] of PAIRS) {
const ratio = wcagContrast(color(tree, fg), color(tree, bg));
if (ratio < min) {
failed++;
console.error(`✗ ${label.padEnd(22)} ${fg} / ${bg} = ${ratio.toFixed(2)} < ${min}`);
}
}
}
console.log(failed ? `${failed} cặp màu không đạt` : `OK: ${inputs.length} tổ hợp × ${PAIRS.length} cặp đều đạt`);
process.exit(failed ? 1 : 0);
Chạy với bộ token ở phần 4:
$ node scripts/check-contrast.mjs
✗ brand=acme theme=dark color.text.muted / color.surface.default = 4.23 < 4.5
✗ brand=nova theme=light color.action.on-primary / color.action.primary = 3.61 < 4.5
✗ brand=nova theme=light color.border.focus / color.surface.default = 2.83 < 3
✗ brand=nova theme=dark color.text.muted / color.surface.default = 4.23 < 4.5
4 cặp màu không đạt
Bốn lỗi, và ba trong số đó nằm ở brand nova — tổ hợp không ai mở khi review vì mặc định là acme. Màu cam brand.600 đẹp nhưng chỉ đạt 3.61:1 với chữ trắng; chữ phụ neutral.500 trên nền tối chỉ đạt 4.23:1 ở cả hai brand. Cách sửa đúng không phải là chèn ngoại lệ "nếu là nova thì dùng 700" vào file theme, mà là để brand tự khai báo ngữ nghĩa: mỗi brand cung cấp token brand.strong là sắc độ đủ đậm cho chữ trắng, còn theme chỉ hỏi "màu đậm đủ chuẩn" thay vì đoán con số 600.
brand/acme.tokens.json (trong group color.brand)
+ "strong": { "$value": "{color.brand.600}", "$description": "Sắc độ đạt 4.5:1 với chữ trắng" }
brand/nova.tokens.json
+ "strong": { "$value": "{color.brand.700}", "$description": "Sắc độ đạt 4.5:1 với chữ trắng" }
theme/light.tokens.json
- "primary": { "$value": "{color.brand.600}" }
+ "primary": { "$value": "{color.brand.strong}" }
- "focus": { "$value": "{color.brand.500}" }
+ "focus": { "$value": "{color.brand.strong}" }
theme/dark.tokens.json
- "muted": { "$value": "{color.neutral.500}" }
+ "muted": { "$value": "{color.neutral.400}" }
$ node scripts/check-contrast.mjs
OK: 4 tổ hợp × 4 cặp đều đạt
color.brand.strong là alias trỏ tới một alias khác (brand.700), và resolver đi theo chuỗi cho tới giá trị thật. Thêm brand thứ ba trong tương lai, CI sẽ tự kiểm tra cả hai theme của brand đó trước khi merge.
Đo trên màu người dùng thật sự thấy
Script trên đo tương phản trên giá trị OKLCH gốc. Với màu ngoài gamut sRGB, màn hình thường sẽ hiển thị bản đã gamut-map, nên độ sáng có thể lệch nhẹ. Nếu muốn chặt chẽ tuyệt đối, hãy map màu về sRGB (ví dụ bằng toGamut của culori) trước khi đo, và giữ một khoảng an toàn thay vì sát ngưỡng 4.5.
9. Lộ trình áp dụng trong 4 tuần
$description cho từng token, dựng file resolver cho theme sáng/tối và các brand. Đồng bộ với Figma Variables hoặc Penpot.color-no-hex và strict-value cho màu. Thay dần mã hex cũ bằng token, ưu tiên các component dùng nhiều nhất.verify:ui.$deprecated kèm hướng thay thế trước khi xóa, đánh version cho gói token, review diff token trong mọi PR như review API.10. Những sai lầm thường gặp
- Đặt tên semantic theo giá trị:
color.blue-buttonhaytext.graysẽ thành lời nói dối ngay lần đổi brand đầu tiên. Tên semantic mô tả vai trò, không mô tả màu. - Token cho từng pixel: tạo component token cho mọi thuộc tính của mọi component làm bộ token phình to và không ai dám sửa. Chỉ tạo component token khi component thật sự cần lệch khỏi semantic.
- Đồng bộ hai chiều không kiểm soát: vừa sửa trong Figma vừa sửa trong Git sẽ sinh xung đột âm thầm. Chọn một chiều chính và để chiều còn lại đi qua pull request.
- Chế độ tối bằng cách đảo màu: đảo ngược thang sáng tối làm hỏng độ tương phản và màu thương hiệu. Theme tối cần bộ semantic riêng, được kiểm tra riêng.
- Tin agent mà không có lint: agent rất giỏi bịa ra tên token nghe hợp lý. Không có bước đối chiếu với tokens.css, lỗi chỉ lộ ra khi người dùng thấy nút mất viền focus.
- Chỉ test theme mặc định: như ví dụ ở phần 8, lỗi thường nằm ở tổ hợp ít ai mở. Hãy để máy duyệt mọi tổ hợp.
Kết luận
AI agent làm cho việc viết giao diện rẻ hơn bao giờ hết, và chính vì rẻ, sự nhất quán trở thành thứ khan hiếm. Design token là cách đưa quyết định thiết kế ra khỏi file Figma và đầu designer, biến chúng thành dữ liệu có kiểu mà mọi công cụ đều đọc được. Với chuẩn DTCG 2025.10, định dạng đó lần đầu tiên ổn định và được cả ngành dùng chung: màu OKLCH thay cho hex, alias và kế thừa group, resolver cho theming nhiều chiều.
Công thức cho năm 2026 khá rõ ràng: token trong Git là nguồn sự thật, CI kiểm tra tương phản trên mọi tổ hợp, AGENTS.md chỉ đường, DESIGN.md giải thích lý do, MCP cho agent tự tra cứu, còn lint và test là hàng rào cuối cùng. Một bước nhỏ để bắt đầu ngay tuần này: chạy grep đếm số mã hex khác nhau trong thư mục src/ — con số đó chính là lý do bạn cần design token.
Nguồn tham khảo
- W3C DTCG — Design Tokens Format Module 2025.10 (Final Community Group Report)
- W3C DTCG — Design Tokens Color Module 2025.10
- W3C DTCG — Design Tokens Resolver Module 2025.10
- W3C DTCG — Design Tokens specification reaches first stable version
- designtokens.org — trang chính thức và danh sách công cụ áp dụng
- Terrazzo — Hướng dẫn Resolvers
- Style Dictionary — Hỗ trợ định dạng DTCG
- Smashing Magazine — Integrating Design And Code With Native Design Tokens In Penpot
- Tokens Studio — Bringing native open-standard design tokens to Penpot
- Figma — Figma MCP server
- Storybook — AI và MCP server
- Google Labs — DESIGN.md (GitHub)
- Builder.io — How to make AI agents follow your design system
- W3C — Web Content Accessibility Guidelines (WCAG) 2.2
- culori — thư viện màu JavaScript
Disclaimer: The opinions expressed in this blog are solely my own and do not reflect the views or opinions of my employer or any affiliated organizations. The content provided is for informational and educational purposes only and should not be taken as professional advice. While I strive to provide accurate and up-to-date information, I make no warranties or guarantees about the completeness, reliability, or accuracy of the content. Readers are encouraged to verify the information and seek independent advice as needed. I disclaim any liability for decisions or actions taken based on the content of this blog.