Màu sắc

Bảng màu Comitor xoay quanh một màu vàng ấm — dùng tiết chế làm điểm nhấn, không bao giờ phủ kín nền. Mọi giá trị bên dưới được khai báo một lần, trong @comitor/ui/styles.css, và đến với mã của app dưới dạng class Tailwind.

Hai điều trên trang này quyết định một component tự viết có đúng hay không: hợp đồng ba vai trò, và việc gói có hai bảng tương phản. Đọc hai mục đó trước khi chọn màu.

Nguồn chuẩn

Brand book Comitor định nghĩa biểu tượng logo, thang gold, ink và paper, và kiểu chữ Inter. @comitor/ui định nghĩa token và component dựng từ đó. Site này ghi tài liệu cho gói và không khai báo màu nào của riêng mình. Khi hai nguồn mâu thuẫn, nguồn đứng trước trong chuỗi đó thắng.

Hợp đồng màu ba vai trò

Đây là luật quan trọng nhất của hệ thống. Mọi sắc màu — thương hiệu, trạng thái, accent, accent riêng của từng app — đều có ba biến thể, và hậu tố nói chính xác nó được phép xuất hiện ở đâu. Chọn sai là lỗi tương phản, không phải chuyện gu.

TokenVai tròĐo vớiVí dụ
--xTô nền — fill đặc, hoặc tint /5–/20Không gì cả. Nó chính là thứ được đem ra để đo.bg-teal · bg-success/15
--x-foregroundChữ và icon nằm trên fill --x đặcChính --x, ở 4,5:1bg-destructive text-destructive-foreground
--x-inkChính màu đó trên nền trang: chữ, icon đứng riêng, viền, gạch chân của mục active--background / --card / --popover / --sidebar, và tint của chính nó, ở 4,5:1 cho chữ và 3:1 cho icon và viềntext-success-ink · border-red-ink

1 · token trần tô nền

bg-info
bg-info/15

2 · nấc -foreground nằm trên fill đặc

ApprovedDeletePending

3 · nấc -ink đặt trên nền trang

Payment receivedOverdueActive tabField is required

Sai, và cách sửa

Đang viếtHỏng ở đâuThay bằng
text-successToken fill dùng làm chữ: 3,13:1 trên nền trang, 2,69:1 trên tint /15 của chính nó.text-success-ink
text-red cho dòng báo lỗi4,38:1 trên nền trang, dưới AA — và token fill thô không dịch chuyển. Bảng tương phản cao kéo --destructive-ink tối xuống #9A3537; text-red vẫn đứng yên.text-destructive-ink
bg-gold-300 cho switch đang bậtKhoá component vào bảng mặc định — bảng tương phản cao không còn dịch chuyển được nó.bg-control-on
bg-ash-300 cho switch đang tắtCùng lý do. Class của thang thô không đi theo bảng màu.bg-switch-track
bg-muted cho phần chưa tô của slider--slider-track có ra chính cho việc này. Ở bảng mặc định nó trỏ vào --muted, nên hiện giờ hai thứ hiện giống hệt nhau — và tách ra ở bảng tương phản cao, nơi rãnh được nâng lên --control-edge còn bg-muted đứng yên.bg-slider-track
text-ink cho dấu tick của checkboxHiện giờ cả hai đều là ink, nhưng --control-check là điểm ghi đè duy nhất cho dấu tick: app nào làm tối --control-on sẽ đổi nó ở đó, và text-ink sẽ không đổi theo.text-control-check
border-border trên một control--border chỉ để trang trí và cố ý dưới 3:1 ở mọi bảng màu.border-input, hoặc border-choice-edge cho checkbox và radio
text-app-accent cho dấu của mục điều hướng đang activeAccent mặc định của app là gold #E8B824 — 1,7–1,9:1 trên thanh bên.text-app-accent-ink

Mọi tỉ lệ trong bảng đó đo ở bảng mặc định, theme sáng — trạng thái của một app khi chưa đặt gì. Đổi bảng thì số đổi, nhưng không cứu được hàng nào: mỗi hàng sai ở vai trò, không phải ở giá trị, và hai bảng chỉ khác nhau ở giá trị đứng sau một cái tên.

Hợp đồng ràng buộc ở đâu, và bản thiết kế đã duyệt thắng ở đâu

Luật -ink ràng buộc chặt ở những cụm tông màu comitor-ds chưa từng có: Badge success và warning, StatusPill, LetterAvatar, StatCard, Toast. Đó là những chỗ chữ đứng trên tint của chính màu nó, và nấc ink là thứ duy nhất giữ cho chữ đọc được.

