feat(client): dual-axis Button design-system component + btn-* tokens

New color×variant×size Button API (solid/outlined/filled/text/link) with legacy shadcn variants auto-mapped; btn-* semantic tokens + touch hit-area in style.css/tailwind; app-wide hoverOnlyWhenSupported; gallery ButtonSection; migrate KnowledgeSpaceSidebar create button to the new API.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Kinyoo
2026-07-14 16:58:32 +08:00
co-authored by Claude Opus 4.8
parent 324f299ffd
commit 9a9e9331e7
5 changed files with 682 additions and 67 deletions
+308 -30
View File
@@ -1,57 +1,335 @@
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cva } from 'class-variance-authority';
import { Outlined } from 'bisheng-icons';
import { cn } from '~/utils';
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium transition-colors focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50',
/**
* Button — design-system base component (docs-ui-refactor/组件-Button按钮.md).
*
* New API is the antd-style dual axis: `color` (primary/default/danger) ×
* `variant` (solid/outlined/filled/text/link) × `size` (small/medium/large),
* plus `iconOnly` for icon buttons and `shape` (square/circle, circle being
* icon-only). All colors go through semantic
* tokens (`btn-*` in tailwind.config / style.css, brand via `blue-*`); hover
* states are disabled on touch (§5.5) and disabled/loading are uniform (§5.2).
*
* The legacy shadcn API (`variant="outline" | "ghost" | ...`, `size="sm" |
* "icon" | ...`) still works through an automatic mapping (§6.3) so existing
* call sites keep rendering; they will be migrated batch-by-batch and the
* mapping removed afterwards.
*/
type ButtonColor = 'primary' | 'default' | 'danger';
type ButtonVariant = 'solid' | 'outlined' | 'filled' | 'text' | 'link';
type ButtonSize = 'small' | 'medium' | 'large';
type ButtonShape = 'square' | 'circle';
/** @deprecated Legacy single-axis variants — auto-mapped to color×variant (§6.3). */
type LegacyVariant =
| 'default'
| 'destructive'
| 'outline'
| 'secondary'
| 'secondaryBrand'
| 'ghost'
| 'submit';
/** @deprecated Legacy sizes — auto-mapped (`icon` → medium + iconOnly). */
type LegacySize = 'default' | 'sm' | 'lg' | 'icon';
const buttonStyles = cva(
// Disabled is uniform across every combo (§5.2) and must beat both the
// combo colors and legacy className overrides, hence the `!` importants.
// `relative` anchors the .btn-touch-hit ::after hot zone (style.css).
// Weight 400 across all sizes/types (§3.1) — heavier weights are not a knob.
'relative inline-flex items-center justify-center whitespace-nowrap font-normal transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:!border-btn-disabled-border disabled:!bg-black/[0.04] disabled:!text-black/25 [&_svg]:shrink-0',
{
variants: {
variant: {
// `btn-brand-primary` applies the green-theme lime override (see style.css).
// Blue theme keeps the shared --primary color; other primary-token usages unaffected.
default: 'bg-primary text-primary-foreground hover:bg-primary/90 btn-brand-primary',
destructive:
'bg-surface-destructive text-destructive-foreground hover:bg-surface-destructive-hover',
outline:
'text-text-primary border border-border-light bg-background hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
// Brand-tinted secondary: light brand bg + dark brand text. The blue-* utilities
// are re-pointed to the --brand-* vars, so this follows the blue⇄green theme.
secondaryBrand: 'bg-blue-50 text-blue-600 hover:bg-blue-100',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
// hardcoded text color because of WCAG contrast issues (text-white)
submit: 'bg-surface-submit text-white hover:bg-surface-submit-hover',
// Color axis only carries what is combo-independent (focus ring, §5.2).
color: {
primary: 'focus-visible:ring-blue-500/40',
default: 'focus-visible:ring-blue-500/40',
danger: 'focus-visible:ring-btn-danger/40',
},
variant: {
solid: '',
outlined: 'border bg-white',
filled: '',
text: '',
link: 'underline-offset-4',
},
// Heights/radii per §2 (24/32/40, 4/6/8px). Font sizes reference the
// PRIMITIVE type scale vars on purpose: the semantic --text-body remaps
// 14→16 under 768px, but control text must not follow (§5.5 / 适配原则 §3).
// Icon size is one 14/16/18 ladder for BOTH icon-only and text+icon (§3.2/3.3).
// Horizontal padding here is the borderless value (8/16/16); bordered
// variants override to 7/15/15 in compoundVariants (visual-width parity).
size: {
default: 'h-9 px-4 py-2',
sm: 'h-9 rounded-lg px-3',
lg: 'h-11 rounded-lg px-8',
icon: 'size-9',
small:
'h-6 gap-1 rounded px-2 text-[length:var(--font-size-3)] leading-[var(--line-height-3)] [&_svg]:size-3.5',
medium:
'btn-touch-hit h-8 gap-2 rounded-[6px] px-4 text-[length:var(--font-size-3)] leading-[var(--line-height-3)] [&_svg]:size-4',
large:
'h-10 gap-2 rounded-[8px] px-4 text-[length:var(--font-size-4)] leading-[var(--line-height-4)] [&_svg]:size-[18px]',
},
// `circle` is declared AFTER `size` so its rounded-full wins the merge
// over the per-size radius; resolveVariants restricts it to icon-only (§1).
shape: {
square: '',
circle: 'rounded-full',
},
iconOnly: {
true: '',
false: '',
},
},
compoundVariants: [
/* ---- color × variant matrix (§5.2; combos the spec leaves implicit
follow the same ramp logic: hover one step, active one deeper) ---- */
{
color: 'primary',
variant: 'solid',
// btn-brand-primary = green-theme !important override (style.css) —
// kept as agreed tech debt until the theme mechanism is reworked (§6.2).
class:
'btn-brand-primary bg-blue-500 text-white hover:bg-blue-400 active:bg-blue-600',
},
{
color: 'primary',
variant: 'outlined',
class:
'border-blue-500 text-blue-500 hover:border-blue-400 hover:text-blue-400 active:border-blue-600 active:text-blue-600',
},
{
color: 'primary',
variant: 'filled',
class: 'bg-blue-50 text-blue-600 hover:bg-blue-100 active:bg-blue-200',
},
{
color: 'primary',
variant: 'text',
class: 'text-blue-500 hover:bg-blue-50 active:bg-blue-100',
},
{
color: 'primary',
variant: 'link',
class:
'text-blue-500 hover:text-blue-400 hover:underline active:text-blue-600',
},
{
color: 'default',
variant: 'solid',
class:
'bg-btn-gray-text text-white hover:bg-btn-gray-text/90 active:bg-btn-gray-text/80',
},
{
color: 'default',
variant: 'outlined',
class:
'border-btn-gray-border text-btn-gray-text hover:bg-btn-fill-1 active:bg-btn-fill-2',
},
{
color: 'default',
variant: 'filled',
class:
'bg-btn-fill-2 text-btn-gray-text hover:bg-btn-fill-3 active:bg-btn-fill-4',
},
{
color: 'default',
variant: 'text',
class:
'text-btn-gray-text hover:bg-btn-fill-1 active:bg-btn-fill-2',
},
{
color: 'default',
variant: 'link',
class:
'text-btn-gray-text hover:text-btn-gray-text/80 hover:underline active:text-btn-gray-text',
},
{
color: 'danger',
variant: 'solid',
class:
'bg-btn-danger text-white hover:bg-btn-danger-hover active:bg-btn-danger-active',
},
{
color: 'danger',
variant: 'outlined',
class:
'border-btn-danger text-btn-danger hover:border-btn-danger-hover hover:text-btn-danger-hover active:border-btn-danger-active active:text-btn-danger-active',
},
{
color: 'danger',
variant: 'filled',
class:
'bg-btn-danger/10 text-btn-danger hover:bg-btn-danger/[0.15] active:bg-btn-danger/20',
},
{
color: 'danger',
variant: 'text',
class:
'text-btn-danger hover:bg-btn-danger/10 active:bg-btn-danger/[0.15]',
},
{
color: 'danger',
variant: 'link',
class:
'text-btn-danger hover:text-btn-danger-hover hover:underline active:text-btn-danger-active',
},
/* ---- bordered padding 7/15/15 incl. 1px border (§2 visual parity) ---- */
{ variant: 'outlined', size: 'small', class: 'px-[7px]' },
{ variant: 'outlined', size: ['medium', 'large'], class: 'px-[15px]' },
/* ---- icon-only squares 24/32/40 (§3.2, icon ladder shared with the
size axis); every size gets the ≥44px touch hot zone (§5.5) ---- */
{ iconOnly: true, size: 'small', class: 'btn-touch-hit w-6 px-0' },
{ iconOnly: true, size: 'medium', class: 'w-8 px-0' },
{ iconOnly: true, size: 'large', class: 'btn-touch-hit w-10 px-0' },
],
// Bare <Button> keeps its historical primary-solid look (§6.3).
defaultVariants: {
variant: 'default',
size: 'default',
color: 'primary',
variant: 'solid',
size: 'medium',
shape: 'square',
iconOnly: false,
},
},
);
export interface ButtonStyleProps {
// `(string & {})` keeps the three literals in autocomplete while still
// accepting `{...props}` spreads that carry the native HTML `color` attr
// (e.g. TooltipAnchor render props); non-axis strings are ignored at runtime.
color?: ButtonColor | (string & {});
variant?: ButtonVariant | LegacyVariant;
size?: ButtonSize | LegacySize;
/** `circle` renders a full circle — icon-only buttons ONLY (§1); ignored otherwise. */
shape?: ButtonShape;
/** Square icon-only button (§3.2) — must ship an `aria-label` + Tooltip. */
iconOnly?: boolean;
}
function isButtonColor(value: unknown): value is ButtonColor {
return value === 'primary' || value === 'default' || value === 'danger';
}
const LEGACY_VARIANT_MAP: Record<string, { color: ButtonColor; variant: ButtonVariant }> = {
default: { color: 'primary', variant: 'solid' },
submit: { color: 'primary', variant: 'solid' },
destructive: { color: 'danger', variant: 'solid' },
outline: { color: 'default', variant: 'outlined' },
secondary: { color: 'default', variant: 'filled' },
secondaryBrand: { color: 'primary', variant: 'filled' },
ghost: { color: 'default', variant: 'text' },
// Bare `variant="link"` predates the color axis — keep its primary look.
link: { color: 'primary', variant: 'link' },
};
function resolveVariants({ color: rawColor, variant, size, shape, iconOnly }: ButtonStyleProps) {
const color = isButtonColor(rawColor) ? rawColor : undefined;
let resolvedColor = color;
let resolvedVariant = variant as ButtonVariant | undefined;
// Legacy values only kick in while the new `color` axis is absent — any
// explicit `color` means the caller is on the new dual-axis API.
if (color === undefined && variant !== undefined && variant in LEGACY_VARIANT_MAP) {
({ color: resolvedColor, variant: resolvedVariant } = LEGACY_VARIANT_MAP[variant]);
} else if (color !== undefined && variant === undefined) {
// New-API ergonomics matching the §1 named types: <Button color="default">
// is THE default button (outlined), primary/danger default to solid.
resolvedVariant = color === 'default' ? 'outlined' : 'solid';
}
let resolvedSize: ButtonSize | undefined;
let resolvedIconOnly = iconOnly;
switch (size) {
case 'default':
case 'sm':
resolvedSize = 'medium';
break;
case 'lg':
resolvedSize = 'large';
break;
case 'icon':
resolvedSize = 'medium';
resolvedIconOnly = iconOnly ?? true;
break;
default:
resolvedSize = size;
}
return {
color: resolvedColor,
variant: resolvedVariant,
size: resolvedSize,
// Circle is an icon-only privilege (§1) — text buttons fall back to square.
shape: shape === 'circle' && resolvedIconOnly ? ('circle' as const) : ('square' as const),
iconOnly: resolvedIconOnly,
};
}
/** Class-only entry point (for <a>/Slot call sites); accepts both APIs. */
export function buttonVariants(props: ButtonStyleProps & { className?: string } = {}) {
const { className, ...styleProps } = props;
return buttonStyles({ ...resolveVariants(styleProps), className });
}
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
// Native `color` attr is shadowed by the color axis.
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'color'>,
ButtonStyleProps {
asChild?: boolean;
/** Single leading icon (§3.3, one icon max); replaced by the spinner while loading. */
icon?: React.ReactNode;
/**
* Built-in loading state (§5.2): spinner takes the icon slot, whole button
* at opacity .65 and not clickable. Do NOT pass your own Spinner.
*/
loading?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
(
{
className,
color,
variant,
size,
shape,
iconOnly,
icon,
loading = false,
asChild = false,
children,
...props
},
ref,
) => {
const Comp = asChild ? Slot : 'button';
return (
<Comp className={cn(buttonVariants({ variant, size }), className)} ref={ref} {...props} />
<Comp
ref={ref}
aria-busy={loading || undefined}
className={cn(
buttonVariants({ color, variant, size, shape, iconOnly }),
loading && 'pointer-events-none opacity-65',
className,
)}
{...props}
>
{asChild ? (
// Slot requires a single element child — icon/spinner injection is
// skipped; asChild callers render their own content.
children
) : (
<>
{loading ? <Outlined.Loading className="animate-spin" /> : icon}
{children}
</>
)}
</Comp>
);
},
);
Button.displayName = 'Button';
export { Button, buttonVariants };
export { Button };
@@ -1,23 +1,28 @@
/**
* Button gallery — DEV-ONLY.
* Button.tsx is already cva-based; it serves as the reference pattern for how other
* components should expose variants/sizes. See docs-ui-refactor/01-设计规范.md.
* Standard-usage documentation for the refactored dual-axis Button
* (docs-ui-refactor/组件-Button按钮.md v1): color × variant × size matrix,
* states, content forms, plus the legacy-API mapping ledger for migration.
*/
import { Outlined } from 'bisheng-icons';
import { Button } from '~/components/ui/Button';
import { ComponentPage, ExampleGroup, ExampleGrid, ExampleCard } from '../components/kit';
import {
ComponentPage,
ExampleGroup,
ExampleGrid,
ExampleCard,
CompareTable,
} from '../components/kit';
const VARIANTS = [
'default',
'secondary',
'secondaryBrand',
'outline',
'ghost',
'destructive',
'submit',
'link',
] as const;
const COLORS = ['primary', 'default', 'danger'] as const;
const VARIANTS = ['solid', 'outlined', 'filled', 'text', 'link'] as const;
const SIZES = ['small', 'medium', 'large'] as const;
const SIZES = ['sm', 'default', 'lg', 'icon'] as const;
const SIZE_META: Record<(typeof SIZES)[number], string> = {
small: '高 24px · 字号 14/22 · 圆角 4px — 表格行内、紧凑工具条',
medium: '高 32px · 字号 14/22 · 圆角 6px — 默认,绝大多数场景',
large: '高 40px · 字号 16/24 · 圆角 8px — 登录页、大表单提交',
};
export function ButtonSection() {
return (
@@ -26,39 +31,318 @@ export function ButtonSection() {
eng="Button"
description={
<>
<code>cva</code> "留改动余地" +
<code>className</code>
antd <code>color × variant</code> color primary / default
/ danger variant solid / outlined / filled / text /
link3×5 token绿
hover 44px docs-ui-refactor/-Button按钮.md
</>
}
whenToUse={[
<> <code>default</code>绿 <code>destructive</code></>,
<> <code>outline</code> / <code>secondary</code> <code>ghost</code> / <code>link</code></>,
<> <code>sm / default / lg / icon</code> /</>,
<> <code>className</code> </>,
<>
<code>primary solid</code>
<code>color=&quot;default&quot;</code>
</>,
<>
<code>small / medium / large</code> 24/32/40px
</>,
<>
footer 12px
8px
</>,
<>
icon Tooltip <code>aria-label</code>
<code>shape=&quot;circle&quot;</code> icon bisheng-icons
lucide
</>,
<>
loading <code>loading</code> Spinner
</>,
<>
<code>className</code>
APIoutline/ghost/submit
</>,
]}
>
<ExampleGroup title="变体 variant" subtitle="每格含常态与 disabled 态">
<ExampleGrid cols={4}>
{VARIANTS.map((v) => (
<ExampleCard key={v} title={`variant="${v}"`}>
<Button variant={v}>{v}</Button>
<Button variant={v} disabled>
disabled
<ExampleGroup
title="常用类型(§1"
subtitle="双轴组合的六个常用别名;其余组合按双轴自然推导。"
>
<ExampleGrid cols={3}>
<ExampleCard title="Primary 主按钮" description="primary × solid — 主行动点,一个区域只放一个">
<Button color="primary" variant="solid">
</Button>
</ExampleCard>
<ExampleCard title="Secondary 次强调" description="primary × filled — 品牌浅底,弱于主按钮">
<Button color="primary" variant="filled">
</Button>
</ExampleCard>
<ExampleCard title="Default 默认按钮" description="default × outlined — 最常用的次级按钮(取消/返回)">
<Button color="default" variant="outlined">
</Button>
</ExampleCard>
<ExampleCard title="Text 文字按钮" description="default × text — 最次级、表格行内、工具栏">
<Button color="default" variant="text">
</Button>
</ExampleCard>
<ExampleCard title="Link 链接按钮" description="primary × link — 导航型操作,hover 不加底">
<Button color="primary" variant="link">
</Button>
</ExampleCard>
<ExampleCard title="Danger 危险按钮" description="danger × solid / outlined / text — 一般配二次确认">
<Button color="danger" variant="solid">
</Button>
<Button color="danger" variant="outlined">
</Button>
<Button color="danger" variant="text">
</Button>
</ExampleCard>
</ExampleGrid>
</ExampleGroup>
<ExampleGroup
title="color × variant 全矩阵"
subtitle="15 种组合全部成立;直接在此悬停/按下体验 hover、active 状态色板(§5.2)。"
>
<CompareTable
head={['variant \\ color', 'primary 品牌', 'default 中性', 'danger 危险']}
rows={VARIANTS.map((v) => [
<code key="v">{v}</code>,
...COLORS.map((c) => (
<Button key={c} color={c} variant={v}>
</Button>
)),
])}
/>
</ExampleGroup>
<ExampleGroup
title="尺寸 size(§2"
subtitle="三档:24 / 32 / 40px,圆角 4 / 6 / 8px;描边与无边框变体水平 padding 视觉等宽。"
>
<ExampleGrid cols={3}>
{SIZES.map((s) => (
<ExampleCard key={s} title={`size="${s}"`} description={SIZE_META[s]}>
<Button color="primary" size={s}>
</Button>
<Button color="default" size={s}>
</Button>
<Button color="default" size={s} iconOnly aria-label="搜索">
<Outlined.Search />
</Button>
</ExampleCard>
))}
</ExampleGrid>
</ExampleGroup>
<ExampleGroup title="尺寸 size">
<ExampleGrid cols={4}>
{SIZES.map((s) => (
<ExampleCard key={s} title={`size="${s}"`}>
<Button size={s}>{s === 'icon' ? '★' : s}</Button>
</ExampleCard>
))}
<ExampleGroup
title="内容形态(§3"
subtitle="纯文字 / 纯 icon / 文字 + icon;一个按钮最多一个 icon。"
>
<ExampleGrid cols={2}>
<ExampleCard
title="纯文字"
description="不换行不省略;两个汉字不加中间空格;字重 400(全尺寸全类型一致)"
>
<Button></Button>
<Button color="default"></Button>
</ExampleCard>
<ExampleCard
title="纯 iconshape square / circle"
description="24/32/40icon 14/16/18pxcircle 正圆仅限纯 icon 按钮;必须带 Tooltip + aria-label,触屏热区自动扩到 ≥44px"
>
<Button color="default" variant="outlined" size="small" iconOnly aria-label="编辑">
<Outlined.Edit />
</Button>
<Button color="default" variant="outlined" size="medium" iconOnly aria-label="编辑">
<Outlined.Edit />
</Button>
<Button color="default" variant="outlined" size="large" iconOnly aria-label="编辑">
<Outlined.Edit />
</Button>
<Button
color="default"
variant="outlined"
shape="circle"
iconOnly
aria-label="搜索"
>
<Outlined.Search />
</Button>
<Button shape="circle" iconOnly aria-label="发送">
<Outlined.Send />
</Button>
<Button color="default" variant="text" iconOnly aria-label="删除">
<Outlined.Delete />
</Button>
<Button color="danger" variant="text" iconOnly aria-label="删除">
<Outlined.Delete />
</Button>
</ExampleCard>
<ExampleCard
title="文字 + iconicon 属性,默认在左)"
description="icon 与纯 icon 同一套 14/16/18px,间距 8pxsmall 4px"
>
<Button icon={<Outlined.Plus />}></Button>
<Button color="default" icon={<Outlined.Download />}>
</Button>
<Button color="danger" variant="outlined" icon={<Outlined.Delete />}>
</Button>
</ExampleCard>
<ExampleCard
title="方向语义 icon 在右"
description="“下一步 →”类方向语义可放右侧:icon 走 children 尾部"
>
<Button>
<Outlined.ArrowRight />
</Button>
</ExampleCard>
</ExampleGrid>
</ExampleGroup>
<ExampleGroup
title="状态 state(§5"
subtitle="hover / active 请在上方矩阵直接体验;disabled 与 loading 全类型统一;focus 环仅键盘(Tab)可见,环色随 color。"
>
<ExampleGrid cols={2}>
<ExampleCard
title="disabled(全类型统一)"
description="灰底 rgba(0,0,0,.04) + 字 rgba(0,0,0,.25) + 边 #d9d9d9cursor: not-allowed"
>
<Button disabled></Button>
<Button color="primary" variant="filled" disabled>
</Button>
<Button color="default" disabled>
</Button>
<Button color="default" variant="text" disabled>
</Button>
<Button color="danger" disabled>
</Button>
</ExampleCard>
<ExampleCard
title="loading(内置 spinner"
description="spinner 顶替 icon 位、整体 opacity .65、期间不可点;禁止业务页自塞 Spinner"
>
<Button loading></Button>
<Button color="default" loading>
</Button>
<Button color="danger" loading>
</Button>
<Button loading icon={<Outlined.Plus />}>
</Button>
</ExampleCard>
</ExampleGrid>
</ExampleGroup>
<ExampleGroup
title="旧 API 兼容映射(迁移台账,§6.3)"
subtitle="旧入参自动映射为新双轴(已标 deprecated),下方按钮全部用旧 API 渲染以验证映射;业务迁完即删。缺省高度 h-9(36) 已归入 medium(32),全站矮 4px,迁移各批带目检回归(§6.6)。"
>
<CompareTable
head={['旧写法(用量)', '映射为', '旧 API 实渲染']}
rows={[
[
<code key="o"> / variant=&quot;default&quot;116 </code>,
<code key="n">primary solid</code>,
<Button key="b" variant="default">
</Button>,
],
[
<code key="o">variant=&quot;submit&quot;11 </code>,
<code key="n">primary solid ChatGPT 绿</code>,
<Button key="b" variant="submit">
</Button>,
],
[
<code key="o">variant=&quot;outline&quot;78 </code>,
<code key="n">default outlined</code>,
<Button key="b" variant="outline">
</Button>,
],
[
<code key="o">variant=&quot;secondary&quot;17 18 primary filled</code>,
<code key="n">default filled</code>,
<Button key="b" variant="secondary">
</Button>,
],
[
<code key="o">variant=&quot;secondaryBrand&quot;0 </code>,
<code key="n">primary filled</code>,
<Button key="b" variant="secondaryBrand">
</Button>,
],
[
<code key="o">variant=&quot;ghost&quot;40 </code>,
<code key="n">default text</code>,
<Button key="b" variant="ghost">
</Button>,
],
[
<code key="o">variant=&quot;destructive&quot;6 </code>,
<code key="n">danger solid</code>,
<Button key="b" variant="destructive">
</Button>,
],
[
<code key="o">variant=&quot;link&quot;0 </code>,
<code key="n">primary link</code>,
<Button key="b" variant="link">
</Button>,
],
[
<code key="o">size / &quot;sm&quot;249 h-9</code>,
<code key="n">medium32px</code>,
<Button key="b" variant="outline" size="sm">
</Button>,
],
[
<code key="o">size=&quot;lg&quot;2 </code>,
<code key="n">large40px</code>,
<Button key="b" size="lg">
</Button>,
],
[
<code key="o">size=&quot;icon&quot;18 </code>,
<code key="n">medium + iconOnly</code>,
<Button key="b" variant="outline" size="icon" aria-label="搜索">
<Outlined.Search />
</Button>,
],
]}
/>
</ExampleGroup>
</ComponentPage>
);
}
@@ -582,11 +582,12 @@ export function KnowledgeSpaceSidebar({
{localize("com_knowledge.go_to_knowledge_square")}
</Button>
<Button
variant="secondary"
color="primary"
variant="filled"
icon={<Outlined.Plus />}
onClick={onCreateSpace}
className="h-8 w-full gap-1 rounded-[6px] bg-blue-100 px-3 py-[5px] text-sm font-normal leading-[22px] text-blue-main hover:bg-blue-200"
className="w-full"
>
<Plus className="size-4" />
{localize("com_knowledge.create_knowledge_space")}
</Button>
</div>
+31
View File
@@ -175,6 +175,21 @@ html {
--brand-main: 22 93 255; /* #165DFF */
--brand-muted: 87 115 180; /* #5773B4 — muted/low-sat brand accent (e.g. pin) */
/* Button semantic tokens (docs-ui-refactor/组件-Button按钮.md §5.1).
Neutral grays follow the Arco ramp; danger red is FIXED — never themed.
RGB channels so the Tailwind `btn-*` colors keep `/<alpha>` working.
Light-mode values only for now (dark mode inherits them — known debt). */
--btn-gray-text: 78 89 105; /* #4E5969 — default-color button text */
--btn-gray-border: 229 230 235; /* #E5E6EB — default-color button border */
--btn-fill-1: 247 248 250; /* #F7F8FA — neutral fill ramp: hover bg */
--btn-fill-2: 242 243 245; /* #F2F3F5 — active bg / filled base */
--btn-fill-3: 229 230 235; /* #E5E6EB — filled hover */
--btn-fill-4: 201 205 212; /* #C9CDD4 — filled active */
--btn-danger: 245 63 63; /* #F53F3F */
--btn-danger-hover: 214 55 58; /* #D6373A */
--btn-danger-active: 208 47 51; /* #D02F33 */
--btn-disabled-border: 217 217 217; /* #D9D9D9 — bordered variants, disabled */
/* Illustration palette — empty-state SVG illustrations use --illus-* instead
of --brand-* so the green theme can render the illustrations' own vivid
green ramp (#19B476 / #BDE6D3 / #7CD0B1) rather than the darker UI brand
@@ -261,6 +276,22 @@ html {
color: #fff !important;
}
/* Touch hit-area expansion (组件-Button按钮.md §5.5): medium and icon-only
buttons get an invisible ≥44×44 hot zone on touch devices — "跟手不跟屏"
(基础-多端适配原则.md §0/§2) — while the visual size stays unchanged.
The base Button sets `relative` + this class; never hand-roll per page. */
@media (hover: none) and (pointer: coarse) {
.btn-touch-hit::after {
content: '';
position: absolute;
left: 50%;
top: 50%;
width: max(100%, 44px);
height: max(100%, 44px);
transform: translate(-50%, -50%);
}
}
.dark {
--presentation: var(--gray-800);
--text-primary: var(--gray-100);
+21
View File
@@ -3,6 +3,14 @@ const plugin = require('tailwindcss/plugin');
/** @type {import('tailwindcss').Config} */
module.exports = {
// 基础-多端适配原则.md §1: hover states are disabled on touch APP-WIDE — every
// `hover:` utility compiles wrapped in a hover-capable media query. Press
// feedback on touch comes from `active:` styles instead (no sticky hover).
// NOTE: components must keep using plain `hover:` (never a custom variant),
// so tailwind-merge can still dedupe business-page hover overrides.
future: {
hoverOnlyWhenSupported: true,
},
content: ['./src/**/*.{js,jsx,ts,tsx}'],
// darkMode: 'class',
darkMode: ['class'],
@@ -159,6 +167,19 @@ module.exports = {
900: 'rgb(var(--brand-900) / <alpha-value>)',
},
'brand-purple': '#ab68ff',
// Button semantic tokens (docs-ui-refactor/组件-Button按钮.md §5.1) —
// RGB-channel vars defined in src/style.css :root; channel form keeps
// `/<alpha>` modifiers working. Neutral fill ramp is shared Arco grays.
'btn-gray-text': 'rgb(var(--btn-gray-text) / <alpha-value>)',
'btn-gray-border': 'rgb(var(--btn-gray-border) / <alpha-value>)',
'btn-fill-1': 'rgb(var(--btn-fill-1) / <alpha-value>)',
'btn-fill-2': 'rgb(var(--btn-fill-2) / <alpha-value>)',
'btn-fill-3': 'rgb(var(--btn-fill-3) / <alpha-value>)',
'btn-fill-4': 'rgb(var(--btn-fill-4) / <alpha-value>)',
'btn-danger': 'rgb(var(--btn-danger) / <alpha-value>)',
'btn-danger-hover': 'rgb(var(--btn-danger-hover) / <alpha-value>)',
'btn-danger-active': 'rgb(var(--btn-danger-active) / <alpha-value>)',
'btn-disabled-border': 'rgb(var(--btn-disabled-border) / <alpha-value>)',
'presentation': 'var(--presentation)',
'text-primary': 'var(--text-primary)',
'text-secondary': 'var(--text-secondary)',