= {
- 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..ebad108344b
--- /dev/null
+++ b/apps/desktop/src/renderer/components/ui/__tests__/input.test.tsx
@@ -0,0 +1,122 @@
+// @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();
+ });
+
+ it('keeps caller inline style instead of silently dropping it', () => {
+ render(
+ {}} style={{ textAlign: 'center', width: 120 }} />,
+ );
+ const el = screen.getByRole('textbox') as HTMLInputElement;
+ expect(el.style.textAlign).toBe('center');
+ expect(el.style.width).toBe('120px');
+ // 组件内置的文本选择样式仍在,未被调用方清空。
+ expect(el.style.userSelect).toBe('text');
+ });
+
+ it('lets caller override the built-in userSelect when explicitly asked', () => {
+ render( {}} style={{ userSelect: 'none' }} />);
+ expect((screen.getByRole('textbox') as HTMLInputElement).style.userSelect).toBe('none');
+ });
+});
+
+describe('Textarea', () => {
+ afterEach(cleanup);
+
+ it('uses the 8px inner-control radius and --text-placeholder', () => {
+ render(