Ở chỗ comitor-ds có sở hữu primitive — Button, Input, Switch, Checkbox, Tabs, Alert, Form — bản thiết kế đã duyệt là nguồn chuẩn và nấc ink đã bị bỏ khỏi đường render. Vì vậy, ở bảng mặc định, --primary-ink chính là --primary và --destructive-ink chính là --destructive. Cả hai vẫn là token thật, công khai, và vẫn dịch chuyển ở bảng tương phản cao — chỉ là bảng mặc định không làm tối chúng.

Hệ quả thực tế: cứ viết tên -ink ở mọi chỗ hợp đồng yêu cầu. Với cặp thương hiệu và destructive, hiện giờ việc đó không tốn gì, và đó là chỗ duy nhất app có thể ghi đè để có nấc thương hiệu tối hơn của riêng mình.

Hai bảng tương phản, một bộ component

Gói có hai bảng màu hoàn chỉnh. Bảng nào đang dùng là một cài đặt của người dùng, độc lập với sáng và tối — mỗi bảng có nhánh sáng và nhánh tối riêng, nên tổng cộng có bốn trạng thái màu.

Mặc định

Bảng màu giao diện đã duyệt. Giá trị gốc của nó — gold, ink, paper và các màu xám ash — lấy từ brand book; bảng này là cách hệ thống áp dụng chúng, và nó thắng mọi mâu thuẫn với một số đo.

Có hiệu lực khi chưa đặt gì. Nó chủ ý mang một số cặp dưới ngưỡng WCAG — liệt kê ở phần dưới trang này.

Tương phản cao

Bảng màu được đo để đạt WCAG, bật bằng <html data-contrast="high">. Viền, switch và chữ phụ tối đi; sắc thương hiệu không đổi. Cổng của gói đo cả hai bảng; đây là bảng mà vi phạm sẽ làm hỏng build.

Người dùng bật nó trong phần cài đặt của app. Nó có cho màn hình tối, tấm nền rẻ tiền và dùng ngoài trời — những nơi bảng mặc định không còn đọc rõ.

app/layout.tsx
// app/layout.tsx — a Server Component. ContrastProvider renders its own
// anti-flash script inside <body>; nothing goes in <head>.
import { ContrastProvider, ThemeProvider } from '@comitor/ui/shell'

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en" suppressHydrationWarning>
      <body>
        <ThemeProvider>
          <ContrastProvider>{children}</ContrastProvider>
        </ThemeProvider>
      </body>
    </html>
  )
}
trang cài đặt
'use client'

// A settings page. The label and description follow ComitorLocaleProvider —
// "High contrast" in English, "Tương phản cao" under locale="vi" — so there is
// nothing to pass; `labels` is only for wording of your own.
import { ContrastToggle } from '@comitor/ui/shell'

export function DisplaySettings() {
  return <ContrastToggle />
}

Cái giá khi viết một component

Hai bảng chỉ khác nhau ở giá trị của các biến CSS. Không component nào có nhánh data-contrast: — chính vì vậy không bảng nào có thể bỏ sót một component. Điều kiện cho việc đó là tuyệt đối:

Màu phải đi qua token vai trò. Component nào viết bg-gold-300, bg-ash-300 hay text-gold-400 ở chỗ của token vai trò sẽ bị ghim vào một bảng, và sai lặng lẽ ở bảng kia. Không có gì lỗi biên dịch; class chỉ đơn giản là đứng yên. Các thang thô bên dưới dành cho bề mặt thương hiệu và cho khung riêng của site tài liệu này — không dành cho component phát hành.

Và class phải được viết đầy đủ. Không bao giờ ghép class lúc chạy — `bg-${color}-300`, `text-${tone}-ink` — ở bất cứ đâu, trong bất cứ file nào. Tailwind quét mã nguồn tĩnh: nó không bao giờ thấy chuỗi đã ghép, nên utility không bao giờ được sinh ra và bị loại khỏi bundle của app. Lỗi này lặng hơn lỗi sai token, vì phần tử hiện ra không có màu nào cả thay vì sai màu. Thay vào đó, chọn từ một bảng tra chứa chuỗi class đầy đủ.

Bộ màu theme — màu thương hiệu là một lựa chọn

Từ 3.15.0, màu thương hiệu là trục thứ ba, độc lập với sáng/tối và với hai bảng tương phản ở trên. Chín bộ màu, đặt tên theo màu, đặt trên <html data-palette="…">: gold là mặc định và không đặt thuộc tính nào. Một bộ màu đổi mọi thứ đi qua token vai trò mang sắc thương hiệu — --primary, --ring, --accent, --link-ink, --control-on, --chart-1 — và không đổi thứ gì viết bằng class thô gold-*; đó là lý do mạnh nhất để bám token vai trò. Thử bằng nút bộ màu trên header của site này.

