版本紀錄
v1.26.0每一版 token/元件/行為的變動都在這裡,同步發布於 GitHub Releases ↗。正本是 releases/*.md。
v1.26.0
2026-10-14可搜尋下拉(Combobox/Multicombo)打開就能捲到全部選項。 起因是 AppSheet 重構後的新系統:下拉選項要跟舊 App 一樣零遺漏,不能因為「只列前 50 筆」而比舊 App 少選項。PM 2026-10-14 拍板,也同意下面這個「分批畫」的做法。
- 怎麼做到不卡:選項全部一次畫出來,1,233 筆在 CPU 4 倍降速下每打一個字約 170ms,會卡。改成分批畫——開啟時先畫 100 筆,捲到接近底部、或用鍵盤走到還沒畫的位置(
End、↑由第一筆繞到最後一筆)時,再往後追加下一批。 - 只追加、不回收:已經畫出來的選項一直留著,所以這不是虛擬捲動,鍵盤游標與螢幕閱讀器都不會指到已被回收的節點。「不做虛擬捲動」這條規則不變。
- 螢幕閱讀器念得出總數:每一列加上
aria-setsize(總筆數)與aria-posinset(第幾筆)。 - 選單頂端的文字:沒打字顯示「共 N 筆」,有打字顯示「符合 N 筆」。原本的「共 N 筆,顯示前 50 筆——請輸入關鍵字縮小範圍」拿掉。
- 多選「勾選這 N 項」不變:一樣只在打字過濾後 ≤ 50 筆時出現,不提供「全選 1,233 筆」。
修正(兩個都是 v1.25.0 以前就有的 bug)
- 用鍵盤上下移動時,選中的那一列會躲到搜尋框底下(vanilla+React):選項很多時按
End再往上按,第 5 下起那一列被頂端的搜尋框蓋住;按Home或從最後一筆往下繞回第一筆時也一樣。現在不論往上、往下、Home、End、繞回,選中的那一列都完整出現在搜尋框下方。滑鼠移過去照舊不會捲動。⚠️ 捲到最頂時第一筆上緣仍有 1px 壓在搜尋框底線下,這是樣式層的間距造成的,樣式檔凍結中,這版不動。 - React:選單開著時把
options換成比較少的清單,鍵盤游標會指到不存在的選項(按Enter沒反應):現在換清單後游標回到第一個可選項、選項從第一批重新畫,跟呼叫setOptions()的結果一致;一樣不發onChange。vanilla 實測沒有這個問題。
常數
| 層 | 新增 | 移除/保留 |
|---|---|---|
vanilla(components.js) | DS.COMBO_RENDER_CHUNK = 100、DS.COMBO_BULK_MAX = 50 | DS.COMBO_VISIBLE_MAX 移除(讀它只會拿到 undefined,不會讓程式壞掉;已知採用者沒有人讀它) |
React(combobox.jsx) | COMBO_RENDER_CHUNK、COMBO_BULK_MAX | COMBO_VISIBLE_MAX 保留(值 50、標 @deprecated、已無作用)——權限申請 repo 的 components/hhg/index.ts 有轉匯出它,直接刪掉會讓對方換新版後建置失敗 |
兩個數都是庫的規則、不是各專案的參數,所以是常數、不是 prop。
會變的地方
| 採用者 | 取用方式 | 會不會自動變 |
|---|---|---|
| 權限申請 prototype、客戶及供應商資料建檔 prototype | vanilla(components.js) | 下次 sync.sh 重灌就變,markup 不用改 |
權限申請 repo(hhg-permissions) | React registry | npx shadcn add @hhg/ds-combobox --overwrite 後就變;index.ts 不用改 |
| 採購審核系統 | Chakra token 層 | 沒有用本庫的 combobox,不受影響 |
驗證
- 獨立驗收(另一位未參與實作的驗收者,自寫探針、以 v1.25.0 當對照組)兩輪皆 ACCEPT with notes:第一輪驗需求 9 條(不截斷、分批只追加、鍵盤、aria、meta、勾選這 N 項、既有行為、效能、文件)全過;第二輪驗兩個修正+回歸全過。4 倍降速下 vanilla 開啟 ≤18.1ms、打字 ≤20.2ms,React production 開啟 ≤55.8ms、打字 ≤32.7ms。另記錄一個 v1.25.0 以前就有的樣式層問題:sticky 搜尋區上方約 5px 縫隙,捲動時選項文字會從縫露出一截(與 1px 例外同因),樣式檔凍結中,這版不動。
以下為實作者自測:
- 真 Chrome,
showcase.html多選 1,233 筆+同頁另掛一顆單選 1,233 筆:開啟畫 100 筆、「共 1233 筆」;真實捲動 12 次補齊 1,233 筆,最後一列aria-posinset=1233/aria-setsize=1233;End到最後一筆、↓繞回第一筆、↑繞回最後一筆(含從未畫的位置)、同時只有 1 個.is-active;Enter選中最後一筆、事件 1 次;搜尋「符合 4 筆」、查無文案正確;多選點追加出來的第 500 筆可勾、選單不關;過濾後 50 筆有「勾選這 50 項」、51 筆沒有;重開後已選項排最前;53 筆全部畫出。Object.keys(window.DS).length= 23。 - React(esbuild 打包
combobox.jsx+真 Tailwind build,同一份清單):上述各項結果與 vanilla 相同,console 零錯誤(dev build)。 - 速度(CPU 4 倍降速、中位數;含兩個修正後重量):vanilla 開啟 19.3ms/打字 17.0ms;React(production build)開啟 32.9ms/打字 8.3ms(v1.25.0 同條件 23.6ms/11.0ms)。
- 修正一(游標躲到搜尋框底下):
End→↑×10→Home→↓×10→End→↓(繞回)→↑(繞回)→Home,共 26 下,每下量作用列與搜尋區下緣、選單內框下緣。修前 vanilla 與 React 各 10 下被蓋(例:↑後作用列 top 104.54 < 搜尋區 bottom 183.04);修後 0 下被蓋,捲動中最小間距 +0.59px、距選單底 ≥ 0.16px。唯一例外是捲到最頂(scrollTop = 0)時第一筆上緣差 1.00px(見上,樣式層)。滑鼠 hover 修前修後都不捲動。 - 修正二(React
options變短):修前 ad 停在…-o1232、節點不存在、0 個作用列、Enter0 次事件;修後 ad=…-o0(存在)、1 個作用列、畫 30 筆、aria-setsize=30、換清單 0 次事件,↓+Enter選到 V0002、1 次事件;再換回 1,233 筆時重畫第一批 100 筆、meta「共 1233 筆」。vanilla 開著時setOptions換 30 筆:ad 指向存在的第一筆、0 次事件。 - 等值工作台 combobox 族 exit 0(12 cell × 4 state × 3 viewport、1,157,760 次比對、白名單外 0);改前版本同機重跑同數。
components.css刪除註解後與 v1.25.0 逐字相同;build-guideline.mjs八處版本一致(反測 exit 1);shadcn build -o r後 12 個r/*.json中 11 個只差版本字串、ds-combobox.json內容與combobox.jsx逐字相同。
v1.25.0
2026-10-06BREAKING卡片標題從 13.5px 升到 16px、加粗到 700。 起因是權限申請的 PM 拍板:「提出申請」流程所有卡片標題要 16px,而且決定改在元件庫、讓全公司採用者的卡片標題一起變大,不在單一專案裡另外調。
- 新的 token:
--fs-card-title(16px)/--fw-card-title(700)。Tailwind 產生器自動產出text-card-title/font-card-title。 - 為什麼不直接用
--fs-app:那顆值也是 16/700,但它的用途限 app shell 的側欄品牌名(v1.5.2 起就寫明不要外借)。卡片標題另開一顆,以後調側欄品牌名不會連帶改到每一張卡。 - 為什麼字重跟著變 700:字級階梯上 16px 以上的標題(頁標題、對話框、抽屜、側欄品牌名)全是 700,13.5px 以下的才是 600;16/600 會是一個沒有前例的組合。權限申請暫用的
text-app font-bold也正是 16/700。 - 跟其他層級比:仍小於對話框標題 19、頁標題 27;卡內的小節標題(13.5/600)與欄位卡標題(13/600)從「跟卡片標題一樣大」變成「明顯小一階」。⚠️ 與抽屜標題(17.5/700)只差 1.5px,抽屜裡放卡片時主要靠位置與分隔線區分。
會變的地方
| 採用者 | 取用方式 | 會不會自動變 |
|---|---|---|
| 權限申請 prototype | vanilla(components.css) | 下次 sync.sh 重灌就變 |
| 客戶及供應商資料建檔 prototype | vanilla | 下次重灌就變 |
| 權限申請 repo | React+Tailwind | 不會自動變:換新 theme 後把卡片標題的 text-app font-bold 改成 text-card-title font-card-title |
| 採購審核系統 | Chakra token 層 | 不會自動變:CardTitle 寫死吃 fsSection/fwSection,重跑轉換後要改吃 fsCardTitle/fwCardTitle |
// 權限申請 repo:之前(暫用 app shell 那一階)
<h2 className="text-app font-bold text-text">…</h2>
// 之後
<h2 className="text-card-title font-card-title text-text">…</h2>v1.24.0
2026-10-05下拉、可搜尋下拉、多選下拉都能直接停用了。 起因是 GitHub issue #12:Select 原本沒有 disabled,下游傳了也不會生效(它被透傳到外層容器,不是按鈕本身),權限申請只好自己拼一顆「鎖住的下拉」。
- 停用(
disabled,三個元件都有):下拉整顆洗淡(跟按鈕、輸入框的停用態同一套,opacity .45),不能點、不能用 Tab 停上、鍵盤也打不開。如果選單開著的時候被切成停用,選單會立刻關掉。 - 唯讀(
readOnly,Combobox/Multicombo 新增):跟 v1.23.0 的唯讀下拉長得一樣、用起來也一樣——灰底、淡字、沒有箭頭;可以用 Tab 停上(保留焦點環),但打不開、不能改值。多選的已選項照常顯示、維持一般深色字(--text),不跟著欄位變淡——它就是這格的資料;只是不能移除(chip 本來就沒有 ✕;選單打不開,所以「清除全部」跟 Backspace 刪除也碰不到)。 - 兩個都給:以停用為準,開發環境的 console 會提醒一次。
- 什麼時候用哪個:這個欄位現在不能操作(例如還沒選上一個欄位、沒有權限)→ 停用;欄位有值要讓人看、但這裡不能改(例如審核畫面上的申請內容)→ 唯讀。唯讀不洗淡,因為那個值正是使用者要看的。
- 表單送出:這三個元件本來就沒有隱藏的表單欄位,值不會跟著原生
<form>送出(一律由程式讀onChange或getValue()),所以停用不需要另外處理「不送出」。程式面的setValue()在停用/唯讀時仍可改值(跟原生input.value一樣,只擋使用者操作)。 - 沒做的:不能按的原因提示(tooltip/Popover)不在這一版。vanilla(
components.css/components.js)不動:vanilla 下拉本來就是加原生disabled就停用(v1.23.0 起),vanilla 仍然沒有下拉唯讀態。
權限申請:拿掉 LockedTrigger
// 之前:自己拼一顆鎖住的 trigger
<button className={cx(TRIGGER, TRIGGER_GEO_FIELD, 'opacity-60 cursor-not-allowed pointer-events-none')} aria-disabled="true">…</button>
// 之後
<Select options={…} value={…} inField disabled />
⚠️ 外觀會從 opacity .60 變成庫的 .45(跟按鈕、輸入框的停用態一致);aria-disabled 換成原生 disabled,所以這顆不再能用 Tab 停上——這是停用的本意。若那個欄位其實是「要讓人看值」,改用 readOnly。
v1.23.0
2026-10-05BREAKING可輸入的表單控件一律灰底。 下拉(含可搜尋下拉、多選下拉)與搜尋框改成跟文字輸入框同一套外觀,同一張表不再混白底加框線與灰底兩種樣子。
- 一套外觀:灰底
--field-bg、淡框--line;hover 框加深到--line-strong;聚焦或展開時轉白底+ink 框;錯誤態紅框。表單欄位(.ds-field)裡的下拉字級也對齊輸入框(14px)。 - 停用(新增):輸入框與下拉都加上原生
disabled就會整顆洗淡、不能點,跟按鈕的停用態同一套。 - 唯讀(vanilla 新增,React 同步調整):
<input class="ds-input" readonly>。灰底不變、框線拿掉、文字退到--muted(對比 6.11:1,仍過 AA)、滑過或點進去都不變色。不洗淡、也不改白底——唯讀欄位的值仍是要讓人看的。 - 唯讀欄位保留鍵盤焦點環:唯讀欄位仍可用 Tab 停上,所以焦點環照留(ink 環+白色間隙,與其他控件同一套),鍵盤使用者看得到自己停在哪。輸入框、日期、多行、React 的唯讀下拉都一樣。⚠️ Chrome 對唯讀輸入框/多行在滑鼠點進去時也會顯示焦點環(瀏覽器把文字欄一律當鍵盤輸入元素);下拉是按鈕,滑鼠點擊不顯示。
- 刻意保留的差異:用鍵盤 Tab 到下拉時仍有焦點環(它是按鈕);可編輯的文字輸入框沒有焦點環(聚焦改靠轉白底+ink 框)是既有的待拍板項,本版不動。
- 沒改的:可編輯明細表格的無框儲存格(
.ds-ecell)、下拉選單本身(白底浮層)、按鈕與卡片。
v1.22.0
2026-10-02新增欄位 label 旁的小說明鈕 .ds-infotip-sm。
- 用途分界:頁面/區塊標題旁=預設尺寸(圈 22);欄位 label 旁=
-sm(圈 16、i 字 10,配 12px 的.ds-field-label)。說明鈕一律緊貼它說明的對象——欄位說明掛在該欄位的 label,不掛在區塊標題。 - 做法:
-sm只覆寫兩顆元件內部變數--ring/--ring-fs,觸控下的命中盒放大、負 margin、圈的位移全部自動跟著走。觸控下命中盒仍是 44(--hit-min),看得見的圈維持 16。 - label 不被撐高:放在
.ds-field-label裡時,負的上下 margin 把 16px 圈收回 14.4 的 label 行高內;同一排有 i 與沒 i 的欄位,輸入框上緣仍對齊(實測差 0.008px)。 - 結構:說明鈕放在
<label>元素外面(.ds-field-label當容器,裡面是<label for>+.ds-infotip)。button 放進 label 不合法,而且輸入框的可及名稱會吃進說明鈕的名稱。 - ⚠️ 觸控下 44 的命中盒會往下伸進輸入框上緣約 8.8px,在 i 正下方那一小塊點輸入框會打開說明。判斷為可接受,有誤觸回報再處理。
v1.21.0
2026-10-02新增審核「通過」鈕 .ds-btn-ok,並定下跨系統的按鈕語意規則。 規格來源是採購單正式系統的 OkButton。
- 按鈕顏色看動作語意:橘
ds-btn-cta只給「開始一個流程」的入口(新增採購單);申請人送出=ds-btn-primary;審核通過=ds-btn-ok(綠);不通過=ds-btn-danger(紅)。依據=採購單與權限申請兩個正式系統的現況。 - 色彩只用 token:靜止態
color-mix(in srgb,var(--green) 80%,var(--ink))(≈#1D8455,白字 4.67:1);hover/activecolor-mix(in srgb,var(--green) 70%,var(--ink))(≈#1D774F,5.50:1)。白字直接壓--green只有 3.43:1,未達 AA,所以底色不直接用--green。 - hover 往深走,不套 React 填色鈕的提亮公式(提亮後 3.37:1,未達 AA);與
OkButton的方向一致。 - ⚠️
OkButton的靜止態仍是--green(3.43:1);採購單要不要跟進由開發端排。 .ds-btn-danger-subtle/.ds-btn-ok-subtle(低重量版)本版未做。
v1.20.0
2026-10-01這一版的主體是下游回報的缺陷。 來源是權限申請 hhg-permissions PR #3 的「屬 DS 元件庫層」清單,以及兩個 React repo 的自製元件盤點。
- v1.20.0(2026-10-01)下游回報的元件庫缺陷一批修掉+新增
ds-checkbox+整列點擊useRowNav。來源:權限申請hhg-permissionsPR #3「屬 DS 元件庫層」清單、前端對齊盤點 SH-6、兩個 React repo 的自製元件盤點。由 5 個 agent 各自在隔離副本實作、主對話合併(5 份 diff 零衝突)。 - TOKENS:none(128 顆不變)。BREAKING:no(新 prop 一律預設現行行為;下列 ⚠️ 為採用端自加樣式時才會碰到的行為變化)。
- 🔴 驗證狀態:程式碼層(jsdom/自建 cascade/Tailwind CLI 產物)已驗,真瀏覽器實測尚未做——像素類結論(白縫消失、底線恢復、陰影消失、焦點環可見)在實測前一律視為未驗證。
- 表格
ds-table(兩層):- React 的 td/th 補上下內距歸零
[:where(&)_td]:py-0(vanilla 的padding:0 12px原本只搬了水平那半,無 reset 宿主吃到 UA 1px、列高 60.5)。刻意:where壓到 (0,0,1),採用端的py-*仍可覆寫(實例:權限workbench.tsx:449的py-10)。 - 修有底色的列(勾選/hover/active)在小數欄界露出 1px 淡縫。根因(真 Chrome 差分實驗坐實):相鄰兩塊不透明底色在小數座標上各自反鋸齒、疊合時蓋不滿,透出底下的白卡——與
tr/td、position:relative、collapse全部無關,純 flex div 也會。修法:狀態列從第二格起box-shadow:-1.5px -.5px 0 -.5px <列底色>補滿邊界像素並避開底線,td 加transition:box-shadow。實測 1440/DPR1 兩層×兩種 reset×勾選/hover:縫 6 → 0(base 反測仍量得到)。殘留:最後一列最底那一排像素。⚠️ 採用端自訂的 tr 底色(例如錯誤列)不會得到補縫。⚠️ 已知副作用:採用端在某一格自訂 td 底色時,該列 hover/勾選期間那一格最右 1px 會被右鄰格的補縫染成列底色(rest 不受影響;現存採用者 0)。⚠️ 只驗過 Chrome 154。(第一版改法「底色由 tr 搬到 td」經獨立驗收推翻後已撤回,列狀態底色仍畫在 tr。) border-collapse:collapse→separate+border-spacing:0,修黏欄不透明底蓋掉表頭底線(黏欄處少半條線)。⚠️ 採用端自加左/右/上框會雙線,要只加單邊。- 🆕
useRowNav(navigate)/ROW_NAV_IGNORE(React only):從權限申請 PR #2 逐行搬入,行為與 PR #2 完全相同(使用者拍板,不加 Cmd/中鍵開新分頁、不判斷選字)。用法const rowNav = useRowNav(router.push)→<Tr {...rowNav(href)}>;列內互動元素與[data-row-nav-ignore]不導頁、portal 冒泡不導頁、黏欄操作欄由呼叫端標記。jsdom 20 案例與 PR #2 原 hook 結果逐案相同,忽略清單字串逐字相同。
- React 的 td/th 補上下內距歸零
- App shell/選單(兩層):
.ds-navitem/.ds-acct加進通用 focus 規則(ink 環+白間隙);.ds-menu-item用內縮環outline-offset:-2px(通用環在.ds-menu-scroll裡會被裁)。≤820 抽屜的box-shadow改成只在開啟態給,收合時視窗左緣不再留灰影。 - Select(React):新增
size="md"(等值 vanilla 自 v1.17.0 起的.ds-select-md)+ exportTRIGGER_GEO_MD;inField優先於size。combobox 的 SSR markup 與改前逐字相同。 - Dialog(React):
ConfirmDialog/TaskDialog新增loading——確認鈕轉圈+aria-busy,連點不重送;loading 期間取消/Esc/點遮罩都不關(程式化關閉不擋)。刻意用aria-disabled不用原生disabled:原生 disabled 會把焦點丟回 body,Esc 從此收不到。loading 期間 backdrop 對 mousedownpreventDefault——否則點到pointer-events:none的確認鈕、卡片或遮罩時焦點會掉到 body、loading 結束後 Esc 失效(獨立驗收在真 Chrome 抓到);副作用是 loading 期間不能用滑鼠點進欄位或拖選文字。呼叫端的onMouseDown照常被呼叫。dialog.md回歸 61/61 與改前逐字相同。 - 🆕
ds-checkbox(React only):.ds-checkbox族 9 條規則逐屬性移植(自建 cascade 34,452 次比對、白名單外 0 差異;行為 28 步)。支援受控/非受控、disabled、indeterminate(不必再自己寫 ref)、label。半選字元預設「−」(vanilla 沒有半選 markup,照搬「✓」會與全選長得一樣),可用indeterminateIcon覆寫。⚠️table.jsx的CheckCell尚未改用它(另案)。 - 規範:護欄 4 補上反方向——宿主沒有 reset 也會壞,reset 是採用 React 元件的前置條件,且必須放在
@layer base。 - 下游動作:React 採用者
npx shadcn add @hhg/ds-table @hhg/ds-select @hhg/ds-dialog @hhg/ds-appshell @hhg/ds-menu @hhg/ds-checkbox --overwrite(先 diff 確認沒有在地修改);吃components.css的 prototype 重跑sync.sh補灌(表格與 shell 兩段有改)。
v1.19.0
2026-09-08這一版的主體是「唯讀」這個狀態本身。 tokens.* 零改動,components.css 也零改動——新東西只寫在 React 層(GUIDELINE 2-1b 遷移紀律第 4 條)。
為什麼要有唯讀,而不是沿用停用
庫裡原本沒有表單控件的唯讀態,也沒有 .ds-input/.ds-select 的停用態。第一個要接元件的取用端(客戶及供應商資料建檔)有大量「這個欄位你看得到但改不了」的欄位,於是這個缺口浮出來。
第一個提案是「補一個 disabled,沿用 .ds-btn[disabled] 的洗淡」。量過之後推翻了:
| 畫法 | input 文字對比 | select 文字對比 | WCAG AA 內文 4.5:1 |
|---|---|---|---|
| 可編輯(現況) | 12.43:1 | 13.54:1 | 通過 |
opacity:.45(= .ds-btn[disabled] 的做法) | 2.54:1 | 2.56:1 | 未達 |
--disabled-bg + --disabled-fg | 1.93:1 | 1.93:1 | 未達 |
| 本版的唯讀 | 12.43:1 | 12.43:1 | 通過 |
(Chrome 152、實際 getComputedStyle 量測後依 WCAG 相對亮度計算;opacity 那一列是把疊在底色上的實際顏色算出來,不是拿宣告值。)
🔴 結論是「停用」與「唯讀」是兩件事,不該共用一套視覺。 停用的控件裡通常沒有要讀的東西,洗淡沒有代價;唯讀的欄位有值,而那個值正是使用者要看的,洗淡等於把資料藏起來。
⇒ 唯讀只拿掉「可以編輯」的暗示,文字對比一律不動:
| 可編輯 | 唯讀 | |
|---|---|---|
| 邊框 | --line / hover --line-strong / focus --ink | transparent(不隨狀態變) |
| 底色 | --field-bg,focus 轉 --surface | --field-bg,focus 不轉 |
| 文字 | --text | --text(相同) |
| 游標 | text / pointer | default |
| Select 的 caret | 有 | 不渲染 |
各元件的變更
ds-select
新增 readOnly prop(預設 false)。為 true 時:
- 觸發器改吃新的
TRIGGER_READONLY(整條替換TRIGGER,不是疊加——同一個屬性兩個 utility 誰贏由產出順序決定,見 GUIDELINE 2-3 護欄 11) - caret 整個不渲染
- 🔴 選單不進 DOM,不是用
hidden藏起來。留著的話螢幕閱讀器與querySelector仍找得到一份使用者永遠打不開的清單 - 不掛
onClick/onKeyDown - ARIA:不宣告
aria-haspopup/aria-expanded,改宣告aria-disabled="true";觸發器仍可聚焦,好讓鍵盤與螢幕閱讀器讀得到內容 - 根節點帶
data-readonly="true"
ds-input(Input 與 Textarea)
兩者各新增 readOnly prop(預設 false)。為 true 時:套原生 readonly 屬性、邊框整條換成 BORDER_READONLY、底色整條換成 BG_READONLY、游標 default、帶 data-readonly="true"。
⚠️ 底色為什麼要整條換而不是沿用 BG:BG 帶著 focus:bg-surface(可編輯時聚焦轉白,是「你正在打字」的回饋),唯讀沒有那件事可回饋。實測沿用 BG 時唯讀 input 聚焦會從 #F6F5F4 跳成 #FFFFFF。
驗收
Chrome 152,三個控件逐項量 getComputedStyle,每一組都帶對照組:
| 斷言 | 結果 |
|---|---|
| 唯讀 Select 點擊不展開 | data-open 維持 false;對照組:可編輯的同一顆在 true/false 間切換 |
| 唯讀 Select 鍵盤(Enter/↓/空白)不展開 | data-open 維持 false |
| 唯讀 Select 選單不在 DOM | [role="listbox"] 數量 0;對照組:可編輯為 1 |
| 唯讀三個控件的底色不隨 rest/hover/focus 變 | 三態皆 #F6F5F4;對照組:可編輯 input 聚焦仍轉 #FFFFFF(代表沒把 focus 效果整個弄壞) |
| 唯讀與可編輯的文字顏色相同 | 兩邊皆 rgb(46,46,49) |
readonly 屬性真的套上 | input.readOnly === true、textarea.readOnly === true |
尚未處理
ds-combobox沒有唯讀態。 它與ds-select共用cx/ROOT/TRIGGER/OPTION/useFlipDirection/useDismiss,很可能有同樣的缺口,本版沒有查證。- 真正的 disabled(控件失效)仍然不存在於
ds-input/ds-select。刻意不在這一版一起補:沒有實際案例就定不出對的樣子。 components.css的 vanilla 版沒有跟上——那一層已凍結(GUIDELINE 2-1b 遷移紀律第 2 條),過渡期兩層不一致是刻意的。
v1.18.0
2026-08-29這一版的主體是元件層與 registry,tokens.* 零改動。 git diff v1.17.0..v1.18.0 -- tokens.json 只有一行:"version": "1.17.0" → "1.18.0",其餘逐字相同(已逐字驗過)。⇒ 又一個「token 層零變動、元件層有變動」的版本,只看 tokens.json 的 diff 會以為什麼都沒發生。
兩個 bug 修復(唯一會動到既有採用者的部分)
1. 🔴 data-shell="plain" 的抽屜在手機上會鎖死整頁
≤820 段的抽屜規則寫成合併選擇器 .ds-side,.ds-appshell[data-shell="plain"] .ds-side,對 plain 命中的是 (0,3,0),勝過 .ds-side.is-open 的 (0,2,0) ⇒ plain 的專案在手機上按漢堡鈕,body 會鎖住捲動、遮罩會蓋上來,但抽屜不會出現(只能按 Escape 或點遮罩逃出來)。
| selector | 動作 |
|---|---|
.ds-appshell[data-shell="plain"] .ds-side.is-open{transform:translateX(0)} | 新增((0,4,0),與同段 icon 鈕覆寫同一個手法:多帶一層把 specificity 拉上去,不靠「後面的規則贏」這種順序巧合) |
@media (prefers-reduced-motion:reduce) 段的 .ds-side{transition:none} | 改成 .ds-side,.ds-appshell[data-shell="plain"] .ds-side{transition:none}(同一個合併選擇器的第二個後果:還原規則原本只有 (0,1,0),對 plain 的 (0,3,0) 無效 ⇒ plain 的抽屜在 prefers-reduced-motion 下動畫照跑) |
受影響族:appshell(.ds-side 的 ≤820 抽屜態)。其餘族零改動。
⚠️ 這兩個缺口為什麼活到 v1.17.0:docs/behavior/appshell.md §7 的 29 條驗證紀錄是在 framed 下做的(⚠️ 29 是那份紀錄跑出的斷言數,下表的「26 步」是規格的編號步驟數 SIDE-I1…SIDE-R7,兩個計數不衝突),而型錄裡 plain 的切換鈕與抽屜是兩個獨立的示範,沒有人在 plain + 窄畫面下按過漢堡。
🔴 更值得記的是:這一族的行為契約全部照樣通過(class 有加、body 有鎖、焦點有進去)——因為它們量的是 JS 的可觀測行為,而破的是 CSS 的 cascade。⇒ 兩層要一起看,單驗行為層驗不出這種缺口。
影響面:採用登記表寫著「採用者可以先用 plain 平移過來、之後再拿掉屬性切到深色」——這條遷移路徑在 v1.18.0 之前不可用於行動裝置。目前唯一的 app shell 採用者 v1.14.0 已直接上 framed,零現存受害者。
2. infotip 初始狀態
DS.initInfotip 初始化時補一次 tipSet(root, false)(+5 行,其餘行為零變動)。原本開關狀態完全由互動時的 tipSet 寫入 ⇒ 採用端漏寫 hidden 的 markup,氣泡在第一次互動前永久展開,且 aria-expanded 與實際狀態不一致。
用 tipSet 而不是直接寫 pop.hidden,因為 aria-expanded 與 hidden 必須一起壓;root._dsTip 冪等守門仍在最前面 ⇒ 宿主重複呼叫 DS.init(容器) 不會把已開啟的氣泡關掉。showcase.html 三處範例 markup 同步補 hidden/aria-expanded="false"。
registry:九族換底完成,從 2 個品項變成 11 個
v1.17.0 的 registry.json 只登記 ds-select/ds-button,另外九支 .jsx 在目錄裡但沒有登記 ⇒ shadcn build 產不出 r/*.json、npx shadcn add 拿不到東西。本版一次補齊:r/ 由 3 個檔變 12 個(11 個品項 + registry.json),逐檔確認 content 內含 DS_VERSION = 'v1.18.0'。
| 品項 | 檔 | 遷移規則數 | 樣式層驗收 | 行為層驗收 |
|---|---|---|---|---|
ds-button | button.jsx | 30 | 281,792 次/白名單 7 筆 | 無行為層 |
ds-input | input.jsx | 26 | 365,568 次/白名單 3 筆全 platform | 無行為層 |
ds-badge | badge.jsx | 7 | 68,544 次/白名單 1 筆 | 無行為層 |
ds-tabs | tabs.jsx | 6 | 159,936 次/白名單 3 筆 | vanilla 無行為層;React 的選中狀態管理是新增便利層 |
ds-segmented | segmented.jsx | 14 | 144,704 次/白名單 3 筆 | 26 步 × 2 側 = 52/52 |
ds-combobox | combobox.jsx | 21(+23 條 select 族沿用) | 1,148,112 次/白名單 2 筆 | 82 條契約,81/83 列通過——未通過的兩列是 CB-I9(結構上不適用)與 CB-Q12(等值優先刻意不通過,見待拍板第 6 條) |
ds-table | table.jsx | 35 | 2,208,640 次/白名單 5 筆/sanity 13/13 | sticky-col 14 步,14/14 兩側量測值逐字相同 |
ds-dialog | dialog.jsx | 22 | 913,920 次/白名單 8 筆全 platform | 46 步,通過 45/不適用 1(CFM-I8 是規範性條目)/未通過 0 +邊界情境 6 條 × 2 側全過(不計入 46) |
ds-appshell | appshell.jsx | 61 | 411,264 次/白名單 5 筆/sanity 42 條 | 抽屜 26 步 × 2 側 = 52/52 |
ds-menu | menu.jsx | 14 | 297,024 次/白名單 3 筆/sanity 24 條 | 選單 29 步 × 2 側 = 58/58 |
合計 236 條規則(去重:ds-combobox 只計自己的 21 條,那 23 條 select 族沿用已計在 ds-select 的 25 條裡)、5,999,504 次 computed 屬性逐一比對、白名單外差異 0、結構不一致 0、console 錯誤 0、compare.mjs 全族 exit 0。 ⚠️ 上表的白名單欄不要相加:badge/tabs/segmented 三族共用同一組 3 筆族設定白名單,欄位裡的 1/3/3 是各族的命中筆數。去重後全庫共 36 筆。Tailwind 表達不了的規則:0 條(九族逐族確認,含四層 background 簡寫、:has()、::before、word-break:keep-all、position:sticky、四個 ::-webkit-*)。
跨品項依賴宣告了兩條(不宣告的話 add 單獨一個會落地一支 import 不到來源的檔):
ds-combobox→@hhg/ds-select(combobox.jsx從./select取cx/ROOT/TRIGGER/OPTION/useFlipDirection/useDismiss/closeAllExcept)ds-dialog→@hhg/ds-button(dialog.jsx的動作列一律用button.jsx的<Button>,不寫死 class)
🔴 .ds-* 的 CSS 一條都還不能刪。族數進度 ≠ CSS 退役進度——本版第五、六、七次成立:.ds-select*/.ds-combobox* 卡在 DS.initSelect/initCombo 仍是 vanilla 唯一實作;.ds-btn* 的阻擋條件已解除(dialog.jsx 改用 <Button>)但仍不能刪(components.js 本體與 prototype/prototype.html 的鏡射副本仍在產生 ds-btn markup);.ds-table 整族是 .ds-etable 的後代選擇器基座;.ds-menu 是通知面板族的定位與開關基座;.ds-input* 被 initCombo 產生的搜尋框用著。⇒ 排期要分開追蹤「元件遷移完成」與「CSS 可退役」兩條進度。
⚠️ .ds-etable(12 條)刻意不在本版範圍:它疊在 .ds-table 上,核心是 DS.initEditTable 的重算狀態機,而那支與對話框族耦合。本版遷的 35 條表格規則就是它的地基。
select 選單向上展開 — 結案(僅 React 層)
採用登記表自 v1.2.0 掛著的最後一條未結清項。registry/hhg/select.jsx 新增自動翻轉 + forceDirection 覆寫,行為契約寫成 SEL-O8–O13。門檻讀選單自己的 computed max-height(calc(7 * var(--hit-min)) = 308)+6px 間距 = 314px;兩側都不夠時取空間大的一側、不縮 max-height;只在開啟當下算一次。
components.css 零改動 ⇒ 過渡期兩層不一致是刻意的,vanilla 採用端看不到這個行為。
🔴 順手拔掉一個殘留:select.jsx 的選單裡原本寫死一個 <div>分組標題</div>(等值量測樣本殘留),任何用 Select 的頁面都會多出這一列。改成 opt-in 的 groupLabel prop(預設 null =不渲染)。
填色鈕 hover/active 統一(僅 React 層)
filter:brightness(1.08) 是以黑為原點的乘法,--ink #1B1D22 跑它只得到 OKLCH ΔL 0.0090(實測)=看不出來。把參考點換成白端,退化就消失。
⇒ React 版統一為 background: oklch(from <底色> calc(l + (1 - l) * 0.18) c h)(明度往白端推進剩餘空間的 18%,彩度、色相與前景色都不動)。係數 0.18 是校準值:讓 primary 落在 --ink-hi 明度上實測需 0.1843 ⇒ 庫唯一手調過的那顆 hover 成為公式錨點。
範圍=filled 三變體(primary/danger/cta);secondary/ghost/icon 不是填色鈕,逐屬性照搬。vanilla 一律不動,差異逐筆進遷移白名單(W1–W4)。
⚠️ 代價要講清楚:三個 filled 變體的 hover 與 vanilla 型錄不同,這是刻意的,不要當成 bug 回報。
新文件
docs/behavior/(新目錄,12 份 + README 索引)——components.js的框架無關行為規格:觸發條件/量測時機/狀態機/鍵盤操作表/已知矛盾,格式照DS.initEditTable那三條硬規則規格化。每族至少一輪實測驗證,實測發現五個原始碼註解與 GUIDELINE 都沒寫的行為,已逐一寫進規格。
🔴 這批規格是本版九族換底的行為層驗收基準——segmented-pills.md26 步、combobox.md82 步、dialog.md46 步、appshell.md26 步、menu.md29 步、sticky-col.md14 步都是照它逐步核對的。docs/adoption/procurement.md——採購審核系統的採用策略。migration/(工作台)——可重跑的等值驗收:families/<族>.mjs(矩陣+白名單)/build.mjs/compare.mjs/behavior/<族>.mjs/lib/。compare.mjs的 exit code 就是驗收結果(0 =白名單外零差異、零結構不一致、sanity 全過),CI 可以直接吃。- 網站三頁:
/prototype、/changelog(build 時讀releases/*.md,不手抄)、/roadmap;/components頁為本版九族補上可互動 demo。
產生器:build-tailwind.mjs 收斂成一份
repo 根新增 canonical 版 build-tailwind.mjs;site/scripts/sync-ds.mjs 原本自帶一份 88 行的同規格實作(檔內註解自承「那支產生器尚未進 repo」),本版刪掉改 import root 版 ⇒ 同規格兩份實作結清,README.md 那條「生成器還沒進 repo」欠條的最後一項清掉。
等值證明是逐字的:以 site 的 attribution 呼叫 root 版、對現行 tokens.json 產出,與收斂前 site 的現行產物 theme.css 逐字比對 0 差異(md5 兩者皆 5f4c14bf03478f3d3fc729b36e86bcfa)。唯一的差別是 generatedBy/source 兩個參數化字串——呼叫端宣告自己是誰,所以產物的出處不說謊。
出廠檢查 6 項(任一不過 exit 1,site build 也會 throw):顆數守恆/護欄 1(產物不得出現 var(--bp-*))/--breakpoint-* 必須字面值/inline 指向的 token 必須真的存在/命名空間內不得重名/--z-index-* 顆數 = 來源 --z-* 顆數。⚠️ 檢查 2/3 掃描前先 stripComments()——產物自己的註解裡就寫著「不可寫 var()」。--selftest 反測由 11 案例擴為 13 案例(11 個映射/護欄案例 + 假陽性防線 + 反向驗證)。
本版新登記的待拍板項(六條)
⚠️ 這些都不是本版引入的,是九族換底逐屬性比對時量出來的既有缺口。 全部有實測數字、全部不在元件層可修範圍(動了會改 token 或改 vanilla 外觀)。
| # | 項目 | 實測 |
|---|---|---|
| 1 | 🔴 --cta + --on-cta 靜止態對比只有 2.66:1 | 13px/500 的文字需 4.5:1。vanilla 是用「hover 時把文字改成 --ink」把它拉到 8.10——那是用狀態去補償靜止態的不合格;統一模式不做這件事(前景色不隨 hover 改變),所以這個既有問題露出來了。選項:改 --on-cta 為 --ink/壓暗 --cta/限定 CTA 只用在大字級。動哪一個都會改 token |
| 2 | 🔴 .ds-link{font:600 inherit/inherit var(--sans)} 是無效宣告 | inherit 不能當 font shorthand 的組件 ⇒ 整條被丟掉,.ds-link 實測 font-weight: 400,全庫的文字連結都不是設計者以為的 600。修法:拆成 font-weight:600;font-family:var(--sans);line-height:inherit;。⚠️ 會改變 vanilla 外觀,屬拍板項不是純修 bug;React 版目前照搬 400 以維持等值 |
| 3 | ⚠️ .ds-input 沒有 [disabled] 規則 | 停用的輸入框與能填的長得一樣(三個 disabled cell 兩側完全等值,且 .ds-input 自己的 background/color 贏過 UA 的 :disabled)。庫內有對照組:.ds-ecell:disabled{background:var(--surface-2);color:var(--muted);cursor:not-allowed} |
| 4 | ⚠️ .ds-input.ds-invalid 沒有連 placeholder 一起轉紅 | invalid 態的 placeholder 兩側都是 --faint。對照組 .ds-ecell.ds-invalid::placeholder{color:var(--red)} 的庫內理由(「空必填欄位畫面上只剩 placeholder」)對 .ds-field 一樣成立 |
| 5 | ⚠️ 三處文字對比未達 WCAG 4.5:1 | .ds-field-error --red on --ground(11.5px/400)= 4.11:1,而它是庫自己指定的錯誤主要文字通道;.ds-req --req-star on --ground(12px/600)= 2.48:1(已 aria-hidden + aria-required,剩下的是低視力視覺使用者);badge 五個 tone 的靜止態只有 slate 達標。通過的三個一併記著免得重算:placeholder --faint on --field-bg 4.66:1、.ds-field-hint 4.74:1、.ds-field-label 6.22:1 |
| 6 | ⚠️ .ds-combobox-meta 的 hidden 無效 | .ds-combobox-meta{display:flex} 蓋掉 UA 的 [hidden] ⇒ 空 meta 仍佔一個 6px gap。修法是補 .ds-combobox-meta[hidden]{display:none},兩層要一起改,否則等值就破了 |
⚠️ 另外兩條 appshell 族的既有缺口本版照搬不修,一併登記在 backlog:≤820 抽屜段有三處前景色沒有還原(framed 抽屜裡導覽項 hover 文字是 93% 白壓在 #FFFAF3 上、帳號鈕 hover 等於沒有回饋、選中計數的字看不見);.ds-navitem/.ds-menu-item/.ds-acct 都不在通用 focus 規則的分支清單裡 ⇒ 焦點環是瀏覽器預設的,而同一份 CSS 裡 .ds-btn/.ds-input/.ds-tab/.ds-option/.ds-page-btn/.ds-dashed-btn 都在清單裡——導覽與選單是鍵盤使用者的主要路徑。
v1.17.0
2026-08-28Token 層
新增 3 顆,零改值、零改名、零移除。既有 125 顆逐字不變。
| token | 值 | 群組 | 為什麼收 |
|---|---|---|---|
--hit-min | 44px | 命中盒(不是視覺尺寸階,新群組) | 觸控最小命中高度(WCAG 2.5.5/2.5.8)。收之前它是庫內六份無名的 44(.ds-option、coarse 的 --h-btn/--h-select/--h-icon、coarse 的 .ds-link、coarse 的 .ds-checkbox/.ds-switch),而原則 10 整段的立論基礎就是這個數字 |
--ds-badge-h | 22px | 控件高度階層(元件層 token,沿用 --ds-tabs-pad 慣例) | 狀態標籤膠囊高。它是 .ds-badge 那條 white-space:nowrap 的前提(高度固定,文字一換行就溢出膠囊),下游已手抄四份 |
--sp-48 | 48px | 間距階梯(第十二階,值即名) | .ds-empty 的 46px 是階梯外髒值(tokens.css 登記的「三個階梯外髒值」漏數的第四個) |
用 token 前要知道的兩個契約:
- ⚠️
--hit-min的契約是「要被手指點到的最小高度」,不是視覺尺寸階。不要拿它當面板高度、間距或圖示直徑,也不要把它跟--h-*並列挑選。 - ⚠️
--ds-badge-h刻意不叫--h-badge、不進--h-*家族。--h-*帶一個隱含契約:pointer:coarse下放大到命中下限。badge 不是控件,跟著放大會撐爆表格列高,所以它刻意不在 coarse 放大清單裡。
⚠️ tokens.css 的 pointer:coarse 區塊仍是字面 44px、不是 var(--hit-min)(保守路線)。理由:tokens.json 的 pointerCoarse 是原樣搬字串,寫成 var() 之後你會拿到字串 "var(--hit-min)"——對值做運算的產生器(Chakra theme 常見)會當場壞掉,而且是無聲的。該區塊「值必須符合 ^\d+(px)?$」的契約自本版起由產生器的出廠檢查 C 強制。
tokens.json 新增第四個頂層資料區塊 controlScale
與 tokens/pointerCoarse/breakpoints 平行,一個都不要漏。來源是新增的手維護檔 component-sizes.json。
"controlScale": {
"rows": {
"form": { "height": "--h-input", "radius": "--r-input", "font": "--fs-body", "members": [...] },
"toolbar": { "height": "--h-btn", "radius": "--r-btn", "font": "--fs-label", "members": [...] },
"dense": { "height": "--h-select", "radius": "--r-btn", "font": "--fs-label", "members": [...] }
},
"frameworkAliases": { "sm": "dense", "md": "toolbar", "lg": "form" },
"notScalable": ["--h-tab"]
}
- 值一律是 token 名而不是數字(數字回
tokens查一次 lookup)。發第二份數字就是第二份事實來源。 - 第一軸刻意是「列情境」而不是 sm/md/lg:原則 4 的判準是「它跟誰同列」,sm/md/lg 在語意上講的是「重要性」。只發三顆別名,工程師仍會按重要性挑,同列多高度不會被杜絕。sm/md/lg 降為
frameworkAliases。 radius與font跟著height一起帶:換階換的不只高度。只對高度會做出「dense 高但 form 圓角」的縫合怪,而它沒有同列參照物、比高度差幾 px 更難察覺。- 🔴 語意反轉要留意:本庫沒有「預設=md」這回事。 無後綴的
.ds-input(form階)才是預設,-md/-sm是變體。你的框架預設多半是md⇒ 照frameworkAliases對映,不寫 size 的欄位會落到toolbar階,而本庫認為它應該落到form階。目前沒出事只是因為呼叫點都顯式寫了 size——下一個沒寫 size 的新欄位會靜靜掉錯階。 - ⚠️
pointer:coarse下toolbar階與dense階會塌成同高(兩顆高度 token 都放大到--hit-min)。這是原則 10 刻意的設計,不是覆寫失效——第一次在 iPad/平板上驗收時不要去「修」它。 - ⚠️
--h-tab刻意不進rows(列在notScalable):它有恆等式綁著(--h-tab + 2×--ds-tabs-pad = --h-btn),不可以當成一個可自由選的高度階直接用在控件上,只能整組用.ds-tabs。 - 駁回下游提的
--h-input-sm/-md/-lg命名:--h-input-sm(38) 與--h-select(38) 同值不同名=第二事實來源,本庫已兩次明文拒絕同值複本。
元件層
新增兩個尺寸變體(純新增,庫內外零既有使用者 ⇒ 既有外觀零影響):
| selector | 階 | 內容 | 結清的欠條 |
|---|---|---|---|
.ds-btn-sm | dense | --h-select/--sp-12/--r-btn/--fs-label | 採用登記自 v1.2.0 掛著的「small button 尺寸」 |
.ds-select-md>.ds-select-trigger | toolbar | --h-btn | 「已知缺 token」表掛了六版的 .ds-select-md |
⇒ 三階矩陣自此閉合。.ds-select-md 用 > 直接子代,避免命中巢在裡面的 combobox/multicombo trigger。
改吃 token/算式/內部變數(computed 值零變動):
| selector | 改動 |
|---|---|
.ds-option | min-height:44px → var(--hit-min) |
coarse 的 .ds-link | min-height:44px → var(--hit-min) |
coarse 的 .ds-checkbox,.ds-switch | min-height:44px → var(--hit-min) |
.ds-badge | height:22px → var(--ds-badge-h) |
.ds-select-menu | max-height:308px → calc(7 * var(--hit-min)) |
.ds-timeline/.ds-timeline-dot/.ds-timeline-step::before | 收成 --dot:16px/--dot-bw:2px,left:7px → calc((var(--dot) - var(--dot-bw))/2) |
coarse 的 .ds-infotip-btn/.ds-infotip-btn::before/.ds-infotip-pop | 三處手算的 22px 收成 --ring(宣告在 .ds-infotip 上) |
.ds-select-menu的政策是「可見 7 列再捲」,不是 308px——308 就是 7 ×.ds-option列高。收成一顆數值 token 會把推導關係固化:列高一改就露出半列,而且看起來還是有 token,比手抄更難發現。⇒ 本庫政策:捲動容器上限一律用「可見列數 × 列高」表達。你自己的 220/400/440 同理(本庫不收那些數值,但請用同樣的寫法)。- timeline/infotip 那兩組原本是手算的衍生值:改圓點大小要同時改兩處、改圈大小要同時改三處,漏一處就偏心/偏位,而且不會報錯。
🔴 外觀變動一共兩處,都是 +2px:
| selector | 舊 → 新 | 性質 |
|---|---|---|
.ds-empty | padding:46px → var(--sp-48)=48px | 收斂。46 是階梯外髒值;依 v1.13.0 已拍板的通則「原實作的值與階梯對不上時一律收斂到階梯,1–2px 位移直接吸收」 |
.ds-stat-chip | height:31px → var(--h-seg)=33px | 修復。31 就是 v1.13.1 以前的 --h-seg 值,v1.14.0 全階升級時它因為寫死沒跟上 ⇒ 統計 chip 與 segmented chip 從此差 2px,庫內部已裂成兩種。v1.14.0 的 Changelog 把它與 .ds-modal-ico(40)、.ds-side-mark(32) 一起判為「巧合的 literal、非漏轉 var」——那個判斷對 40 與 32 成立,對 31 不成立(它在階梯上待過) |
文件層(零 computed 變動,但影響你怎麼引用本庫):
- 散文去數值:
components.css註解裡引用當前值的括號數字全部刪掉、只留 token 名(約 9 處)。起因是實測踩過:v1.14.0 全階升級只改了tokens.css,這些註解漂了一整階 ⇒ 照註解對齊會對到已經不存在的高度階。改法是刪數字、不是換新數字。同樣的處理也做在GUIDELINE.md的原則 4 與原則 10。 - 「刻意字面值」註解補齊七處:
.ds-infotip-btn22/.ds-avatar30/.ds-notif-ico30/.ds-modal-ico40/.ds-side-mark32/.ds-notif-badge17/.ds-notif-dot7。其中.ds-notif-ico原本只寫「不吃--h-icon」、沒寫「那該吃什麼」——那半句話就是下游只能寫裸 px 的根因,現在補完整。 components.css凍結宣告下新增行號警語:本檔行號不穩定,不要當引用錨點;引用規格用 selector 名稱+版本號(例:.ds-badge @ DS v1.17.0),也不要引用散文裡的數值。
交付物與機制
- 產生器
build-tokens.mjs進 repo(結清 README「生成器還沒進 repo」的欠條)。同批三改:- 🔴 breakpoints 由硬編碼三顆名單改為動態掃
--bp-*。寫死名單的坑已實測踩過一次:v1.15.0 加第四顆--bp-xl時靜靜漏掉,而 breakpoints 不在既有檢查①②的比對範圍內 ⇒ 完全沒有紅燈。 - 出廠檢查 A:
controlScale引用的每個 token 名必須存在。B:components.css動態掃尺寸變體 class,必須在controlScale.members或具名例外清單內。C:pointerCoarse每個值必須符合^\d+(px)?$。三道都會exit 1。
- 🔴 breakpoints 由硬編碼三顆名單改為動態掃
.github/workflows/release-on-tag.yml新增:tag push 自動開 GitHub Release。
🔴 請把本 repo 的 Watch 設成Custom → Releases,否則做了你也收不到。v1.14.0~v1.16.0 的 Release 會回填。CONVERSION.md大幅增補:rem/px 規範(本庫全庫零rem,這是刻意的)、同列同高自我檢查法(含可抄的斷言)、「什麼值進 token」三問判準+「本庫收 vs 專案自訂」界線表、捲動上限政策、裸 hex/CSS Module 死角、引用規範、版本落後守門腳本。
⚠️ 通知解決「知不知道」,守門解決「會不會忘」,兩個都要——一封四天前的通知一樣會被漏看。showcase.html:新增.ds-btn-sm/.ds-select-md示例(與同列元件並排、展示同高);補.ds-avatar(在showcase-appshell.html的 iframe 內)與.ds-modal-ico(在確認型對話框內)的展示指引——「在 DS 找不到」有一部分是型錄缺口造成的,這筆帳算本庫的。- 🔴 型錄第 2 節「幾何階層 Scale」在本版之前顯示的是 v1.13.1 的一整組舊值(圓角 18/16/14…、高度 46/40/36/31/26、頁標題 26)——v1.14.0 全階升級只改了
tokens.css,那一節是硬寫的所以沒跟上。如果你曾照型錄第 2 節對齊過幾何,請重新對一次。 本版把整節改成吃var(--token)+ 現值由getComputedStyle動態填,並補上原本缺席的--h-icon/--h-row/--h-bar/--hit-min/間距階梯(階數動態探測,--sp-48自動出現)⇒ 從此不可能再漂。第 10 節表格說明的舊值(radius 14/列高 54/分頁鈕 40×40/命中盒 40·觸控 44)同批改成 token 名。
對下游 issue 的回覆對應
本版落地的是 hhg-procurement issue #1 建議五~八的裁決。三件不收的事也一併明文(判準與逐項裁決表見 GUIDELINE.md 的「什麼樣的幾何值會變成 token」):
- 不為裝飾/指示器尺寸開新 token 家族(30/40/32/17/7 等):判準三條皆不滿足,且
--h-*的 coarse 契約排除它們 ⇒ 元件內部字面值+「刻意」註解。 - 不收捲動上限的數值(260/168/420/
min(46vh,…)):五個值回答五個不同的問題,收成一顆是把五個理由壓成一個數字。 - 「序號圓章 24px」本庫查無此物:全文零命中「序號」,最接近的對應物是
.ds-step-dot(28)。若指的是步驟指示請收斂到 28;未讀圓點的 6 同理請收斂到 7(相鄰中間值並存本身就違反原則 4)。
v1.16.0
2026-08-25回填⚠️ 本則是 2026-08-28 回填的。v1.16.0 發布當時 GitHub Releases 是零筆,tag push 本身不發任何通知 ⇒ 下游「期間沒有任何訊號」是精確的描述。
Token 層
零變動。 git diff v1.15.0..v1.16.0 -- tokens.json 只有一行:"version": "1.15.0" → "1.16.0"。
⇒ 這一版是「token 層零變動、元件層有變動」的例子。 只看 tokens.json 的 diff 會以為什麼都沒發生。
元件層:33 條 :hover 全數補上 :active
🔴 起因是結構性缺陷,不是美化:改動前庫內 :hover 33 條、:active 0 條 ⇒ 在觸控裝置上把 hover 當成唯一的點擊回饋來源,而「主管用手機審核」是這批系統的既定主要情境。Tailwind v4 把 hover: 包進 @media (hover: hover),遷到 React+Tailwind 之後這個缺陷會從「舊 CSS 剛好蓋住了」變成「使用者按下去沒有任何反應」。這也是 Tailwind 官方的建議(*treating hover functionality as an enhancement, and not depending on it*)。
做法:X:hover{…} → X:hover,X:active{…},:active 複用 :hover 的宣告。桌機幾乎無感(滑鼠按下時本來就在 hover 狀態),觸控獲得「按下亮、放開消失」的正確回饋,且沒有 sticky hover 殘留。
受影響 selector(引用一律用 selector 名稱,不用行號——理由見 CONVERSION.md):
.ds-btn-primary .ds-btn-secondary .ds-btn-ghost .ds-btn-danger .ds-btn-cta .ds-btn-icon
.ds-input .ds-select-trigger .ds-option .ds-tab .ds-seg .ds-pill .ds-page-btn
.ds-dashed-btn .ds-link .ds-ecell .ds-multicombo .ds-infotip-btn
.ds-navitem .ds-acct .ds-side .ds-appshell .ds-menu-item .ds-menu-danger
.ds-notif-item .ds-listcard-clickable .ds-table .ds-table-clickable .ds-row-clickable .ds-col-sticky
⚠️ 刻意略過 1 條:日期輸入的 ::-webkit-calendar-picker-indicator opacity 微調,在觸控上沒有意義。
:active 是新增態,不覆蓋任何既有樣式 ⇒ 桌機外觀零變動。
採用者要做什麼
- 吃
tokens.json的:不必動。 - 吃
components.css的:整檔換掉即可(33 條都是同一種機械改寫)。 - 視覺驗收:型錄
showcase.html已同步 v1.16.0。
⚠️ 一個要更正的既有描述
components.css 的凍結宣告與全檔行號 +24 發生在 v1.15.0,不是本版。本版對行號零影響(git diff v1.15.0..v1.16.0 -- components.css 是 33 加 33 減、淨 0)。⇒ 若你有既有的行號引用,它們是在 v1.15.0(8/24) 就失效的,比一般以為的早一天。
v1.15.0
2026-08-24回填BREAKING⚠️ 本則是 2026-08-28 回填的。v1.15.0 發布當時 GitHub Releases 是零筆,tag push 本身不發任何通知。
🔴 BREAKING:--bp-lg 改名+改值
| 動作 | token | 舊值 → 新值 |
|---|---|---|
| 改值 | --bp-lg | 1200px → 992px |
| 新增 | --bp-xl | — → 1200px(=原本 --bp-lg 的值搬過來) |
⇒ 不是加一顆而已,是改名+改值。 斷點由 3 階改 4 階:640 / 820 / 992(新) / 1200。
為什麼 BREAKING=yes:判準是「下游不改任何一行,畫面或行為會變」,不是「API 有沒有移除」。這一顆是改值,所以它是靜默的——你的產生器重跑之後 lg: 的觸發點就換了,沒有任何編譯錯、沒有任何警告。
為什麼要改名而不是加一顆:走 Tailwind 之後 token 名會直接變成 variant 名(--bp-lg → lg:),而開發端既有的 lg: 期望 992。若讓 --bp-lg 留在 1200,你們既有的 lg: 會靜靜變成 1200 才觸發 ⇒ 992–1200 這段版面無聲壞掉。
改名時的實測:庫內對 --bp-lg 的引用=0(1200 從未出現在任何 @media,庫內 @media 全寫死 640/820),兩個下游 prototype 也只用 --bp-md ⇒ 庫端零風險,風險全在下游的 Tailwind variant 名。
992 這一階目前在庫內零使用者——它是為下游而存在的常數,不要以為漏改。收 992 的依據=開發端 2026-08-17 建議書附的 4 處真實轉折(表頭 grid 進 4 欄/欄位卡分隔線/登入頁直排切橫排/系統圖頁單欄切兩欄+344px 輔助欄)。
⚠️ 不要改回三階、也不要把 --bp-lg 改回 1200。
⚠️ CSS 的 @media 不吃 custom property(@media (max-width:var(--bp-md)) 無效)。--bp-* 是規範用的常數表,不是可引用的變數——寫 media query 時照抄數字。轉到 Tailwind 的 screens 或 Chakra 的 breakpoints 時要對應過去,不要期待能 import 一個變數。
元件層
components.css +26 行,全部在檔頭註解,零 selector 規則變動:
- 🔴 加入凍結宣告:本檔進入凍結狀態、逐族退役。元件層拍板走「形態乙」——改寫成 React 元件、樣式用 Tailwind v4 utility 寫在元件裡,透過 shadcn registry 分發。本檔自此不再是元件層的事實來源;已遷移的族其規則會被刪除。不要在本檔新增元件族;要改既有未遷移族的樣式仍改這裡。
- 補正檔頭版本號
v1.3.0→v1.15.0(漂了十二版)。
⚠️ 這一版讓 components.css 全檔行號 +24,所有既有行號引用一次全失效,而且看起來仍然有效——原本指按鈕 transition 的第 21 行,升版後指到「本檔對應那幾族的規則會被刪除」這一句完整、可讀、但意思無關的話。 ⇒ 引用本庫規格請改用「selector 名稱+版本號」(例:.ds-badge @ DS v1.15.0),不要用行號。
同批新增的文件(非 token/非元件)
GUIDELINE.md新增「元件層導入:Tailwind v4 + shadcn registry」整節(含拍板結果、形態乙的定義、遷移成本實測、三條實測踩出來的護欄、疊層階梯對照表與遷移驗收)。showcase.html/showcase-appshell.html/GUIDELINE.md/README.md首次補齊到 repo(在此之前 repo 上只有 token 層與元件層,視覺驗收基準完全沒有)。
採用者要做什麼
- 重跑產生器拿到新的
breakpoints(四顆)。 - 🔴 手動確認一次你的
lg:/breakpoints.lg現在指到 992 而不是 1200,並檢查 992–1200 這個區間的版面。這一步不能靠 diff——你的 theme 檔是生成的,舊值在你那邊已經被覆蓋掉了。 - 吃
components.css的:讀凍結宣告,並把既有的行號引用改成 selector+版本號。
v1.14.0
2026-08-17回填BREAKING⚠️ 本則是 2026-08-28 回填的。v1.14.0 發布當時 GitHub Releases 是零筆。
ℹ️ 這是本 repo 的初版(tag message:「初版:token、元件層與轉換說明」),也是第一次把
tokens.json/build-tokens.mjs/CONVERSION.md交出去。所以下面的「新增/改值」有兩個視角,分開列。
🔴 這一版的主軸:warm 主題併入 tokens.css 成為庫預設
原本的 [data-theme="warm"] 主題層自此併入 tokens.css 成為預設,不再是可切換的主題。tokens.css 41 顆 token 逐顆改值(不新增第二份宣告、不加覆寫區塊);另有 14 顆 warm 值與原值完全相同、併入無作用。
為什麼是那個時間點:這批元件庫即將分發給 GitHub 上的開發專案吃 token,一旦分發出去,改預設主題的成本就從「一個 prototype 重 sync」變成「四個 repo 各驗一次」——那是分發前的最後一個免費時機。
改值分三群(41 顆)
① 暖化的中性色與 CTA 淺階(17 顆) --ground/--surface-2/--text/--muted/--faint/--disabled-fg/--disabled-fg-2/--line/--line-strong/--line-soft/--disabled-bg/--field-bg/--chip-bg/--cta-weak/--cta-ink/--hover-bg/--nav-sel-bg
- ⚠️
--cta-ink改用官方品牌橙深階#C96A00(原#A45D25是自製的、通過 AA 的值)。官方深階實測未達 AA:對白卡 3.79:1、對暖底 3.53:1(門檻 4.5)。這是 warm 探索階段就記錄且已接受的已知限制,用途仍嚴格限「caption 與小狀態提示」——連結、強調字、控件選中一律維持 ink。 - ⚠️
--surface-2改為#F2F1F1,與--slate-bg(#F1F2F6)從此不再同值。如果你手邊有抄過#F1F2F6當次表面色的地方,它現在是錯的。
② 陰影與遮罩(4 顆) --sh-card/--sh-pop/--sh-menu/--backdrop,從多層小陰影改成單層大陰影(warm 的「柔和」語彙)。
③ 幾何(20 顆)— 這一群是外觀變動的主要來源
| 圓角 | 舊 → 新 | 控件高度 | 舊 → 新 | |
|---|---|---|---|---|
--r-drawer | 18 → 24 | --h-input | 46 → 48 | |
--r-menu | 16 → 20 | --h-btn | 40 → 42 | |
--r-card | 14 → 18 | --h-select | 36 → 38 | |
--r-input | 12 → 14 | --h-tab | 32 → 34 | |
--r-fieldcard | 10 → 14 | --h-icon | 32 → 34 | |
--r-btn | 8 → 12 | --h-seg | 31 → 33 | |
--r-chip | 7 → 10 | --h-chip | 26 → 28 | |
--r-tag | 6 → 8 | --h-row | 54 → 58 | |
--r-check | 2 → 4 | --h-bar | 64 → 66 |
字級:--fs-page 26 → 27、--ls-page -.015em → -.008em。
@media (pointer:coarse)恆等式複算(覆寫值本身不變,只是 base 端起點變了):滑鼠--h-tab(34) + 2×--ds-tabs-pad(4) = --h-btn(42)✓;觸控--h-tab(40) + 2×--ds-tabs-pad(2) = --h-btn(44)✓。- 🔴 待觀察:
--r-fieldcard與--r-input升階後同為 14px,圓角階梯在此少一階(原本 10 vs 12 是相鄰兩級)。這是 warm 原值就有的重疊,照拍板值進,不自行發明新值修正。
元件層
components.css 掃過一輪,沒有找到上述 41 顆舊值被寫死(hex/border-radius/height 皆用 var())。三處巧合的 literal px(.ds-stat-chip height:31px、.ds-modal-ico height:40px、.ds-side-mark height:32px)當時判為「獨立元件的固定尺寸、非漏轉 var 的殘留」,本版未動。
⚠️ 2026-08-28 更正這個判斷:那句話對 40 與 32 成立,對 31 不成立——31 就是 v1.13.1 以前的
--h-seg值,它在階梯上待過,本版升階時它因為寫死所以沒跟上 ⇒ 統計 chip 與 segmented chip 從此差 2px,庫內部已裂成兩種。已在 v1.17.0 改吃--h-seg(+2px,屬修復)。
交付物(同批補登記,token 值零變動)
build-tokens.mjs(產生器)/tokens.json(產物:124 顆 token +pointerCoarse7 顆 +breakpoints)/CONVERSION.md(轉換說明與三個實測踩過的坑)。- 已由 fresh-context agent 獨立驗收:124 顆零漏抓、零幻覺、零值差;
pointerCoarse7 顆不多不少;版本三處一致。 - 同批修掉一個既有缺陷:
tokens.css的「白色當『材質』而非前景」群組標頭被 v1.9.0 插入的一組 token 拆離、描述不到任何東西(機械解析成tokens.json時它成了零顆 token 的空群組才被發現)。純註解移動、零 computed 值變動。
採用者要做什麼
- 初次導入:寫一支產生器讀
tokens.json吐你 stack 的 theme。🔴 不要手抄一次——手抄的那一刻起就是兩份事實來源,我們改版你不會知道。 - 已在用 v1.13.x 的:這一版不是 no-op。圓角與控件高度各升一階,
tokens.json重跑之後外觀就會變 ⇒ 要跑一輪視覺驗收(基準=showcase.html,活型錄、開檔即現值)。 - ⚠️ 注意
pointerCoarse不是可有可無的一組覆寫值。漏掉它,觸控裝置上的按鈕會太小,而且在桌機瀏覽器測不出來。