做到 Day 26,我們的 CUI 已經有了不少成果。
從最初的 Design Tokens、Button,到 Form、Table、Pagination、Tooltip、Dialog,再到最近兩天建立的 Storybook 與 Accessibility Testing。
目前的架構大致是:
CUI
│
├── Design Tokens
├── React Components
├── Component Contract
├── Accessibility Testing
└── Storybook
如果今天要在新的 React 專案使用 CUI,至少已經有了可以繼續整理與封裝的基礎。
但還記得 Day 1 的目標嗎?
我希望這套 UI Kit 不只能在 React 裡使用,也能讓既有的 Legacy 系統使用。
例如我工作上會接觸到的 mod_plsql 系統。
這類系統通常是由後端產生 HTML,前端不一定有 React、Vite 或其他現代化的建置流程。
可能就是:
<form>
<label for="name">姓名</label>
<input id="name" name="name">
<button type="submit">
送出
</button>
</form>
那麼問題來了:
我們花了 26 天建立的 CUI,能不能讓這種頁面也使用?
如果答案是:
「可以,但要先把整個系統改成 React。」
那好像就失去原本的意義了。🤣
所以今天,我們要開始處理另一條輸出路線:
CDN Build。
今天的目標是建立一套可以重複執行的 CSS Build Pipeline。
預期產出:
dist/
└── cdn/
├── cui.css
└── cui.min.css
讓不使用 React 的頁面也能載入:
<link rel="stylesheet" href="/assets/cui.min.css">
並透過 CUI 的公開 Contract 套用樣式:
<button
type="button"
data-cui-slot="button"
data-cui-variant="primary"
data-cui-size="md"
>
儲存
</button>
今天先以 CSS-only 元件為主:
至於 Tooltip、Dialog 等需要互動行為的元件,則留到下一階段討論。
假設我們現在有:
<Button variant="primary" size="md">
儲存
</Button>
它是一個 React Component。
裡面可能包含:
React Props
TypeScript
JSX
Base UI Primitive
Tailwind Classes
瀏覽器不會因為我們提供:
<script src="cui.js"></script>
就自動理解:
<Button variant="primary">
儲存
</Button>
因為 JSX 不是一般 HTML。
所以我們需要先區分兩件事:
React Component 的實作,以及 瀏覽器最終看到的 HTML 與 CSS。
例如 React 最後可能輸出:
<button
class="inline-flex items-center justify-center ..."
data-slot="button"
data-cui-slot="button"
data-cui-variant="primary"
data-cui-size="md"
>
儲存
</button>
瀏覽器真正處理的是 DOM 與 CSS。
因此 Legacy 支援不一定需要 React Component 本身。
它需要的是:
一套可以用一般 HTML 表達的元件結構,以及對應的 CSS。
這裡是今天最重要的架構問題。
我們一直說:
Single Source, Multiple Outputs。
但 Single Source 並不是指所有環境都必須執行完全相同的程式碼。
例如:
React
→ 使用 React Component
Legacy
→ 使用 HTML + CSS
兩者的執行環境不同。
真正應該共享的是:
Design Tokens
Component Contract
Styling Rules
Accessibility Requirements
架構可以理解成:
CUI
│
Design Tokens
│
Style Contract
│
┌───────────┴───────────┐
↓ ↓
React Legacy
│ │
React Component HTML Markup
│ │
└───────────┬───────────┘
↓
Shared Styling
這樣未來修改 Primary Color 時,不需要分別修改:
React Button Color
Legacy Button Color
而是共同使用 Design Tokens。
不過還有一個更重要的問題:
我們要怎麼避免 React 和 Legacy 各自維護一份 Button CSS?
目前 CUI 的 Button 是以 shadcn/ui 為基礎。
例如:
const buttonVariants = cva(
"inline-flex items-center justify-center ...",
{
variants: {
variant: {
primary: "...",
secondary: "...",
outline: "...",
},
size: {
sm: "...",
md: "...",
lg: "...",
},
},
}
)
這裡使用:
CVA
+
Tailwind Utility Classes
組合出不同樣式。
例如:
<Button variant="primary" size="md">
儲存
</Button>
最後得到一組 Tailwind Classes。
但 Legacy 不能只寫:
<button data-cui-variant="primary">
儲存
</button>
就期待 Tailwind 自動幫它套樣式。
因為 Tailwind 的 Utility CSS 是根據建置時偵測到的 Class 產生的。
而:
data-cui-variant="primary"
本身並不等於:
background-color: var(--cui-primary);
所以我們需要一層能讓 HTML Contract 對應到實際樣式的機制。
目前至少有兩條路線。
例如:
<button
class="inline-flex items-center justify-center rounded-md ..."
>
儲存
</button>
這種方式看起來最簡單。
因為可以直接使用已經產生的 Utility CSS。
但問題是:
Legacy HTML
需要知道所有 Tailwind Classes
而且只要 Button 樣式修改,Legacy 頁面也可能需要同步修改。
這不符合我們希望提供穩定元件 API 的目標。
例如:
<button
data-cui-slot="button"
data-cui-variant="primary"
data-cui-size="md"
>
儲存
</button>
CSS:
[data-cui-slot="button"] {
display: inline-flex;
align-items: center;
justify-content: center;
}
[data-cui-slot="button"][data-cui-variant="primary"] {
background: var(--cui-primary);
color: var(--cui-on-primary);
}
這樣 Legacy 不需要知道底層用了哪些 Tailwind Classes。
只需要知道:
slot
variant
size
也就是 Day 22 整理的 Component Contract。
對 CUI 來說,我比較傾向方案 B。
但接下來還有一個問題:
這份 Semantic CSS 要放在哪裡,才能避免 React 和 Legacy 各自維護?
我們不希望變成:
src/
├── components/ui/button.tsx
│ └── 一套 Button Styles
│
└── legacy/button.css
└── 另一套 Button Styles
因為未來修改 Button 時,很可能只改到其中一份。
比較理想的方式是:
src/
├── components/
│ └── ui/
│ └── button.tsx
│
└── styles/
├── tokens.css
├── theme.css
└── components/
└── button.css
其中:
tokens.css
→ Design Tokens
theme.css
→ Tailwind Theme Bridge
components/button.css
→ CUI Button 的共用樣式
React Button 可以輸出:
data-cui-slot="button"
data-cui-variant="primary"
data-cui-size="md"
Legacy Button 也使用同樣的 Attributes。
兩邊就可以共用:
[data-cui-slot="button"] {
...
}
不過這也代表我們要逐步把原本散落在 CVA 裡的部分樣式責任,整理到共用 CSS。
這是一個架構調整,不是單純多建一個 CSS 檔案。
今天先不要一次重構所有元件。
我們先拿 Button 當實驗對象。
建立:
src/styles/components/button.css
例如:
[data-cui-slot="button"] {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
border: 1px solid transparent;
border-radius: var(--cui-radius-md);
font: inherit;
font-weight: 500;
line-height: 1.25;
cursor: pointer;
transition:
background-color 150ms ease,
border-color 150ms ease,
color 150ms ease;
}
[data-cui-slot="button"][data-cui-size="md"] {
min-height: 2.5rem;
padding-inline: 1rem;
}
[data-cui-slot="button"][data-cui-variant="primary"] {
background-color: var(--cui-primary);
color: var(--cui-on-primary);
}
[data-cui-slot="button"]:disabled {
cursor: not-allowed;
opacity: 0.5;
}
[data-cui-slot="button"]:focus-visible {
outline: 2px solid var(--cui-focus);
outline-offset: 2px;
}
這裡的 Token 名稱只是示範,實際要對應目前 CUI 已經建立的 Design Tokens。
另外,這還不是完整的 Button CSS。
目前只先示範:
Base
Size
Primary Variant
Disabled
Focus Visible
其他 Variants、Sizes、Hover、Active States,後續也需要逐一對應。
如果要讓 React 與 Legacy 共用剛才的 CSS,React Button 也需要輸出相同的 Contract。
例如:
<Button
variant="primary"
size="md"
>
儲存
</Button>
最後應該具有:
data-cui-slot="button"
data-cui-variant="primary"
data-cui-size="md"
而原本的:
data-slot
仍然可以保留。
例如:
<button
data-slot="button"
data-cui-slot="button"
data-cui-variant={variant}
data-cui-size={size}
>
{children}
</button>
不過這裡要特別注意:
不能只是把共用 CSS 加上去,卻完全不處理原本 CVA 裡的重複樣式。
否則可能變成:
Tailwind Utility
+
Semantic CSS
同時設定:
Background
Padding
Border
Focus
最後產生樣式覆蓋問題。
因此正式遷移時,需要決定:
哪些樣式由 Shared CSS 負責?
哪些樣式仍由 Component Class 負責?
至少對需要支援 CDN 的部分,應該建立明確的單一樣式來源。
這裡我覺得不用太激進。
因為 CUI 本來就是:
React
+
Tailwind
+
shadcn/ui
我們不是要把整套專案改回傳統 CSS。
而是讓需要跨平台使用的元件樣式,有穩定的公開 Contract。
例如:
Button
Badge
Alert
Input
Table
可以逐步建立 Shared Component Styles。
而 React 專屬的組合、Layout 或 Demo,仍然可以使用 Tailwind Utilities。
換句話說:
不是放棄 Tailwind,而是不要讓 Legacy 使用者必須理解 Tailwind 的內部實作。
接著開始建立真正的 Build Pipeline。
新增:
src/styles/cdn.css
例如:
@import "./tokens.css";
@import "./components/button.css";
之後再加入:
@import "./components/badge.css";
@import "./components/alert.css";
@import "./components/input.css";
@import "./components/table.css";
這個檔案的用途很單純:
告訴建置工具,CDN CSS 需要包含哪些共用樣式。
它不應該直接依賴:
React Component
Storybook
App.tsx
因為 Legacy 使用者不需要這些東西。
這裡有個很值得注意的問題。
Tailwind CSS 通常會包含 Preflight,也就是一組基礎 CSS Reset。
例如可能影響:
Heading
Button
Input
Margin
Border
在全新的 React 專案裡,這通常沒什麼問題。
但 Legacy 系統可能已經有自己的 CSS。
如果我們直接把整份 Tailwind Preflight 放進去,可能會讓原本的頁面:
標題大小改變
表單樣式改變
按鈕邊框消失
既有 Layout 受到影響
所以對 Legacy CDN,我比較傾向:
不要直接輸出完整的 Tailwind Preflight。
而是只輸出:
CUI Tokens
+
CUI Component Styles
這樣可以減少對既有頁面的影響。
當然,實際整合時仍然要測試 CSS Cascade 與既有樣式是否衝突。
例如:
button {
background: blue;
}
這種寫法不適合直接放進 Legacy CDN。
因為它會影響整個網站的 Button。
我們希望只影響有明確使用 CUI 的元件。
所以使用:
[data-cui-slot="button"] {
...
}
而不是:
button {
...
}
同樣:
[data-cui-slot="input"] {
...
}
而不是:
input {
...
}
這樣 Legacy 系統可以逐步導入 CUI,而不需要一次改造整個網站。
假設 Legacy 原本就有:
:root {
--primary: red;
}
而 CUI 又定義:
:root {
--primary: blue;
}
兩邊就可能互相影響。
因此 CUI 的公開 Tokens 應該使用自己的 Namespace。
例如:
:root {
--cui-primary: ...;
--cui-on-primary: ...;
--cui-radius-md: ...;
}
這也是我們前面整理 Token Contract 的原因。
讓:
Legacy Existing CSS
和:
CUI CSS
可以盡量降低互相干擾的機會。
今天先使用簡單的 CSS Build 工具完成輸出。
例如使用 PostCSS 搭配 CSS Import 與 Minification 插件。
安裝:
npm install -D postcss postcss-import cssnano
新增:
scripts/build-cdn.mjs
我們希望它能:
讀取 cdn.css
↓
解析 @import
↓
輸出 cui.css
↓
壓縮
↓
輸出 cui.min.css
示範:
import fs from "node:fs/promises"
import path from "node:path"
import postcss from "postcss"
import postcssImport from "postcss-import"
import cssnano from "cssnano"
const inputPath = "src/styles/cdn.css"
const outputDir = "dist/cdn"
const input = await fs.readFile(inputPath, "utf8")
await fs.mkdir(outputDir, {
recursive: true,
})
const result = await postcss([
postcssImport(),
]).process(input, {
from: path.resolve(inputPath),
})
await fs.writeFile(
path.join(outputDir, "cui.css"),
result.css
)
const minified = await postcss([
cssnano(),
]).process(result.css, {
from: undefined,
})
await fs.writeFile(
path.join(outputDir, "cui.min.css"),
minified.css
)
console.log("CUI CDN CSS build completed.")
這是一個最小化的示範。
它適合處理一般 CSS 與 @import,但不是完整的 Tailwind CSS v4 編譯流程。
因此這個 CDN Entry 應該使用已經可以直接由瀏覽器理解的 CSS,而不是依賴未處理的 Tailwind 指令。
接著修改:
package.json
新增:
{
"scripts": {
"build:cdn": "node scripts/build-cdn.mjs"
}
}
原本的其他 Scripts 保留。
執行:
npm run build:cdn
預期產生:
dist/
└── cdn/
├── cui.css
└── cui.min.css
這樣 CDN CSS 就有自己的建置流程了。
我覺得應該分開。
因為兩者的目標不同。
React Build:
React Application / Component Output
CDN Build:
Browser-ready CSS
所以:
npm run build
和:
npm run build:cdn
可以各自執行。
未來如果需要,也可以建立:
npm run build:all
例如:
{
"scripts": {
"build:all": "npm run build && npm run build:cdn && npm run build-storybook"
}
}
這樣正式發布前就可以一次產生多種輸出。
不過目前先維持獨立流程,除錯會比較容易。
接下來我們不使用 React。
建立:
examples/
└── legacy/
└── index.html
內容:
<!doctype html>
<html lang="zh-Hant">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, initial-scale=1">
<title>CUI Legacy Demo</title>
<link
rel="stylesheet"
href="../../dist/cdn/cui.css"
>
</head>
<body>
<main>
<h1>CUI Legacy Demo</h1>
<button
type="button"
data-cui-slot="button"
data-cui-variant="primary"
data-cui-size="md"
>
儲存
</button>
</main>
</body>
</html>
注意這裡沒有:
React
JSX
TypeScript
只有一般 HTML。
如果 CSS Build 正確,而且 Button Contract 與樣式已經完整對應,就應該能呈現 CUI 的 Button 外觀。
因為它可以直接驗證:
我們產出的 CSS 是否真的不依賴 React Runtime?
如果今天只是:
npm run build:cdn
→ 成功
其實還不夠。
因為成功產生 CSS 檔案,不代表 Legacy 頁面能正確使用。
還需要實際確認:
CSS 載入成功
Token 生效
Button 樣式正確
Focus 樣式正確
沒有影響其他元素
這也是 Day 28 要進一步驗證的事情。
假設目前 Button 支援:
primary
secondary
outline
ghost
destructive
link
以及:
sm
md
lg
icon-sm
icon-md
icon-lg
那我們至少需要確認:
每種 Variant
每種 Size
Disabled
Focus Visible
Hover
Active
都能正確呈現。
因為:
Primary Button 正常
不代表:
所有 Button 都正常
尤其是 CVA 與 Shared CSS 遷移期間,更容易出現兩邊不一致的問題。
我們現在已經有 Storybook。
所以可以拿:
Storybook / Button / Primary
和:
Legacy Demo / Button / Primary
進行比較。
檢查:
Background
Text Color
Border
Radius
Padding
Height
Focus Indicator
Disabled State
如果兩邊使用相同的 Shared CSS,理論上可以減少樣式差異。
但仍然要注意:
不同 HTML Structure
既有 Global CSS
Font
CSS Cascade
都可能影響最終畫面。
因此共用 CSS 是一致性的基礎,不代表不需要實際驗證。
技術上,只要是瀏覽器可讀取的 CSS,就可以透過:
<link rel="stylesheet" href="...">
載入。
但今天產生的是:
Build Artifact
還不是已經部署到公開 CDN 的檔案。
真正要讓其他網站使用,仍然需要決定:
Hosting
Versioning
Caching
Release
例如:
/assets/cui/0.1.0/cui.min.css
這樣未來更新時,舊系統不會因為 CSS 被直接覆蓋而突然改變樣式。
對 Legacy 系統而言,穩定性尤其重要。
假設今天:
cui.min.css
已經被十個系統引用。
某天我們修改:
Button Padding
Table Layout
Input Height
如果直接覆蓋同一個檔案,十個系統可能同時受到影響。
所以比較好的方式是:
cui/0.1.0/cui.min.css
cui/0.2.0/cui.min.css
讓系統可以選擇升級時間。
這樣也比較符合 UI Kit 作為共用基礎設施的定位。
cui.js 呢?這裡先回到 Day 23、Day 24。
當時我們做了:
Tooltip
Dialog
這些元件和 Button、Badge 不太一樣。
因為它們需要:
Open / Close
Focus
Escape
Position
所以單靠:
cui.css
通常不夠。
未來可能需要:
cui.js
但我不想今天直接把所有互動元件的 JavaScript 全部寫完。
因為那等於要另外處理:
Tooltip Behavior
Dialog Behavior
Select Behavior
而且還要確認 Accessibility。
所以今天先建立:
CSS Build Pipeline
Day 28 再討論 Legacy 的互動策略與實際整合方式。
可以先整理成:
| Component | CDN CSS | 額外 JS |
|---|---|---|
| Button | 需要 | 一般按鈕不需要 |
| Badge | 需要 | 不需要 |
| Alert | 需要 | 靜態訊息不需要 |
| Input | 需要 | 原生輸入不需要 |
| Table | 需要 | 靜態表格不需要 |
| Separator | 需要 | 不需要 |
| Tooltip | 需要 | 通常需要 |
| Dialog | 需要 | 需要互動控制,或採用原生 <dialog> 搭配 JS |
| Select | 需要 | 原生 <select> 不需要;自訂 Select 通常需要 |
這張表也讓我們更清楚:
不是所有 CUI Component 都需要 JavaScript。
對 Legacy 系統而言,能用原生 HTML 解決的事情,就不必額外增加 JavaScript 依賴。
今天我們談了很多:
CSS
Tokens
Variants
Sizes
但 CUI 是 Accessible UI Kit。
所以 Legacy 不能只拿到外觀。
例如:
<button
data-cui-slot="button"
data-cui-variant="primary"
>
儲存
</button>
這裡使用原生 <button>,就保留了 Button 的基本語意與鍵盤行為。
但如果寫成:
<div
data-cui-slot="button"
data-cui-variant="primary"
>
儲存
</div>
即使外觀看起來一模一樣,也不代表它是 Accessible Button。
所以我們需要把:
Semantic HTML
視為 Contract 的一部分。
不是只要 Attribute 正確,就能隨便使用任何 HTML Tag。
未來 Legacy 開發者可能不會看:
button.tsx
也不會打開:
Storybook Source
所以我們需要提供:
HTML Usage Examples
例如:
<button
type="submit"
data-cui-slot="button"
data-cui-variant="primary"
data-cui-size="md"
>
送出
</button>
以及:
<button
type="button"
data-cui-slot="button"
data-cui-variant="secondary"
data-cui-size="md"
>
取消
</button>
再補充:
Supported Variants
Supported Sizes
Accessibility Requirements
Required HTML Structure
這樣 Legacy 才能真正使用 CUI。
而不只是拿到一個 CSS 檔案。
Build
npm run build:cdn 可以成功執行cui.css
cui.min.css
Contract
data-cui-slot
Legacy
先建置 CDN CSS:
npm run build:cdn
接著開啟:
examples/legacy/index.html
確認純 HTML 頁面能正常使用。
再執行:
npm run build
npm run lint
npm run check:contrast
最後檢查:
git status
git diff
今天的 Branch 可以使用:
git switch -c feat/cdn-build
完成後:
git add .
git diff --staged
git commit -m "build: add CUI CDN CSS pipeline"
今天我們開始讓 CUI 從 React 專案走向 Legacy。
原本:
CUI
│
├── React Components
└── Storybook
現在增加了:
CUI
│
├── React Components
├── Storybook
│
└── CDN Build
├── cui.css
└── cui.min.css
但今天最重要的收穫,其實不是成功產生兩個 CSS 檔案。
而是重新確認了:
Single Source, Multiple Outputs 的真正意義。
我們希望共享的是:
Design Tokens
Component Styles
Semantic Contract
Accessibility Requirements
而不是強迫所有環境使用相同的 Framework。
React 可以繼續使用 React Component。
Legacy 可以使用一般 HTML。
只要兩邊遵守同一份 CUI Contract,就能逐步建立一致的使用方式。
當然,目前這還只是第一步。
因為我們只建立了 CSS Build 的基本流程。
真正的 Legacy 整合,還需要驗證:
Existing CSS Compatibility
Form Behavior
Responsive Layout
Accessibility
Interactive Components
所以今天最值得記住的是:
讓 UI Kit 支援 Legacy,不是把 React 拿掉,而是把可以跨環境共享的部分真正抽出來。
今天我們已經有:
cui.css
cui.min.css
明天要真的拿它們來使用。
而且這次不在 React 裡。
我們會建立一個模擬 mod_plsql 輸出 HTML 的 Legacy 頁面。
包含:
表單
Button
Alert
Table
並檢查:
CSS 是否衝突?
表單是否正常?
Keyboard 是否可操作?
Screen Reader 是否能理解?
另外也會碰到一個問題:
Tooltip、Dialog 這類互動元件,在沒有 React 的環境裡該怎麼辦?
Day 28:
Legacy 實戰:讓 mod_plsql 頁面真正用上 CUI。