Bộ màu là accent của sản phẩm dựng trên hệ thống này, không phải của chính Comitor. Gói cũng được dùng cho sản phẩm của tổ chức khác và ai cũng dùng được, nên nó có chín bộ. Màu thương hiệu của Comitor là vàng, như brand book định nghĩa: biểu tượng Comitor, website công ty và tài liệu công ty giữ màu vàng. Một sản phẩm Comitor vẫn có thể chọn bộ màu hợp với nó, và Comitor SaaS cho mỗi người dùng tự chọn bộ của mình. Biểu tượng không bao giờ đổi màu theo bộ màu.

Bộ màuThương hiệu · sángLink · sángThương hiệu · tối
goldmặc định#E8B824#B88A00#F5D060
green#5C9732#41791F#8CC35C
teal#006885#006885#5CB2C9
blue#1960F8#1A56DB#7EA6FF
indigo#4F46E5#4F46E5#A5B0FB
violet#7C3AED#7036D6#BFA3FB
rose#D6214E#C01C46#FB7F9B
orange#C2410C#B23C0B#FB9A52
graphite#2B303A#3A3F4A#E4E6EA

Tám bộ màu khác gold đạt WCAG AA ở bảng thường; các chỗ hụt đo ở mục “Hai bảng tương phản” phía trên chỉ thuộc về gold. Giá trị lấy từ themePalettes trong @comitor/ui/tokens — cũng là object mà email hay PDF đọc. Cách gắn một bộ màu, cố định hoặc do người dùng chọn, nằm ở trang Display Axes.

Thương hiệu — thang gold

Bảy nấc. gold-300 là --primary ở theme sáng, gold-200 là --primary ở theme tối. Mọi nấc khác trong thang có mặt vì có một token vai trò trỏ vào nó.

Trung tính thương hiệu

Cặp ink/paper của brand book. Tách khỏi thang ash — hai màu này ấm, ash thì không. --background và --card là #FFFFFF trơn ở theme sáng và #0E1116 / #16181D ở theme tối.

Trung tính — thang ash

Chỉ sáu nấc: 50 / 100 / 300 / 500 / 700 / 900. Các khoảng trống là cố ý. Nếu thang này vẫn tên gray-*, thì gray-200/400/600/800 sẽ lặng lẽ rơi xuống màu gray dựng sẵn của Tailwind v4 — ngả xanh dương, trong khi xám Comitor ngả tím — và hai thứ sẽ trộn lẫn trong một component. Đổi namespace thành ash-* nghĩa là một nấc thiếu chỉ là không có gì lúc build, chứ không phải màu lệch lúc chạy. gray-* không thuộc design system này.

Màu accent — vai trò fill

Các màu này được chọn để làm nền: fill đặc, hoặc tint /5–/20. Không bao giờ dùng chúng làm chữ. Lưu ý không có --red-foreground: #D64545 đặc không mang được chữ trắng (4,38:1) lẫn chữ ink (4,32:1), nên fill đỏ cần chứa chữ thì đi qua --destructive.

Màu accent — vai trò ink

Cùng sáu sắc màu, cố định ở một nấc đủ tối để đọc được trên nền trang hoặc trên tint /5–/20 của chính nó. Ở theme tối, mỗi màu trỏ ngược về màu fill, nên text-teal-ink viết một lần và đúng ở cả hai theme. Hex hiển thị là của theme sáng.

Sắc nhận diện — blue, purple, pink

Ba sắc màu hoàn toàn không mang nghĩa trạng thái, thêm ở 3.10.0 cho tông avatar và icon app, thay cho navy, ash và neutral — ba màu xám ngả xanh khiến các app khác nhau trông như cùng một màu. Mỗi màu có đủ ba vai trò: fill, một nấc ink đọc được trên nền trắng và trên tint /15, /20 của chính nó, và từ 3.11.0 thêm -foreground cho chữ trên fill đặc, nên chúng cũng dùng được làm accent khung của một app. Không bao giờ dùng chúng để nói một việc thành công hay thất bại. Hex hiển thị là của theme sáng; fill ở theme tối là #82A8F5, #A78BFA và #FF69B4, và các nấc ink trỏ ngược về chúng.

Ghi đè một token

App được phép khai báo lại bất kỳ biến nào trong số này trong CSS của mình. Ba trường hợp đi kèm một nghĩa vụ, vì một giá trị thứ hai ở chỗ khác trong file được suy ra từ giá trị đang đổi và sẽ không đổi theo.

Đổi một accent thì tính lại nấc ink của nó

--teal-ink, --green-ink và --red-ink là hex viết thẳng. Chúng là kết quả đã cố định của color-mix(in srgb, <hue>, --ink 30–40%), không phải biểu thức sống — nên ghi đè --teal sẽ tô lại mọi tint mà để chữ bên trên đứng nguyên chỗ cũ. Đặt nấc ink trong cùng khối, và đo hai lần: trên nền trang và trên tint /15 của màu mới. Cả hai số đo phải đạt 4,5:1; thường tint là chỗ chật hơn.

