@qijenchen/design-system 0.1.0-beta.64 → 0.1.0-beta.65

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/CLAUDE.md +1 -1
  2. package/dist/components/Checkbox/checkbox.d.ts.map +1 -1
  3. package/dist/components/Checkbox/checkbox.js +28 -3
  4. package/dist/components/Checkbox/checkbox.js.map +1 -1
  5. package/dist/components/PeoplePicker/person-display.d.ts.map +1 -1
  6. package/dist/components/PeoplePicker/person-display.js +1 -1
  7. package/dist/components/PeoplePicker/person-display.js.map +1 -1
  8. package/dist/components/RadioGroup/radio-group.d.ts +1 -1
  9. package/dist/components/RadioGroup/radio-group.d.ts.map +1 -1
  10. package/dist/components/RadioGroup/radio-group.js +46 -14
  11. package/dist/components/RadioGroup/radio-group.js.map +1 -1
  12. package/dist/components/Rating/rating.d.ts.map +1 -1
  13. package/dist/components/Rating/rating.js +5 -3
  14. package/dist/components/Rating/rating.js.map +1 -1
  15. package/dist/components/Slider/slider.d.ts +1 -1
  16. package/dist/components/Slider/slider.d.ts.map +1 -1
  17. package/dist/components/Slider/slider.js +11 -6
  18. package/dist/components/Slider/slider.js.map +1 -1
  19. package/dist/components/Switch/switch.d.ts +9 -7
  20. package/dist/components/Switch/switch.d.ts.map +1 -1
  21. package/dist/components/Switch/switch.js +30 -5
  22. package/dist/components/Switch/switch.js.map +1 -1
  23. package/dist/components/Tabs/tabs.d.ts.map +1 -1
  24. package/dist/components/Tabs/tabs.js +9 -3
  25. package/dist/components/Tabs/tabs.js.map +1 -1
  26. package/ds-canonical/hooks/check_consumer_app_invariants.sh +9 -0
  27. package/ds-canonical/references/story-baseline-registry.json +18 -2
  28. package/ds-canonical/references/ui-dev-rules.md +21 -0
  29. package/llms-full.txt +1 -1
  30. package/llms.txt +1 -1
  31. package/package.json +1 -1
  32. package/src/components/Accordion/accordion.spec.md +1 -1
  33. package/src/components/AppShell/app-shell.stories.tsx +3 -3
  34. package/src/components/Carousel/carousel.principles.stories.tsx +3 -3
  35. package/src/components/Checkbox/checkbox.spec.md +9 -1
  36. package/src/components/Checkbox/checkbox.tsx +45 -3
  37. package/src/components/Field/field-controls.spec.md +3 -1
  38. package/src/components/Field/field.anatomy.stories.tsx +3 -1
  39. package/src/components/Field/field.stories.tsx +14 -1
  40. package/src/components/PeoplePicker/person-display.tsx +4 -3
  41. package/src/components/ProgressBar/progress-bar.anatomy.stories.tsx +2 -2
  42. package/src/components/RadioGroup/radio-group.anatomy.stories.tsx +1 -1
  43. package/src/components/RadioGroup/radio-group.spec.md +2 -0
  44. package/src/components/RadioGroup/radio-group.tsx +59 -15
  45. package/src/components/Rating/rating.tsx +7 -3
  46. package/src/components/Sidebar/sidebar.spec.md +2 -0
  47. package/src/components/Slider/slider.anatomy.stories.tsx +2 -1
  48. package/src/components/Slider/slider.spec.md +8 -7
  49. package/src/components/Slider/slider.tsx +24 -11
  50. package/src/components/Switch/switch.anatomy.stories.tsx +4 -4
  51. package/src/components/Switch/switch.principles.stories.tsx +3 -3
  52. package/src/components/Switch/switch.spec.md +10 -6
  53. package/src/components/Switch/switch.tsx +45 -12
  54. package/src/components/Tabs/tabs.anatomy.stories.tsx +3 -3
  55. package/src/components/Tabs/tabs.principles.stories.tsx +1 -1
  56. package/src/components/Tabs/tabs.spec.md +4 -0
  57. package/src/components/Tabs/tabs.stories.tsx +4 -4
  58. package/src/components/Tabs/tabs.tsx +9 -3
  59. package/src/patterns/header-canonical/header-canonical.spec.md +1 -1
  60. package/src/styles/base.css +9 -2
  61. package/src/tokens/color/semantic.css +5 -1
@@ -6,11 +6,11 @@ import { cva, type VariantProps } from "class-variance-authority"
6
6
 
7
7
  import { cn } from "@/lib/utils"
8
8
  import type { FieldMode, FieldVariant } from "@/design-system/components/Field/field-types"
9
- import { useResolvedFieldMode, useResolvedFieldDisabled } from "@/design-system/components/Field/field-context"
9
+ import { useFieldContext, useResolvedFieldMode, useResolvedFieldDisabled, useResolvedFieldSize } from "@/design-system/components/Field/field-context"
10
10
  import { SelectionItem } from "@/design-system/components/SelectionControl/selection-item"
11
11
  import type { LucideIcon } from "lucide-react"
12
12
  import type { AvatarData } from "@/design-system/components/Avatar/avatar"
13
- import { EMPTY_DISPLAY } from "@/design-system/components/Field/field-wrapper"
13
+ import { EMPTY_DISPLAY, fieldWrapperStyles } from "@/design-system/components/Field/field-wrapper"
14
14
 
15
15
  // ── RadioGroup display mode ─────────────────────────────────────────────────
16
16
  // RadioGroup mode='display' 時:Group 不渲染 Radix primitive(無 radio 視覺),
@@ -29,7 +29,7 @@ export interface RadioGroupProps
29
29
  * display — **純展示**:不渲染 Radix Root / 任何 radio 視覺;RadioGroup 本體 walk
30
30
  * children,僅 control.value === group.value 那筆把 label 渲染為純文字 span。
31
31
  * 對齊 Carbon read-only / DataTable single-select cell read mode。
32
- * readonly — child item 各自 readOnly:radio 視覺保留 + 鎖互動
32
+ * readonly — standalone:ReadonlyContext items 鎖互動保留視覺;Field 內:灰框 + 選中項 label(不渲染 radio 群組)
33
33
  * disabled — 同 RadioGroupPrimitive.Root disabled 屬性
34
34
  */
35
35
  mode?: FieldMode
@@ -44,12 +44,38 @@ export interface RadioGroupProps
44
44
  // (item 已支援 readOnly prop + data-[readonly] 樣式;Radix Root 無 readOnly,故用 context)。
45
45
  const RadioGroupReadonlyContext = React.createContext(false)
46
46
 
