一句話:後台 primitive 一律 import workspace 套件
@toko-cloud/ui/*(packages/ui),domain 元件 import@/components/admin/*;page/route code 不手刻裸 HTML 元素、不在頁面內自刻/客製 primitive、不寫 arbitrary[hsl(...)]、不新增 ad-hoc CSS 變數、不寫靜態 inlinestyle。本檔是後台唯一的元件目錄 +「該用 vs 禁止」對照 + 規則適用範圍 + 允許例外清單。
橫切規約文件。 描述後台 design system 的目標形狀與使用紀律;元件實作以
packages/ui/src/*的 code 為準(code is truth),落地/遷移狀態見 roadmap。與公開站 design system 的分界以../CLAUDE.md「兩套 design system 不要混用」為權威,本檔只管後台那套。
後台 primitive 已抽成 workspace 套件 packages/ui(@toko-cloud/ui,31 個元件),但治理問題不變:純文件規約會漂移——該用的沒人用、缺中階組合件逼大家手刻、每頁各自重造 page header / 空狀態 / 提示盒。本檔把規則寫到無歧義,review/codex 以此為準。
../CLAUDE.md / AGENTS.md 的硬規則段。不加 ESLint gate(程式碼由 agent 產出,文件即主要載體)。shadcn-ui/ui repo 的 apps/v4/registry/bases/base/ui/<name>.tsx(raw GitHub,main 分支;CLI / registry JSON 沒有 base-ui 端點)。primitive 套件是 @base-ui/react(1.5 stable)——改名前的 @base-ui-components/react rc 已斷頭棄用。移植時取官方的結構(function 元件、data-slot、*.Props 型別、行為 props),不取 cn-* className 系統(綁官方主題 CSS)與 IconPlaceholder;樣式用 admin-tokens 的 Tailwind utility。shadcn 真的沒有才手刻 house 元件。Select;選項來自 DB/API 或筆數會長 → Combobox(可搜尋);GET filter bar → FilterSelect(domain 層,選擇即提交)。不再有「簡單下拉用原生 <select>」的路徑。@toko-cloud/ui/*(primitive)與 @/components/admin/*(domain)有沒有;有就 import,沒有就先查 shadcn 官方 base 變體、再考慮新增進 packages/ui,不要在頁面 inline 重造、也不要把 domain 邏輯複製成新元件。@toko-cloud/ui 零 app 依賴:套件內禁止 @/ import;cn 用套件內 ./lib/utils、跨元件相對路徑、hook 放 packages/ui/src/hooks/。app(@/components/admin/*、頁面)可以 import @toko-cloud/ui,反向不行。src/app/(admin)/layout.tsx)外層 <div className="admin-scope">;token 經 :where(.admin-scope) 零特異度 scope,不污染公開站。新元件樣式只依賴 admin-tokens 的語意 token。admin-scope class:Base UI Portal 把浮層掛到 document.body(.admin-scope 之外),token 變數會解析不到 → 透明背景。所以 dialog/alert-dialog/sheet 的 Backdrop+Popup、select/popover/dropdown-menu/tooltip/combobox 的 Positioner,className 第一個 class 一律 admin-scope。新增任何會 portal 的元件都要比照,否則必出「浮層透明/overlay 蓋過本體」回歸。render={<Link/>},不要寫 asChild。Base UI 1.5 的 className 型別是 string | (state)=>string,wrapper 一律 Omit<…,'className'> & { className?: string } 收窄才能進 cn()/cva。[hsl(...)]、寫死 hex、為單一用途新增 CSS 變數。style 只准用在值要在 runtime 算的場景(見 §7)。這套「禁止裸元素 / 禁止 inline style」規則只治理 page/route code,不是全 repo 一刀切——否則會誤殺 DS 元件自己(packages/ui/src/input.tsx 內部就是裸 <input>)。範圍如下:
| 類別 | 路徑 | 規則 |
|---|---|---|
| 受治理(嚴格) | src/app/(admin)/**(頁面、*-form.tsx、*-dialog.tsx、view 等 route code) | §5 全套硬規則生效 |
| DS wrapper 內部(豁免) | packages/ui/src/** | 這些就是包裝 primitive 的地方,內部本來就要用裸 <input>/<button>/<table>、CSS-var inline style(如 sidebar.tsx 的 rail 按鈕、寬度變數)。不受「禁止裸元素」約束 |
| domain 元件(豁免內部、鼓勵複用) | src/components/admin/** | 內部可組合 primitive;對 page code 而言它們是該優先複用的現成抽象(見 §4 domain 層) |
| hidden / metadata input | 任意 | <input type="hidden" name=…>(帶 server-action 資料)、Select/Combobox 內部 hidden 提交 input 等不是 UI 控制項,不受約束 |
| a11y-only markup | 任意 | 純無障礙用途的原生元素(screen-reader 專用、role 載體)允許 |
| 列印頁(豁免) | src/app/(admin)/admin/payment-requests/[id]/print/** | 走 @media print + window.print()、刻意在 .admin-scope 之外,保留裸 HTML + inline style |
review / codex 以此範圍判定違規:只有「受治理」列的 raw 元素 / 靜態 inline style 算違規。
import 路徑:primitive 一律 @toko-cloud/ui/<檔名>;domain 元件在 @/components/admin/<檔名>。全部 31 個 primitive(一檔一 primitive,含 Select/Field/Card 等家族檔;packages/ui/src/*.tsx 的實際檔數,不計 hooks/、lib/ 子目錄)已對齊 shadcn 官方 Base UI 變體(function 元件 + data-slot + @base-ui/react 1.5)。
| 元件 | 檔 | 用途 / 何時用 |
|---|---|---|
Button | button.tsx | 所有可點動作;換 tag 用 render。本地 variant:default(bg-foreground)/primary/destructive/outline/secondary/ghost/link;type 預設 'button'。禁止裸 <button>/當按鈕用的 <a> |
Input | input.tsx | 單行輸入。禁止裸 <input type=text/email/number/date…> |
Textarea | textarea.tsx | 多行輸入。禁止裸 <textarea> |
Label | label.tsx | 表單標籤;多數情況改用 FieldLabel(見下) |
Select 家族 | select.tsx | 表單內所有靜態 enum 下拉。<Select name defaultValue items> 放 <form> 內即原生提交(Root 內 hidden input)。⚠️ 兩個必踩坑:(1) trigger 要顯示 label 必須給 items(popup 未開過時 Base UI 拿不到 SelectItem 的文字,會顯示原始 value)——items 與 .map() render 餵同一個常數避免兩份漂移;(2) '' 是合法值但不顯示 placeholder(1.5 只在 null 時顯示)——「全部/不限/繼承」選項要有自己的 label。受控用 value+onValueChange(參數是 value 不是 event,且型別 nullable 要 guard) |
Combobox | combobox.tsx | 選項來自 DB/API 或筆數會長(會員、行程、梯次、員工…)的可搜尋下拉。高階 wrapper:{ name, options: {value,label}[], defaultValue?, placeholder? }(hidden input 提交)。受控/多選/chips 用 ComboboxRoot + family 組合(multiple = 同名多 hidden input,action 端 formData.getAll) |
Checkbox | checkbox.tsx | 勾選(支援 name)。⚠️ server-action 布林語意:勾選送 value、未勾選預設送不出欄位;要表達 true/false 必須給 uncheckedValue(或 action 端以 key 存在與否判斷)。禁止裸 <input type=checkbox> |
RadioGroup / RadioGroupItem | radio-group.tsx | 單選群組。禁止裸 <input type=radio> |
Switch | switch.tsx | 開關切換(新增官方 size prop) |
Field 家族 | field.tsx | 原生表單欄位的標準包裝(純組合、不綁 RHF):Field/FieldLabel/FieldDescription/FieldError/FieldGroup/FieldSet/FieldLegend/FieldContent/FieldTitle/FieldSeparator。label 與控制項的間距由 Field 的 gap 管——禁止手刻 space-y-*+<Label>(就是 label/input 無間隔 bug 的來源) |
InputGroup 家族 | input-group.tsx | 帶 icon / addon 的輸入框,取代 filter bar 手刻 relative+absolute 排 icon |
RHF 版
form.tsx已淘汰(零使用者;react-hook-form 仍用於公開站 checkout)。後台表單一律<form action={serverAction}>+Field家族。
| 元件 | 檔 | 用途 |
|---|---|---|
Card 家族 | card.tsx | Card/CardHeader/CardTitle/CardDescription/CardContent/CardFooter/CardAction |
PageHeader | page-header.tsx | 頁首:標題 + 描述 + 右側 actions slot(house 元件) |
StatCard | stat-card.tsx | KPI 數字卡:label / value / hint / tone / unit(house 元件) |
Separator | separator.tsx | 分隔線 |
Tabs 家族 | tabs.tsx | 分頁(新增官方 variant="line") |
Table 家族 | table.tsx | 禁止裸 <table>/<thead>/<tbody>/<tr>/<th>/<td> |
| 元件 | 檔 | 用途 |
|---|---|---|
Badge | badge.tsx | 狀態標籤。variant:default/secondary/destructive/outline/success/warning/info(tone 全走語意 token,見 §6)。禁止手刻 bg-[hsl(...)] 徽章 |
Alert 家族 | alert.tsx | 提示盒:Alert/AlertTitle/AlertDescription/AlertAction;warning/success/info 是本地擴充 variant |
Empty 家族 | empty.tsx | 空狀態:Empty/EmptyHeader/EmptyMedia/EmptyTitle/EmptyDescription/EmptyContent |
Skeleton | skeleton.tsx | 載入骨架 |
Toaster / toast(sonner) | sonner.tsx | 全域 toast(掛在 admin layout 的 .admin-scope 內) |
Tooltip | tooltip.tsx | 提示氣泡 |
| 元件 | 檔 | 用途 |
|---|---|---|
Dialog 家族 | dialog.tsx | 對話框(showCloseButton prop) |
AlertDialog 家族 | alert-dialog.tsx | 確認/破壞性對話框 |
Sheet 家族 | sheet.tsx | 側邊抽屜(house wrapper on Base UI Dialog;新增上/下變體要加 cva 變體) |
Popover | popover.tsx | 浮層 |
DropdownMenu 家族 | dropdown-menu.tsx | 下拉選單(Content 預設 align='start',貼齊 trigger)。DropdownMenuLabel 是 plain div(Base UI GroupLabel 要求在 Group 內,radix 慣用法直接放 Content 下會噴錯) |
Command | command.tsx | 命令面板(cmdk;官方 base 變體也仍用 cmdk) |
Breadcrumb 家族 | breadcrumb.tsx | 麵包屑 |
Sidebar 家族 | sidebar.tsx | 後台側欄(CSS-var inline style 屬 DS 內部豁免;useIsMobile 在 @toko-cloud/ui/hooks/use-mobile) |
src/components/admin/*)primitive 之上、與業務語意綁定的元件——page code 該優先複用,不要重造或硬塞進 packages/ui:
filter-select.tsx(FilterSelect):GET filter bar 的標準下拉——name+options+defaultValue,選擇即 requestSubmit 所屬 form。order-status-badge.tsx(OrderStatusBadge 與 PaymentStatusBadge 同檔)、assignment-badge.tsx(AssignmentBadge):包裝 Badge 的業務狀態徽章。status-filter-tabs.tsx(StatusFilterTabs):列表頁的狀態篩選列。permission-matrix.tsx(PermissionMatrix):RBAC 權限矩陣。mdx/mdx-field.tsx(+ mdx/* editor 群)、lodging/amenity-picker.tsx / lodging/gallery-manager.tsx 等模組級組合元件。新 domain 元件放這層(不是 packages/ui);packages/ui 只放與業務無關的 primitive。
這張表是無歧義的硬規則,僅適用 §3 的「受治理」範圍。寫後台頁面前先掃一遍;review/codex 以此為準。
| 場景 | ❌ 禁止 | ✅ 該用 |
|---|---|---|
| 按鈕 | 裸 <button>、當按鈕的 <a> | <Button> / <Button render={<Link/>}> |
| 文字/數字/日期輸入 | 裸 <input> | <Input> |
| 多行輸入 | 裸 <textarea> | <Textarea> |
| 表單內靜態 enum 下拉 | 裸 <select>、<NativeSelect>(已廢除) | <Select name defaultValue items> 家族 |
| 選項來自 DB/API 的下拉 | 裸 <select>、把幾百筆塞 <Select> | <Combobox>(可搜尋) |
| GET filter bar 下拉 | 裸 <select onChange=submit> | <FilterSelect>(domain 層) |
| 勾選 | 裸 <input type=checkbox> | <Checkbox name=… uncheckedValue=…> |
| 單選 | 裸 <input type=radio> + 手刻卡片 | <RadioGroup> / <RadioGroupItem> |
| 表單欄位排版 | 手刻 <div class="space-y-1.5"><Label>…、local Field | <Field> / <FieldLabel> / <FieldDescription> / <FieldError> |
| 表格 | 裸 <table>/<thead>/<tbody>/<tr>/<th>/<td> | <Table> 家族 |
| 提示 / 說明 / 危險盒 | 手刻 <div class="rounded-md bg-muted/40 p-3">、bg-destructive/5 盒 | <Alert variant=…> |
| 空狀態 | 手刻 <p class="py-12 text-center text-muted-foreground"> | <Empty> 家族 |
| KPI 數字卡 | 手刻 Card + text-3xl tabular-nums | <StatCard> |
| 頁首 | 手刻 <div class="flex justify-between"><h1> | <PageHeader> |
| 業務狀態徽章 | 手刻 Badge + 業務文案散落各頁 | 複用 OrderStatusBadge / PaymentStatusBadge / AssignmentBadge(domain 層) |
| 語意色(成功/警告/資訊/危險) | text-[hsl(var(--success))]、任意 [hsl(...)] | text-success / bg-warning/10 / <Badge variant="success"> / <Alert variant="warning"> |
| 顏色 / 間距 token | 寫死 hex/hsl、為單一用途新增 CSS 變數 | admin-tokens 既有語意 token 的 Tailwind utility |
| 靜態 inline style | style={{ padding: 12 }} 等靜態值 | Tailwind utility / 元件 props |
| 頁面內自刻 primitive | 在頁面/*-form.tsx 內手組 Base UI primitive、複製 SELECT_CLS 類字串 | 先查 @toko-cloud/ui → 查 shadcn base 變體 → 新增進 packages/ui |
不新增任何 token。 admin-tokens.css 已把 --success / --warning / --primary(可當 info 的藍)/ --destructive 等經 @theme inline 註冊成 --color-*,Tailwind named utility + opacity modifier 直接可用——bg-success/10、text-success、border-warning/30、bg-primary/10。
Badge / Alert 的 tone variant 一律:
success: bg-success/10 text-success border-success/30
warning: bg-warning/10 text-warning border-warning/30
info: bg-primary/10 text-primary border-primary/30
§3 已列「檔案層級」豁免(DS wrapper 內部、domain 內部、hidden/a11y、列印頁)。本節補「受治理 page code 內」仍允許的 inline style 樣態,且只有這些:
style:值必須在 runtime 計算、無法用固定 utility 表達者,例如
style={{ width: pct + '%' }}style={{ background: avatarColor(seed) }}(guides 頭像)leaderboard 依資料算出的尺寸@mdxeditor/editor 等自帶 style 的整合點。例外以外的 inline style(靜態值)與裸元件一律視為違規。新增例外要在本節(或 §3)補一條並說明為何無法用 utility/元件表達。
https://raw.githubusercontent.com/shadcn-ui/ui/main/apps/v4/registry/bases/base/ui/<name>.tsx)→ 對齊已安裝 @base-ui/react 的實際 .d.ts → 樣式換成 admin-tokens utility(不取 cn-*)→ 會 portal 的浮層加 admin-scope class(§2)→ export 名維持 superset。tests/e2e/_helpers/select.ts 的 pickOption(page, trigger, '選項文字');locator.selectOption() 只對原生 <select> 有效。既有 data-testid 不可變動(掛在 SelectTrigger / ComboboxInput 上)。bun run typecheck + bun run test + bun run test:e2e 全綠;浮層類改動要實際開 dialog/dropdown/select 截圖確認(portal token 問題 typecheck 抓不到)。../CLAUDE.md「兩套 design system」段與 AGENTS.md 同步硬規則摘要。Tabs 的 variant="line"、Switch size、AlertDialog size 等官方新 variant 已移植但尚無使用面;要不要在現有頁面採用待設計決定。DataTable(排序/分頁/篩選的高階表格)尚未有足夠重複需求支撐,暫不建;先用 Table + 既有 server-side 篩選。0617 會議定調:人員長時間盯螢幕,後台要加深色 / 淺色切換。只限後台(公開站是另一套 Apple-style design system,自有主題,不在本檔範圍)。
深色模式沿用同一組語意 token(--success / --warning / --primary / --destructive / 背景 / 前景…),只在 dark selector 下重新宣告它們的值。元件、utility(bg-success/10、text-fg…)一律零改動——換的是 token 值,不是 token 名,也不是元件。這直接延伸 §6 的「不新增任何 token」。
:where(.admin-scope);深色值放兩條並列的零特異度 selector(同塊翻值):
:where([data-theme='dark'] .admin-scope):主路徑。next-themes 把 data-theme 寫在 <html>,主樹 wrapper 與 portal 浮層(portal 到 document.body)都是 <html> 後代 → 一條全包。:where(.admin-scope[data-theme='dark']):保留作防護(wrapper 自身若帶 data-theme 也命中);採 next-themes 後 data-theme 只在 <html>、wrapper 不帶,故此條目前不觸發,留著無害。data-theme 由 next-themes 寫在 <html>(attribute="data-theme")。永遠是 resolved 的 light|dark(system 經 prefers-color-scheme 解析後寫入),不會是字面 system。admin-scope(§2 portal 規則),token 從最近的 .admin-scope 解析;因為 <html> 帶 data-theme,第一條 selector 讓浮層在深色下吃深色 token。移除該 selector → dialog/select/dropdown 等浮層在深色下仍是淺色。apps/web/src/app/admin-tokens.css 的 dark 區塊、apps/web/src/components/theme-provider.tsx(next-themes Provider)、apps/web/src/components/admin/theme-toggle.tsx(topbar 切換鈕,useTheme())。light / dark / system(system 跟作業系統 prefers-color-scheme)。apps/wireframes 的 components/shell/theme.tsx)。 root layout 包 <ThemeProvider attribute="data-theme" defaultTheme="system" enableSystem>(apps/web/src/components/theme-provider.tsx)。切換鈕用 useTheme();system 模式 OS 切換即時跟隨(next-themes 內建 matchMedia 監聽 + 跨分頁同步)。storageKey="admin-theme")持久化,並在 <head> 注入 paint-前 inline script,在 hydrate 前把 resolved data-theme 補到 <html>,首屏即正確色(白底不閃)。<html suppressHydrationWarning>(root layout):server HTML 無 data-theme(system 模式 server 無從得知 OS 偏好),next-themes 於 hydrate 前補上會觸發屬性 mismatch;shadcn dark-mode/next 明文要求加此屬性(只抑制 <html> 一層屬性比對,不 cascade 子樹)。bg-warning/10)在兩主題都成立:它是 token 顏色疊 alpha,深色翻值後自然跟著變,不需為深色另寫 tone。[hsl(...)]」(§6)在深色下更關鍵——寫死色不會跟著翻、會爆掉。深色模式是這條禁令的回歸測試。selector 抉擇已決議(見 §10.2):採
[data-theme='dark'] .admin-scope(html,涵蓋主樹 + portal)為主路徑、.admin-scope[data-theme='dark']並列保留;主題引擎採 next-themes(attribute="data-theme",對齊 shadcn dark-mode/next 與 wireframetheme.tsx)。
@theme)。| 版本 | 日期 | 變更 | Commit |
|---|---|---|---|
| 8 | 2026-06-23 | 主題引擎由「cookie + data-theme 自管」改為 next-themes(對齊 shadcn dark-mode/next 與 wireframe theme.tsx):root layout <ThemeProvider attribute="data-theme"> + <html suppressHydrationWarning>;刪 admin-theme.ts 自管模組與 3 個自管測試;theme-toggle 改用 useTheme();持久化由 cookie 改 localStorage(storageKey="admin-theme");data-theme 只寫 <html>、主路徑 selector 改 [data-theme='dark'] .admin-scope。同步把 sidebar IA 對齊 wireframe §5(8 組功能軸取代舊 5 組 phase 制)。 | — |
| 7 | 2026-06-22 | N7/B5f 後台深淺色落地:cookie+data-theme 自管、雙 selector(wrapper .admin-scope[data-theme='dark'] 並列 html [data-theme='dark'] .admin-scope)portal 跟隨、sonner 去 next-themes、§10.4 hsl 清理(dashboard SVG / avatar / 通知點 / 斜紋)。§10.2/§10.3 內文吸收 selector 決議,§10.5 移除該開放議題。 | — |
| 6 | 2026-06-20 | 對齊 code:primitive 數由 33 改 31(packages/ui/src/*.tsx 實際檔數,§1 / §4);§4 點明計法(一檔一 primitive、含 Select/Field/Card 等家族檔、不計 hooks//lib/ 子目錄)。 | — |
| 5 | 2026-06-20 | N7 深淺色模式(§10):後台深色沿用同一組語意 token、只在 :where(.admin-scope[data-theme='dark']) 翻值(不新增 token、元件零改動);cookie 持久化免 FOUC;portal 浮層跟隨。未實作。 | — |
| 4 | 2026-06-12 | primitive 抽成 workspace 套件 packages/ui(@toko-cloud/ui),全面對齊 shadcn 官方 Base UI 變體(@base-ui/react 1.5 stable、function 元件、data-slot);NativeSelect 廢除,下拉三分法(Select / Combobox / FilterSelect);Field 家族取代手刻 label 排版;portal 浮層 admin-scope class 規則(修 overlay 蓋本體/浮層透明);dropdown 對齊預設 start;Select items 與 '' placeholder 兩坑寫入目錄;badge/alert token 化落地;RHF form.tsx 淘汰 | — |
| 3 | 2026-06-03 | 修正 domain 層檔名(PaymentStatusBadge 與 OrderStatusBadge 同檔);MDX/lodging domain 元件補實際檔路徑 | — |
| 2 | 2026-06-03 | 納入 codex review:§3 規則適用範圍;NativeSelect 首選 + Select caveat;Checkbox uncheckedValue;shadcn 移植對齊已安裝 API;Alert 本地擴充;Badge tone 遷移目標 | — |
| 1 | 2026-06-03 | 初版:元件目錄 +「該用 vs 禁止」+ token/tone 規則 + 允許例外;三決策 | — |