Hai sắc màu còn lại hỏng theo cùng kiểu vì một lý do khác. --yellow-ink mượn --gold-500 chứ không suy ra từ --yellow, còn --navy-ink là một hex riêng ở theme tối. Cả hai cũng không bám theo fill của mình.

Vì sao cố định chứ không suy ra

Ba lý do, đáng biết trước khi kết luận lớp gián tiếp này là sai lầm. Một: token cố định là một chỗ duy nhất phải đo lại khi accent đổi — cách kia là đo lại ở mọi nơi tình cờ viết phép trộn. Hai: không component nào phải lặp lại chuỗi color-mix() — đó là cách tỉ lệ phần trăm trôi lệch giữa Badge, LetterAvatar và Input mà không ai để ý. Ba: hex thật là thứ tokens.ts phản chiếu được, còn canvas, HTML email và PDF không có engine CSS nào để tính phép trộn cho chúng.

Bề mặt dòng bảng được trộn sẵn

--table-row-stripe và --table-row-hover là kết quả trộn phẳng của hai token khác: --muted ở 30% và ở 50%, đã trộn sẵn lên --card. Ở theme sáng đó là #FDFDFD và #FBFBFC; ở theme tối, #181B20 và #1A1D23. Đổi --muted hay --card thì hai token này lặng lẽ lệch nhịp — không có lỗi gì, chỉ là sọc xen kẽ không còn là một sắc độ của bề mặt bên dưới nữa. Tính lại cả hai.

Chúng đục vì bắt buộc chứ không phải vì sở thích. Cột ghim lấy bg-inherit từ dòng của nó, nên còn chút alpha nào trong nền dòng là ô ghim thành trong mờ, và các cột cuộn bên dưới hiện xuyên qua. Và chúng viết bằng hex chứ không bằng color-mix() vì cùng lý do với các nấc ink: cổng tương phản của gói đọc token dưới dạng giá trị hex viết thẳng, và token nó không đọc được là token nó không kiểm được.

Accent riêng của app là khai báo CSS, không phải style inline

Mỗi app trong hệ sinh thái tô accent riêng của mình. Khai báo nó trong stylesheet của app, sau dòng import gói, kèm nhánh .dark — không phải style inline trên <body>. Một giá trị inline duy nhất không có chỗ cho nhánh tối, và accent đủ tối để đọc trên nền trắng gần như luôn quá tối để đọc trên #0E1116.

app/globals.css
/* app/globals.css — after @import "@comitor/ui/styles.css" */

:root {
  --app-accent: #1F8A8B;            /* solid fill and /5–/20 tints only        */
  --app-accent-foreground: #0E1116; /* text on that solid fill      → 4.56:1   */
  --app-accent-ink: #1A6668;        /* the app colour on the page   → 6.68:1   */
}

.dark {
  --app-accent: #2DD4BF;
  --app-accent-foreground: #0E1116; /* 10.16:1                                 */
  --app-accent-ink: #2DD4BF;        /* 10.16:1 on --background                 */
}

Ba biến, không phải hai. Bỏ --app-accent-ink thì mọi dấu active trên thanh bên và khung icon của PageHeader rơi về gold mặc định — đọc được, nhưng sai màu, và không gì báo cho biết. Gói cố ý không suy nấc ink từ --app-accent bằng color-mix(): mỗi sắc màu cần một hệ số khác nhau, và phép suy tự động cho ra một tỉ lệ chưa ai đo. Thêm cả --app-accent-foreground mỗi khi accent của app sáng hơn hoặc tối hơn gold.

Một giới hạn đã biết của cổng tương phản: mọi token cần một giá trị mặc định trong :root

Cổng kiểm màu của gói có thể trỏ vào stylesheet đã ghi đè của chính app (nó đọc COMITOR_UI_STYLES và COMITOR_UI_SRC). Khi làm vậy, cần biết nó đọc gì: chỉ bốn khối — :root, .dark, [data-contrast="high"] và .dark[data-contrast="high"]. Nó không bao giờ mở [data-density] hay [data-font-size].

Nên một token kích thước chỉ khai báo trong khối mật độ hay khối cỡ chữ thì, với cổng, là chuỗi rỗng. Điều đó quan trọng vì cổng quyết định chấm một fill khắt khe đến đâu dựa vào hình mà luật CSS vẽ ra: một trục cố định từ 24px trở xuống, hoặc một thanh rộng hết bề ngang, là bằng chứng mạnh rằng đây là một dấu hiệu mang nghĩa, và dấu hiệu phải đạt 3:1. Một độ dài nó không tính ra được làm mọi phép thử đó thành sai, nên dấu hiệu bị đọc như một bề mặt thường và ngưỡng nó phải đạt không bao giờ được áp. Hướng hỏng là hướng tệ: không phải báo nhầm, mà là cho qua nhầm.