47
+ // walk children 找 control.value === selectedValue 的 SelectionItem label(display / readonly-in-Field 共用)
48
+ function findSelectedRadioLabel(children: React.ReactNode, selectedValue: string | undefined): React.ReactNode {
49
+ let selectedLabel: React.ReactNode = null
50
+ React.Children.forEach(children, (child) => {
51
+ if (!React.isValidElement(child)) return
52
+ const cProps = child.props as { control?: unknown; label?: React.ReactNode; value?: unknown }
53
+ // 形狀 1:<RadioGroupItem value label>(主用法)— value/label 直掛 props
54
+ if (cProps.value === selectedValue && cProps.value !== undefined) {
55
+ selectedLabel = cProps.label ?? selectedValue
56
+ return
57
+ }
58
+ // 形狀 2:<SelectionItem control={<RadioGroupItem value/>} label>(組合用法)
59
+ const control = cProps.control
60
+ if (React.isValidElement(control)) {
61
+ const controlValue = (control.props as { value?: unknown }).value
62
+ if (controlValue === selectedValue) {
63
+ selectedLabel = cProps.label ?? selectedValue
64
+ }
65
+ }
66
+ })
67
+ return selectedLabel
68
+ }
69
+
47
70
  const RadioGroup = React.forwardRef<
48
71
  React.ElementRef<typeof RadioGroupPrimitive.Root>,
49
72
  RadioGroupProps
