From 0284603847761b5e224b10b015d932f3a0cb9ddd Mon Sep 17 00:00:00 2001 From: "xiaohan.yuan" Date: Sun, 18 Oct 2026 19:11:38 +0800 Subject: [PATCH] feat(ui): add base component tokens and primitives --- src/renderer/src/main.tsx | 4 + src/renderer/src/settings.tsx | 4 + src/renderer/src/ui/Button.tsx | 82 ++++++++ src/renderer/src/ui/IconButton.tsx | 40 ++++ src/renderer/src/ui/Input.tsx | 54 +++++ src/renderer/src/ui/Switch.tsx | 65 ++++++ src/renderer/src/ui/button.css | 318 +++++++++++++++++++++++++++++ src/renderer/src/ui/input.css | 124 +++++++++++ src/renderer/src/ui/switch.css | 147 +++++++++++++ src/renderer/src/ui/tokens.css | 292 ++++++++++++++++++++++++++ 10 files changed, 1130 insertions(+) create mode 100644 src/renderer/src/ui/Button.tsx create mode 100644 src/renderer/src/ui/IconButton.tsx create mode 100644 src/renderer/src/ui/Input.tsx create mode 100644 src/renderer/src/ui/Switch.tsx create mode 100644 src/renderer/src/ui/button.css create mode 100644 src/renderer/src/ui/input.css create mode 100644 src/renderer/src/ui/switch.css create mode 100644 src/renderer/src/ui/tokens.css diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index 587ae14..3308c25 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -4,6 +4,10 @@ import App from './App' import { SettingsWindow } from './components/SettingsDialog' import './workbuddy/typography/tokens.css' import './styles/tokens.css' +import './ui/tokens.css' +import './ui/button.css' +import './ui/input.css' +import './ui/switch.css' import './styles/app.css' import './styles/sidebar.css' import './workbuddy/conversation/source-styles.css' diff --git a/src/renderer/src/settings.tsx b/src/renderer/src/settings.tsx index c941513..80ebc73 100644 --- a/src/renderer/src/settings.tsx +++ b/src/renderer/src/settings.tsx @@ -3,6 +3,10 @@ import { createRoot } from 'react-dom/client' import { SettingsWindow } from './components/SettingsDialog' import './workbuddy/typography/tokens.css' import './styles/tokens.css' +import './ui/tokens.css' +import './ui/button.css' +import './ui/input.css' +import './ui/switch.css' import './styles/app.css' import './styles/settings-entry.css' diff --git a/src/renderer/src/ui/Button.tsx b/src/renderer/src/ui/Button.tsx new file mode 100644 index 0000000..cd3068b --- /dev/null +++ b/src/renderer/src/ui/Button.tsx @@ -0,0 +1,82 @@ +import { Children, type ButtonHTMLAttributes, type ReactNode, type Ref } from 'react' +import './button.css' + +export type ButtonVariant = 'primary' | 'secondary' | 'grey' | 'ghost' | 'link' | 'danger' +export type ButtonSize = 'sm' | 'md' | 'lg' + +export interface ButtonProps extends ButtonHTMLAttributes { + variant?: ButtonVariant + size?: ButtonSize + iconOnly?: boolean + loading?: boolean + iconLeft?: ReactNode + iconRight?: ReactNode + ref?: Ref +} + +/** + * 原版按钮的加载图标(16 图标栅格、8 辐条、currentColor); + * 尺寸由 button.css 按 size 覆盖,旋转由 .yc-button__loading-icon 驱动。 + */ +function ButtonLoadingIcon(): ReactNode { + return ( + + + + ) +} + +/** + * 统一按钮。DOM 结构与原版按钮一致: + * iconLeft -> .yc-button__icon--left,children -> .yc-button__content + * (iconOnly 时 children 进 .yc-button__icon),loading -> .yc-button__loading。 + */ +export function Button({ + variant = 'secondary', + size = 'md', + iconOnly = false, + loading = false, + iconLeft, + iconRight, + className, + children, + type, + disabled, + ref, + 'aria-busy': ariaBusy, + ...rest +}: ButtonProps): React.JSX.Element { + const classes = ['yc-button', `yc-button--${variant}`, `yc-button--${size}`] + if (iconOnly) classes.push('yc-button--icon-only') + if (loading) classes.push('yc-button--loading') + if (disabled || loading) classes.push('yc-button--disabled') + if (className) classes.push(className) + + const hasContent = Children.count(children) > 0 + + return ( + + ) +} diff --git a/src/renderer/src/ui/IconButton.tsx b/src/renderer/src/ui/IconButton.tsx new file mode 100644 index 0000000..57d33f0 --- /dev/null +++ b/src/renderer/src/ui/IconButton.tsx @@ -0,0 +1,40 @@ +import type { ReactNode } from 'react' +import { Button, type ButtonProps } from './Button' + +/** 图标按钮没有文本,必须有可读名称:aria-label 或 title 至少给一个。 */ +type IconButtonLabel = + | { 'aria-label': string; title?: string } + | { title: string; 'aria-label'?: string } + +export type IconButtonProps = Omit & + IconButtonLabel & { + children: ReactNode + } + +/** + * Button 的 iconOnly 语法糖:默认 variant="ghost"、size="md"。 + * + * 缺少 aria-label 与 title 时:开发环境抛出明确错误;生产构建不渲染 DOM, + * 避免留下对读屏不可用的空白按钮(类型层已用联合类型要求二者之一)。 + */ +export function IconButton({ + variant = 'ghost', + size = 'md', + children, + 'aria-label': ariaLabel, + title, + ...rest +}: IconButtonProps): React.JSX.Element | null { + if (!ariaLabel && !title) { + const message = 'IconButton 需要 aria-label 或 title:图标按钮缺少可读名称。' + if (import.meta.env.DEV) throw new Error(message) + console.error(message) + return null + } + + return ( + + ) +} diff --git a/src/renderer/src/ui/Input.tsx b/src/renderer/src/ui/Input.tsx new file mode 100644 index 0000000..86c286e --- /dev/null +++ b/src/renderer/src/ui/Input.tsx @@ -0,0 +1,54 @@ +/** + * YCode UI Input(P2-B)。 + * + * 结构与几何来源:`docs/界面基础组件规范-2026-10-18.md` 的 Input 章节, + * 等价于设置页恢复样式 source-general-settings-recovered.css 中 `.wb-input-wrapper` + * (height 28/32/48、padding-x 8/8/12、radius 6/8/12、filled/disabled/error 态)。 + * 只消费 ui/tokens.css 的 `--yc-*` L2 token,不写裸值。 + */ +import type { InputHTMLAttributes, ReactNode } from 'react' +import './input.css' + +export type InputSize = 'sm' | 'md' | 'lg' + +export interface InputProps extends Omit, 'size' | 'prefix'> { + size?: InputSize + invalid?: boolean + filled?: boolean + prefix?: ReactNode + suffix?: ReactNode + wrapperClassName?: string +} + +function classNames(...values: Array): string { + return values.filter(Boolean).join(' ') +} + +export function Input({ + size = 'md', + invalid = false, + filled = false, + prefix, + suffix, + wrapperClassName, + className, + disabled, + ...inputProps +}: InputProps): React.JSX.Element { + const wrapperClass = classNames( + 'yc-input', + `yc-input--${size}`, + filled && 'yc-input--filled', + invalid && 'yc-input--invalid', + disabled && 'yc-input--disabled', + wrapperClassName + ) + + return ( +
+ {prefix ? {prefix} : null} + + {suffix ? {suffix} : null} +
+ ) +} diff --git a/src/renderer/src/ui/Switch.tsx b/src/renderer/src/ui/Switch.tsx new file mode 100644 index 0000000..1e54371 --- /dev/null +++ b/src/renderer/src/ui/Switch.tsx @@ -0,0 +1,65 @@ +/** + * YCode UI Switch(P2-B)。 + * + * 结构与几何来源:`docs/界面基础组件规范-2026-10-18.md` 的 Switch 章节, + * 对应 GeneralSettingsPanel 本地 Switch 与恢复样式 source-general-settings-recovered.css + * 的 `.wb-switch*` 闭包(track 24×14 / 26×16 / 34×20,thumb 10 / 12 / 16)。 + * 只消费 ui/tokens.css 的 `--yc-*` L2 token,不写裸值。 + */ +import type { ChangeEvent } from 'react' +import './switch.css' + +export type SwitchSize = 'sm' | 'md' | 'lg' + +export interface SwitchProps { + checked: boolean + onCheckedChange?(checked: boolean): void + disabled?: boolean + readOnly?: boolean + size?: SwitchSize + 'aria-label': string + className?: string +} + +function classNames(...values: Array): string { + return values.filter(Boolean).join(' ') +} + +export function Switch({ + checked, + onCheckedChange, + disabled = false, + readOnly = false, + size = 'md', + className, + 'aria-label': ariaLabel +}: SwitchProps): React.JSX.Element { + const handleChange = (event: ChangeEvent): void => { + if (readOnly) return + onCheckedChange?.(event.target.checked) + } + + return ( + + + + + + + + + ) +} diff --git a/src/renderer/src/ui/button.css b/src/renderer/src/ui/button.css new file mode 100644 index 0000000..1fd0d5d --- /dev/null +++ b/src/renderer/src/ui/button.css @@ -0,0 +1,318 @@ +/** + * YCode Button / IconButton(L2:--yc-button-*)。 + * + * 由原版 .wb-button 的已验证闭包翻译:类名前缀 wb-button -> yc-button, + * 子元素沿用原结构(__icon / __content / __loading / __loading-icon)。 + * 颜色与几何只消费 ui/tokens.css 的 --yc-button-* 与 styles/tokens.css 的 + * L0/L1 token;不写裸值,自身不带外间距(间距由调用方决定)。 + */ + +.yc-button { + box-sizing: border-box; + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--yc-button-gap-md); + border: none; + border-radius: var(--yc-button-radius-md); + font-family: var(--yc-font-sans); + font-weight: var(--yc-font-weight-500); + white-space: nowrap; + user-select: none; + cursor: pointer; + text-decoration: none; + outline: none; + transition: + background-color var(--yc-button-transition-duration) var(--yc-button-transition-easing), + color var(--yc-button-transition-duration) var(--yc-button-transition-easing), + border-color var(--yc-button-transition-duration) var(--yc-button-transition-easing), + transform var(--yc-button-transition-duration) var(--yc-button-transition-easing); +} + +/* ---------------------------------------------------------------- 尺寸 */ + +.yc-button--sm { + height: var(--yc-button-height-sm); + padding: 0 var(--yc-button-padding-sm); + gap: var(--yc-button-gap-sm); + border-radius: var(--yc-button-radius-sm); + font-size: var(--yc-button-font-size-sm); + line-height: var(--yc-button-line-height-sm); +} + +.yc-button--md { + height: var(--yc-button-height-md); + padding: 0 var(--yc-button-padding-md); + gap: var(--yc-button-gap-md); + border-radius: var(--yc-button-radius-md); + font-size: var(--yc-button-font-size-md); + line-height: var(--yc-button-line-height-md); +} + +.yc-button--lg { + height: var(--yc-button-height-lg); + padding: 0 var(--yc-button-padding-lg); + gap: var(--yc-button-gap-lg); + border-radius: var(--yc-button-radius-lg); + font-size: var(--yc-button-font-size-lg); + line-height: var(--yc-button-line-height-lg); +} + +.yc-button--icon-only { + padding: 0; +} + +.yc-button--sm:not(.yc-button--icon-only):not(.yc-button--link) { + min-width: var(--yc-button-min-width-sm); +} + +.yc-button--md:not(.yc-button--icon-only):not(.yc-button--link) { + min-width: var(--yc-button-min-width-md); +} + +.yc-button--icon-only.yc-button--sm { + width: var(--yc-button-height-sm); +} + +.yc-button--icon-only.yc-button--md { + width: var(--yc-button-height-md); +} + +.yc-button--icon-only.yc-button--lg { + width: var(--yc-button-height-lg); +} + +/* ------------------------------------------------------ 图标与文本容器 */ + +.yc-button__icon, +.yc-button__loading-icon { + width: var(--yc-button-icon-md); + height: var(--yc-button-icon-md); +} + +.yc-button--sm :is(.yc-button__icon, .yc-button__loading-icon) { + width: var(--yc-button-icon-sm); + height: var(--yc-button-icon-sm); +} + +.yc-button--lg :is(.yc-button__icon, .yc-button__loading-icon) { + width: var(--yc-button-icon-lg); + height: var(--yc-button-icon-lg); +} + +.yc-button__icon { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.yc-button__icon svg { + width: 100%; + height: 100%; + color: currentColor; + fill: currentColor; +} + +.yc-button__content { + display: inline-flex; + align-items: center; +} + +/* ---------------------------------------------------------------- 变体 */ + +.yc-button--primary { + background-color: var(--yc-button-primary-bg); + color: var(--yc-button-primary-fg); +} + +.yc-button--primary:hover:not(:disabled) { + background-color: var(--yc-button-primary-bg-hover); +} + +.yc-button--primary:active:not(:disabled) { + background-color: var(--yc-button-primary-bg-active); +} + +.yc-button--primary:disabled, +.yc-button--primary.yc-button--disabled { + background-color: var(--yc-button-primary-bg-disabled); + color: var(--yc-button-primary-fg-disabled); + cursor: not-allowed; +} + +.yc-button--secondary { + background-color: var(--yc-button-secondary-bg); + color: var(--yc-button-secondary-fg); + border: var(--yc-border-width) solid var(--yc-button-secondary-border); +} + +.yc-button--secondary:hover:not(:disabled) { + background-color: var(--yc-button-secondary-bg-hover); +} + +.yc-button--secondary:active:not(:disabled) { + background-color: var(--yc-button-secondary-bg-active); +} + +.yc-button--secondary:disabled, +.yc-button--secondary.yc-button--disabled { + color: var(--yc-button-secondary-fg-disabled); + border-color: var(--yc-button-secondary-border-disabled); + cursor: not-allowed; +} + +.yc-button--grey { + background-color: var(--yc-button-grey-bg); + color: var(--yc-button-grey-fg); +} + +.yc-button--grey:hover:not(:disabled) { + background-color: var(--yc-button-grey-bg-hover); +} + +.yc-button--grey:active:not(:disabled) { + background-color: var(--yc-button-grey-bg-active); +} + +.yc-button--grey:disabled, +.yc-button--grey.yc-button--disabled { + background-color: var(--yc-button-grey-bg-disabled); + color: var(--yc-button-grey-fg-disabled); + cursor: not-allowed; +} + +.yc-button--ghost { + background-color: var(--yc-button-ghost-bg); + color: var(--yc-button-ghost-fg); + font-weight: var(--yc-font-weight-400); +} + +.yc-button--ghost:hover:not(:disabled) { + background-color: var(--yc-button-ghost-bg-hover); + color: var(--yc-button-ghost-fg-hover); +} + +.yc-button--ghost:active:not(:disabled) { + background-color: var(--yc-button-ghost-bg-active); + color: var(--yc-button-ghost-fg-hover); +} + +.yc-button--ghost:disabled, +.yc-button--ghost.yc-button--disabled { + background-color: var(--yc-button-ghost-bg); + color: var(--yc-button-ghost-fg-disabled); + cursor: not-allowed; +} + +.yc-button--link { + height: auto; + padding: 0; + background-color: var(--yc-button-link-bg); + color: var(--yc-button-link-fg); + font-weight: var(--yc-font-weight-400); +} + +.yc-button--link:hover:not(:disabled) { + color: var(--yc-button-link-fg-hover); + text-decoration: underline; +} + +.yc-button--link:active:not(:disabled) { + color: var(--yc-button-link-fg-hover); + text-decoration: underline; +} + +.yc-button--link:disabled, +.yc-button--link.yc-button--disabled { + color: var(--yc-button-link-fg-disabled); + cursor: not-allowed; +} + +.yc-button--danger { + background-color: var(--yc-button-danger-bg); + color: var(--yc-button-danger-fg); +} + +.yc-button--danger:hover:not(:disabled) { + background-color: var(--yc-button-danger-bg-hover); +} + +.yc-button--danger:active:not(:disabled) { + background-color: var(--yc-button-danger-bg-active); +} + +.yc-button--danger:disabled, +.yc-button--danger.yc-button--disabled { + background-color: var(--yc-button-danger-bg-disabled); + color: var(--yc-button-danger-fg-disabled); + cursor: not-allowed; +} + +/* -------------------------------------------------------------- loading */ + +.yc-button--loading { + position: relative; + pointer-events: none; +} + +/* 内容与图标保留占位(只隐藏),加载指示器绝对居中,几何不跳动 */ +.yc-button--loading .yc-button__content, +.yc-button--loading .yc-button__icon { + opacity: 0; +} + +.yc-button__loading { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; +} + +.yc-button__loading-icon { + flex: none; + animation: yc-button-spin var(--yc-button-spinner-duration) linear infinite; +} + +@keyframes yc-button-spin { + 0% { + transform: rotate(0); + } + + to { + transform: rotate(360deg); + } +} + +/* 加载态按钮同时是 disabled,spinner 需取变体本身的可见色而非 disabled 前景色 */ +.yc-button--primary .yc-button__loading-icon { + color: var(--yc-button-primary-fg); +} + +.yc-button--secondary .yc-button__loading-icon { + color: var(--yc-button-secondary-fg); +} + +.yc-button--grey .yc-button__loading-icon { + color: var(--yc-button-grey-fg); +} + +.yc-button--ghost .yc-button__loading-icon { + color: var(--yc-button-ghost-fg); +} + +.yc-button--link .yc-button__loading-icon { + color: var(--yc-button-link-fg); +} + +.yc-button--danger .yc-button__loading-icon { + color: var(--yc-button-danger-bg-disabled); +} + +/* ------------------------------------------------------------ 键盘焦点 */ + +.yc-button:focus-visible { + outline: var(--yc-focus-ring-width) solid var(--yc-focus-ring-color); + outline-offset: var(--yc-focus-ring-offset); +} diff --git a/src/renderer/src/ui/input.css b/src/renderer/src/ui/input.css new file mode 100644 index 0000000..b1d8ce6 --- /dev/null +++ b/src/renderer/src/ui/input.css @@ -0,0 +1,124 @@ +/** + * YCode UI Input 样式(P2-B)。 + * + * 仅消费 `--yc-*` token(L2 组件 token + L0/L1 基础 token),不写裸值、强制声明或 + * 外部外边距。几何与状态对应 source-general-settings-recovered.css + * 的 `.wb-input-wrapper*` 闭包。 + */ + +.yc-input { + box-sizing: border-box; + display: inline-flex; + align-items: center; + gap: var(--yc-space-2); + width: 100%; + color: var(--yc-input-fg); + font-family: inherit; + line-height: var(--yc-input-line-height); + background: var(--yc-input-bg); + border: var(--yc-border-width) solid var(--yc-input-border); + outline: none; + transition: border-color var(--yc-input-transition-duration) var(--yc-button-transition-easing); +} + +.yc-input__inner { + box-sizing: border-box; + flex: 1 1 auto; + min-width: 0; + height: 100%; + margin: 0; + padding: 0; + color: inherit; + font-family: inherit; + font-size: inherit; + font-weight: inherit; + line-height: inherit; + background: none; + border: none; + outline: none; +} + +.yc-input__inner::placeholder { + color: var(--yc-input-placeholder); +} + +.yc-input__prefix, +.yc-input__suffix { + display: inline-flex; + flex: none; + align-items: center; + color: var(--yc-input-icon); +} + +.yc-input__prefix svg, +.yc-input__suffix svg { + width: 1em; + height: 1em; +} + +/* 尺寸 */ +.yc-input--sm { + height: var(--yc-input-height-sm); + padding: 0 var(--yc-input-padding-x-sm); + border-radius: var(--yc-input-radius-sm); + font-size: var(--yc-input-font-size-sm); +} + +.yc-input--md { + height: var(--yc-input-height-md); + padding: 0 var(--yc-input-padding-x-md); + border-radius: var(--yc-input-radius-md); + font-size: var(--yc-input-font-size-md); +} + +.yc-input--lg { + height: var(--yc-input-height-lg); + padding: 0 var(--yc-input-padding-x-lg); + border-radius: var(--yc-input-radius-lg); + font-size: var(--yc-input-font-size-lg); +} + +/* filled */ +.yc-input--filled { + background: var(--yc-input-bg-filled); + border-color: transparent; +} + +/* hover / focus(invalid 优先级更高,保持错误态边框) */ +.yc-input:hover:not(.yc-input--disabled):not(:has(.yc-input__inner:read-only)) { + border-color: var(--yc-input-border-hover); +} + +.yc-input:focus-within:not(.yc-input--disabled):not(:has(.yc-input__inner:read-only)) { + border-color: var(--yc-input-border-focus); +} + +.yc-input--invalid { + border-color: var(--yc-input-border-invalid); +} + +.yc-input--invalid:hover:not(.yc-input--disabled), +.yc-input--invalid:focus-within:not(.yc-input--disabled) { + border-color: var(--yc-input-border-invalid); +} + +/* disabled */ +.yc-input--disabled, +.yc-input:has(.yc-input__inner:disabled) { + color: var(--yc-input-fg-disabled); + background: var(--yc-input-bg-disabled); + cursor: not-allowed; +} + +.yc-input--disabled .yc-input__inner, +.yc-input:has(.yc-input__inner:disabled) .yc-input__inner { + color: var(--yc-input-fg-disabled); + cursor: not-allowed; +} + +.yc-input--disabled .yc-input__prefix, +.yc-input--disabled .yc-input__suffix, +.yc-input:has(.yc-input__inner:disabled) .yc-input__prefix, +.yc-input:has(.yc-input__inner:disabled) .yc-input__suffix { + color: var(--yc-input-icon-disabled); +} diff --git a/src/renderer/src/ui/switch.css b/src/renderer/src/ui/switch.css new file mode 100644 index 0000000..85943c0 --- /dev/null +++ b/src/renderer/src/ui/switch.css @@ -0,0 +1,147 @@ +/** + * YCode UI Switch 样式(P2-B)。 + * + * 仅消费 `--yc-*` token,不写裸值、强制声明或外部外边距。 + * 几何与状态对应 source-general-settings-recovered.css 的 `.wb-switch*` 闭包; + * 文档未提供 focus 环颜色 token,暂用同计算值的 `--yc-input-border-focus`。 + */ + +.yc-switch { + display: inline-flex; + align-items: center; + color: inherit; + cursor: pointer; + user-select: none; +} + +.yc-switch[data-disabled='true'] { + cursor: not-allowed; +} + +.yc-switch__control { + position: relative; + display: inline-flex; + flex: none; +} + +.yc-switch__input { + position: absolute; + inset: 0; + margin: 0; + padding: 0; + width: 100%; + height: 100%; + cursor: inherit; + opacity: 0; +} + +.yc-switch__track { + box-sizing: border-box; + display: inline-flex; + align-items: center; + padding: 0 var(--yc-space-1); + border-radius: var(--yc-button-radius-pill); + background: var(--yc-switch-track-bg); + border: var(--yc-border-width) solid var(--yc-switch-track-border); + transition: background var(--yc-input-transition-duration) var(--yc-button-transition-easing), + border-color var(--yc-input-transition-duration) var(--yc-button-transition-easing); +} + +.yc-switch__thumb { + border-radius: var(--yc-button-radius-pill); + background: var(--yc-switch-thumb-bg); + box-shadow: var(--yc-switch-thumb-shadow); + transition: transform var(--yc-input-transition-duration) var(--yc-button-transition-easing); +} + +/* 尺寸:track 用 padding 把 thumb 定位到左侧,与恢复样式一致 */ +.yc-switch--sm .yc-switch__track { + width: var(--yc-switch-track-width-sm); + height: var(--yc-switch-track-height-sm); +} + +.yc-switch--sm .yc-switch__thumb { + width: var(--yc-switch-thumb-size-sm); + height: var(--yc-switch-thumb-size-sm); +} + +.yc-switch--md .yc-switch__track { + width: var(--yc-switch-track-width-md); + height: var(--yc-switch-track-height-md); +} + +.yc-switch--md .yc-switch__thumb { + width: var(--yc-switch-thumb-size-md); + height: var(--yc-switch-thumb-size-md); +} + +.yc-switch--lg .yc-switch__track { + width: var(--yc-switch-track-width-lg); + height: var(--yc-switch-track-height-lg); +} + +.yc-switch--lg .yc-switch__thumb { + width: var(--yc-switch-thumb-size-lg); + height: var(--yc-switch-thumb-size-lg); +} + +/* checked */ +.yc-switch__input:checked + .yc-switch__track { + background: var(--yc-switch-track-bg-checked); + border-color: var(--yc-switch-track-bg-checked); +} + +.yc-switch__input:checked + .yc-switch__track:hover { + background: var(--yc-switch-track-bg-checked-hover); + border-color: var(--yc-switch-track-bg-checked-hover); +} + +.yc-switch--sm .yc-switch__input:checked + .yc-switch__track .yc-switch__thumb { + transform: translateX(var(--yc-switch-thumb-translate-sm)); +} + +.yc-switch--md .yc-switch__input:checked + .yc-switch__track .yc-switch__thumb { + transform: translateX(var(--yc-switch-thumb-translate-md)); +} + +.yc-switch--lg .yc-switch__input:checked + .yc-switch__track .yc-switch__thumb { + transform: translateX(var(--yc-switch-thumb-translate-lg)); +} + +/* focus-visible */ +.yc-switch__input:focus-visible + .yc-switch__track { + outline: var(--yc-focus-ring-width) solid var(--yc-focus-ring-color); + outline-offset: var(--yc-focus-ring-offset); +} + +/* readOnly:保持几何,仅移除可点击语义 */ +.yc-switch[data-readonly='true'] { + cursor: default; +} + +.yc-switch[data-readonly='true'] .yc-switch__input { + cursor: default; +} + +/* disabled:保持几何,用 token 降低视觉强度 */ +.yc-switch[data-disabled='true'] .yc-switch__input:checked + .yc-switch__track { + background: var(--yc-switch-track-bg-checked-disabled); + border-color: var(--yc-switch-track-bg-checked-disabled); +} + +.yc-switch[data-disabled='true'] .yc-switch__input:checked + .yc-switch__track:hover { + background: var(--yc-switch-track-bg-checked-disabled); + border-color: var(--yc-switch-track-bg-checked-disabled); +} + +.yc-switch[data-disabled='true'] .yc-switch__input:not(:checked) + .yc-switch__track { + background: var(--yc-switch-track-bg-disabled); +} + +.yc-switch[data-disabled='true'] .yc-switch__input:not(:checked) + .yc-switch__track:hover { + background: var(--yc-switch-track-bg-disabled); +} + +.yc-switch[data-disabled='true'] .yc-switch__thumb { + opacity: 0.5; +} diff --git a/src/renderer/src/ui/tokens.css b/src/renderer/src/ui/tokens.css new file mode 100644 index 0000000..5b3bdfb --- /dev/null +++ b/src/renderer/src/ui/tokens.css @@ -0,0 +1,292 @@ +/** + * YCode UI 组件 token(L2)。 + * + * 只在组件层消费;页面不得重定义。数值来自当前 --wb-* 控件闭包, + * 迁移期间必须保持现有视觉,不允许在这里发明新设计。 + * Import order: workbuddy/typography/tokens.css -> styles/tokens.css -> ui/tokens.css + */ + +:root { + /* Button */ + --yc-button-height-sm: 24px; + --yc-button-height-md: 32px; + --yc-button-height-lg: 48px; + --yc-button-padding-sm: 8px; + --yc-button-padding-md: 12px; + --yc-button-padding-lg: 32px; + --yc-button-gap-sm: 2px; + --yc-button-gap-md: 4px; + --yc-button-gap-lg: 8px; + --yc-button-radius-sm: 6px; + --yc-button-radius-md: 8px; + --yc-button-radius-lg: 12px; + --yc-button-radius-pill: 9999px; + --yc-button-font-size-sm: var(--yc-font-size-3); + --yc-button-font-size-md: var(--yc-font-size-5); + --yc-button-font-size-lg: var(--yc-font-size-5); + --yc-button-line-height-sm: var(--yc-line-height-5); + --yc-button-line-height-md: var(--yc-line-height-5-2); + --yc-button-line-height-lg: var(--yc-line-height-5-2); + --yc-button-icon-sm: var(--yc-size-3); + --yc-button-icon-md: var(--yc-size-4); + --yc-button-icon-lg: var(--yc-size-4); + --yc-button-transition-duration: 0.1s; + --yc-button-transition-easing: cubic-bezier(0.4, 0, 0.2, 1); + --yc-button-spinner-duration: 0.8s; + --yc-button-min-width-sm: 40px; + --yc-button-min-width-md: 68px; + + --yc-button-primary-bg: rgba(0, 0, 0, 0.9); + --yc-button-primary-bg-hover: rgba(0, 0, 0, 0.75); + --yc-button-primary-bg-active: rgba(0, 0, 0, 0.75); + --yc-button-primary-bg-disabled: rgba(0, 0, 0, 0.2); + --yc-button-primary-fg: #ffffff; + --yc-button-primary-fg-disabled: #ffffff; + + --yc-button-secondary-bg: #ffffff; + --yc-button-secondary-bg-hover: #f7f7f7; + --yc-button-secondary-bg-active: #f7f7f7; + --yc-button-secondary-bg-disabled: #f7f7f7; + --yc-button-secondary-fg: rgba(0, 0, 0, 0.9); + --yc-button-secondary-fg-disabled: rgba(0, 0, 0, 0.3); + --yc-button-secondary-border: #dedede; + --yc-button-secondary-border-disabled: #ebebeb; + + --yc-button-grey-bg: #ebebeb; + --yc-button-grey-bg-hover: #e6e6e6; + --yc-button-grey-bg-active: #e6e6e6; + --yc-button-grey-bg-disabled: #ebebeb; + --yc-button-grey-fg: rgba(0, 0, 0, 0.9); + --yc-button-grey-fg-disabled: rgba(0, 0, 0, 0.3); + + --yc-button-ghost-bg: transparent; + --yc-button-ghost-bg-hover: rgba(0, 0, 0, 0.06); + --yc-button-ghost-bg-active: rgba(0, 0, 0, 0.06); + --yc-button-ghost-fg: rgba(0, 0, 0, 0.9); + --yc-button-ghost-fg-hover: rgba(0, 0, 0, 0.9); + --yc-button-ghost-fg-disabled: rgba(0, 0, 0, 0.3); + + --yc-button-link-bg: transparent; + --yc-button-link-fg: #1470b4; + --yc-button-link-fg-hover: #1470b4; + --yc-button-link-fg-disabled: #94b7d1; + + --yc-button-danger-bg: #e85c5c; + --yc-button-danger-bg-hover: #ec7474; + --yc-button-danger-bg-active: #ec7474; + --yc-button-danger-bg-disabled: #f5b9b9; + --yc-button-danger-fg: #ffffff; + --yc-button-danger-fg-disabled: #ffffff; + + /* Focus ring(Button / Switch / 后续控件共用) */ + --yc-focus-ring-color: rgba(0, 0, 0, 0.3); + --yc-focus-ring-width: 2px; + --yc-focus-ring-offset: 2px; + + /* Input */ + --yc-input-height-sm: 28px; + --yc-input-height-md: 32px; + --yc-input-height-lg: 48px; + --yc-input-padding-x-sm: 8px; + --yc-input-padding-x-md: 8px; + --yc-input-padding-x-lg: 12px; + --yc-input-radius-sm: 6px; + --yc-input-radius-md: 8px; + --yc-input-radius-lg: 12px; + --yc-input-font-size-sm: var(--yc-font-size-3); + --yc-input-font-size-md: var(--yc-font-size-5); + --yc-input-font-size-lg: var(--yc-font-size-5); + --yc-input-line-height: var(--yc-line-height-5-2); + --yc-input-bg: #ffffff; + --yc-input-bg-filled: #f2f2f2; + --yc-input-bg-disabled: #f7f7f7; + --yc-input-border: #e6e6e6; + --yc-input-border-hover: rgba(0, 0, 0, 0.3); + --yc-input-border-focus: rgba(0, 0, 0, 0.3); + --yc-input-border-invalid: #f64041; + --yc-input-fg: rgba(0, 0, 0, 0.9); + --yc-input-fg-disabled: rgba(0, 0, 0, 0.3); + --yc-input-placeholder: rgba(0, 0, 0, 0.3); + --yc-input-icon: rgba(0, 0, 0, 0.5); + --yc-input-icon-hover: rgba(0, 0, 0, 0.7); + --yc-input-icon-disabled: rgba(0, 0, 0, 0.3); + --yc-input-transition-duration: 0.1s; + + /* Switch */ + --yc-switch-track-width-sm: 24px; + --yc-switch-track-width-md: 26px; + --yc-switch-track-width-lg: 34px; + --yc-switch-track-height-sm: 14px; + --yc-switch-track-height-md: 16px; + --yc-switch-track-height-lg: 20px; + --yc-switch-thumb-size-sm: 10px; + --yc-switch-thumb-size-md: 12px; + --yc-switch-thumb-size-lg: 16px; + --yc-switch-thumb-translate-sm: 8px; + --yc-switch-thumb-translate-md: 8px; + --yc-switch-thumb-translate-lg: 12px; + --yc-switch-track-bg: #e6e6e6; + --yc-switch-track-bg-disabled: #e6e6e6; + --yc-switch-track-bg-checked: #00c29a; + --yc-switch-track-bg-checked-hover: #40d1b3; + --yc-switch-track-bg-checked-disabled: #9fe8d9; + --yc-switch-track-border: rgba(0, 0, 0, 0.08); + --yc-switch-thumb-bg: #ffffff; + --yc-switch-thumb-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); + --yc-switch-focus-outline: 2px; + --yc-switch-focus-offset: 2px; + + /* Select */ + --yc-select-height-sm: 24px; + --yc-select-height-md: 32px; + --yc-select-height-lg: 48px; + --yc-select-padding-x-sm: 6px; + --yc-select-padding-x-md: 8px; + --yc-select-padding-x-lg: 12px; + --yc-select-radius-sm: 6px; + --yc-select-radius-md: 8px; + --yc-select-radius-lg: 12px; + --yc-select-font-size-sm: var(--yc-font-size-3); + --yc-select-font-size-md: var(--yc-font-size-5); + --yc-select-font-size-lg: var(--yc-font-size-5); + --yc-select-line-height: var(--yc-line-height-5-2); + --yc-select-icon: var(--yc-size-4); + --yc-select-bg: #ffffff; + --yc-select-fg: rgba(0, 0, 0, 0.9); + --yc-select-placeholder: rgba(0, 0, 0, 0.3); + --yc-select-border: #e6e6e6; + --yc-select-border-hover: rgba(0, 0, 0, 0.3); + --yc-select-border-open: rgba(0, 0, 0, 0.5); + --yc-select-bg-disabled: #f7f7f7; + --yc-select-fg-disabled: rgba(0, 0, 0, 0.3); + + /* Dialog */ + --yc-dialog-bg: #ffffff; + --yc-dialog-border: #dedede; + --yc-dialog-radius: 24px; + --yc-dialog-radius-confirm: 12px; + --yc-dialog-max-width-s: 480px; + --yc-dialog-max-width-m: 640px; + --yc-dialog-max-width-l: 960px; + --yc-dialog-max-height: calc(100vh - 160px); + --yc-dialog-overlay: rgba(0, 0, 0, 0.15); + --yc-dialog-shadow: var(--yc-shadow-dialog); + + /* Popover */ + --yc-popover-bg: #ffffff; + --yc-popover-border: #dedede; + --yc-popover-radius: 16px; + --yc-popover-padding: 8px; + --yc-popover-fg: rgba(0, 0, 0, 0.9); + --yc-popover-shadow: var(--yc-shadow-popover); + + /* Segmented */ + --yc-segmented-track-padding: 2px; + --yc-segmented-track-radius: 8px; + --yc-segmented-track-bg: #f2f2f2; + --yc-segmented-item-gap: 4px; + --yc-segmented-item-radius: 6px; + --yc-segmented-item-height: 32px; + --yc-segmented-item-padding-x: 12px; + --yc-segmented-item-font-size: var(--yc-font-size-5); + --yc-segmented-item-line-height: var(--yc-line-height-5-2); + --yc-segmented-item-fg: rgba(0, 0, 0, 0.7); + --yc-segmented-item-fg-hover: rgba(0, 0, 0, 0.9); + --yc-segmented-item-fg-active: rgba(0, 0, 0, 0.9); + --yc-segmented-item-fg-disabled: rgba(0, 0, 0, 0.3); + --yc-segmented-item-bg-hover: #e6e6e6; + --yc-segmented-thumb-bg: #ffffff; + --yc-segmented-thumb-shadow: 0 1px 4px rgba(0, 0, 0, 0.05); + --yc-segmented-item-weight: 400; + --yc-segmented-item-weight-active: 600; +} + +html[data-theme='dark'] { + --yc-button-primary-bg: rgba(255, 255, 255, 0.9); + --yc-button-primary-bg-hover: rgba(255, 255, 255, 0.75); + --yc-button-primary-bg-active: rgba(255, 255, 255, 0.75); + --yc-button-primary-bg-disabled: rgba(255, 255, 255, 0.2); + --yc-button-primary-fg: rgba(0, 0, 0, 0.9); + --yc-button-primary-fg-disabled: rgba(0, 0, 0, 0.9); + + --yc-button-secondary-bg: #262626; + --yc-button-secondary-bg-hover: #2e2e2e; + --yc-button-secondary-bg-active: #2e2e2e; + --yc-button-secondary-bg-disabled: #262626; + --yc-button-secondary-fg: rgba(255, 255, 255, 0.9); + --yc-button-secondary-fg-disabled: rgba(255, 255, 255, 0.3); + --yc-button-secondary-border: #3d3d3d; + --yc-button-secondary-border-disabled: #3a3a3a; + + --yc-button-grey-bg: #383838; + --yc-button-grey-bg-hover: #404040; + --yc-button-grey-bg-active: #404040; + --yc-button-grey-bg-disabled: #404040; + --yc-button-grey-fg: rgba(255, 255, 255, 0.9); + --yc-button-grey-fg-disabled: rgba(255, 255, 255, 0.3); + + --yc-button-ghost-bg-hover: rgba(255, 255, 255, 0.1); + --yc-button-ghost-bg-active: rgba(255, 255, 255, 0.1); + --yc-button-ghost-fg: rgba(255, 255, 255, 0.9); + --yc-button-ghost-fg-hover: rgba(255, 255, 255, 0.9); + --yc-button-ghost-fg-disabled: rgba(255, 255, 255, 0.3); + + --yc-button-link-fg: #46a8f0; + --yc-button-link-fg-hover: #5ea9f2; + --yc-button-link-fg-disabled: #94b7d1; + + --yc-button-danger-bg: #af191a; + --yc-button-danger-bg-hover: #a31717; + --yc-button-danger-bg-active: #a31717; + --yc-button-danger-bg-disabled: #803838; + --yc-button-danger-fg-disabled: rgba(255, 255, 255, 0.7); + + --yc-focus-ring-color: rgba(255, 255, 255, 0.3); + + --yc-input-bg: #242424; + --yc-input-bg-filled: #262626; + --yc-input-bg-disabled: #171717; + --yc-input-border: #454545; + --yc-input-border-hover: rgba(255, 255, 255, 0.3); + --yc-input-border-focus: rgba(255, 255, 255, 0.3); + --yc-input-fg: rgba(255, 255, 255, 0.9); + --yc-input-fg-disabled: rgba(255, 255, 255, 0.3); + --yc-input-placeholder: rgba(255, 255, 255, 0.3); + --yc-input-icon: rgba(255, 255, 255, 0.5); + --yc-input-icon-hover: rgba(255, 255, 255, 0.7); + --yc-input-icon-disabled: rgba(255, 255, 255, 0.3); + + --yc-switch-track-bg: #3c3c3c; + --yc-switch-track-bg-disabled: #3c3c3c; + --yc-switch-track-bg-checked: #22cba8; + --yc-switch-track-bg-checked-hover: #40d1b3; + --yc-switch-track-bg-checked-disabled: rgba(61, 217, 182, 0.35); + --yc-switch-track-border: rgba(255, 255, 255, 0.14); + --yc-switch-thumb-bg: #ffffff; + --yc-switch-thumb-shadow: 0 1px 4px rgba(0, 0, 0, 0.6); + + --yc-select-bg: #242424; + --yc-select-fg: rgba(255, 255, 255, 0.9); + --yc-select-placeholder: rgba(255, 255, 255, 0.3); + --yc-select-border: #454545; + --yc-select-border-hover: rgba(255, 255, 255, 0.3); + --yc-select-border-open: rgba(255, 255, 255, 0.5); + --yc-select-bg-disabled: #171717; + --yc-select-fg-disabled: rgba(255, 255, 255, 0.3); + + --yc-dialog-bg: #1f1f1f; + --yc-dialog-border: #3d3d3d; + --yc-dialog-overlay: rgba(0, 0, 0, 0.4); + + --yc-popover-bg: #242424; + --yc-popover-border: #3d3d3d; + --yc-popover-fg: rgba(255, 255, 255, 0.9); + + --yc-segmented-track-bg: #242424; + --yc-segmented-item-fg: rgba(255, 255, 255, 0.7); + --yc-segmented-item-fg-hover: rgba(255, 255, 255, 0.9); + --yc-segmented-item-fg-active: rgba(255, 255, 255, 0.9); + --yc-segmented-item-fg-disabled: rgba(255, 255, 255, 0.3); + --yc-segmented-item-bg-hover: rgba(255, 255, 255, 0.08); + --yc-segmented-thumb-bg: #1f1f1f; +}