Luật rút ra: cả năm token mật độ — --row-py, --menu-item-py, --list-item-py, --nav-item-py, --table-head-h — đều mang giá trị comfortable trong :root, và khối mật độ chỉ viết lại nó. Thêm token thứ sáu cũng theo cách đó. Chỉ khai báo trong khối mật độ vẫn chạy hoàn hảo trên trình duyệt — chính vì thế thiếu sót này dễ lọt vào bản phát hành.

Token vai trò

Đây là những tên một component dùng tới. Tên nào cũng được khai báo trong khối @theme inline của styles.css — chính khối đó biến chúng thành utility của Tailwind. Danh sách dưới đây là bộ đã phát hành — tên nào không có ở đây thì không tồn tại.

Chữ inline gánh việc thật. Nó nhúng var(--x) vào utility được sinh ra, nên utility tính biến ngay trên trình duyệt và .dark { --primary } vẫn tô lại nó lúc chạy. @theme trơn thì cố định giá trị lúc build — mọi utility sẽ đóng cứng hex của theme sáng và theme tối đơn giản là ngừng chạy. Nếu fork styles.css, giữ lại từ khoá này.

Viền control

BiếnUtilityVai trò
--input / --control-edgeborder-input · border-control-edgeViền của input, textarea, select, combobox, ô OTP, nút outline, badge outline. --control-edge trỏ vào --input.
--control-edge-strongborder-control-edge-strongCùng viền đó khi hover và active. ash-300 ở theme sáng, ash-500 ở theme tối.
--choice-edgeborder-choice-edgeViền của checkbox hay radio chưa chọn. Token riêng vì ở đây viền là toàn bộ control.
--choice-edge-strongborder-choice-edge-strongNấc hover của token trên. ash-500 — ash-300 sẽ nhạt hơn trạng thái nghỉ.
--input-filldark:bg-input-fillLớp phủ mờ bên trong input ở theme tối. Trong suốt ở theme sáng và ở bảng tương phản cao.
--border / --sidebar-borderborder-border · border-sidebar-borderChỉ cho đường kẻ trang trí — khung thẻ lúc nghỉ, đường kẻ bảng, vạch chia menu. Không mang thông tin, nên không chịu ngưỡng nào.

Control đang bật hoặc đang chọn

BiếnUtilityVai trò
--control-onbg-control-onRãnh switch khi bật, checkbox đã chọn, chấm radio, phần đã tô của slider, ngày được chọn trên lịch.
--control-knobbg-control-knobNúm switch và chấm radio. Trắng ở bảng mặc định, cả hai theme.
--control-checktext-control-checkKý tự dấu tick và nhãn của ngày được chọn trên lịch. Ink ở cả hai bảng — 10,19:1 trên gold mặc định, 5,77:1 trên gold tối hơn của bảng tương phản cao. Tách khỏi núm để app nào làm tối --control-on có thể đổi dấu tick mà không tô lại núm switch.
--switch-trackbg-switch-trackRãnh switch khi tắt. ash-300 ở theme sáng, ash-700 ở theme tối.
--slider-trackbg-slider-trackRãnh slider chưa tô. Khác nấc với rãnh switch — hai thứ không dùng chung một token được.
--ringoutline-ring · ring-ringVòng focus. Cố ý vẫn là --primary ở cả hai bảng — token control duy nhất mà bảng tương phản cao không nâng. Đó là quyết định, không phải sơ suất: WCAG 2.2 chỉ đặt ngưỡng tương phản cho chỉ báo focus ở mức AAA, và AAA nằm ngoài mục tiêu của gói. Nên vòng focus giữ sắc thương hiệu thay vì tối đi và xa khỏi nó.

Màu trên nền trang

BiếnUtilityVai trò
--primary-inktext-primary-inkGold thương hiệu khi phải nằm trên trang. Bằng --primary ở bảng mặc định; gold-500 ở bảng tương phản cao, theme sáng.
--link-ink / --link-ink-hovertext-link-inkButton variant link. Tách khỏi --primary-ink vì bản thiết kế đã duyệt dùng gold-400 cho chữ link nhưng gold-300 cho vòng focus.
--destructive-inktext-destructive-inkChữ lỗi màu đỏ trên trang — chữ hướng dẫn dưới input, dòng lỗi của form.
--app-accent-inktext-app-accent-inkAccent riêng của app trên trang: dấu active ở thanh bên, khung icon của PageHeader. App nào ghi đè --app-accent cũng phải ghi đè token này.
--muted-foregroundtext-muted-foregroundChữ phụ: placeholder, mô tả, nhãn trục biểu đồ.

Bộ ba trạng thái