50
73
  >(({ className, mode, variant: _chrome, value, defaultValue, ...props }, ref) => {
51
74
  // 2026-06-08 SSOT cascade:resolvedMode 經 resolver hook 讀 fieldCtx(原 root 完全不讀 → <Field disabled>/<Field mode> 失效)
52
75
  const resolvedMode = useResolvedFieldMode({ mode, disabled: (props as { disabled?: boolean }).disabled })
76
+ const fieldCtx = useFieldContext()
77
+ // readonly 灰框 size:走 SSOT resolver(RadioGroup 無 size prop → ctx > 'md')
78
+ const resolvedBoxSize = useResolvedFieldSize(undefined, 'md') as 'sm' | 'md' | 'lg'
53
79
  // mode='display' — 純展示 selected option 的 label,不渲染任何 radio control 視覺。
54
80
  // 對齊 Carbon read-only single-select(只顯示 selected 內容)+ Airtable / Notion read-only。
55
81
  // 實作:walk children 找 control.value === selectedValue 的 SelectionItem,render label plain text。
@@ -59,18 +85,7 @@ const RadioGroup = React.forwardRef<
59
85
  if (!selectedValue) {
60
86
  return <div role="group" className={cn('grid', className)}><span className="text-fg-muted">{EMPTY_DISPLAY}</span></div>
61
87
  }
62
- let selectedLabel: React.ReactNode = null
63
- React.Children.forEach(props.children, (child) => {
64
- if (!React.isValidElement(child)) return
65
- const cProps = child.props as { control?: unknown; label?: React.ReactNode }
66
- const control = cProps.control
67
- if (React.isValidElement(control)) {
68
- const controlValue = (control.props as { value?: unknown }).value
69
- if (controlValue === selectedValue) {
70
- selectedLabel = cProps.label ?? selectedValue
71
- }
72
- }
73
- })
88
+ const selectedLabel = findSelectedRadioLabel(props.children, selectedValue)
74
89
  return (
75
90
  <div role="group" className={cn('grid', className)}>
76
91
  <span className="text-foreground">{selectedLabel ?? selectedValue}</span>
@@ -78,6 +93,35 @@ const RadioGroup = React.forwardRef<
78
93
  )
79
94
  }
80
95
 
96
+ // ── mode='readonly' in Field(2026-06-12 user 拍板,與 Checkbox/Switch 灰框模型一致)──
97
+ // Field 內 readonly 單選 = fieldWrapperStyles readonly 灰框 + 選中項 label 文字
98
+ // (= Select readonly 同款呈現:同為「單選資料」,鎖定時呈現一致)。
99
+ // standalone readonly(無 Field)維持原樣鎖互動(ReadonlyContext 路徑)。
100
+ if (resolvedMode === 'readonly' && fieldCtx?.hasFieldWrapper === true) {
101
+ const selectedValue = (value ?? defaultValue) as string | undefined
102
+ const selectedLabel = selectedValue ? findSelectedRadioLabel(props.children, selectedValue) : null
103
+ const boxSize = resolvedBoxSize
104
+ return (
105
+ <div
106
+ role="radiogroup"
107
+ aria-readonly="true"
108
+ aria-labelledby={fieldCtx?.labelId}
109
+ aria-invalid={fieldCtx?.invalid || undefined}
110
+ data-readonly="true"
111
+ tabIndex={0}
112
+ className={cn(
113
+ fieldWrapperStyles({ size: boxSize, mode: 'readonly', variant: 'default' }),
114
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
115
+ className,
116
+ )}
117
+ >
118
+ {selectedValue
119
+ ? <span className="text-foreground">{selectedLabel ?? selectedValue}</span>
120
+ : <span className="text-fg-muted">{EMPTY_DISPLAY}</span>}
121
+ </div>
122
+ )
123
+ }
124
+
81
125
  // mode='disabled' → Radix Root disabled(原生 propagate 給所有 item);
82
126
  // mode='readonly' → context 傳 readOnly 給 items(item 渲染為 data-[readonly] 鎖互動 + aria-readonly)。
83
127
  return (
@@ -2,7 +2,7 @@
2
2
  import * as React from 'react'
3
3
  import { Star, type LucideIcon } from 'lucide-react'
4
4
  import { cn } from '@/lib/utils'
5
- import { useFieldContext, useResolvedFieldSize, useResolvedFieldDisabled } from '@/design-system/components/Field/field-context'
5
+ import { useFieldContext, useResolvedFieldSize, useResolvedFieldDisabled, useResolvedFieldMode } from '@/design-system/components/Field/field-context'
6
6
 
7
7
  /**
8
8
  * Rating — 星星評分元件
@@ -98,7 +98,7 @@ const Rating = React.forwardRef<HTMLDivElement, RatingProps>(
98
98
  max = 5,
99
99
  size: sizeProp,
100
100
  precision = 'full',
101
- readOnly = false,
101
+ readOnly: readOnlyProp = false,
102
102
  disabled: disabledProp,
103
103
  loading = false,
104
104
  icon: Icon = Star,
@@ -115,6 +115,10 @@ const Rating = React.forwardRef<HTMLDivElement, RatingProps>(
115
115
  const fieldCtx = useFieldContext() // 保留:aria-labelledby 用 fieldCtx.labelId
116
116
  // 2026-06-08 SSOT:<Field disabled> cascade(原 isInteractive 只看 local disabled prop)
117
117
  const disabled = useResolvedFieldDisabled(disabledProp)
118
+ // <Field mode="readonly"> cascade(2026-06-12 補):Rating 的 readonly 呈現 = 星星本身
119
+ // (星星即值語言,role=img,全業界 review-stars canonical)——不包灰框,只鎖互動。
120
+ const resolvedMode = useResolvedFieldMode({ mode: undefined, disabled, readOnly: readOnlyProp })
121
+ const readOnly = readOnlyProp || resolvedMode === 'readonly'
118
122
  const size = useResolvedFieldSize<'xs' | 'sm' | 'md' | 'lg'>(sizeProp, 'xs') // SSOT:統一 size resolution(Rating default 'xs')
119
123
  const [internalValue, setInternalValue] = React.useState(defaultValue)
120
124
  const [hoverValue, setHoverValue] = React.useState<number | null>(null)
@@ -157,7 +161,7 @@ const Rating = React.forwardRef<HTMLDivElement, RatingProps>(
157
161
  // Standalone → 仍需 consumer 傳 aria-label。對齊 TimePicker / DatePicker 同 canonical
158
162
  // (time-picker.tsx:313 / date-picker.tsx:514:aria-labelledby={fieldCtx?.labelId})。
159
163
  // 置於 {...props} 前,consumer 顯式傳的 aria-labelledby 仍可覆寫。
160
- aria-labelledby={isInteractive ? fieldCtx?.labelId : undefined}
164
+ aria-labelledby={fieldCtx?.labelId} // 2026-06-12 修:readonly/disabled(role=img)也需 accessible name,labelledby 對 img 合法
161
165
  aria-valuenow={isInteractive ? currentValue : undefined}
162
166
  aria-valuemin={isInteractive ? 0 : undefined}
163
167
  aria-valuemax={isInteractive ? max : undefined}
@@ -130,6 +130,8 @@ SidebarProvider ← 全域 context(open 狀態、cookie、快捷
130
130
 
131
131
  **Chrome header 不跟 size 變**:`SidebarHeader` / `SidebarFooter` 固定用 `var(--chrome-header-height)`,只跟 **density** 連動,不跟 `size` prop 變——因為 header 是結構槽位,不是 row。
132
132
 
133
+ **SidebarHeader brand 必單行(2026-06-12 明文,fork drift anchor)**:Avatar 24 + 名稱單行,**無副標 slot**——Fixed-h chrome 結構上不可成長;plan/org 等第二行資訊歸 workspace switcher dropdown row,對齊 Slack/Notion/Linear 現行版單行。shadcn TeamSwitcher demo 的「Acme Inc + Enterprise」兩行是 demo 慣例非本 DS canonical(fork repo AI 曾以訓練先驗在 spec 沉默處回填此副標)。Registry antiPattern `sidebar-header-subtitle-second-line` 機械攔(flex-col 直接子層簽名)。
134
+
133
135
  ---
134
136
 
135
137
  ## 內容形態選擇(核心設計決策)
@@ -104,7 +104,8 @@ export const ColorMatrix: Story = {
104
104
  <tr><Td>Range 填滿色 default</Td><Td><TokenCell token="--primary" display="bg-primary" /></Td><Td><TokenCell token="--primary" /></Td></tr>
105
105
  <tr><Td>Range 填滿色 disabled</Td><Td><TokenCell token="--border" display="bg-border" /></Td><Td><TokenCell token="--border" display="--border(neutral-5)" /></Td></tr>
106
106
  <tr><Td>Thumb 直徑</Td><Td mono>16px(h-4 w-4)</Td><Td>—</Td></tr>
107
- <tr><Td>Thumb 底色</Td><Td><TokenCell token="--surface" display="bg-surface(白,default+disabled 不變)" /></Td><Td><TokenCell token="--surface" /></Td></tr>
107
+ <tr><Td>Thumb 底色 rest</Td><Td><TokenCell token="--on-emphasis" display="bg-on-emphasis(白,深淺不反轉)" /></Td><Td><TokenCell token="--on-emphasis" /></Td></tr>
108
+ <tr><Td>Thumb 底色 disabled</Td><Td><TokenCell token="--canvas" display="bg-canvas(不透明背景色,沉回)" /></Td><Td><TokenCell token="--canvas" /></Td></tr>
108
109
  <tr><Td>Thumb 邊框 default</Td><Td><TokenCell token="--primary" display="border-2 border-primary" /></Td><Td><TokenCell token="--primary" display="--primary(與 Range default 同色)" /></Td></tr>
109
110
  <tr><Td>Thumb 邊框 disabled</Td><Td><TokenCell token="--border" display="border-border" /></Td><Td><TokenCell token="--border" display="--border(與 Range disabled 同色)" /></Td></tr>
110
111
  <tr><Td>Thumb hover</Td><Td mono>border primary-hover + --elevation-100</Td><Td>—</Td></tr>
@@ -101,7 +101,8 @@ Slider 接收 `size?: 'sm' | 'md' | 'lg'` prop(預設 `md`),**這個 prop 只決
101
101
  | **Range** 填滿色 rest | `bg-primary` | `--primary` |
102
102
  | **Range** 填滿色 disabled | `bg-border`(neutral-5)| `--border` |
103
103
  | **Thumb** 直徑 | 16px | — |
104
- | **Thumb** 底色(rest + disabled 不變)| `bg-surface`() | `--surface` |
104
+ | **Thumb** 底色 rest | `bg-on-emphasis`(白,深淺主題不反轉;對齊 Switch thumb / Radix Themes / iOS) | `--on-emphasis` |
105
+ | **Thumb** 底色 disabled | `bg-canvas`(不透明頁面背景色,沉回背景;= Radix disabled thumb gray-1 同款) | `--canvas` |
105
106
  | **Thumb** 邊框 rest | 2px,色 = Range rest 同 token | `--primary` |
106
107
  | **Thumb** 邊框 disabled | `border-border`(= Range disabled 同色)| `--border` |
107
108
  | **Thumb** hover | border `primary-hover` + 陰影 `--elevation-100` | `--primary-hover` |
@@ -191,11 +192,11 @@ Radix Slider 原生支援多 thumb——只要 `value` / `defaultValue` 傳長
191
192
 
192
193
  | State | 視覺 | 觸發 |
193
194
  |---|---|---|
194
- | Rest | track `bg-secondary`,range `bg-primary`,thumb `bg-surface + border-primary` | 預設 |
195
+ | Rest | track `bg-secondary`,range `bg-primary`,thumb `bg-on-emphasis + border-primary` | 預設 |
195
196
  | Hover(thumb) | 加 `--elevation-100` 陰影 | 滑鼠 hover 在 thumb 上 |
196
197
  | Active(拖曳中) | 加 `--elevation-200` 陰影 | 按住拖曳 |
197
198
  | Focus | thumb border 加深成 `primary-hover`(跟 hover 同視覺,不加 ring / halo)| 鍵盤 Tab 聚焦 |
198
- | Disabled | 灰階降級:range `bg-border`、thumb `border-border`(= range 同 token)、thumb bg 保留 `bg-surface` 白底、`cursor-not-allowed`、hover 陰影關閉 | `disabled` prop 或 Field context disabled |
199
+ | Disabled | 灰階降級:range `bg-border`、thumb `border-border`(= range 同 token)、thumb bg 沉回 `bg-canvas`(不透明背景色)、`cursor-not-allowed`、hover 陰影關閉 | `disabled` prop 或 Field context disabled |
199
200
 
200
201
  **Mode / readonly / dark mode / density** 詳見 `../Field/field-controls.spec.md`(Slider 作為 Field 家族整合時繼承其 canonical;semantic token 自動處理 dark mode,無需元件內特殊 handling)。
201
202
 
@@ -208,7 +209,7 @@ track (muted, n-2) < range = thumb border (border, n-5) < text (n-7+)
208
209
  **三階**,不是四階。Range 和 Thumb border 刻意用同一個 token(見前面「Range 色 ↔ Thumb border 色的綁定規則」),不拆成兩階。
209
210
 
210
211
  - **Track n-2**:最底的凹槽底線
211
- - **Range + Thumb border n-5**:同層——thumb border 是 range 的連續視覺,兩者融為一體;thumb 的 fill 保持 surface 色,與 range fill 對比,位置由 range 的長度段決定
212
+ - **Range + Thumb border n-5**:同層——thumb border 是 range 的連續視覺,兩者融為一體;thumb 的 fill(rest / disabled canvas)與 range fill 對比,位置由 range 的長度段決定
212
213
  - **Text n-7+**:label / description 在所有視覺元件之上
213
214
 
214
215
  ### 為什麼 range disabled 不用 `--fg-disabled`
@@ -267,7 +268,7 @@ Switch 是**唯一需要 opacity 的特例**,因為它的 on/off 沒有任何形
267
268
 
268
269
  ### 常見錯誤(避免)
269
270
 
270
- **不要把 thumb 的 disabled bg 改成 `bg-muted`**——`--muted` 和 `--bg-disabled` 在這個系統都等於 `var(--color-neutral-2)`,同一個顏色。Thumb `bg-muted` 會跟 track `bg-muted` 完全融色,只剩 border 可見,失去 thumb 形狀辨識(真實踩過的 bug)。**保留 `bg-surface` 白底**,只改 border 顏色即可達成降級。
271
+ **不要把 thumb 的 disabled bg 改成 `bg-muted`**——`--muted` 和 `--bg-disabled` 在這個系統都等於 `var(--color-neutral-2)`,同一個顏色。Thumb `bg-muted` 會跟 track `bg-muted` 完全融色,只剩 border 可見,失去 thumb 形狀辨識(真實踩過的 bug)。Disabled 用 **`bg-canvas`**(不透明頁面背景色,與 track 的 muted 不同值且隔 n-5 邊框,不融色)。2026-06-12 補:rest 態原為 `bg-surface`,但 `--surface` 深色 = 8% 白半透明 → thumb 在深色變破洞且 track 穿透,故改 `bg-on-emphasis`(固定白不反轉)。
271
272
 
272
273
  **不要同時套 opacity + 灰階 swap**——兩個策略互斥,同時用會導致「灰階 swap 後再打 opacity 一層」,視覺雙重降級,整個 slider 褪色過度。選一條路走到底。
273
274
 
@@ -275,9 +276,9 @@ Switch 是**唯一需要 opacity 的特例**,因為它的 on/off 沒有任何形
275
276
 
276
277
  Slider 沒有獨立的 error 視覺——拖曳選值本身不太會「無效」。如果業務邏輯需要限制範圍,用 `min` / `max` 直接限制使用者能拖到的範圍,不要讓他拖到再報錯。
277
278
 
278
- ### readonly mode
279
+ ### Readonly(僅 Field cascade,無獨立 prop)
279
280
 
280
- Slider `readonly` 等同於 `disabled`——一個不能操作的 slider 本質上就是 disabled。不同於 Input 有「可以 focus 但不能改」的 readonly 差異,slider focus 除了拖曳沒有其他行為。若要顯示「歷史值」,用純文字或另一個 display 元件。
281
+ Slider 無獨立 `readOnly` prop;但在 `<Field mode="readonly">` 內(2026-06-12 拍板)= **鎖互動、保留正常視覺**(pointer-events-none + thumb tabIndex=-1 + aria-readonly on thumb;值仍可讀、不降色——readonly disabled)。理由:readonly 表單中 Slider 的值(thumb 位置 + range 長度)本身就是 value 呈現。若要在非表單情境顯示「歷史值」,用純文字或另一個 display 元件。
281
282
 
282
283
  ---
283
284
 
@@ -2,7 +2,7 @@ import * as React from 'react'
2
2
  import * as SliderPrimitive from '@radix-ui/react-slider'
3
3
  import { cva, type VariantProps } from 'class-variance-authority'
4
4
  import { cn } from '@/lib/utils'
5
- import { useFieldContext, useResolvedFieldDisabled } from '@/design-system/components/Field/field-context'
5
+ import { useFieldContext, useResolvedFieldDisabled, useResolvedFieldMode } from '@/design-system/components/Field/field-context'
6
6
 
7
7
  /**
8
8
  * Slider — 數值範圍選取器
@@ -61,7 +61,10 @@ const Slider = React.forwardRef<
61
61
  // Field 家族整合:被 <Field mode="disabled"> 包裹時自動 disabled(per slider.spec.md「Slider 作為 Field
62
62
  // 家族整合時繼承其 canonical」)。Slider 已有完整 data-[disabled] 視覺,故只需把 fieldCtx disabled 接上。
63
63
  // 2026-06-08 SSOT:讀 useResolvedFieldDisabled()(fieldCtx.disabled)→ <Field disabled> 與 <Field mode="disabled"> 都生效
64
+ // 2026-06-12 補:<Field mode="readonly"> → 鎖互動保留視覺(readonly ≠ disabled,不降色)
64
65
  const fieldDisabled = useResolvedFieldDisabled()
66
+ const fieldMode = useResolvedFieldMode({ mode: undefined, disabled: undefined, readOnly: undefined })
67
+ const fieldReadonly = fieldMode === 'readonly'
65
68
  // 2026-06-10 a11y:Field 內 Slider thumb(role=slider)無 accessible name(deep-audit axe 抓 aria-input-field-name)
66
69
  // → 預設接 FieldLabel(aria-labelledby),consumer ariaLabel 優先。對齊 rating/time-picker labelId 接線。
67
70
  const fieldLabelId = useFieldContext()?.labelId
@@ -77,9 +80,12 @@ const Slider = React.forwardRef<
77
80
  ref={ref}
78
81
  value={value}
79
82
  defaultValue={defaultValue}
80
- className={cn(sliderRootVariants({ size }), className)}
83
+ className={cn(sliderRootVariants({ size }), fieldReadonly && 'pointer-events-none', className)}
81
84
  {...props}
82
85
  disabled={(props as { disabled?: boolean }).disabled || fieldDisabled}
86
+ // <Field mode="readonly"> cascade(2026-06-12 補):鎖互動、保留正常視覺(readonly
87
+ // 不降色;值仍可讀)。pointer-events-none 擋滑鼠,thumb tabIndex=-1 擋鍵盤。
88
+ data-readonly={fieldReadonly || undefined}
83
89
  >
84
90
  {/*
85
91
  Track — rest 用 bg-secondary(n-3,「微淡可辨」),disabled 用 bg-muted(n-2,退化)。
@@ -115,27 +121,34 @@ const Slider = React.forwardRef<
115
121
  - Rest: `border-primary` ↔ Range `bg-primary`
116
122
  - Disabled: `border-border` ↔ Range `bg-border`
117
123
  這個一致性讓 thumb border 跟 range 融為一體,看起來像「range 包住 thumb」
118
- 的連續視覺。thumb 的白底則是「被 range 圍住的空心洞」,讓 thumb 的位置
119
- 清楚浮出。不論 state,thumb border 跟 range 永遠同色。
124
+ 的連續視覺。不論 state,thumb border range 永遠同色。
120
125
 
121
- **為什麼 thumb bg 不能改**:`bg-surface`(白)必須在 rest / disabled 都維持,
122
- 否則會融入 track 的 `bg-muted` 裡消失。這是之前踩過的同色融色 bug
123
- (曾經寫成 `data-[disabled]:bg-muted` thumb track 完全融合)。
126
+ **Thumb bg 兩態(2026-06-12 user 拍板,深色模式破洞修正)**:
127
+ - Rest/hover/active:`bg-on-emphasis`(固定白、深淺主題不反轉)— 對齊自家
128
+ Switch thumb(switch.tsx bg-on-emphasis)+ Radix Themes(thumb 字面 white
129
+ 無 dark override)+ Apple iOS(深色模式旋鈕仍白)。原 `bg-surface` 在深色
130
+ = 8% 白半透明 → thumb 變深色破洞且 track 穿透。
131
+ - Disabled:`bg-canvas`(不透明頁面背景色)— 沉回背景表達「不可動」,
132
+ = Radix disabled thumb 用 gray-1(app background)同款;不透明故 track
133
+ 不穿透。不可用 bg-muted(曾踩 thumb 跟 track 同色融合 bug;canvas 與
134
+ track 的 muted 隔 n-5 邊框 + 不同值,不融)。
124
135
  */}
125
136
  {Array.from({ length: thumbCount }).map((_, i) => (
126
137
  <SliderPrimitive.Thumb
127
138
  key={i}
139
+ tabIndex={fieldReadonly ? -1 : undefined}
140
+ aria-readonly={fieldReadonly || undefined}
128
141
  className={cn(
129
142
  'block h-4 w-4 shrink-0 rounded-full cursor-grab',
130
- 'bg-surface border-2 border-primary',
143
+ 'bg-on-emphasis border-2 border-primary',
131
144
  'transition-all duration-150',
132
145
  // Hover:border 加深到 primary-hover + elevation 陰影
133
146
  'hover:border-primary-hover hover:[box-shadow:var(--elevation-100)]',
134
147
  'active:cursor-grabbing active:border-primary-hover active:[box-shadow:var(--elevation-200)]',
135
148
  // Focus:border 加深(跟 hover 同視覺),不加 ring 或 halo
136
149
  'outline-none focus-visible:border-primary-hover',
137
- // Disabled:border 跟 Range 一起退成 border(n-5),bg 保留 bg-surface
138
- 'data-[disabled]:cursor-not-allowed data-[disabled]:border-border',
150
+ // Disabled:border 跟 Range 一起退成 border(n-5),bg 沉回 canvas(不透明背景色)
151
+ 'data-[disabled]:cursor-not-allowed data-[disabled]:border-border data-[disabled]:bg-canvas',
139
152
  'data-[disabled]:hover:[box-shadow:none]',
140
153
  )}
141
154
  // aria-label 策略(對齊 WAI-ARIA APG multi-thumb slider + Radix 原生語意標籤):
@@ -174,7 +187,7 @@ export const sliderMeta = {
174
187
  },
175
188
  states: ['default', 'hover', 'active', 'focus-visible', 'disabled'],
176
189
  tokens: {
177
- bg: ['bg-muted', 'bg-primary', 'bg-secondary', 'bg-surface'],
190
+ bg: ['bg-muted', 'bg-primary', 'bg-secondary', 'bg-on-emphasis', 'bg-canvas'],
178
191
  fg: [],
179
192
  ring: [],
180
193
  },
@@ -45,7 +45,7 @@ export const Overview: Story = {
45
45
  ['label', 'ReactNode', '—', 'inline label(Field context 內會被忽略)'],
46
46
  ['description', 'ReactNode', '—', 'inline description(與 label 搭配)'],
47
47
  ['disabled', 'boolean', 'false', '停用(opacity-disabled 保留顏色,見 spec)'],
48
- ['readOnly', 'boolean', 'false', '鎖定互動但視覺正常(pointer-events-none + aria-readonly)'],
48
+ ['readOnly', 'boolean', 'false', 'standalone:鎖定互動視覺正常;Field 內:灰框 + ✓/—(2026-06-12 拍板)'],
49
49
  ].map(([p, t, d, desc]) => (
50
50
  <tr key={p}><Td mono>{p}</Td><Td mono>{t}</Td><Td mono>{d}</Td><Td>{desc}</Td></tr>
51
51
  ))}
@@ -87,7 +87,7 @@ export const Inspector: InspectorStory = {
87
87
  },
88
88
  defaultChecked: { control: 'boolean', description: '預設 ON / OFF' },
89
89
  disabled: { control: 'boolean', description: '停用(opacity-disabled 保留顏色)' },
90
- readOnly: { control: 'boolean', description: '唯讀(視覺正常,互動鎖定)' },
90
+ readOnly: { control: 'boolean', description: '唯讀(standalone 視覺正常鎖互動;Field 內灰框 + ✓/—)' },
91
91
  label: { control: 'text', description: 'inline label(留空 → 只渲染 switch 本體)' },
92
92
  description: { control: 'text', description: 'inline description(需搭配 label)' },
93
93
  },
@@ -135,7 +135,7 @@ export const StateBehavior: Story = {
135
135
  <div className="flex flex-col gap-8">
136
136
  <div>
137
137
  <H3>視覺狀態對照</H3>
138
- <Desc>disabled 用 opacity-disabled 保留顏色(Switch 特例,見下「Disabled 策略」)。readonly 視覺正常但互動鎖定。</Desc>
138
+ <Desc>disabled 用 opacity-disabled 保留顏色(Switch 特例,見下「Disabled 策略」)。readonly:standalone 視覺正常鎖互動;Field 內灰框 + ✓/—。</Desc>
139
139
  <div className="overflow-x-auto mb-4">
140
140
  <table className="text-caption border-collapse">
141
141
  <thead><tr><Th>State</Th><Th>Track</Th><Th>Thumb</Th><Th>Check icon</Th></tr></thead>
@@ -144,7 +144,7 @@ export const StateBehavior: Story = {
144
144
  <tr><Td>ON</Td><Td><TokenCell token="--primary" display="bg-primary" /></Td><Td><span className="inline-flex items-center gap-1.5"><Swatch value="--primary" size="sm" /><span className="font-mono">白色 + 2px primary border</span></span></Td><Td><TokenCell token="--primary" display="primary check" /></Td></tr>
145
145
  <tr><Td>Disabled OFF</Td><Td>opacity-disabled 套於整體</Td><Td>同 OFF(顏色保留)</Td><Td>—</Td></tr>
146
146
  <tr><Td>Disabled ON</Td><Td>opacity-disabled 套於整體</Td><Td>同 ON(顏色保留)</Td><Td>同 ON</Td></tr>
147
- <tr><Td>Readonly</Td><Td>正常顏色</Td><Td>正常視覺</Td><Td>視 on/off</Td></tr>
147
+ <tr><Td>Readonly(standalone)</Td><Td>正常顏色</Td><Td>正常視覺</Td><Td>視 on/off</Td></tr>
148
148
  </tbody>
149
149
  </table>
150
150
  </div>
@@ -99,7 +99,7 @@ export const UsageGuidance: Story = {
99
99
  {/* vs 近親 — ReadonlyVsDisabledRule — 原 ReadonlyVsDisabledRule */}
100
100
  <div>
101
101
  <Rule
102
- title="Readonly 保留正常顏色(可讀)/ Disabled 降透明度(弱化)"
102
+ title="Readonly(standalone)保留正常顏色(可讀)/ Disabled 降透明度(弱化)"
103
103
  note="兩者都鎖定互動,但視覺訊號不同:readonly 告訴使用者「這個值就是這樣,你看得清」;disabled 告訴使用者「這個 field 目前不可用」(弱化暗示低優先)"
104
104
  >
105
105
  <div className="grid grid-cols-2 gap-4">
@@ -116,9 +116,9 @@ export const UsageGuidance: Story = {
116
116
 
117
117
  <Rule
118
118
  title="使用場景對照"
119
- note="Readonly:表單 readonly 呈現、DataTable cell 非編輯態——值重要、視覺不能弱化。Disabled:外部條件造成暫時不可用(方案限制、權限不足)——傳達「現在用不到」"
119
+ note="Readonly(standalone settings list):值重要、視覺不能弱化。表單 readonly = Field 內灰框 + ✓/—(2026-06-12 拍板);DataTable cell 非編輯態 = mode display。Disabled:外部條件造成暫時不可用(方案限制、權限不足)——傳達「現在用不到」"
120
120
  >
121
- <Label>兩者都不可切換、不在 tab order 內,但機制不同:readonly 用 `pointer-events-none`(視覺正常);disabled 走 native `disabled` + `cursor-not-allowed`(降透明度)</Label>
121
+ <Label>兩者都不可切換、不在 tab order 內(standalone),機制不同:readonly 用 `pointer-events-none`(視覺正常);disabled 走 native `disabled` + `cursor-not-allowed`(降透明度)。Field 內 readonly 灰框則可聚焦(tabIndex=0)</Label>
122
122
  </Rule>
123
123
  </div>
124
124
  </div>
@@ -102,7 +102,8 @@ sm 和 md 視覺相同(純粹命名 mapping,讓消費者可直接傳同一
102
102
  | OFF | `bg-border`(neutral-5) | 白色 + 2px `border-border`(neutral-5,與 OFF track 同色) | 無 |
103
103
  | ON | `bg-primary` | 白色 + 2px primary border | primary check |
104
104
  | Disabled | 套 `opacity-disabled`(整體透明度降級) | 同 ON/OFF | 同 ON/OFF |
105
- | Readonly | 視覺同一般態 | 但 `pointer-events-none` + `aria-readonly` | — |
105
+ | Readonly(standalone)| 視覺同一般態 | 但 `pointer-events-none` + `aria-readonly` | — |
106
+ | Readonly(Field 內,2026-06-12 user 拍板)| 不渲染 toggle — 改渲染 `fieldWrapperStyles` readonly 灰框(= Input readonly 同源)+ ✓/—(display 同款值語言) | role="switch" + aria-checked + aria-readonly + 可 focus | — |
106
107
 
107
108
  ### Disabled 用 `opacity`
108
109
 
@@ -117,22 +118,25 @@ sm 和 md 視覺相同(純粹命名 mapping,讓消費者可直接傳同一
117
118
 
118
119
  詳細對照見 `../Slider/slider.spec.md`「Disabled 策略」節。
119
120
 
120
- ### Readonly vs Disabled
121
+ ### Readonly vs Disabled(standalone;Field 內 readonly = 灰框 + ✓/—,見上方狀態表)
121
122
 
122
- | | Readonly | Disabled |
123
+ | | Readonly(standalone) | Disabled |
123
124
  |---|---|---|
124
125
  | 視覺 | 正常顏色(可讀) | 降透明度(弱化) |
125
126
  | 互動 | 不可切換(pointer-events-none) | 不可切換(cursor-not-allowed) |
126
127
  | aria | `aria-readonly` | `disabled` |
127
128
  | Tab 焦點 | 不在 tab order | 不在 tab order |
128
- | 用途 | 表單 readonly 呈現、DataTable cell 非編輯態 | 外部條件造成不可操作 |
129
+ | 用途 | settings list 鎖定呈現 | 外部條件造成不可操作 |
130
+
131
+ 表單 readonly 呈現 = Field 內灰框 + ✓/—(2026-06-12 拍板);DataTable cell 非編輯態 = `mode="display"`(✓/—),皆非本表 standalone readonly。
129
132
 
130
133
  ### `mode` prop(Field mode,正交於 size)
131
134
 
132
135
  `mode?: 'edit' | 'display' | 'readonly' | 'disabled'`(默認 inherit Field context 或 `'edit'`),對齊 `field-types.ts` FieldMode:
133
136
  - `edit`(預設)— 可切換的 Switch。
134
137
  - `display` — **純展示**:渲染 ✓ / —(非互動 Switch),語意由 context(如 DataTable boolean cell 的行/列 header)提供,對齊 Carbon read-only / Input·Select·Textarea display mode 一致。
135
- - `readonly` / `disabled` 同上表「Readonly vs Disabled」(readonly=正常色鎖互動 / disabled=opacity 弱化)。
138
+ - `readonly` **Field 內** = 灰框 + ✓/—(消費 `fieldWrapperStyles` readonly,與 Input readonly 同一視覺語言;世界級:Salesforce output ✓ glyph / SAP 靜態文字 — readonly 下 boolean 是資料 value 非控件);**standalone** = 正常色鎖互動(settings list 場景)。
139
+ - `disabled` — 同上表「Readonly vs Disabled」(opacity 弱化);`mode='disabled'` 直傳與 `disabled` prop 等效(effectiveDisabled,2026-06-12)。
136
140
 
137
141
  ---
138
142
 
@@ -177,7 +181,7 @@ Switch 可透過 `label` / `description` props 內部直接渲染緊鄰文字:
177
181
 
178
182
  ## 邊界案例
179
183
 
180
- - **Readonly 的滑鼠 / 鍵盤**:`pointer-events-none` 使點擊無反應;`tabIndex=-1` 不入 tab order,故無鍵盤互動(視覺正常、僅鎖互動)
184
+ - **Readonly 的滑鼠 / 鍵盤**(standalone):`pointer-events-none` 使點擊無反應;`tabIndex=-1` 不入 tab order,故無鍵盤互動(視覺正常、僅鎖互動)。Field 內 readonly 灰框則 tabIndex=0 可聚焦(對齊 readonly input 可聚焦慣例)
181
185
  - **Disabled 的鍵盤**:native `disabled`——不可聚焦、無鍵盤行為
182
186
  - **Label 過長**:自動換行(label 容器 `flex-1 min-w-0`),Switch 錨定第一行行高置中(`h-[1lh]`)且不被擠壓(`shrink-0`)
183
187
  - **無 loading state**:Switch 無 loading prop——async 進度不屬 Switch(見「何時不用」,用 Button loading)
@@ -5,7 +5,8 @@ import { Check } from 'lucide-react'
5
5
  import { cva, type VariantProps } from 'class-variance-authority'
6
6
  import { cn } from '@/lib/utils'
7
7
  import type { FieldMode, FieldVariant } from '@/design-system/components/Field/field-types'
8
- import { useFieldContext, useResolvedFieldDisabled, useResolvedFieldMode } from '@/design-system/components/Field/field-context'
8
+ import { useFieldContext, useResolvedFieldDisabled, useResolvedFieldMode, useResolvedFieldSize } from '@/design-system/components/Field/field-context'
9
+ import { fieldWrapperStyles } from '@/design-system/components/Field/field-wrapper'
9
10
 
10
11
  /**
11
12
  * Switch — 開關控件
@@ -22,7 +23,8 @@ import { useFieldContext, useResolvedFieldDisabled, useResolvedFieldMode } from
22
23
  * OFF: track border (neutral-5), thumb 白色 + 2px border-border(neutral-5,與 OFF track 同色)無 check
23
24
  * ON: track primary, thumb 白色 + 2px primary border + primary check icon
24
25
  * disabled: opacity-disabled(整體透明度)
25
- * readOnly: 視覺同一般態,但 pointer-events-none + aria-readonly
26
+ * readOnly(standalone): 視覺同一般態,但 pointer-events-none + aria-readonly
27
+ * readOnly(Field 內): 渲染 readonly 灰框 + ✓/—(2026-06-12 拍板,= Input readonly 同視覺語言)
26
28
  *
27
29
  * ── label / description / readOnly ──
28
30
  * Switch 可以透過 `label` 和 `description` props 在元件內直接渲染緊鄰的文字,
@@ -42,7 +44,8 @@ import { useFieldContext, useResolvedFieldDisabled, useResolvedFieldMode } from
42
44
  *
43
45
  * readOnly 模式:
44
46
  * <Switch readOnly checked={true} label="..." />
45
- * 視覺維持 ON/OFF 正確狀態,但無法互動、不在 tab order 內、寫入 aria-readonly。
47
+ * standalone:視覺維持 ON/OFF 正確狀態,但無法互動、不在 tab order 內、寫入 aria-readonly。
48
+ * Field 內:渲染 readonly 灰框 + ✓/—(不渲染 toggle;見 forwardRef 內 readonly 分支)。
46
49
  * 與 disabled 的差異:readonly 不降色(可讀),disabled 降色(弱化)。
47
50
  */
48
51
 
@@ -98,9 +101,9 @@ export interface SwitchProps
98
101
  */
99
102
  description?: React.ReactNode
100
103
  /**
101
- * readonly 模式:鎖定互動但維持 ON/OFF 視覺正確。
102
- * 與 disabled 的差異:readonly 不降色(可讀),disabled 降色(弱化)。
103
- * 用於表單 readonly 呈現、DataTable cell 非編輯態。
104
+ * readonly 模式:standalone = 鎖定互動但維持 ON/OFF 視覺;Field 內 = 灰框 + ✓/—。
105
+ * 與 disabled 的差異:readonly 不降色(可讀),disabled 降色(弱化)。
106
+ * DataTable cell 非編輯態用 mode="display"(✓/—),非 readOnly。
104
107
  */
105
108
  readOnly?: boolean
106
109
  /**
@@ -108,8 +111,8 @@ export interface SwitchProps
108
111
  * edit — 一般可互動 Switch(預設)
109
112
  * display — **純展示**:渲染 ✓ / —(無互動 primitive、無 input chrome);
110
113
  * 對齊 Carbon read-only / DataTable boolean cell。
111
- * `readonly` 保留 toggle 視覺 + 鎖互動;`display` 完全無 toggle 形體 — 兩者語意分離(field-types.ts)。
112
- * readonly — 同 readOnly prop
114
+ * `display` 完全無 toggle 形體;`readonly` 視場景(field-types.ts)。
115
+ * readonly — standalone 同 readOnly prop(保留視覺鎖互動);Field 內 = 灰框 + ✓/—
113
116
  * disabled — 同 disabled prop
114
117
  */
115
118
  mode?: FieldMode
@@ -151,6 +154,10 @@ const Switch = React.forwardRef<
151
154
  const disabled = useResolvedFieldDisabled(disabledProp)
152
155
  const resolvedMode = useResolvedFieldMode({ mode, disabled, readOnly })
153
156
  const effectiveReadOnly = readOnly || resolvedMode === 'readonly'
157
+ // mode='disabled' 直傳(無 Field ctx 的 cell 場景)必須落到真 disabled chrome(同 Checkbox 2026-06-12 修)
158
+ const effectiveDisabled = disabled || resolvedMode === 'disabled'
159
+ // readonly 灰框 size:走 SSOT resolver(prop > ctx > 'md',field-context.ts:150-161)
160
+ const resolvedBoxSize = useResolvedFieldSize(size ?? undefined, 'md') as 'sm' | 'md' | 'lg'
154
161
  const insideField = fieldCtx?.hasFieldWrapper === true
155
162
  const effectiveLabel = insideField ? undefined : label
156
163
  const effectiveDescription = insideField ? undefined : description
@@ -176,12 +183,38 @@ const Switch = React.forwardRef<
176
183
  : <span className="text-fg-muted">—</span>
177
184
  }
178
185
 
186
+ // ── mode='readonly' in Field(2026-06-12 user 拍板「灰框 + ✓/—」,與 Checkbox 同款)──
187
+ // Field 內 readonly boolean = fieldWrapperStyles readonly 灰框(= Input readonly 同源)
188
+ // + ✓/— 值語言;standalone readOnly(settings list)維持原樣鎖互動。詳 checkbox.tsx 同段註解。
189
+ if (effectiveReadOnly && insideField) {
190
+ const isChecked = (props.checked ?? props.defaultChecked) === true
191
+ const boxSize = resolvedBoxSize
192
+ return (
193
+ <div
194
+ role="switch"
195
+ aria-checked={isChecked}
196
+ aria-readonly="true"
197
+ aria-labelledby={fieldCtx?.labelId}
198
+ aria-invalid={fieldCtx?.invalid || undefined}
199
+ data-readonly="true"
200
+ tabIndex={0}
201
+ className={cn(
202
+ fieldWrapperStyles({ size: boxSize, mode: 'readonly', variant: 'default' }),
203
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
204
+ className,
205
+ )}
206
+ >
207
+ {isChecked ? <span className="text-foreground">✓</span> : <span className="text-fg-muted">—</span>}
208
+ </div>
209
+ )
210
+ }
211
+
179
212
  const rootEl = (
180
213
  <SwitchPrimitives.Root
181
214
  id={inputId}
182
215
  className={cn(switchVariants({ size }), alignRightInField, className)}
183
216
  ref={ref}
184
- disabled={disabled}
217
+ disabled={effectiveDisabled}
185
218
  aria-readonly={effectiveReadOnly || undefined}
186
219
  data-readonly={effectiveReadOnly || undefined}
187
220
  tabIndex={effectiveReadOnly ? -1 : undefined}
@@ -220,7 +253,7 @@ const Switch = React.forwardRef<
220
253
  htmlFor={inputId}
221
254
  className={cn(
222
255
  'inline-flex items-start gap-3 select-none',
223
- disabled ? 'cursor-not-allowed' : readOnly ? 'cursor-default' : 'cursor-pointer'
256
+ effectiveDisabled ? 'cursor-not-allowed' : readOnly ? 'cursor-default' : 'cursor-pointer'
224
257
  )}
225
258
  >
226
259
  {/* Label↔desc gap typography-mode-aware:
@@ -237,7 +270,7 @@ const Switch = React.forwardRef<
237
270
  className={cn(
238
271
  // Reading mode 字級:lg → text-body-lg (16px),sm/md → text-body (14px)
239
272
  sizeKey === 'lg' ? 'text-body-lg' : 'text-body',
240
- disabled ? 'text-fg-disabled' : 'text-foreground'
273
+ effectiveDisabled ? 'text-fg-disabled' : 'text-foreground'
241
274
  )}
242
275
  >
243
276
  {effectiveLabel}
@@ -245,7 +278,7 @@ const Switch = React.forwardRef<
245
278
  {effectiveDescription != null && (
246
279
  <span
247
280
  className={cn(
248
- disabled ? 'text-fg-disabled' : 'text-fg-secondary'
281
+ effectiveDisabled ? 'text-fg-disabled' : 'text-fg-secondary'
249
282
  )}
250
283
  // Reading mode description:**最小 14px**(spec 14→14px, 16→14px),lh 預設 1.5。
251
284
  // 用 inline style 直接繞過 tailwind-merge 對 text-body / text-fg-* 的潛在衝突。
@@ -166,13 +166,13 @@ export const Inspector: Story = {
166
166
  </>
167
167
  )}
168
168
  </TabsList>
169
- <TabsContent value="overview" className="mt-4 text-body text-fg-secondary">
169
+ <TabsContent value="overview" className=" text-body text-fg-secondary">
170
170
  專案的總覽資訊(KPI / 最近活動 / 團隊成員簡介)
171
171
  </TabsContent>
172
- <TabsContent value="members" className="mt-4 text-body text-fg-secondary">
172
+ <TabsContent value="members" className=" text-body text-fg-secondary">
173
173
  專案成員列表(3 人待邀請)
174
174
  </TabsContent>
175
- <TabsContent value="settings" className="mt-4 text-body text-fg-secondary">
175
+ <TabsContent value="settings" className=" text-body text-fg-secondary">
176
176
  專案設定(12 個通知待閱讀)
177
177
  </TabsContent>
178
178
  </Tabs>
@@ -120,7 +120,7 @@ export const UsageGuidance: Story = {
120
120
  <TabsTrigger value="products">產品</TabsTrigger>
121
121
  <TabsTrigger value="settings" startIcon={Settings}>設定</TabsTrigger>
122
122
  </TabsList>
123
- <TabsContent value="orders" className="mt-4 text-body text-fg-muted">(訂單頁的 toolbar、filters、table…)</TabsContent>
123
+ <TabsContent value="orders" className="text-body text-fg-muted">(訂單頁的 toolbar、filters、table…)</TabsContent>
124
124
  </Tabs>
125
125
  </div>
126
126
  <div>
@@ -181,6 +181,10 @@ Tabs 是 **navigation anchor**,不是 compact control:
181
181
 
182
182
  **Triggers 之間 gap**:使用 `--layout-space-loose`(md density 16px / lg density 24px),與其他「pattern 層級的鬆散留白」共用同一 token。Tabs 的 gap 不是裝飾性空白,而是告訴使用者「每個 tab 是獨立的視圖入口」的視覺分隔。
183
183
 
184
+ **TabsList 容器寬度(2026-06-12 user 拍板,解 L58 spec≠code)**:**跨滿父容器**(none 模式 `w-full`;scroll/menu 模式 inner list `min-w-full` 保留溢出成長)——底線連成整條、右端達內容區邊緣,與「跨父容器寬度、與 header border 對齊」的角色定義一致(對齊 Ant nav `left:0;right:0` 底線 / Primer UnderlineNav;shadcn 為 pill 式不同型不適用)。Trigger 本身仍 hug content(上段不變)。**左右邊距 ownership**:TabsList **不自加水平 padding**——邊距由容器的 `--layout-space-loose` gutter 提供(standalone = 父容器 padding;withTabs = 繼承 header padding,W2 lockstep),故 tab 左緣永遠對齊兄弟內容左緣。
185
+
186
+ **TabsContent 與 TabsList 間距(2026-06-12 user 拍板)**:`--layout-space-tight`(md 12px,density 連動),**內建於 TabsContent**(`mt-[var(--layout-space-tight)]`,className 可覆寫;full-height 佈局用 `mt-0`)。依 `layoutSpace.spec`「親疏 3 級」第一級(同 bundle,元件 spec own)+ 規則 3「直接功能依賴 = tight」精神;**不**取 Ant 的固定 16px(M23 自家 token 優先)。內容本身的排版歸 item-anatomy / 各內容元件管,本條只管 list↔content gap。
187
+
184
188
  ---
185
189
 
186
190
  ## Underline 與 TabsList border 的視覺關係
@@ -32,10 +32,10 @@ export const Default: Story = {
32
32
  <TabsTrigger value="notifications" badge={<Badge count={3} />}>通知</TabsTrigger>
33
33
  <TabsTrigger value="settings">設定</TabsTrigger>
34
34
  </TabsList>
35
- <TabsContent value="overview" className="p-4 text-body text-fg-secondary">專案的總覽資訊(KPI、最近活動、團隊成員簡介)</TabsContent>
36
- <TabsContent value="members" className="p-4 text-body text-fg-secondary">專案成員列表(3 人待邀請)</TabsContent>
37
- <TabsContent value="notifications" className="p-4 text-body text-fg-secondary">3 則未讀通知(提及、指派、留言回覆)</TabsContent>
38
- <TabsContent value="settings" className="p-4 text-body text-fg-secondary">專案設定(一般、權限、整合)</TabsContent>
35
+ <TabsContent value="overview" className="text-body text-fg-secondary">專案的總覽資訊(KPI、最近活動、團隊成員簡介)</TabsContent>
36
+ <TabsContent value="members" className="text-body text-fg-secondary">專案成員列表(3 人待邀請)</TabsContent>
37
+ <TabsContent value="notifications" className="text-body text-fg-secondary">3 則未讀通知(提及、指派、留言回覆)</TabsContent>
38
+ <TabsContent value="settings" className="text-body text-fg-secondary">專案設定(一般、權限、整合)</TabsContent>
39
39
  </Tabs>
40
40
  ),
41
41
  }