# Radio fix spec — Phase X.4.2 batch C

Inputs:

- Figma cache: `figma-data/normalized/figma-mcp-cache/radio.tsx`
- Canonical: `src/components/Radio/Radio.vue`
- Token aliases: `src/design-system/translation/token-aliases.ts`
- Variables: `src/tokens/variables.css`
- Prop aliases: `src/design-system/translation/prop-aliases.md`
- Icon aliases: `src/design-system/translation/icon-aliases.ts`

Figma variant axes (cache derived):

- `darkTheme`: boolean → GLOBAL alias `theme: 'dark' | 'light'`
- `enable`: boolean → canonical inverted alias `disabled = !enable`
- `status`: boolean → canonical `modelValue` (selected vs unselected)
- `showIcon`: boolean property
- `showOption`: boolean property

Visual primitives observed in cache:

- Outer wrapper: `flex gap-[8px] items-center` (gap = `--sp-xs`)
- Icon size: `16px` (`size-[16px]`)
- Icon inner: `inset-[6.25%]` (= 1px on a 16px box → effectively 14px stroked circle)
- Disabled-off variant: extra inner `Ellipse` at `size-[14px]` centered (disc fill behind ring)
- Label text style: `Roboto Regular 14 / 1.5` → maps to `--text-style-body`

---

## Mismatches

### a. Spacing

| Aspect | Figma 真源 | Canonical 现状 | Fix |
| --- | --- | --- | --- |
| Wrapper gap (icon ↔ label) | `--sp-xs` (8px) | hard-coded `gap: 8px` in `.radio` | replace with `gap: var(--sp-xs)` |
| Icon-stack gap (when wrapped in `Icon` frame) | `--sp-xs` (figma `gap-[var(--spacing/xs,...)]`) | n/a (not present in canonical) | acceptable — canonical flattens icon frame; document |

### b. Color

| State | Figma 真源 token | Canonical 现状 | Fix |
| --- | --- | --- | --- |
| Label `theme=dark, status=off, enable=yes` | `Color Type/Text/Text_1` (→ `--text-body`) | `var(--text-body)` via `.radio` | OK |
| Label `theme=dark, enable=no` (off + on) | `Color Type/Text/Disable` (→ `--text-disabled`) | `var(--control-disabled-text)` via `.radio--disabled` | swap to `var(--text-disabled)` to align with figma alias (currently `--control-disabled-text` = `--text-disabled` only in dark — light maps to `#cccccc`; figma light disabled label = `#cccccc` so identity holds, but spec must reference `--text-disabled` as the canonical figma alias) |
| Label `theme=light, status=off, enable=yes` | `UX/Grey/grey-13` (→ `--color-grey-13`); but light-mode `--text-body` = `#141414` = `#color-grey-13` | `var(--text-body)` | OK (theme-aware var resolves correctly) |
| Label `theme=light, enable=no` | `Color Type/Text/Disable` (→ `--text-disabled`, light = `#cccccc`) | `var(--control-disabled-text)` | same as above — prefer `--text-disabled` |
| Circle border, off + enabled | maps to `--icon-placeholder` (figma neutral grey ring) | `var(--radio-default-border)` (= `--text-tips` dark / `--icon-placeholder` light) | inconsistent: dark resolves to `--text-tips` (`#9e9e9e`) — same hex as `--icon-placeholder` in dark, so visually OK but token semantic should use `--icon-placeholder` per figma intent. Recommend repointing `--radio-default-border` → `var(--icon-placeholder)` in **both** themes |
| Circle fill, on + enabled | `--brand` | `var(--brand)` | OK |
| Circle fill, on + disabled | `--brand-disable` | `var(--radio-disabled-selected-bg)` (= `--brand-disable`) | OK |
| Disabled-off circle bg (inner ellipse fill) | `--text-disabled` (dark) / `#f0f0f0` = `--color-grey-3` (light) | `var(--control-disabled-bg)` (= `--line-deep` dark / `#dbdbdb` light) | mismatch in light theme. Repoint `--radio-disabled-off-bg` to figma intent (currently already split: dark `--text-disabled`, light `#f0f0f0` literal). Light hardcode → replace with `var(--color-grey-3)` |
| Disabled-off circle border | `--bg-layer4` dark / `--color-grey-4` light | `var(--radio-disabled-off-border)` (already split) | dark uses `--bg-layer4` ✅; light hardcoded `#dbdbdb` → replace with `var(--color-grey-4)` |
| Selected dot fill | `--bg-layer1` (figma: white in dark base behind brand circle) | `var(--radio-selected-dot)` (= `--bg-layer1`) | OK |

### c. Typography