BiếnUtilityVai trò
--success / -foreground / -inkbg-success · text-success-foreground · text-success-inkBí danh của green. Foreground là ink ở cả hai theme — trắng trên green chỉ đạt 3,13:1.
--warning / -foreground / -inkbg-warning · text-warning-foreground · text-warning-inkBí danh của yellow. Sắc màu khó nhất: --warning trên nền trắng là 1,59:1, nên mới có nấc ink.
--info / -foreground / -inkbg-info · text-info-foreground · text-info-inkBí danh của teal. Ink trên teal ở 4,56:1 là cặp đạt sát ngưỡng nhất trong gói.
--destructive / -foreground / -hoverbg-destructive · text-destructive-foreground · hover:bg-destructive-hoverToken riêng, không phải bí danh của red. --destructive-hover phải là token vì hai bảng đẩy nó theo hai hướng ngược nhau.

Bề mặt

BiếnUtilityVai trò
--table-row-stripebg-table-row-stripeSọc xen kẽ trên bảng có sọc. Đục vì bắt buộc — ô ghim thừa kế nền của dòng.
--table-row-hoverbg-table-row-hoverDòng dưới con trỏ. Cũng đục, và một giá trị cho cả dòng có sọc lẫn dòng trơn.
--sidebar + 4 token nữabg-sidebar · text-sidebar-foreground · bg-sidebar-accent …Thanh bên có họ bề mặt riêng để khung app có thể khác khung trang mà không cần giá trị tuỳ ý.
--app-accent / -foregroundbg-app-accent · text-app-accent-foregroundFill app-accent đặc và chữ trên nó. Khai báo cho từng app trong CSS kèm nhánh tối, không bao giờ dưới dạng style inline.

Cũng có trong gói và theo cùng hợp đồng: bộ ngữ nghĩa của shadcn (--background, --foreground, --card, --popover, --primary, --secondary, --muted, --accent, mỗi token kèm một -foreground), năm nấc biểu đồ --chart-1 … --chart-5, và bốn nấc độ nổi shadow-surface / control / card / overlay.

Tương phản

Bảng mặc định không đạt mọi ngưỡng WCAG, và giả vờ như có sẽ khiến ai đó đi "sửa" một giá trị được chọn có chủ ý. Các chỗ hụt dưới đây là quyết định đã ghi lại. Chỗ để nâng chúng là bảng tương phản cao, không phải bảng mặc định.

Chỗ hụt đã biết của bảng mặc định

CặpTỉ lệNgưỡng bị hụt
Viền input (--input / --control-edge) trên nền trang1.16:1Không áp dụng
Viền checkbox và radio (--choice-edge) trên nền trang2.99:11.4.11 · 3:1
Rãnh switch khi bật (--control-on) so với nền trang và so với núm1.86:11.4.11 · 3:1
Gạch chân tab active và viền focus của input (gold)1.86:11.4.11 · 3:1
--muted-foreground trên --muted3.58:11.4.3 · 4.5:1
Chữ trắng trên --destructive4.38:11.4.3 · 4.5:1
Button variant link (--gold-400) trên nền trang3.15:11.4.3 · 4.5:1
Badge destructive — --red trên red/103.82:11.4.3 · 4.5:1

Tám hàng đó là hồ sơ của design system, chép từ khối ở đầu styles.css. Chúng là quyết định về token, không phải số đếm chỗ dùng. Đếm chỗ dùng là việc của cổng, và trước 1.6.0 cổng không làm được việc đó ở đây: nó gộp khối [data-contrast="high"] vào cả hai bảng biến của nó, nên mỗi lần chạy đều đo bảng tương phản cao hai lần và in hai kết quả ra thành “sáng” và “tối”. Bảng mặc định — bảng của app khi chưa đặt gì — chưa từng được đo.

Từ 1.6.0, bảng màu là một trục riêng, nên cổng đo bốn tổ hợp thay vì hai, và hai bảng chịu hai mức yêu cầu khác nhau. Bảng tương phản cao chặn: vi phạm ở đó làm hỏng build, và hiện không có vi phạm nào. Bảng mặc định được đo và in ra, không chặn — mỗi lần chạy kết thúc bằng một con số, hiện là 384 cặp dưới ngưỡng, 78% trong đó từ ba token: --muted-foreground, --destructive-ink và --input. Nâng chúng nghĩa là sửa giá trị token, làm đổi diện mạo của mọi app, nên đó vẫn là quyết định của design system chứ không phải thứ đi kèm một bản sửa cổng. Đọc con số đó như một khoản nợ có sổ ghi, không phải giấy chứng nhận sạch — và mọi số liệu “sáng” viết trước 1.6.0 hãy coi là của bảng tương phản cao cho tới khi được đo lại.

Hai ngoại lệ, đã sửa ở cả hai bảng

Hai cặp trong bản thiết kế đã duyệt đặt chữ gần màu nền của nó đến mức không màn hình nào hiện rõ được. Chúng không đợi người dùng đổi bảng — nền giữ như bản duyệt, chỉ chữ được làm tối. Badge warning: 1,52:1 → 6,24:1 qua --yellow-ink. Badge success: 2,82:1 → 5,88:1 qua --green-ink.

