diff --git a/src/frontend/client/src/components/ui/Button.tsx b/src/frontend/client/src/components/ui/Button.tsx
index e9a536732..cc35ded9f 100644
--- a/src/frontend/client/src/components/ui/Button.tsx
+++ b/src/frontend/client/src/components/ui/Button.tsx
@@ -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
+
+
+
+ 下一步
+
+
+
+
+
+
+
+ 主按钮
+
+ 次强调
+
+
+ 默认
+
+
+ 文字
+
+
+ 删除
+
+
+
+ 提交中
+
+ 提交中
+
+
+ 删除中
+
+ }>
+ 新建
+
+
+
+
+
+
+ 缺省 / variant="default"(116 处),
+ primary solid,
+
+ 按钮
+ ,
+ ],
+ [
+ variant="submit"(11 处),
+ primary solid(原写死 ChatGPT 绿,随迁移废除),
+
+ 按钮
+ ,
+ ],
+ [
+ variant="outline"(78 处),
+ default outlined,
+
+ 按钮
+ ,
+ ],
+ [
+ variant="secondary"(17 处,原 18:知识空间侧栏“创建知识空间”已迁 primary filled),
+ default filled,
+
+ 按钮
+ ,
+ ],
+ [
+ variant="secondaryBrand"(0 处),
+ primary filled,
+
+ 按钮
+ ,
+ ],
+ [
+ variant="ghost"(40 处),
+ default text,
+
+ 按钮
+ ,
+ ],
+ [
+ variant="destructive"(6 处),
+ danger solid,
+
+ 按钮
+ ,
+ ],
+ [
+ variant="link"(0 处),
+ primary link,
+
+ 按钮
+ ,
+ ],
+ [
+ size 缺省 / "sm"(249 处,旧 h-9),
+ medium(32px),
+
+ 按钮
+ ,
+ ],
+ [
+ size="lg"(2 处),
+ large(40px),
+
+ 按钮
+ ,
+ ],
+ [
+ size="icon"(18 处),
+ medium + iconOnly,
+
+
+ ,
+ ],
+ ]}
+ />
+
);
}
diff --git a/src/frontend/client/src/pages/knowledge/sidebar/KnowledgeSpaceSidebar.tsx b/src/frontend/client/src/pages/knowledge/sidebar/KnowledgeSpaceSidebar.tsx
index 6d0c164cc..26556c605 100644
--- a/src/frontend/client/src/pages/knowledge/sidebar/KnowledgeSpaceSidebar.tsx
+++ b/src/frontend/client/src/pages/knowledge/sidebar/KnowledgeSpaceSidebar.tsx
@@ -582,11 +582,12 @@ export function KnowledgeSpaceSidebar({
{localize("com_knowledge.go_to_knowledge_square")}
}
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"
>
-
{localize("com_knowledge.create_knowledge_space")}
diff --git a/src/frontend/client/src/style.css b/src/frontend/client/src/style.css
index 06c6c0d95..2db772e2a 100644
--- a/src/frontend/client/src/style.css
+++ b/src/frontend/client/src/style.css
@@ -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 `/` 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);
diff --git a/src/frontend/client/tailwind.config.cjs b/src/frontend/client/tailwind.config.cjs
index 9285a4a6d..71c9833b8 100644
--- a/src/frontend/client/tailwind.config.cjs
+++ b/src/frontend/client/tailwind.config.cjs
@@ -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) / )',
},
'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
+ // `/` modifiers working. Neutral fill ramp is shared Arco grays.
+ 'btn-gray-text': 'rgb(var(--btn-gray-text) / )',
+ 'btn-gray-border': 'rgb(var(--btn-gray-border) / )',
+ 'btn-fill-1': 'rgb(var(--btn-fill-1) / )',
+ 'btn-fill-2': 'rgb(var(--btn-fill-2) / )',
+ 'btn-fill-3': 'rgb(var(--btn-fill-3) / )',
+ 'btn-fill-4': 'rgb(var(--btn-fill-4) / )',
+ 'btn-danger': 'rgb(var(--btn-danger) / )',
+ 'btn-danger-hover': 'rgb(var(--btn-danger-hover) / )',
+ 'btn-danger-active': 'rgb(var(--btn-danger-active) / )',
+ 'btn-disabled-border': 'rgb(var(--btn-disabled-border) / )',
'presentation': 'var(--presentation)',
'text-primary': 'var(--text-primary)',
'text-secondary': 'var(--text-secondary)',