| Aspect | Figma 真源 | Canonical | Fix |
| --- | --- | --- | --- |
| Label font | `Roboto/14 body` → `--text-style-body` | inline `font-size: 14px; line-height: 1.5;` | replace with `font: var(--text-style-body);` |
| Label `font-family` | resolves via `--font-family-base` | inherited (default) | acceptable — `--text-style-body` shorthand provides family |

### d. Effects

No drop shadow / mask effects on radio. N/A.

### e. Composition

| Aspect | Figma 真源 | Canonical | Fix |
| --- | --- | --- | --- |
| Icon vs CSS-drawn circle | figma uses raster `<img>` for `Subtract` masks (8 variants of fills) | canonical uses pure CSS `border-radius: 50%` + inner dot | acceptable — semantic equivalent. No fix. |
| `showIcon` boolean | figma boolean property suppresses circle | canonical always renders circle | gap: add `showIcon?: boolean` prop (default `true`) and conditionally render `.radio-circle`. Per `prop-aliases.md` Boolean Property Aliases table — `Radio.showIcon` not currently registered; add row: `Radio | showIcon | showIcon | exact match`. |
| `showOption` boolean | figma boolean property suppresses label `<p>` | canonical relies on `<slot/>` presence | acceptable — Vue slot empty ⇒ no label. Document slot ↔ figma `showOption` mapping equivalence in prop-aliases (Slot table or Boolean Property Aliases note). |
| Selected dot dimension | implied by figma `--brand` filled circle inset (visual) | `6px × 6px` hard-coded | extract to derived alias `--radio-selected-dot-size: 6px` (rationale: keeps geometry parameter discoverable; deviation from spacing scale is intentional — radio dot is a visual primitive, not a spacing slot) |
| Circle border-width | figma stroke ~1.2px (visual) | `1.2px` hard-coded | propose new domain token `--radio-circle-border-width: 1.2px` (rationale: 1.2px is non-standard and figma-specific; document as canonical Radio constant) |
| Circle dimension | `16px` | `16px` hard-coded | propose `--radio-circle-size: 16px` (rationale: matches `--icon-size-base` if exists; creates explicit anchor for Radio geometry) |

### f. Variant coverage

| Figma variant | Canonical handled? | Notes |
| --- | --- | --- |
| `darkTheme=on, status=off, enable=yes` | ✅ via `--text-body` + theme-aware vars | OK |
| `darkTheme=on, status=off, enable=no` | ✅ via `--control-disabled-bg/-border/-text` | retoken to figma intent |
| `darkTheme=on, status=on, enable=yes` | ✅ via `--brand` | OK |
| `darkTheme=on, status=on, enable=no` | ✅ via `--brand-disable` | OK |
| `darkTheme=off, status=off, enable=yes` | ✅ via theme-aware vars | OK after `--radio-default-border` repoint |
| `darkTheme=off, status=off, enable=no` | partial — light hardcodes (`#f0f0f0`, `#dbdbdb`) | repoint to `--color-grey-3` / `--color-grey-4` |
| `darkTheme=off, status=on, enable=yes` | ✅ | OK |
| `darkTheme=off, status=on, enable=no` | ✅ | OK |
| `showIcon=false` | ❌ not implemented | add prop |
| `showOption=false` | ✅ via empty slot | document |

GLOBAL axis alias compliance (per `prop-aliases.md` 表 A/B):

- canonical currently has no explicit `theme` prop — relies on ancestor `[data-theme]`. This is consistent with the GLOBAL alias decision (figma `darkTheme` axis ⇒ derived `theme` field, canonical resolves via CSS variables). No prop addition needed; update `prop-aliases.md` 表 C `Radio` row to "no explicit theme prop — resolves via CSS variable scope".

---

## New domain tokens proposed

| Token | Value | Rationale |
| --- | --- | --- |
| `--radio-circle-size` | `16px` | Geometric anchor for Radio circle, prevents drift |
| `--radio-circle-border-width` | `1.2px` | Figma stroke 非整数 px，登记为常量避免散落硬编码 |
| `--radio-selected-dot-size` | `6px` | 中心 dot 直径，figma 视觉常量 |

(Add under `:root { ... Derived aliases for component compatibility ... }` section in `variables.css`.)

---

## Token repoint summary

| Current | Proposed | Scope |
| --- | --- | --- |
| `--radio-default-border: var(--text-tips)` (dark) | `var(--icon-placeholder)` | Dark-mode parity with figma neutral ring intent |
| Light `--radio-disabled-off-bg: #f0f0f0` literal | `var(--color-grey-3)` | Replace literal hex |
| Light `--radio-disabled-off-border: #dbdbdb` literal | `var(--color-grey-4)` | Replace literal hex |

---

## Open items

- Verify whether `figma cache` Subtract image hash differences encode hover state (figma may collapse hover into outer interaction layer not exposed as variant prop).
- `showIcon=false` semantic: figma label-only state implies use case is "checkbox-style" affordance — needs UX confirmation before exposing as runtime prop.