Không bao giờ làm ngược lại: không làm tối fill cho đạt

Cả hai ngoại lệ đều làm tối chữ và để nguyên fill. Chiều ngược lại — chuyển một phần được tô, một thanh, một nền hay một cung sang nấc ink hoặc nấc tối hơn của thang chỉ để qua một số đo — bị loại từ 3.13.1. Bản 3.13.0 đã thử việc đó trên thanh Progress (gold-600 trên gold-100), và thanh nâu sẫm lấn át mọi thứ quanh nó. Thanh đó đã trở lại --primary-ink — gold thương hiệu ở bảng này, 1,73:1 so với nền trang — và con số đó được chấp nhận. Chỗ nào fill cần đọc rõ hơn, câu trả lời là chữ bên cạnh, một tone, hoặc bảng tương phản cao; một nấc tối hơn của thang đặt vào chỗ đó cũng sẽ là một giá trị thô mà bảng kia không dịch chuyển được.

Bảng tương phản cao đổi những gì

TokenBảng mặc địnhTương phản cao
--muted-foreground#7E8290 · 3.83:1#686C7A · 5.23:1
--input / --control-edge#EDEEF1 · 1.16:1#7E8290 · 3.83:1
--choice-edge#959597 · 2.99:1= --input · 3.83:1
--border#EDEEF1 · 1.16:1#A4A4A6 · 2.49:1
--control-on#E8B824 · 1.86:1#B48700 · 3.28:1
--primary-ink#E8B824 · 1.86:1#7A5800 · 6.51:1
--link-ink#B88A00 · 3.15:1#7A5800 · 6.51:1
--destructive#D64545 · chữ trắng 4.38:1#C93B3B · chữ trắng 5.03:1
--orange-ink / --severe-ink#AC440F · 5.85:1#9D3F10 · 6.66:1

Số liệu của theme sáng. Lưu ý --border được nâng lên 2,49:1 chứ không lên tới nấc viền control: nó đóng khung mọi thẻ và mọi dòng bảng, và kéo nó ngang --input sẽ xoá ranh giới giữa "cái này thao tác được" và "đây là một đường kẻ". Cũng lưu ý --control-on thành #B48700 chứ không phải gold-500 — gold-500 đạt ngưỡng nhưng trông như nâu ô liu, và một chế độ tương phản không được đổi sắc thương hiệu.

…và những gì nó đổi thêm ở theme tối

Tương phản cao là trục thứ hai, không phải theme thứ hai, nên nó có nhánh tối riêng: .dark[data-contrast="high"]. Khoảng nửa bảng trên sẽ sai nếu để nhánh sáng áp xuống theme tối — #A4A4A6 là xám nhạt, và trên #16181D nó thành một đường kẻ chói mắt thay vì một đường kẻ lặng. Nên nhánh tối khai báo lại các token đó ở giá trị cho ra cùng tỉ lệ từ phía bên kia.

TokenBảng mặc định · tốiTương phản cao · tối
--muted-foreground#7E8290 · 4.64:1 trên --card#888D9B · 5.35:1 trên --card
--input / --control-edge#434A5A · 2.00:1 trên --card#7E8290 · 4.64:1 trên --card
--border / --sidebar-border#1E2128 · 1.10:1 trên --card#50586B · 2.49:1 trên --card
--control-on · --primary-ink= --primary #F5D060= --primary · 12.67:1
--link-ink / -hovergold-400 / gold-300gold-200 / gold-100
--teal → --teal-ink#1F8A8B#2DD4BF · 7.12:1 trên tint /15 của nó
--green → --green-ink#2EA56A#4ADE80 · 7.44:1 trên tint /15 của nó
--red → --red-ink#D64545#F87171 · 5.18:1 trên tint /15 của nó
--yellow → --yellow-ink#F2C94C#FACC15 · 7.19:1 trên tint /20 của nó
--orange → --orange-ink#FB923C#FB923C · 6.09:1 trên tint /15 của nó

Bốn hàng cuối là những hàng nhánh sáng không có tương đương. Ở theme sáng, các accent có cùng hex ở cả hai bảng; ở theme tối chúng được nâng lên nấc sáng hơn, và các nấc ink tự đi theo, không tốn gì — theme tối khai báo --teal-ink: var(--teal) và tương tự cho green, red và yellow, nên nâng fill là nâng chữ theo, không phải giữ đồng bộ giá trị thứ hai nào. Đó là phần được hưởng từ một luật trông như thừa ở nhánh sáng. Cũng lưu ý độ ưu tiên (specificity): [data-contrast="high"] và .dark đều nặng (0,1,0), nên chính thứ tự các khối trong styles.css mới khiến việc này chạy được.

