From 9a9e9331e7b9dc417a49803f9e290e0ffcfeaa87 Mon Sep 17 00:00:00 2001 From: Kinyoo Date: Tue, 14 Jul 2026 16:58:32 +0800 Subject: [PATCH] feat(client): dual-axis Button design-system component + btn-* tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../client/src/components/ui/Button.tsx | 338 +++++++++++++++-- .../pages/_gallery/sections/ButtonSection.tsx | 352 ++++++++++++++++-- .../sidebar/KnowledgeSpaceSidebar.tsx | 7 +- src/frontend/client/src/style.css | 31 ++ src/frontend/client/tailwind.config.cjs | 21 ++ 5 files changed, 682 insertions(+), 67 deletions(-) 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 - + + + + + + + + + + + + + + + + + + + + + + + [ + {v}, + ...COLORS.map((c) => ( + + )), + ])} + /> + + + + + {SIZES.map((s) => ( + + + + ))} - - - {SIZES.map((s) => ( - - - - ))} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 缺省 / 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")} 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)',