From 0458af96d106140edd72dcaab11c08a22e504ca9 Mon Sep 17 00:00:00 2001 From: kirozeng Date: Fri, 4 Sep 2026 18:44:53 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(design-system):=20DS-4=20Button=20?= =?UTF-8?q?=E4=B8=8E=20Input=20=E6=A0=87=E5=87=86=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 设计系统改造系列 DS-4:给 Desktop 建立第一批标准控件,落进既有 components/ui/,并迁移首批真实调用点。 组件 - ui/button.tsx:升格 ProvidersSection 的私有 PillButton / CtaPillButton, 实现 DESIGN.md §4 三变体(primary / secondary / cta)+ rest / hover / focus-visible / pressed / disabled 状态矩阵。 - ui/input.tsx:升格 SettingsTextInput(含 secret 眼睛显形、mono、trailing、 三档高度),新增 Textarea 变体(8px 圆角)。SettingsTextInput 保留为薄封装, 既有 6 个消费者零改动。 六项规格裁决落地(拍板人 = 用户/设计师 2026-09-03) - G1 按钮双档 32/36、输入三档 32/36/40,按钮不设 40 - G2 hover 机制统一换色 token,禁用透明度 hover - G3 pressed 进最低状态矩阵 - G4 text-13 / font-medium - G5 secondary 绑 Tier-1 不继承域 alias;同值性核查为「不同值」, 用户裁决接受有意统一成白底 - G6 ivory 保留为显式 variant 并三处标登记债 状态梯改为派生(比例经用户 2026-09-04 裁决) 初版把 hover alias 到既有 slot 后实测发现:暗色下 --surface-hover 与 --surface-chip 同值,default-dark / cindy-dark / one-dark-pro / monokai-pro 四个主题悬停零反馈;--surface-hover-soft 在 cindy-dark 距 --surface-elevated 仅 2/255。改为从本变体 rest 底色朝本变体前景色 color-mix 派生(hover 8%、 pressed 自 hover 再 10%),跟随主题覆盖,11 个内置主题每档 ΔRGB ≥ 8。 派生值按治理合同 §3.4 只登记不进 DTCG 影子层。 迁移 - ProvidersSection 按钮族全部走 ui/button - SettingsTextInput 六消费者随薄封装自动覆盖 - AgentResourceSection / CollaborationSection 四个数字输入改用 ui/input - PermissionPrompt 零触碰;confirm-dialog 按钮族只登记不迁 Token - 新增 button-primary-hover / -pressed、button-secondary-hover / -pressed、 button-cta-hover / -pressed 六个 component 级 token - 同 PR 更新 DS-2b 冻结快照 + 影子包 component 层与 classification 登记 守卫 - themes/__tests__/buttonStateContrast.test.ts:11 主题 × 3 变体状态梯 ΔRGB ≥ 8,含自证伪 - ui/__tests__/button.test.tsx、input.test.tsx:变体 token 合同表、圆角、 字号字重、尺寸档、禁用态无裸 hover:,含自证伪 文档 - DESIGN.md §4 补全按钮高度 / hover / pressed / 字号字重,去掉 ⚠ not yet specified;input 补高度与 ivory 登记债 - design-governance.md §10 登记「单行输入 focus 环 spec 与实现不一致」待裁决 - design-decision-log.md 归档六项裁决、派生梯裁决与一次流程越界说明 - design-inventory.md:desktop.settings 标 pilot,登记 --settings-input-* 四个域 alias 收口为 DS-5 待办 - docs/design-evidence/2026-09-04/:Level 2 实机逐格证据(CDP 强制伪类, CINDY Light / Dark 各四格) Signed-off-by: kirozeng --- .../providersSectionAssetModule.test.tsx | 16 +- .../settings/AgentResourceSection.tsx | 21 +- .../settings/CollaborationSection.tsx | 50 ++-- .../components/settings/ProvidersSection.tsx | 38 +--- .../components/settings/SettingsTextInput.tsx | 166 +------------- .../components/ui/__tests__/button.test.tsx | 116 ++++++++++ .../components/ui/__tests__/input.test.tsx | 99 ++++++++ .../src/renderer/components/ui/button.tsx | 77 +++++++ .../src/renderer/components/ui/input.tsx | 215 ++++++++++++++++++ .../__tests__/buttonStateContrast.test.ts | 130 +++++++++++ .../fixtures/desktop-color-defaults.json | 32 ++- apps/desktop/src/renderer/themes/colors.ts | 40 ++++ docs/design-evidence/2026-09-04/README.md | 83 +++++++ .../cindy-dark-secondary-disabled-hover.png | Bin 0 -> 8199 bytes .../cells/cindy-dark-secondary-hover.png | Bin 0 -> 8075 bytes .../cells/cindy-dark-secondary-pressed.png | Bin 0 -> 8575 bytes .../cells/cindy-dark-secondary-rest.png | Bin 0 -> 8953 bytes .../cindy-light-secondary-disabled-hover.png | Bin 0 -> 9624 bytes .../cells/cindy-light-secondary-hover.png | Bin 0 -> 9517 bytes .../cells/cindy-light-secondary-pressed.png | Bin 0 -> 9722 bytes .../cells/cindy-light-secondary-rest.png | Bin 0 -> 10078 bytes .../ds4-button-input-state-matrix.png | Bin 0 -> 106125 bytes .../2026-09-04/ds4-g5-secondary-compare.png | Bin 0 -> 135505 bytes .../2026-09-04/live-add-provider-wizard.png | Bin 0 -> 274641 bytes .../2026-09-04/live-settings-providers.png | Bin 0 -> 337380 bytes docs/design-rules/DESIGN.md | 33 ++- docs/design-rules/cindy-design-system.md | 2 + docs/design-rules/design-decision-log.md | 107 +++++++++ docs/design-rules/design-governance.md | 1 + docs/design-rules/design-inventory.md | 6 +- packages/design-tokens/README.md | 13 +- .../src/__tests__/classification.test.ts | 17 ++ .../__tests__/fixtures/component-literal.json | 18 ++ .../src/__tests__/structure.test.ts | 15 +- packages/design-tokens/src/build-layers.ts | 58 ++++- .../design-tokens/src/classification.json | 58 ++++- packages/design-tokens/src/component-roles.ts | 38 ++++ .../design-tokens/src/component/color.json | 15 ++ packages/design-tokens/src/generate.ts | 2 + packages/design-tokens/src/guards.ts | 41 +++- packages/design-tokens/src/paths.ts | 7 + .../design-tokens/src/reference/color.json | 2 +- 42 files changed, 1252 insertions(+), 264 deletions(-) create mode 100644 apps/desktop/src/renderer/components/ui/__tests__/button.test.tsx create mode 100644 apps/desktop/src/renderer/components/ui/__tests__/input.test.tsx create mode 100644 apps/desktop/src/renderer/components/ui/button.tsx create mode 100644 apps/desktop/src/renderer/components/ui/input.tsx create mode 100644 apps/desktop/src/renderer/themes/__tests__/buttonStateContrast.test.ts create mode 100644 docs/design-evidence/2026-09-04/README.md create mode 100644 docs/design-evidence/2026-09-04/cells/cindy-dark-secondary-disabled-hover.png create mode 100644 docs/design-evidence/2026-09-04/cells/cindy-dark-secondary-hover.png create mode 100644 docs/design-evidence/2026-09-04/cells/cindy-dark-secondary-pressed.png create mode 100644 docs/design-evidence/2026-09-04/cells/cindy-dark-secondary-rest.png create mode 100644 docs/design-evidence/2026-09-04/cells/cindy-light-secondary-disabled-hover.png create mode 100644 docs/design-evidence/2026-09-04/cells/cindy-light-secondary-hover.png create mode 100644 docs/design-evidence/2026-09-04/cells/cindy-light-secondary-pressed.png create mode 100644 docs/design-evidence/2026-09-04/cells/cindy-light-secondary-rest.png create mode 100644 docs/design-evidence/2026-09-04/ds4-button-input-state-matrix.png create mode 100644 docs/design-evidence/2026-09-04/ds4-g5-secondary-compare.png create mode 100644 docs/design-evidence/2026-09-04/live-add-provider-wizard.png create mode 100644 docs/design-evidence/2026-09-04/live-settings-providers.png create mode 100644 packages/design-tokens/src/__tests__/fixtures/component-literal.json create mode 100644 packages/design-tokens/src/component-roles.ts create mode 100644 packages/design-tokens/src/component/color.json diff --git a/apps/desktop/src/renderer/__tests__/providersSectionAssetModule.test.tsx b/apps/desktop/src/renderer/__tests__/providersSectionAssetModule.test.tsx index 02e9f8ac0cd..16c2acea66f 100644 --- a/apps/desktop/src/renderer/__tests__/providersSectionAssetModule.test.tsx +++ b/apps/desktop/src/renderer/__tests__/providersSectionAssetModule.test.tsx @@ -270,11 +270,11 @@ describe('ProvidersSection — Cindy AI 账户资产模块', () => { ); const buyPlan = screen.getByText('settings.providers.xd.asset.buyPlan').closest('button'); - expect(buyPlan?.style.backgroundColor).toBe('var(--accent-cta-bg-pure)'); - expect(buyPlan?.style.color).toBe('var(--accent-pure-cta-fg)'); + expect(buyPlan?.className).toContain('bg-[var(--accent-cta-bg-pure)]'); + expect(buyPlan?.className).toContain('text-[var(--accent-pure-cta-fg)]'); const usageButton = screen.getByText('settings.providers.xd.asset.viewUsage').closest('button'); - expect(usageButton?.style.backgroundColor).toBe('var(--settings-btn-secondary-bg)'); - expect(usageButton?.style.color).toBe('var(--settings-btn-secondary-text)'); + expect(usageButton?.className).toContain('bg-[var(--surface-elevated)]'); + expect(usageButton?.className).toContain('text-[var(--text-primary)]'); expect(screen.queryByText('billing.settings.topupCard.action')).toBeNull(); expect(screen.queryByText('settings.providers.xd.asset.upgradePlan')).toBeNull(); @@ -292,8 +292,8 @@ describe('ProvidersSection — Cindy AI 账户资产模块', () => { expect(screen.queryByText('billing.settings.topupCard.action')).toBeNull(); const upgrade = screen.getByText('settings.providers.xd.asset.upgradePlan').closest('button'); - expect(upgrade?.style.backgroundColor).toBe('var(--accent-cta-bg-pure)'); - expect(upgrade?.style.color).toBe('var(--accent-pure-cta-fg)'); + expect(upgrade?.className).toContain('bg-[var(--accent-cta-bg-pure)]'); + expect(upgrade?.className).toContain('text-[var(--accent-pure-cta-fg)]'); fireEvent.click(screen.getByText('settings.providers.xd.asset.upgradePlan')); await waitFor(() => @@ -313,8 +313,8 @@ describe('ProvidersSection — Cindy AI 账户资产模块', () => { expect(screen.queryByText('settings.providers.xd.asset.upgradePlan')).toBeNull(); const topup = screen.getByText('billing.settings.topupCard.action').closest('button'); - expect(topup?.style.backgroundColor).toBe('var(--accent-cta-bg-pure)'); - expect(topup?.style.color).toBe('var(--accent-pure-cta-fg)'); + expect(topup?.className).toContain('bg-[var(--accent-cta-bg-pure)]'); + expect(topup?.className).toContain('text-[var(--accent-pure-cta-fg)]'); fireEvent.click(screen.getByText('billing.settings.topupCard.action')); await waitFor(() => diff --git a/apps/desktop/src/renderer/components/settings/AgentResourceSection.tsx b/apps/desktop/src/renderer/components/settings/AgentResourceSection.tsx index d48cf815f07..60c9278a029 100644 --- a/apps/desktop/src/renderer/components/settings/AgentResourceSection.tsx +++ b/apps/desktop/src/renderer/components/settings/AgentResourceSection.tsx @@ -10,6 +10,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { Input } from '@/components/ui/input'; import { Switch } from '@/components/ui/switch'; import { cn } from '@/lib/utils'; import { toast } from '@/lib/toast'; @@ -40,18 +41,7 @@ const ROW_HINT_CLASS = /** 行间分割线:左右缩进与行内边距对齐。 */ const DIVIDER_CLASS = 'mx-4 h-px bg-[var(--settings-theme-card-border)]'; -/** - * 数字输入框的框体规格,与设置页统一输入框 `SettingsTextInput` 的 md 档一致 - * (DESIGN.md §4-5:单行输入走药丸圆角;fill 必须是 `--surface-elevated` —— 设置卡片 - * 本身是 ivory,ivory 输入压在 ivory 卡上会与背景同色、填充对比度归零)。 - * 上下调节沿用浏览器原生步进器,不自绘 —— 设置页的控件样式统一是独立议题,留待整体收敛。 - */ -const NUMBER_INPUT_CLASS = cn( - 'h-9 w-24 shrink-0 rounded-full px-4 text-13 outline-none transition-colors', - 'bg-[var(--surface-elevated)] text-[var(--settings-input-text)]', - 'border border-[var(--settings-input-border)] focus:border-[var(--settings-input-border-focus)]', - 'focus:ring-2 focus:ring-[var(--focus-ring)]', -); +/** 数字输入走标准 Input md 档(32/36/40 中的 36px),原生步进器不自绘。 */ /** * 均衡档并发值:本机核数的一半,至少 2(与 main 侧 toolchain-thread-cap 的口径一致), @@ -308,13 +298,13 @@ export function AgentResourceSection() { {t('settings.agentResource.maxConcurrentHint')} - setMaxDraft(e.target.value)} + onChange={setMaxDraft} onBlur={() => { // 唯一提交点(DESIGN §14.3:Settings 单行编辑器 Enter 不得提交): // 仅 0..64 的整数且与当前值不同才写盘;非法/未变草稿作废, @@ -327,7 +317,8 @@ export function AgentResourceSection() { if (raw === settings.maxConcurrentCommands) return; persist('maxConcurrentCommands', raw); }} - className={NUMBER_INPUT_CLASS} + size="md" + className="w-24 shrink-0" /> diff --git a/apps/desktop/src/renderer/components/settings/CollaborationSection.tsx b/apps/desktop/src/renderer/components/settings/CollaborationSection.tsx index cf2bc6cdd19..69d0f1f5bb0 100644 --- a/apps/desktop/src/renderer/components/settings/CollaborationSection.tsx +++ b/apps/desktop/src/renderer/components/settings/CollaborationSection.tsx @@ -4,6 +4,7 @@ import { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; +import { Input } from '@/components/ui/input'; import { cn } from '@/lib/utils'; import { toast } from '@/lib/toast'; import { DefaultOverrideControls } from './DefaultOverrideControls'; @@ -22,18 +23,7 @@ const ROW_HINT_CLASS = /** 行间分割线:左右缩进与行内边距对齐。 */ const DIVIDER_CLASS = 'mx-4 h-px bg-[var(--settings-theme-card-border)]'; -/** - * 数字输入框的框体规格,与设置页统一输入框 `SettingsTextInput` 的 md 档一致 - * (DESIGN.md §4-5:单行输入走药丸圆角;fill 必须是 `--surface-elevated` —— 设置卡片 - * 本身是 ivory,ivory 输入压在 ivory 卡上会与背景同色、填充对比度归零)。 - * 上下调节沿用浏览器原生步进器,不自绘 —— 设置页的控件样式统一是独立议题,留待整体收敛。 - */ -const NUMBER_INPUT_CLASS = cn( - 'h-9 w-24 shrink-0 rounded-full px-4 text-13 outline-none transition-colors', - 'bg-[var(--surface-elevated)] text-[var(--settings-input-text)]', - 'border border-[var(--settings-input-border)] focus:border-[var(--settings-input-border-focus)]', - 'focus:ring-2 focus:ring-[var(--focus-ring)]', -); +/** 数字输入走标准 Input md 档(32/36/40 中的 36px),原生步进器不自绘。 */ interface CollaborationSettings { workerSoftLimit: number; @@ -114,16 +104,17 @@ export function CollaborationSection() { {t('settings.collaboration.workerSoftLimitHint')} - { - const v = Math.max(1, Math.min(settings.workerHardLimit, Number(e.target.value) || 1)); + value={String(settings.workerSoftLimit)} + onChange={(text) => { + const v = Math.max(1, Math.min(settings.workerHardLimit, Number(text) || 1)); persist('workerSoftLimit', v); }} - className={NUMBER_INPUT_CLASS} + size="md" + className="w-24 shrink-0" /> @@ -139,16 +130,20 @@ export function CollaborationSection() { {t('settings.collaboration.workerHardLimitHint')} - { - const v = Math.max(settings.workerSoftLimit, Math.min(20, Number(e.target.value) || settings.workerSoftLimit)); + value={String(settings.workerHardLimit)} + onChange={(text) => { + const v = Math.max( + settings.workerSoftLimit, + Math.min(20, Number(text) || settings.workerSoftLimit), + ); persist('workerHardLimit', v); }} - className={NUMBER_INPUT_CLASS} + size="md" + className="w-24 shrink-0" /> @@ -164,17 +159,18 @@ export function CollaborationSection() { {t('settings.collaboration.idleReleaseHint')} - { - const raw = Number(e.target.value); + value={String(settings.workerIdleReleaseMinutes)} + onChange={(text) => { + const raw = Number(text); const v = Math.max(0, Math.min(120, Number.isFinite(raw) ? Math.trunc(raw) : 0)); persist('workerIdleReleaseMinutes', v); }} - className={NUMBER_INPUT_CLASS} + size="md" + className="w-24 shrink-0" /> diff --git a/apps/desktop/src/renderer/components/settings/ProvidersSection.tsx b/apps/desktop/src/renderer/components/settings/ProvidersSection.tsx index c0ccf27e838..476bfa6ce08 100644 --- a/apps/desktop/src/renderer/components/settings/ProvidersSection.tsx +++ b/apps/desktop/src/renderer/components/settings/ProvidersSection.tsx @@ -44,6 +44,7 @@ import { useModelAccessStatus } from '@/hooks/useModelAccessStatus'; import { useModelAccessCreditUsage } from '@/hooks/useModelAccessCreditUsage'; import { useXdAssetPrimaryAction } from '@/hooks/useXdAssetPrimaryAction'; import { useReducedMotion } from '@/hooks/useReducedMotion'; +import { Button } from '@/components/ui/button'; import { useConfirmDialog } from '@/components/ui/confirm-dialog-provider'; import { useSignInToCindy } from '@/hooks/useSignInToCindy'; import { useProviderOAuthDeviceCode } from '@/hooks/useProviderOAuthDeviceCode'; @@ -201,23 +202,9 @@ function PillButton({ disabled?: boolean; }) { return ( - + ); } @@ -238,24 +225,9 @@ function CtaPillButton({ className?: string; }) { return ( - + ); } diff --git a/apps/desktop/src/renderer/components/settings/SettingsTextInput.tsx b/apps/desktop/src/renderer/components/settings/SettingsTextInput.tsx index 4d1f382192e..89cd83fce72 100644 --- a/apps/desktop/src/renderer/components/settings/SettingsTextInput.tsx +++ b/apps/desktop/src/renderer/components/settings/SettingsTextInput.tsx @@ -1,160 +1,12 @@ /** - * SettingsTextInput —— 设置页统一的单行文本 / 密钥输入框。 + * SettingsTextInput —— `components/ui/input` 的设置页薄封装。 * - * 收敛背景:设置页各处原本各写一份私有 TextInput(高度 32 / 36 / 40 / 42px 混杂), - * 密钥字段的「明文切换」只在 CustomProviderDialog 做了——同一个 API key 在「添加供应商」 - * 向导里看不见、在编辑弹窗里看得见。本组件以 CustomProviderDialog 那份为基线(它是唯一 - * 走 DESIGN.md §4-5 药丸圆角的实现),把 eye 显形收进 `secret`,调用点不再各自手写 - * showKey state + 按钮。 - * - * `secret` 只显形**用户本次输入的草稿**(草稿本就在 renderer state 里)。已存密钥要不要 - * 回显由调用方决定:内置 API-key 走 MAIN_ONLY 的 builtinApiKey* IPC,renderer 架构上读不到 - * 明文,回显无从谈起;自定义供应商的 key 由调用方显式读回。本组件不触碰凭证读取边界。 - * - * 尺寸:DESIGN.md §4 锁定单行输入的 radius(9999px)与颜色 token,未锁高度,因此保留 - * sm / md / lg 三档以贴合各调用点既有版式。radius 与 `--settings-input-*` token 不可变。 - */ -import { useState } from 'react'; -import { Eye, EyeOff } from 'lucide-react'; -import { useTranslation } from 'react-i18next'; - -import { cn } from '@/lib/utils'; - -export type SettingsTextInputSize = 'sm' | 'md' | 'lg'; -export type SettingsTextInputSurface = 'elevated' | 'ivory'; - -/** - * 底色:默认 `elevated` = DESIGN.md §4 `input/text` 规定的 fill(`--surface-elevated`)。 - * - * `ivory` 是给「输入压在白色弹窗面板上」的调用点保留的既有底色(`--settings-input-bg`, - * 解析到 `--surface-card-ivory`)。这份 ivory 在设计文档里没有任何背书,属 colors.ts 的 - * 无文档漂移;它在白面板上能给出 fill 抬升,所以不在本 PR 里翻成白色,但也不能当默认—— - * settings 卡片本身就是 ivory(`--settings-theme-card-bg`),ivory 输入压在 ivory 卡上会和 - * 背景同色,填充对比度归零。settings 域这处 ivory / elevated 的收口是独立议题。 + * DS-4 把实现升格进 `components/ui/input.tsx`。本文件保留原导出名与类型别名, + * 让既有设置页调用点零行为改动(除 ivory 登记债与 placeholder 已收口到 + * `--text-placeholder`)。新代码请直接 import `{ Input }` from `@/components/ui/input`。 */ -const SURFACE_STYLES: Record = { - elevated: 'bg-[var(--surface-elevated)]', - ivory: 'bg-[var(--settings-input-bg)]', -}; - -/** 各档的框体几何 + 无 trailing 时的右内边距(有 trailing 时统一让位给按钮)。 */ -const SIZE_STYLES: Record< - SettingsTextInputSize, - { box: string; paddingLeft: string; paddingRight: string; trailingPaddingRight: string } -> = { - sm: { - box: 'h-8 text-12', - paddingLeft: 'pl-3', - paddingRight: 'pr-3', - trailingPaddingRight: 'pr-8', - }, - md: { - box: 'h-9 text-13', - paddingLeft: 'pl-4', - paddingRight: 'pr-4', - trailingPaddingRight: 'pr-9', - }, - lg: { - box: 'h-[40px] text-14', - paddingLeft: 'pl-[12px]', - paddingRight: 'pr-[12px]', - trailingPaddingRight: 'pr-9', - }, -}; - -/** eye 按钮:小号框用小图标 / 更贴边,避免挤压 32px 行高。 */ -const EYE_STYLES: Record = { - sm: { iconSize: 14, offset: 'right-[10px]' }, - md: { iconSize: 16, offset: 'right-[12px]' }, - lg: { iconSize: 16, offset: 'right-[12px]' }, -}; - -export function SettingsTextInput({ - value, - onChange, - onBlur, - placeholder, - type = 'text', - size = 'lg', - surface = 'elevated', - mono = false, - secret = false, - trailing, - className, -}: { - value: string; - onChange: (v: string) => void; - onBlur?: () => void; - placeholder?: string; - /** `secret` 为真时由组件接管(明文 / 遮罩切换),此处传值无效。 */ - type?: string; - size?: SettingsTextInputSize; - /** 底色档:默认 §4 规定的 `--surface-elevated`;`ivory` 见 SURFACE_STYLES 注释。 */ - surface?: SettingsTextInputSurface; - /** 等宽字体——密钥、ID 这类需要逐字核对的值。 */ - mono?: boolean; - /** 密钥字段:遮罩输入 + 自带明文切换按钮。 */ - secret?: boolean; - /** 自定义尾随元素(需自行绝对定位)。`secret` 优先,两者不叠加。 */ - trailing?: React.ReactNode; - /** 附加到**外层容器**(内层 input 恒为 w-full),供 flex 行传 `flex-1 min-w-0`。 */ - className?: string; -}) { - const { t } = useTranslation(); - const [revealed, setRevealed] = useState(false); - const sizeStyle = SIZE_STYLES[size]; - const eyeStyle = EYE_STYLES[size]; - - const eyeButton = secret ? ( - - ) : null; - const trailingNode = eyeButton ?? trailing; - - return ( -
- onChange(e.target.value)} - onBlur={onBlur} - placeholder={placeholder} - // 只对密钥关掉:密码管理器建议和拼写红线对密钥是干扰,但普通文本字段(显示名称、 - // baseUrl、模型名、请求头)该保留浏览器的自动补全与拼写检查,不能一并禁掉。 - autoComplete={secret ? 'off' : undefined} - spellCheck={secret ? false : undefined} - className={cn( - // 单行输入按设计规范走药丸圆角(DESIGN.md §4-5:9999px,明令禁止 10px)。 - 'w-full rounded-full outline-none transition-colors', - sizeStyle.box, - sizeStyle.paddingLeft, - trailingNode ? sizeStyle.trailingPaddingRight : sizeStyle.paddingRight, - mono && 'font-mono', - 'text-[var(--settings-input-text)] placeholder:text-[var(--settings-input-placeholder)]', - SURFACE_STYLES[surface], - 'border border-[var(--settings-input-border)] focus:border-[var(--settings-input-border-focus)]', - // Focus Blue 环(DESIGN.md §4 Inputs 的 focus 槽 + §「Focus Blue」的 --focus-ring): - // 迁移进来的密钥输入原本就带 focus:ring-2,不能在收敛时丢掉——同一个表单里紧邻的 - // 名称框仍是蓝环,只留 border 变色会让上下两个框焦点表现不一致。用 opaque - // --focus-ring 而非 --focus-ring-soft:与这些框的原值、以及相邻未迁移输入一致。 - 'focus:ring-2 focus:ring-[var(--focus-ring)]', - )} - style={{ userSelect: 'text', WebkitUserSelect: 'text' }} - /> - {trailingNode} -
- ); -} +export { Input as SettingsTextInput } from '@/components/ui/input'; +export type { + InputSize as SettingsTextInputSize, + InputSurface as SettingsTextInputSurface, +} from '@/components/ui/input'; diff --git a/apps/desktop/src/renderer/components/ui/__tests__/button.test.tsx b/apps/desktop/src/renderer/components/ui/__tests__/button.test.tsx new file mode 100644 index 00000000000..a6f6313d7e9 --- /dev/null +++ b/apps/desktop/src/renderer/components/ui/__tests__/button.test.tsx @@ -0,0 +1,116 @@ +// @vitest-environment jsdom + +/** + * DS-4 Button 样式合同(Level 1 静态守卫)。 + * + * 只锁「token 表达式 / 圆角 / 字号字重 / 尺寸档 / 禁用态」这些设计合同,不锁实现细节。 + * 状态值本身是否在各主题下可区分由 themes/__tests__/buttonStateContrast.test.ts 守。 + */ +import { cleanup, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { Button, type ButtonVariant } from '../button'; + +/** 合同表:每个变体必须出现的 token 表达式。改绑 token 会在这里红。 */ +const CONTRACT: Record = { + primary: [ + 'bg-[var(--surface-chip)]', + 'text-[var(--text-primary)]', + 'enabled:hover:bg-[var(--button-primary-hover)]', + 'enabled:active:bg-[var(--button-primary-pressed)]', + ], + secondary: [ + 'bg-[var(--surface-elevated)]', + 'border-[var(--border-default)]', + 'text-[var(--text-primary)]', + 'enabled:hover:bg-[var(--button-secondary-hover)]', + 'enabled:active:bg-[var(--button-secondary-pressed)]', + ], + cta: [ + 'bg-[var(--accent-cta-bg-pure)]', + 'text-[var(--accent-pure-cta-fg)]', + 'enabled:hover:bg-[var(--button-cta-hover)]', + 'enabled:active:bg-[var(--button-cta-pressed)]', + ], +}; + +/** 一律禁止出现的写法。 */ +const FORBIDDEN = [ + 'hover:opacity', // G2:禁用透明度 hover + 'settings-btn-secondary', // G5:不继承设置页域 alias + 'h-10', // G1:按钮不设 40px 档 + 'h-[40px]', + 'rounded-lg', // §5:按钮一律胶囊 + 'rounded-xl', +]; + +function classOf(name: string): string { + return screen.getByRole('button', { name }).className; +} + +function missing(cls: string, required: string[]): string[] { + return required.filter((token) => !cls.includes(token)); +} + +describe('Button 样式合同', () => { + afterEach(cleanup); + + for (const variant of Object.keys(CONTRACT) as ButtonVariant[]) { + it(`${variant}:token 绑定齐全、胶囊圆角、text-13/500`, () => { + render(); + const cls = classOf(variant); + + expect(missing(cls, CONTRACT[variant]), `${variant} 缺失 token`).toEqual([]); + expect(cls).toContain('rounded-full'); + expect(cls).toContain('text-13'); + expect(cls).toContain('font-medium'); + for (const banned of FORBIDDEN) { + expect(cls, `${variant} 不应出现 ${banned}`).not.toContain(banned); + } + }); + } + + it('尺寸只有 32 / 36 两档', () => { + render( + <> + + + , + ); + expect(classOf('Md')).toContain('h-8'); + expect(classOf('Lg')).toContain('h-9'); + }); + + it('focus-visible 走 --focus-ring;禁用态走普通指针 + 60% 不透明度', () => { + render(); + const cls = classOf('Disabled'); + expect(cls).toContain('focus-visible:ring-[var(--focus-ring)]'); + expect(cls).toContain('disabled:cursor-not-allowed'); + expect(cls).toContain('disabled:opacity-60'); + }); + + it('禁用态不得再触发 hover / active 换色(hover 一律带 enabled: 前缀)', () => { + // CSS 的 :hover 对 disabled 元素照样匹配。不带 enabled: 前缀时,禁用按钮 + // 鼠标悬停仍会换底色——旧 PillButton 根本没有 hover,属迁移引入的行为回归。 + for (const variant of Object.keys(CONTRACT) as ButtonVariant[]) { + cleanup(); + render(); + const cls = classOf(variant); + const bare = cls + .split(/\s+/) + .filter((c) => (c.startsWith('hover:') || c.startsWith('active:'))); + expect(bare, `${variant} 存在无 enabled: 前缀的交互态`).toEqual([]); + } + }); + + it('自证伪:合同表被改错时守卫必然红', () => { + render(); + const cls = classOf('S'); + // 装作有人把 secondary 改回设置页域 alias:合同检查必须报缺失。 + expect(missing(cls, ['bg-[var(--settings-btn-secondary-bg)]'])).toEqual([ + 'bg-[var(--settings-btn-secondary-bg)]', + ]); + // 装作有人把透明度 hover 加回来:禁令检查必须命中。 + expect('hover:opacity-90').toMatch(/hover:opacity/); + }); +}); diff --git a/apps/desktop/src/renderer/components/ui/__tests__/input.test.tsx b/apps/desktop/src/renderer/components/ui/__tests__/input.test.tsx new file mode 100644 index 00000000000..445682661f0 --- /dev/null +++ b/apps/desktop/src/renderer/components/ui/__tests__/input.test.tsx @@ -0,0 +1,99 @@ +// @vitest-environment jsdom + +import { cleanup, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key, i18n: { language: 'zh-CN' } }), +})); + +import { Input, Textarea } from '../input'; + +function inputClass(): string { + return (screen.getByRole('textbox') as HTMLInputElement).className; +} + +describe('Input', () => { + afterEach(cleanup); + + it('binds §4 fill / text / border / placeholder tokens and stays a pill', () => { + render( {}} placeholder="hint" />); + const cls = inputClass(); + expect(cls).toContain('rounded-full'); + expect(cls).toContain('bg-[var(--surface-elevated)]'); + expect(cls).toContain('text-[var(--text-primary)]'); + expect(cls).toContain('placeholder:text-[var(--text-placeholder)]'); + expect(cls).toContain('border-[var(--border-default)]'); + expect(cls).toContain('focus:ring-[var(--focus-ring)]'); + expect(cls).not.toContain('settings-input-placeholder'); + }); + + it('keeps sm/md/lg at 32/36/40px', () => { + const { rerender } = render( {}} size="sm" />); + expect(inputClass()).toContain('h-8'); + rerender( {}} size="md" />); + expect(inputClass()).toContain('h-9'); + rerender( {}} size="lg" />); + expect(inputClass()).toContain('h-[40px]'); + }); + + it('keeps ivory as an explicit registered-debt variant', () => { + render( {}} surface="ivory" />); + expect(inputClass()).toContain('bg-[var(--settings-input-bg)]'); + expect(inputClass()).not.toContain('bg-[var(--surface-elevated)]'); + }); + + it('uses --error-* tokens in the error state', () => { + render( {}} error />); + const cls = inputClass(); + expect(cls).toContain('border-[var(--error-border)]'); + expect(cls).toContain('focus:border-[var(--error-fg)]'); + }); + + it('masks secret fields and exposes a reveal control', () => { + render( {}} secret />); + const field = document.querySelector('input'); + expect(field?.getAttribute('type')).toBe('password'); + expect(screen.getByRole('button', { name: 'settings.apiKey.showKey' })).toBeTruthy(); + }); +}); + +describe('Textarea', () => { + afterEach(cleanup); + + it('uses the 8px inner-control radius and --text-placeholder', () => { + render(