Các cặp đạt

Mức xếp hạng tính cho chữ cỡ thường: AA là từ 4,5:1 trở lên, AAA là từ 7:1 trở lên. Một cặp nằm giữa 4,5 và 7 chỉ đạt AA, không hơn, dù trông dễ đọc đến đâu.

ChữNềnTỉ lệXếp hạng
ink #0E1116--background #FFFFFF18.91:1AAA
ash-50 #F7F7F8--background tối #0E111617.66:1AAA
ink #0E1116paper #FBF7EE17.69:1AAA
--navy-foreground #FFFFFFnavy #0F1E3D16.49:1AAA
--primary-foreground #0E1116--primary gold-200 #F5D060 (tối)12.67:1AAA
--warning-foreground #0E1116--warning #F2C94C11.92:1AAA
ink-soft #3A3F4A--background #FFFFFF10.55:1AAA
--primary-foreground #0E1116--primary gold-300 #E8B824 (sáng)10.19:1AAA
--red-ink #9A3537--background #FFFFFF7.15:1AAA
--info-ink #1A6668--background #FFFFFF6.68:1AA
--success-ink #216A48--background #FFFFFF6.52:1AA
--warning-ink #7A5800--background #FFFFFF6.51:1AA
--success-foreground #0E1116--success #2EA56A6.04:1AA
--success-ink #216A48tint green/155.60:1AA
--info-ink #1A6668tint teal/155.54:1AA
--info-foreground #0E1116--info #1F8A8B4.56:1AA

Token ngoài Tailwind — @comitor/ui/tokens

Một entry riêng xuất các giá trị này dưới dạng object TypeScript thuần, cho những nơi không có Tailwind để biên dịch class: render canvas, xuất PDF, thư viện biểu đồ chạy bằng hex, và HTML email. Entry này không có peer dependency nào.

Đọc cho chính xác: @comitor/ui/tokens không phải bản sao của entry chính, nó chính là module token của entry chính. @comitor/ui có export * from './tokens.js', nên /tokens là tập con thực sự của . — tách ra cửa riêng chỉ để mã cần hằng số có thể bỏ qua hẳn React và Tailwind. Import semantic từ cửa nào cũng nhận đúng một object, không phải hai bản sao có thể lệch nhau. Dùng /tokens trong worker, trình render mail hay script; ở mọi chỗ khác, dùng cửa nào đã được import sẵn.

tsx
import { accentInk, chart, getAvatarToneColors, layout, semantic } from '@comitor/ui/tokens'

// Canvas, PDF export, chart libraries with no Tailwind, HTML email.
const series = chart.light[0]                     // '#E8B824'
const headerPx = layout.headerHeight              // 56
const errorText = semantic.dark.destructiveInk    // '#D64545'
const successText = accentInk.light.green         // '#216A48'

// Same tone table and same hash as getAvatarToneClasses() in '@comitor/ui',
// so one person gets one colour on the web and in email alike.
const { background, foreground } = getAvatarToneColors('Nguyen Duc Thanh', 'light')
ExportHình dạng
gold, ash, brandNeutralMap phẳng của ba thang thô.
accent, accentInk, accentForegroundBa vai trò của navy / teal / red / green / yellow / orange và các sắc nhận diện blue / purple / pink (3.10.0, foreground từ 3.11.0), mỗi cái tách thành light và dark. accentForeground không có red, khớp với CSS.
semanticsemantic.light và semantic.dark — trọn bộ shadcn cộng mọi token control.
highContrastChỉ những token mà bảng tương phản cao ghi đè. Spread nó lên semantic để có một bảng hoàn chỉnh.
statussuccess / warning / info / destructive cùng các nấc -Foreground và -Ink của chúng.
chartchart.light và chart.dark — năm giá trị hex theo thứ tự chuỗi mặc định.
layoutheaderHeight 56, sidebarWidth 256, sidebarCollapsedWidth 53, moduleRailWidth 84, modulePanelWidth 240, radius 6, breakpoints, minTapTarget 44 — đều là px, đều ở nấc cỡ chữ mặc định.
typographyHai font stack dưới dạng chuỗi.
getAvatarToneColors, getAvatarToneIndexHex đục { background, foreground } cho một cái tên — cùng bảng tông và hàm băm với getAvatarToneClasses() trong @comitor/ui.

Luật đi kèm

Trong ngữ cảnh có Tailwind, luôn dùng class — không bao giờ lấy hex từ module này rồi thả vào style inline. Class tính biến CSS lúc chạy, nên nó đi theo sáng và tối, và đi theo bảng màu; hex thì đứng yên ở đúng lúc nó được gõ. Có hai thứ cố ý không được xuất ra: --elevation-* (chuỗi box-shadow chỉ có nghĩa trong CSS) và --app-accent* (thuộc về app, không thuộc về gói).