35 changed files with 873 additions and 281 deletions
+1
View File
@@ -12,3 +12,4 @@ preview-shots/
capture-log.txt
scripts/
preview/
"tmp-foundation.css"
@@ -395,7 +395,10 @@ function AssistantTurnBase({
if (answerText.trim() && !hasAnswer) setHasAnswer(true)
return (
<div className="min-w-0 max-w-full">
<div
className="min-w-0 max-w-full"
style={{ animation: 'fade-up 400ms cubic-bezier(0.23, 1, 0.32, 1) both' }}
>
{hasProcess && (
<div className="border-b border-line pb-1">
<button
@@ -651,7 +654,11 @@ function MessageItemBase({
if (message.role === 'assistant') {
return (
<div ref={ref} className="min-w-0 max-w-full">
<div
ref={ref}
className="min-w-0 max-w-full"
style={{ animation: 'fade-up 400ms cubic-bezier(0.23, 1, 0.32, 1) both' }}
>
<AssistantTurn
messages={[message]}
tools={tools}
@@ -263,7 +263,7 @@ const CUSTOM_API_OPTIONS: Array<{ value: string; label: string }> = [
]
const customInputCls =
'w-full rounded-control border border-line-strong bg-surface px-2.5 py-1.5 text-[12px] text-ink outline-none placeholder:text-ink-3 focus:border-ink-3'
'w-full rounded-control bg-field shadow-inset-field px-2.5 py-1.5 text-[12px] text-ink outline-none placeholder:text-ink-3'
/** 模型设置:搜索/过滤 + @tanstack/react-virtual 虚拟列表(供应商多、模型上千时滚动不卡顿)。
* 卡片视觉按行拼装:provider 头行圆角上盖、模型行接左右边框、末行圆角下盖,行尾留组间距。
@@ -423,7 +423,7 @@ function ModelSettings({
value={keyValue}
onChange={(e) => onKeyChange(e.target.value)}
placeholder="输入 API Key"
className="flex-1 rounded-control border border-line-strong bg-surface px-2.5 py-1.5 font-mono text-[12px] text-ink outline-none focus:border-ink-3"
className="flex-1 rounded-control bg-field shadow-inset-field px-2.5 py-1.5 font-mono text-[12px] text-ink outline-none"
/>
<button
type="button"
@@ -491,7 +491,7 @@ function ModelSettings({
value={search}
onChange={(e) => onSearch(e.target.value)}
placeholder="搜索模型或提供商"
className="w-full rounded-control border border-line-strong bg-surface py-1.5 pl-8 pr-3 text-[12.5px] text-ink outline-none placeholder:text-ink-3 focus:border-ink-3"
className="w-full rounded-control bg-field shadow-inset-field py-1.5 pl-8 pr-3 text-[12.5px] text-ink outline-none placeholder:text-ink-3"
/>
</div>
{loading && <span className="text-[11px] text-ink-3">加载中…</span>}
@@ -909,7 +909,7 @@ function PluginSettingsForm({
// 注意不要写 w-full:string/number 控件要定宽(w-56/w-28)跟 label 同行,w-full 会把 label 挤成竖排
const inputCls =
'rounded-control border border-line-strong bg-surface px-2.5 py-1.5 text-[12px] text-ink outline-none placeholder:text-ink-3 focus:border-ink-3'
'rounded-control bg-field shadow-inset-field px-2.5 py-1.5 text-[12px] text-ink outline-none placeholder:text-ink-3'
return (
<div className="space-y-1">
@@ -1023,7 +1023,7 @@ function PluginSettingsForm({
<select
value={typeof value === 'string' ? value : ''}
onChange={(e) => onChange(item.key, e.target.value)}
className="w-44 shrink-0 rounded-control border border-line-strong bg-surface px-2 py-1.5 text-[12px] text-ink outline-none focus:border-ink-3"
className="w-44 shrink-0 rounded-control bg-field shadow-inset-field px-2 py-1.5 text-[12px] text-ink outline-none"
>
{!value && <option value="">(未设置)</option>}
{item.options.map((o) => (
@@ -4,29 +4,33 @@ import { ButtonHTMLAttributes } from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
/* 设计规范:矩形控件统一 5px 圆角(rounded-control),rounded-full 仅限
* icon 圆钮/状态点/头像。动效走全局默认 400ms + ease(@theme 注入)。 */
/* beautifului.dev 原版 Button:Pill 胶囊形是应用的核心按钮语言。显式对称
* padding(不锁高度)保证上下留白永远相等;filled 变体带一道 1px 内高光;
* 按压回缩 0.96、150ms ease-out。 */
const filledShadow = "shadow-[inset_0_1px_0_rgba(255,255,255,0.14)]";
export const buttonVariants = cva(
`inline-flex items-center justify-center font-medium select-none
transition-[transform,background-color,opacity]
active:scale-[0.98] disabled:opacity-50 disabled:pointer-events-none`,
transition-[transform,background-color,opacity] duration-150 ease-out
active:scale-[0.96] disabled:opacity-50 disabled:pointer-events-none`,
{
variants: {
variant: {
primary: "bg-ink text-canvas hover:opacity-90",
primary: `bg-ink text-canvas hover:opacity-90 dark:bg-ink dark:text-canvas ${filledShadow}`,
secondary: "bg-surface text-ink shadow-btn hover:bg-inset aria-expanded:bg-hover",
ghost: "bg-hover-2 text-ink hover:bg-line-strong",
accent: "bg-accent text-white hover:bg-accent-ink",
success: "bg-green text-white hover:brightness-95",
accent: `bg-accent text-white hover:bg-accent-ink ${filledShadow}`,
success: `bg-green text-white hover:brightness-95 ${filledShadow}`,
/* transparent until hovered — for dense toolbars/action rows */
quiet: "text-ink hover:bg-hover",
},
size: {
/* compact toolbar control — fixed height, lighter weight */
xs: "h-7 rounded-control px-2.5 text-[12px] font-normal leading-none gap-1",
/* canonical action button */
sm: "h-[27px] px-3 text-[13px] leading-none rounded-control gap-1.5",
md: "px-4 py-[9px] text-sm leading-none rounded-control gap-2",
/* compact toolbar pill — fixed height, lighter weight */
xs: "h-7 rounded-full px-2.5 text-[12px] font-normal leading-none gap-1",
/* canonical action pill — 27px tall, roomy sides */
sm: "h-[27px] px-3 text-[13px] leading-none rounded-full gap-1.5",
md: "px-4 py-[9px] text-sm leading-none rounded-full gap-2",
},
},
defaultVariants: { variant: "secondary", size: "md" },
@@ -1,115 +1,127 @@
/* Beautiful UI foundation — design tokens 按项目设计规范(OpenAI 浅色体系)重映射。
* 规范要点:浅色界面;accent #8e8ea0(muted purple-gray);正文近黑;8px 间距网格;
* 圆角统一 5px(圆形仅限 icon 按钮/状态点/头像);动效统一 400ms + ease;
* 平面层级:surface 之间用 hairline 边框分层,仅浮层(modal/popover/审批卡)允许一层中性软阴影。
* 层级语义:--accent 负责焦点环/选区/选中态/装饰 tint;--accent-ink(加深版)负责
* 链接与可交互文本(保证 AA 对比度);--ink-3 是弱化文本的 AA 安全灰。 */
/* Beautiful UI foundation — design tokens 与 beautifului.dev 原版 foundation.css
* 对齐(MIT):冷调近白画布、白色卡片、hairline 边框、「ring + 柔和分层阴影」
* 的层级体系、蓝色 accent、克制语义色。亮暗两套完整 token,切换 = 一次重绘。
* 圆角体系:chip 6 / control 8 / card 10 / window 14 / pill 999。
* 动效:强缓动曲线(--ease-out-strong 等)+ pill 控件 150ms ease-out。 */
@import "shadow-plugin/unprefixed";
@custom-variant dark (&:where(.dark, .dark *));
/* ──────────────────────────────────────────────────────────
* OpenAI light — design tokens
* Beautiful UI — design tokens
* Cool near-white canvas, white cards, hairline borders, layered
* single-digit-opacity shadows, a neutral ink ramp, and semantic
* color used sparingly as a condiment.
* ────────────────────────────────────────────────────────── */
:root {
/* motion — 规范统一值 */
--duration-fast: 400ms;
--duration-base: 400ms;
--duration-slow: 400ms;
--ease-standard: ease;
/* motion — pill 控件/悬停反馈走 150ms ease-out;大位移动效用强曲线变量 */
--duration-fast: 150ms;
--duration-base: 220ms;
--duration-slow: 360ms;
--ease-standard: ease-out;
/* surfaces — 中性灰阶,白底为主 */
--page: #ffffff;
--canvas: #ffffff;
--surface: #ffffff;
--inset: #f4f4f5;
--hover: #f0f0f1;
--hover-2: #e6e6e8;
/* surfaces — cool, blue-tinted neutrals */
--page: oklch(0.985 0.001 286.376);
--canvas: oklch(0.961 0.002 247.84);
--surface: oklch(1 0 0);
--inset: oklch(0.979 0.002 247.839);
--hover: oklch(0.97 0.002 247.839);
--hover-2: oklch(0.933 0.003 247.86);
/* ink ramp — 正文近黑(规范 #000 的 a11y 修正版),弱化层为 AA 安全灰 */
--ink: #0d0d0d;
--ink-2: #5d5d5d;
--ink-3: #75757f;
/* ink ramp */
--ink: oklch(0.247 0.006 258.361);
--ink-2: oklch(0.506 0.01 264.477);
--ink-3: oklch(0.695 0.009 264.505);
/* borders — 实色 hairline,不用 alpha */
--line: #ececec;
--line-strong: #dcdce0;
--line-soft: #f4f4f5;
/* borders — solid and crisp, not alpha */
--line: oklch(0.946 0.003 264.542);
--line-strong: oklch(0.912 0.005 258.326);
--line-soft: oklch(0.966 0.002 264.542);
--grid-line: color-mix(in srgb, var(--line) 78%, transparent);
--field: #f4f4f5;
--stripe: rgba(13, 13, 13, 0.04);
--stripe-bg: #f7f7f8;
--field: oklch(0.961 0.001 286.375);
--stripe: oklch(0.405 0 0 / 0.075);
--stripe-bg: oklch(0.97 0 0);
/* accent — 规范主色 #8e8ea0 及其加深可交互版 */
--accent: #8e8ea0;
--accent-ink: #707084;
--accent-tint: rgba(142, 142, 160, 0.12);
/* accent */
--accent: oklch(0.626 0.205 254.947);
--accent-ink: oklch(0.556 0.187 255.617);
--accent-tint: oklch(0.96 0.019 252.878);
/* semantic — 唯一允许的彩色例外(状态语义),已降饱和融入中性界面 */
--green: #2e7d5b;
--green-tint: #e9f3ee;
--orange: #b45309;
--orange-tint: #faf1e3;
--red: #b91c1c;
--red-tint: #fbecec;
/* semantic */
--green: oklch(0.603 0.155 150.883);
--green-tint: oklch(0.958 0.017 159.118);
--orange: oklch(0.689 0.179 49.902);
--orange-tint: oklch(0.964 0.021 67.581);
--red: oklch(0.621 0.192 23.042);
--red-tint: oklch(0.956 0.017 17.462);
/* chart overlays */
--tooltip-bg: #2a2a2e;
--tooltip-fg: #f7f7f8;
--tooltip-muted: #a5a5af;
--tooltip-border: #454549;
/* chart overlays — neutral system colors with enough contrast on either theme */
--tooltip-bg: oklch(0.272 0.008 264.435);
--tooltip-fg: oklch(0.976 0.002 247.839);
--tooltip-muted: oklch(0.731 0.008 260.731);
--tooltip-border: oklch(0.356 0.007 264.474);
/* shadows — 平面优先:hairline 分层;仅浮层保留一层中性软阴影(文档化例外) */
/* shadows — solid hairline ring + shadow-plugin's smooth layered stacks
* (--shadow-xs…lg come from shadow-plugin/unprefixed) so every surface reads
* consistently across the app. */
--shadow-hairline: 0 0 0 1px var(--line);
--shadow-btn: 0 0 0 1px var(--line-strong);
--shadow-card: 0 0 0 1px var(--line);
--shadow-raised: 0 0 0 1px var(--line-strong);
--shadow-overlay: 0 0 0 1px var(--line-strong), 0 12px 32px rgba(13, 13, 13, 0.08);
--shadow-inset-field: inset 0 0 0 1px var(--line-strong);
--shadow-btn: 0 0 0 1px var(--line-strong), var(--shadow-xs);
--shadow-card: 0 0 0 1px var(--line), var(--shadow-sm);
--shadow-raised: 0 0 0 1px var(--line), var(--shadow-md);
--shadow-overlay: 0 0 0 1px var(--line), var(--shadow-lg);
--shadow-inset-field: inset 0 1px 2px oklch(0 0 0 / 0.12);
}
.dark {
--page: #212121;
--canvas: #212121;
--surface: #2f2f2f;
--inset: #181818;
--hover: #2f2f2f;
--hover-2: #424242;
--page: oklch(0.209 0.004 264.477);
--canvas: oklch(0.231 0.004 264.487);
--surface: oklch(0.26 0.006 271.191);
--inset: oklch(0.243 0.004 264.492);
--hover: oklch(0.289 0.006 271.22);
--hover-2: oklch(0.318 0.007 274.747);
--ink: #ececec;
--ink-2: #b4b4b4;
--ink-3: #8e8ea0;
--ink: oklch(0.964 0.002 247.839);
--ink-2: oklch(0.731 0.008 260.731);
--ink-3: oklch(0.541 0.01 264.484);
--line: #383838;
--line-strong: #4a4a4a;
--line-soft: #2a2a2a;
--field: #2f2f2f;
--stripe: rgba(255, 255, 255, 0.05);
--stripe-bg: #262626;
--line: oklch(0.308 0.006 258.354);
--line-strong: oklch(0.356 0.007 264.474);
--line-soft: oklch(0.278 0.006 258.354);
--field: oklch(0.293 0.006 271.223);
--stripe: oklch(1 0 0 / 0.055);
--stripe-bg: oklch(0.226 0.004 264.485);
--accent: #a3a3b5;
--accent-ink: #bcbccc;
--accent-tint: rgba(142, 142, 160, 0.18);
--accent: oklch(0.68 0.173 253.301);
--accent-ink: oklch(0.788 0.113 248.33);
--accent-tint: oklch(0.68 0.173 253.301 / 0.16);
--green: #6fbf95;
--green-tint: rgba(111, 191, 149, 0.15);
--orange: #d19a55;
--orange-tint: rgba(209, 154, 85, 0.15);
--red: #e07a70;
--red-tint: rgba(224, 122, 112, 0.15);
--green: oklch(0.705 0.154 153.814);
--green-tint: oklch(0.705 0.154 153.814 / 0.14);
--orange: oklch(0.746 0.156 55.642);
--orange-tint: oklch(0.746 0.156 55.642 / 0.14);
--red: oklch(0.666 0.18 21.433);
--red-tint: oklch(0.666 0.18 21.433 / 0.14);
--tooltip-bg: #171717;
--tooltip-fg: #ececec;
--tooltip-muted: #8e8ea0;
--tooltip-border: #383838;
--tooltip-bg: oklch(0.182 0.004 264.459);
--tooltip-fg: oklch(0.964 0.002 247.839);
--tooltip-muted: oklch(0.731 0.008 260.731);
--tooltip-border: oklch(0.308 0.006 258.354);
--shadow-hairline: 0 0 0 1px var(--line);
--shadow-btn: 0 0 0 1px var(--line-strong);
--shadow-card: 0 0 0 1px var(--line);
--shadow-raised: 0 0 0 1px var(--line-strong);
--shadow-overlay: 0 0 0 1px var(--line-strong), 0 16px 40px rgba(0, 0, 0, 0.45);
--shadow-inset-field: inset 0 0 0 1px var(--line-strong);
/* Elevation rings go to a subtle *light* hairline in dark mode: the ring
* paints over the surface behind the element, so a low-alpha white edge
* dissolves into the shadow as one continuous stroke (per smooth-shadow-ring),
* graduated by how far the surface floats. */
--shadow-btn: 0 0 0 1px oklch(1 0 0 / 0.1), 0 1px 2px oklch(0 0 0 / 0.3);
--shadow-card:
0 0 0 1px oklch(1 0 0 / 0.11), 0 1px 2px oklch(0 0 0 / 0.2),
0 2px 6px oklch(0 0 0 / 0.2);
--shadow-raised:
0 0 0 1px oklch(1 0 0 / 0.13), 0 2px 10px oklch(0 0 0 / 0.22);
--shadow-overlay:
0 0 0 1px oklch(1 0 0 / 0.15), 0 8px 28px oklch(0 0 0 / 0.34);
--shadow-inset-field: inset 0 1px 2px oklch(0 0 0 / 0.4);
}
@theme inline {
@@ -146,20 +158,20 @@
/* type 沿用 globals.css 中的 --font-sans / --font-mono,不覆盖 */
/* radii — 规范统一 5px;rounded-full 仅限 icon 圆钮/状态点/头像 */
--radius-chip: 5px;
--radius-control: 5px;
--radius-card: 5px;
--radius-window: 5px;
/* radii — chips 6, controls 8, cards 10, windows 14, pills 999 */
--radius-chip: 6px;
--radius-control: 8px;
--radius-card: 10px;
--radius-window: 14px;
/* easing — 规范统一 ease;保留旧变量名以兼容 primitives */
--ease-out-strong: ease;
--ease-in-out-strong: ease;
--ease-link: ease;
/* easing — strong curves; built-ins are too weak */
--ease-out-strong: cubic-bezier(0.23, 1, 0.32, 1);
--ease-in-out-strong: cubic-bezier(0.77, 0, 0.175, 1);
--ease-link: cubic-bezier(0.16, 1, 0.3, 1);
/* motion — Tailwind transition-* 工具类的全局默认:400ms + ease */
--default-transition-duration: 400ms;
--default-transition-timing-function: ease;
/* motion — pill 控件/悬停反馈的全局默认:150ms ease-out(原版手感) */
--default-transition-duration: 150ms;
--default-transition-timing-function: ease-out;
}
/* ── base ─────────────────────────────────────────────── */
@@ -170,6 +182,43 @@ html {
text-rendering: optimizeLegibility;
}
body {
color: var(--ink);
font-feature-settings: "cv11", "ss01";
letter-spacing: -0.01em;
}
::selection {
background: var(--accent-tint);
color: var(--ink);
}
/* headings balance; descriptions avoid orphans */
h1,
h2,
h3,
h4 {
text-wrap: balance;
}
p {
text-wrap: pretty;
}
/* underlines clear descenders */
a {
text-underline-position: from-font;
text-decoration-skip-ink: auto;
}
/* theme switches swap every token at once — freeze transitions so the flip
* is one clean repaint instead of hundreds of mismatched color fades */
.theme-switching *,
.theme-switching *::before,
.theme-switching *::after {
transition: none !important;
}
/* Tailwind v4 resets buttons to cursor:default — anything
* clickable must invite the click */
button:not(:disabled),
@@ -207,7 +256,7 @@ select:focus-visible,
background: currentColor;
transform: scaleX(0);
transform-origin: left center;
transition: transform var(--duration-base) var(--ease-standard);
transition: transform 280ms var(--ease-link);
}
a:hover .animated-underline::after,
@@ -216,23 +265,24 @@ a:focus-visible .animated-underline::after {
}
/* ── primitive spacing system ─────────────────────────────
* 8px 网格上的共享间距。
* Shared optical offsets for small cards. Use these
* instead of hand-tuning 10/12/14px per component.
*/
.primitive-card-pad {
padding: 8px;
padding: 12px;
}
.primitive-card-bar {
padding: 8px;
padding: 10px 12px;
}
.primitive-card-footer {
padding: 8px;
padding: 10px;
}
.primitive-table-cell {
padding: 8px;
padding: 10px 12px;
}
.primitive-icon-button {
@@ -244,83 +294,6 @@ a:focus-visible .animated-underline::after {
border-radius: var(--radius-control);
}
/* ── sidebar nav(beautifului.dev Sidebar Nav,官方组件级 CSS)──
* 收起态靠行宽切换保证图标对齐:.sidebar-row 固定 208px,
* data-sidebar-collapsed 时收为 36px(size-5 图标 + px-2 ×2),
* 恰好落在 52px 轨道正中;glide 高亮层同步收窄。
* --sidebar-copy-duration / --sidebar-copy-offset / --sidebar-easing
* 由 <aside data-sidebar-collapsed> 上注入。
*/
.sidebar-row {
width: 208px;
transition-duration: var(--sidebar-copy-duration, 180ms);
transition-timing-function: var(--sidebar-easing, ease);
}
.sidebar-copy {
opacity: 1;
transform: translateX(0);
transition:
opacity var(--sidebar-copy-duration, 180ms) ease-out,
transform var(--sidebar-copy-duration, 180ms) var(--sidebar-easing, ease);
}
.sidebar-logo,
.sidebar-collapse-control,
.sidebar-expand-control {
transition-duration: var(--sidebar-copy-duration, 180ms);
transition-timing-function: ease-out;
}
.sidebar-expand-control {
pointer-events: none;
opacity: 0;
}
.sidebar-glide-highlight {
right: 8px;
left: 8px;
}
[data-sidebar-collapsed='true'] .sidebar-row {
width: 36px;
}
[data-sidebar-collapsed='true'] .sidebar-copy {
pointer-events: none;
opacity: 0;
transform: translateX(calc(var(--sidebar-copy-offset, 8px) * -1));
}
[data-sidebar-collapsed='true'] .sidebar-workspace-control {
pointer-events: none;
}
[data-sidebar-collapsed='true'] .sidebar-logo,
[data-sidebar-collapsed='true'] .sidebar-collapse-control {
pointer-events: none;
opacity: 0;
}
[data-sidebar-collapsed='true'] .sidebar-expand-control {
pointer-events: auto;
opacity: 1;
}
[data-sidebar-collapsed='true'] .sidebar-glide-highlight {
right: auto;
width: 36px;
}
/* 折叠态专属元素(会话切换入口):展开时彻底不占位,折叠时以 flex 显示 */
.sidebar-collapsed-only {
display: none;
}
[data-sidebar-collapsed='true'] .sidebar-collapsed-only {
display: flex;
}
/* ── shared keyframes ─────────────────────────────────── */
@keyframes shimmer-text {
@@ -391,3 +364,61 @@ a:focus-visible .animated-underline::after {
transition-duration: 0.01ms !important;
}
}
/* ── 项目机制类:侧栏折叠(对应 SidebarNav 原版 CSS,含应用内专用入口) ── */
.sidebar-logo,
.sidebar-collapse-control,
.sidebar-expand-control {
transition-duration: var(--sidebar-copy-duration, 180ms);
transition-timing-function: ease-out;
}
.sidebar-expand-control {
pointer-events: none;
opacity: 0;
}
.sidebar-glide-highlight {
right: 8px;
left: 8px;
}
[data-sidebar-collapsed='true'] .sidebar-row {
width: 36px;
}
[data-sidebar-collapsed='true'] .sidebar-copy {
pointer-events: none;
opacity: 0;
transform: translateX(calc(var(--sidebar-copy-offset, 8px) * -1));
}
[data-sidebar-collapsed='true'] .sidebar-workspace-control {
pointer-events: none;
}
[data-sidebar-collapsed='true'] .sidebar-logo,
[data-sidebar-collapsed='true'] .sidebar-collapse-control {
pointer-events: none;
opacity: 0;
}
[data-sidebar-collapsed='true'] .sidebar-expand-control {
pointer-events: auto;
opacity: 1;
}
[data-sidebar-collapsed='true'] .sidebar-glide-highlight {
right: auto;
width: 36px;
}
/* 折叠态专属元素(会话切换入口):展开时彻底不占位,折叠时以 flex 显示 */
.sidebar-collapsed-only {
display: none;
}
[data-sidebar-collapsed='true'] .sidebar-collapsed-only {
display: flex;
}
@@ -15,7 +15,7 @@ body,
* {
scrollbar-width: thin;
scrollbar-color: rgba(142, 142, 160, 0.35) transparent;
scrollbar-color: color-mix(in srgb, var(--ink-3) 45%, transparent) transparent;
}
*::-webkit-scrollbar {
@@ -24,36 +24,23 @@ body,
}
*::-webkit-scrollbar-thumb {
background: rgba(142, 142, 160, 0.35);
background: color-mix(in srgb, var(--ink-3) 45%, transparent);
border-radius: 4px;
}
*::-webkit-scrollbar-thumb:hover {
background: rgba(142, 142, 160, 0.55);
background: color-mix(in srgb, var(--ink-3) 70%, transparent);
}
*::-webkit-scrollbar-track {
background: transparent;
}
::selection {
background: rgba(142, 142, 160, 0.28);
}
/* 流式输出光标 */
@keyframes caret-blink {
0%,
60% {
opacity: 1;
}
61%,
100% {
opacity: 0;
}
}
/* ::selection 在 beautifului.css 基座(accent-tint + ink) */
/* 流式输出光标:以 ::after 附加到流式正文最后一个文本块的最后一行文字之后
(inline 随文,而不是孤零零落在新的一行),结束后随容器 data-streaming 消失 */
(inline 随文,而不是孤零零落在新的一行),结束后随容器 data-streaming 消失。
对齐 beautifului StreamText:流式期间光标常亮(不闪烁)。 */
.streaming-body[data-streaming='true'] > *:last-child > :is(p, h1, h2, h3, h4, h5, h6):last-child::after,
.streaming-body[data-streaming='true'] > *:last-child > :is(ul, ol):last-child > li:last-child::after {
content: '';
@@ -64,7 +51,6 @@ body,
vertical-align: -0.15em;
background: var(--ink);
border-radius: 1px;
animation: caret-blink 0.9s steps(1) infinite;
}
/* 禁用 textarea 的默认 resize 与滚动条闪烁 */
+92 -54
View File
@@ -1,26 +1,32 @@
# DESIGN.md · 插件视觉一致性规范
# DESIGN.md · 插件视觉一致性规范(beautifului 审美)
> 读者是**写 UI 的 Agent/开发者**。目标:任何来源的插件,界面观感与宿主完全一致。
> 读者是**写 UI 的 Agent/开发者**。目标:任何来源的插件,界面观感与宿主完全一致,
> 达到 [beautifului.dev](https://www.beautifului.dev/)(MIT)的质感。
> 规则分两类:**强制**(可机器校验/一眼可辨)与**默认**(无充分理由不得偏离)。
> 契约问题看 [../plugins.md](../plugins.md);本文件只管"长什么样"。
> Token 单一事实源:`apps/desktop/src/renderer/src/styles/beautifului.css`(= foundation 原版),
> 组件参考源码归档在 `docs/ui-ref/beautifului/*.json`(26 个 registry)。
## 0. 两条铁律
1. **色值只允许来自 token**。任何 `#hex`/`rgb()` 硬编码(规范中列出的除外)都算违规;语义永远用 token 名表达(`text-ink-3` 而不是 `text-gray-400`)。
2. **不发明新组件形态**。先查本文 §4 标准模式;确需新形态时,用 §1 的 token 组合拼装,不引入新色/新圆角/新阴影。
1. **色值只允许来自 token**。任何 `#hex`/`rgb()`/`oklch()` 硬编码(规范中列出的除外)都算违规;
语义永远用 token 名表达(`text-ink-3` 而不是 `text-gray-400`)。
2. **不发明新组件形态**。先查本文 §4 标准模式与公共组件库;确需新形态时,
用 §1 的 token 组合拼装,不引入新色/新圆角/新阴影。
## 1. Token 表(单一事实源:`peer://assets/tokens.css` = 宿主 beautifului.css)
### 表面(背景)
### 表面(背景)——冷调近白,亮暗两套完整 token
| token | 意图 |
|---|---|
| `--page` | 页面最底层背景 |
| `--surface` | 卡片/面板表面 |
| `--canvas` | 画布(primary 按钮的文字色取这里) |
| `--surface` | 卡片/面板表面(纯白) |
| `--inset` | 内嵌区域(输入框底、预览底、代码块底) |
| `--hover` / `--hover-2` | 悬停态一级/二级 |
### 文字
### 文字——中性 ink ramp(深灰,不用纯黑)
| token | 意图 |
|---|---|
@@ -28,51 +34,90 @@
| `--ink-2` | 次级文字(正文说明) |
| `--ink-3` | 弱化文字(占位、提示、元信息、路径) |
### 边线
### 边线——实色 hairline,不用 alpha
| token | 意图 |
|---|---|
| `--line` | 常规边框(卡片、列表分隔) |
| `--line-strong` | 强边框(输入框、可点击描边按钮) |
| `--line-strong` | 强边框(输入框、可点击描边) |
| `--line-soft` | 极弱分隔 |
### 强调与语义
### 强调与语义——蓝色 accent,语义色只作"调味料"
| token | 意图 |
|---|---|
| `--accent` / `--accent-ink` / `--accent-tint` | 品牌强调(主按钮、选中、链接) |
| `--accent` / `--accent-ink` / `--accent-tint` | 焦点环/选区/选中态/链接(accent-ink 负责可交互文本,保 AA 对比) |
| `--green` / `--green-tint` | 成功/正常 |
| `--orange` / `--orange-tint` | 警示 |
| `--red` / `--red-tint` | 错误/危险 |
### 阴影(全部是描边式,禁止投影类阴影)
### 阴影——「ring + 分层柔影」,不是纯 hairline
`--shadow-hairline`(发丝线)· `--shadow-btn`(按钮)· `--shadow-card`(卡片)· `--shadow-raised`(浮起)· `--shadow-overlay`(弹层)· `--shadow-inset-field`(内嵌输入)
| token | 意图 |
|---|---|
| `--shadow-btn` | ring(line-strong) + xs——secondary 按钮 |
| `--shadow-card` | ring(line) + sm——卡片 |
| `--shadow-raised` | ring(line) + md——悬浮件 |
| `--shadow-overlay` | ring(line) + lg——弹层/模态 |
| `--shadow-inset-field` | 输入框内嵌阴影 |
| `--shadow-hairline` | 纯 ring(无投影)|
### 动效
暗色模式 ring 自动切换为低透明白渐进(0.10→0.15 按浮起高度),阴影加深——
**黑白主题不是简单反色**:暗色有一套专属 elevation 表达。
`--duration-fast/base/slow`(统一 400ms)+ `--ease-standard`。悬停/切换用 `transition-colors` 即可。
### 圆角体系(关键差异)
| token | 值 | 用途 |
|---|---|---|
| `--radius-chip` | 6px | chips、小徽标 |
| `--radius-control` | 8px | 输入框、菜单行、icon 按钮 |
| `--radius-card` | 10px | 卡片 |
| `--radius-window` | 14px | 窗口级容器(composer 外壳、弹窗) |
| `rounded-full` | 999px | **按钮(pill 是核心语言)**、状态点、头像 |
### 动效——强缓动曲线,内置 ease 太弱
| token | 值 | 用途 |
|---|---|---|
| `--ease-out-strong` | cubic-bezier(0.23, 1, 0.32, 1) | 大位移(滑层、展开、fade-up) |
| `--ease-in-out-strong` | cubic-bezier(0.77, 0, 0.175, 1) | 往返位移 |
| `--ease-link` | cubic-bezier(0.16, 1, 0.3, 1) | 下划线/微交互 |
| 控件默认 | 150ms ease-out | pill 按钮、悬停反馈 |
| 按压 | `active:scale-[0.96]` | 所有可点 pill |
## 2. 类名词汇(宿主 Tailwind 映射,renderer 组件直接用)
背景 `bg-page / bg-surface / bg-inset / bg-hover / bg-accent / bg-accent/10` ·
文字 `text-ink / text-ink-2 / text-ink-3 / text-accent / text-red` ·
背景 `bg-page / bg-canvas / bg-surface / bg-inset / bg-hover / bg-hover-2 / bg-accent / bg-accent/10` ·
文字 `text-ink / text-ink-2 / text-ink-3 / text-accent / text-accent-ink / text-red` ·
边框 `border-line / border-line-strong / border-dashed border-line` ·
圆角 `rounded-card`(卡片/输入块)`rounded-control`(按钮/输入框/徽标)。
圆角 `rounded-chip`(6)`rounded-control`(8)`rounded-card`(10)`rounded-window`(14)`rounded-full`(pill)·
阴影 `shadow-btn / shadow-card / shadow-raised / shadow-overlay`。
## 3. 字号与间距(默认)
## 3. 字号与间距
- 字号阶:`10px`(徽标)/ `11px`(辅助按钮)/ `11.5px`(次要按钮、说明)/ `12px`(正文说明) / `12.5px`(行标题/正文)/ `16px`(页面标题)。**不使用其他字号**;行高 `leading-4/5`。
- 间距基数为 4px:卡片内边距 `px-3 py-2.5`,区块间 `space-y-1.5`,小节间 `pt-3`。
- 图标一律 **lucide**(宿主白名单 `plugin-icons.tsx`,约 100 个常用名):`manifest.icon` / 状态胶囊 `icon` 填 lucide 图标名(如 `"Globe"`,kebab-case 亦可),12–16px、`currentColor` 由宿主保证;**禁止自绘 svg**(旧内联 svg 仅对存量插件兼容),未知名回落拼图图标
- 基准 14px/1.5,`letter-spacing -0.01em`;字号阶:`11px`(元信息/徽标)→ `12px`(chips、正文说明)→ `12.5/13px`(行标题、按钮)→ `14px`(正文)→ `16px`(页面标题)。**不使用其他字号**。
- 间距基数 4px:卡片内边距 `px-3 py-2.5`(紧凑)或 `primitive-card-pad`(12px),区块间 `space-y-1.5`,小节间 `pt-3`。
- 图标 12–16px 线条风格,`currentColor`;**一律 lucide**(宿主白名单 `plugin-icons.tsx`),禁止自绘 svg。
- 颜色只作"调味料":结构永远是中性 ink + line,彩色仅出现在状态/数值/选中。
## 4. 标准模式(复制即用)
## 4. 标准模式
> **renderer 组件优先用组件库**:本节模式已在 `@aso/plugin-sdk/ui` 代码化
> (`Button`/`SettingRow`/`EmptyState`/`LoadingState`/`ErrorState`/`Badge`…,
> 见 [L6](./07-ui-components.md)),直接 import,不要手写。
> **renderer 组件优先用组件库**:`@aso/plugin-sdk/ui` 的 `Button`/`SettingRow`/`LoadingState`/
> `ErrorState`/`EmptyState`/`Badge`… 已按本规范实现(见 [L6](./07-ui-components.md))。
> 下面的 HTML 形态留给 iframe 插件页作者。
### 按钮(pill 是核心语言)
```html
<!-- primary:墨底 canvas 字 + 内高光;accent:蓝底白字;secondary:白底 ring+xs -->
<button class="inline-flex h-[27px] items-center justify-center gap-1.5 rounded-full px-3
text-[13px] font-medium leading-none transition-[transform,background-color,opacity]
duration-150 ease-out active:scale-[0.96] bg-accent text-white hover:bg-accent-ink
shadow-[inset_0_1px_0_rgba(255,255,255,0.14)]">主操作</button>
<!-- quiet:透明直到悬停(密集工具行) -->
<button class="h-7 rounded-full px-2.5 text-[12px] text-ink hover:bg-hover">次要</button>
```
### 设置行(label 左 / 控件右)
```html
@@ -85,52 +130,45 @@
</div>
```
### 按钮
### 卡片(window 级容器用 14px)
```html
<!-- primary -->
<button class="rounded-control bg-accent px-3 py-1.5 text-[11.5px] font-medium text-white hover:opacity-90">主操作</button>
<!-- ghost -->
<button class="rounded-control border border-line-strong px-3 py-1.5 text-[11.5px] text-ink hover:bg-hover">次要操作</button>
<div class="rounded-window border border-line bg-surface shadow-card">…</div>
```
### 徽标
### 徽标 / 空态 / 开关
```html
<span class="rounded-control bg-hover px-1.5 py-0.5 text-[10px] text-ink-3">全局 v0.1.0</span>
```
徽标 `rounded-chip bg-hover px-1.5 py-0.5 text-[10px] text-ink-3`;
空态 = 虚线卡(同旧版);开关 `h-5 w-9`(行内)/ `h-6 w-10`(表单级),
滑块 `size-4/5 rounded-full bg-white shadow`,开 `bg-accent`、关 `bg-line-strong`。
### 空态
## 5. 动效范式
```html
<div class="flex flex-col items-center justify-center gap-1 rounded-card border border-dashed border-line py-12 text-center">
<div class="text-[13px] font-medium text-ink-2">暂无数据</div>
<div class="text-[11.5px] text-ink-3">怎么产生的提示</div>
</div>
```
- 悬停反馈:150ms ease-out,只变 background-color/text(配 `transition-colors`)
- 内容进场:`fade-up 400ms var(--ease-out-strong)`(聊天消息、卡片)
- 弹层:`pop-in`(scale 0.95→1)+ `shadow-overlay`
- 滑动高亮层(菜单/标签条):220ms `cubic-bezier(0.23,1,0.32,1)`(GlideMenu)
- 流式输出:光标 2px `--ink` 常亮→结束后闪烁;`prefers-reduced-motion` 全局降级
### 开关(switch)
## 6. iframe 插件页附加规则
宿主规格:`h-5 w-9`(行内)/ `h-6 w-10`(表单级),滑块 `size-4/5 rounded-full bg-white shadow`,开 `bg-accent`、关 `bg-line-strong`。
- `<head>` 必须引入 `<link rel="stylesheet" href="peer://assets/tokens.css" />`,并用同名 token 编译自己的 Tailwind
- 主题跟随宿主推送(`useHarnessTheme`),**禁止** `prefers-color-scheme` 自测;亮暗两套 token 都要核对
- 与宿主的视觉差异应趋近于零:同 token + 同类名词汇 + 同字号阶 + 同圆角/阴影体系
## 5. iframe 插件页附加规则
- `<head>` 必须引入 `<link rel="stylesheet" href="peer://assets/tokens.css" />`,并用同名 token 编译自己的 Tailwind(token 名 → 变量:`page/ink/ink-2/ink-3/line/line-strong/accent…`)
- 主题跟随宿主推送(`useHarnessTheme`),**禁止** `prefers-color-scheme` 自测
- 与宿主的视觉差异应趋近于零:同 token + 同类名词汇 + 同字号阶
## 6. renderer 组件附加规则
## 7. renderer 组件附加规则
- 类名直接用宿主词汇(§2),无需引样式;**优先 import 公共组件库**([L6](./07-ui-components.md)),不要手写标准模式
- React 必须来自 `ctx.react`(见 [L4](./05-renderer-components.md)),否则 hooks 失效
- 禁止操作宿主 DOM(document 全局查询/改样式),一切交互限制在自己的组件子树
- 机器关卡:`pnpm plugin:check <插件目录>`(hex 色值 / React 副本 / 三态缺失等,见 [L7](./08-performance.md))
## 7. 自检清单(提发布前)
## 8. 自检清单(提发布前)
- [ ] `pnpm plugin:check <插件目录>` 0 error(机器关卡,见 [L7](./08-performance.md))
- [ ] 无硬编码色值/阴影/圆角(铁律 1)
- [ ] 字号都在 §3 的阶上
- [ ] 深浅色都检查过(切主题看一眼;token 正确时自动成立)
- [ ] 圆角落在体系上:chip 6 / control 8 / card 10 / window 14 / pill 999
- [ ] 字号都在 §3 的阶上;按钮是 pill(rounded-full)
- [ ] 深浅色都检查过(暗色不是反色:ring 是低透白、阴影更深)
- [ ] 复用了 §4 模式(renderer 直接用组件库)而非新造形态
- [ ] iframe 页引了 tokens.css 且无 `prefers-color-scheme`
File diff suppressed because one or more lines are too long
+21
View File
@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
"name": "button",
"type": "registry:ui",
"title": "Button",
"description": "Shared building block used by other Beautiful UI components.",
"dependencies": [
"class-variance-authority"
],
"registryDependencies": [
"https://www.beautifului.dev/r/foundation.json"
],
"files": [
{
"path": "components/atoms/Button.tsx",
"content": "\"use client\";\n\nimport { ButtonHTMLAttributes } from \"react\";\nimport { cva, type VariantProps } from \"class-variance-authority\";\nimport { cn } from \"@/lib/utils\";\n\nconst filledShadow = \"shadow-[inset_0_1px_0_rgba(255,255,255,0.14)]\";\n\n/* Pill-shaped by default — the app's core button style. Explicit symmetric\n * padding (not a fixed height) so the top/bottom spacing is always equal. */\nexport const buttonVariants = cva(\n `inline-flex items-center justify-center font-medium select-none\n transition-[transform,background-color,opacity] duration-150 ease-out\n active:scale-[0.96] disabled:opacity-50 disabled:pointer-events-none`,\n {\n variants: {\n variant: {\n primary: `bg-ink text-canvas hover:opacity-90 dark:bg-ink dark:text-canvas ${filledShadow}`,\n secondary: \"bg-surface text-ink shadow-btn hover:bg-inset aria-expanded:bg-hover\",\n ghost: \"bg-hover-2 text-ink hover:bg-line-strong\",\n accent: `bg-accent text-white hover:bg-accent-ink ${filledShadow}`,\n success: `bg-green text-white hover:brightness-95 ${filledShadow}`,\n /* transparent until hovered — for dense toolbars/action rows */\n quiet: \"text-ink hover:bg-hover\",\n },\n size: {\n /* compact toolbar pill — fixed height, lighter weight */\n xs: \"h-7 rounded-full px-2.5 text-[12px] font-normal leading-none gap-1\",\n /* canonical action pill — 27px tall, roomy sides */\n sm: \"h-[27px] px-3 text-[13px] leading-none rounded-full gap-1.5\",\n md: \"px-4 py-[9px] text-sm leading-none rounded-full gap-2\",\n },\n },\n defaultVariants: { variant: \"secondary\", size: \"md\" },\n },\n);\n\nexport type ButtonVariant = NonNullable<VariantProps<typeof buttonVariants>[\"variant\"]>;\n\nexport function Button({\n variant,\n size,\n className,\n ...props\n}: ButtonHTMLAttributes<HTMLButtonElement> & VariantProps<typeof buttonVariants>) {\n return <button className={cn(buttonVariants({ variant, size }), className)} {...props} />;\n}\n",
"type": "registry:ui",
"target": "components/atoms/Button.tsx"
}
]
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,18 @@
{
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
"name": "context-cards",
"type": "registry:component",
"title": "Context Cards",
"description": "Retrieved knowledge chunks with their sources.",
"registryDependencies": [
"https://www.beautifului.dev/r/foundation.json"
],
"files": [
{
"path": "components/primitives/ContextCards.tsx",
"content": "\"use client\";\n\nimport { useEffect, useState } from \"react\";\n\n/* ─────────────────────────────────────────────────────────\n * CONTEXT CARDS\n * Retrieved chunks enter once, then remain available.\n * ───────────────────────────────────────────────────────── */\n\nexport type ContextChunk = {\n title: string;\n chars: string;\n body: string;\n source: string;\n badge: string;\n tone: string;\n};\n\nexport type ContextCardsLabels = {\n header: string;\n count: string;\n};\n\nconst DEFAULT_LABELS: ContextCardsLabels = {\n header: \"All chunks\",\n count: \"32\",\n};\n\nconst CHUNKS: ContextChunk[] = [\n {\n title: \"Vendor onboarding rule\",\n chars: \"290 characters\",\n body: \"Cold-chain certification must be verified before a new dairy can be added to the reorder workflow.\",\n source: \"Dairy Onboarding SOP.pdf\",\n badge: \"PDF\",\n tone: \"bg-red\",\n },\n {\n title: \"Seasonal demand row\",\n chars: \"1,250 characters\",\n body: \"Q4 velocity table: pistachio +18%, vanilla +6%, rocky road -11%; retire flavors below 40 scoops weekly.\",\n source: \"Sales Velocity Export.csv\",\n badge: \"CSV\",\n tone: \"bg-green\",\n },\n];\n\nexport default function ContextCards({\n chunks = CHUNKS,\n labels,\n className,\n}: {\n /** Accepted for gallery/registry parity; ContextCards has no visual variants. */\n variant?: string;\n chunks?: ContextChunk[];\n labels?: Partial<ContextCardsLabels>;\n className?: string;\n} = {}) {\n const [chipsShown, setChipsShown] = useState(false);\n const copy = { ...DEFAULT_LABELS, ...labels };\n\n useEffect(() => {\n const chips = setTimeout(() => setChipsShown(true), 700);\n return () => clearTimeout(chips);\n }, []);\n\n return (\n <div className={`flex w-full max-w-95 flex-col gap-2${className ? ` ${className}` : \"\"}`}>\n <div\n className=\"flex items-center gap-2 px-0.5\"\n style={{ animation: \"fade-in 400ms ease-out both\" }}\n >\n <span className=\"text-[13px] font-semibold text-ink\">{copy.header}</span>\n <span className=\"inline-flex h-5 items-center rounded-md bg-inset px-1.5 text-[11.5px] font-medium text-ink-2 shadow-hairline tabular-nums\">\n {copy.count}\n </span>\n </div>\n\n {chunks.map((chunk, i) => (\n <div\n key={chunk.title}\n className=\"overflow-hidden rounded-card bg-surface shadow-card\"\n style={{\n animation: `fade-up 400ms cubic-bezier(0.23,1,0.32,1) ${i * 100}ms both`,\n }}\n >\n <div className=\"primitive-card-bar flex items-center gap-2.5 border-b border-line\">\n <span className=\"flex min-w-0 items-center gap-1.5 text-[13px] font-medium text-ink\">\n <svg width=\"11\" height=\"11\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2.5\" strokeLinecap=\"round\"><path d=\"M4 6h16M4 12h16M4 18h10\" /></svg>\n <span className=\"truncate\">{chunk.title}</span>\n </span>\n <span className=\"ml-auto shrink-0 text-[12px] text-ink-3 tabular-nums\">{chunk.chars}</span>\n </div>\n <p className=\"px-3 pt-2 pb-1 text-[12.5px] leading-relaxed text-ink-2\">\n {chunk.body}\n </p>\n <div className=\"px-3 pb-3\">\n <span\n className=\"inline-flex h-6 items-center gap-1.5 rounded-full bg-inset px-2\n text-[12px] font-medium text-ink-2 shadow-btn\n transition-[opacity,transform,background-color] duration-300 hover:bg-hover\"\n style={{\n opacity: chipsShown ? 1 : 0,\n transform: chipsShown ? \"scale(1)\" : \"scale(0.95)\",\n transitionTimingFunction: \"cubic-bezier(0.23, 1, 0.32, 1)\",\n transitionDelay: `${i * 80}ms`,\n }}\n >\n <span className={`flex size-3.5 items-center justify-center rounded-[4px] ${chunk.tone} text-[7px] font-bold text-white`}>\n {chunk.badge}\n </span>\n {chunk.source}\n <svg width=\"9\" height=\"9\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\"><path d=\"M7 17L17 7M7 7h10v10\" /></svg>\n </span>\n </div>\n </div>\n ))}\n </div>\n );\n}\n",
"type": "registry:component",
"target": "components/primitives/ContextCards.tsx"
}
]
}
File diff suppressed because one or more lines are too long
+18
View File
@@ -0,0 +1,18 @@
{
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
"name": "entity-chip",
"type": "registry:ui",
"title": "EntityChip",
"description": "Shared building block used by other Beautiful UI components.",
"registryDependencies": [
"https://www.beautifului.dev/r/foundation.json"
],
"files": [
{
"path": "components/atoms/EntityChip.tsx",
"content": "/** Monogram mark — a colored disc with an initial or short glyph.\n * The shared building block for entity chips and monogram headings. */\nexport function Monogram({\n children,\n color = \"#e08a3c\",\n className = \"\",\n}: {\n children: React.ReactNode;\n color?: string;\n className?: string;\n}) {\n return (\n <span\n className={`flex size-4 shrink-0 items-center justify-center rounded-full\n text-[9px] font-semibold leading-none text-white ${className}`}\n style={{ background: color }}\n >\n {children}\n </span>\n );\n}\n\n/** Inline entity reference — a monogram + name in a soft field pill.\n * Names a supplier, person, or record inside running text. Softer than a\n * StatusPill (no dot, no state) and not a mono token (see Chip). */\nexport function EntityChip({\n name,\n color,\n monogram,\n className = \"\",\n}: {\n name: string;\n color?: string;\n monogram?: React.ReactNode;\n className?: string;\n}) {\n return (\n <span\n className={`mx-0.5 inline-flex items-center gap-1 rounded-full bg-field\n py-px pl-[3px] pr-1.5 align-middle shadow-hairline ${className}`}\n >\n <Monogram color={color}>{monogram ?? name.charAt(0)}</Monogram>\n <span className=\"text-[12px] font-medium text-ink\">{name}</span>\n </span>\n );\n}\n",
"type": "registry:ui",
"target": "components/atoms/EntityChip.tsx"
}
]
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+18
View File
@@ -0,0 +1,18 @@
{
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
"name": "glide-menu",
"type": "registry:ui",
"title": "GlideMenu",
"description": "Shared building block used by other Beautiful UI components.",
"registryDependencies": [
"https://www.beautifului.dev/r/foundation.json"
],
"files": [
{
"path": "components/primitives/GlideMenu.tsx",
"content": "\"use client\";\n\nimport { useRef, useState, type ReactNode } from \"react\";\n\ntype GlideMenuProps = {\n children: ReactNode;\n className?: string;\n highlightClassName?: string;\n rowSelector?: string;\n};\n\n/** A single hover layer that glides between interactive menu rows. */\nexport default function GlideMenu({\n children,\n className = \"\",\n highlightClassName = \"inset-x-0 rounded-[8px] bg-hover\",\n rowSelector = \"[data-menu-row]\",\n}: GlideMenuProps) {\n const ref = useRef<HTMLDivElement>(null);\n const [box, setBox] = useState<{ top: number; height: number } | null>(null);\n const [visible, setVisible] = useState(false);\n\n const moveTo = (target: EventTarget | null) => {\n const container = ref.current;\n if (!(target instanceof Element) || !container) return;\n const row = target.closest(rowSelector);\n if (!(row instanceof HTMLElement) || !container.contains(row)) return;\n const containerRect = container.getBoundingClientRect();\n const rowRect = row.getBoundingClientRect();\n setBox({ top: rowRect.top - containerRect.top, height: rowRect.height });\n setVisible(true);\n };\n\n return (\n <div\n ref={ref}\n onMouseOver={(event) => moveTo(event.target)}\n onMouseLeave={() => setVisible(false)}\n onFocusCapture={(event) => moveTo(event.target)}\n onBlurCapture={(event) => {\n if (!ref.current?.contains(event.relatedTarget as Node | null)) setVisible(false);\n }}\n className={`group/glide-menu relative ${className}`}\n >\n <span\n aria-hidden\n className={`pointer-events-none absolute ${highlightClassName}`}\n style={{\n top: box?.top ?? 0,\n height: box?.height ?? 0,\n opacity: box && visible ? 1 : 0,\n transition:\n \"top 220ms cubic-bezier(0.23,1,0.32,1), height 220ms cubic-bezier(0.23,1,0.32,1), opacity 150ms ease\",\n }}\n />\n {children}\n </div>\n );\n}\n",
"type": "registry:ui",
"target": "components/primitives/GlideMenu.tsx"
}
]
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+19
View File
@@ -0,0 +1,19 @@
{
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
"name": "search",
"type": "registry:component",
"title": "Search",
"description": "Command search with live filtering and an empty state.",
"registryDependencies": [
"https://www.beautifului.dev/r/foundation.json",
"https://www.beautifului.dev/r/glide-menu.json"
],
"files": [
{
"path": "components/primitives/SearchList.tsx",
"content": "\"use client\";\n\nimport { useState } from \"react\";\nimport GlideMenu from \"@/components/primitives/GlideMenu\";\n\n/* ─────────────────────────────────────────────────────────\n * SEARCH — command search with live filtering.\n * The field, clear action, and results are directly usable.\n * ───────────────────────────────────────────────────────── */\n\nexport type SearchItem = string;\n\nexport type SearchListLabels = {\n placeholder: string;\n ariaLabel: string;\n emptyTitle: string;\n emptyHint: string;\n};\n\nconst ITEMS: SearchItem[] = [\n \"Forecast summer demand\",\n \"Find waffle cone suppliers\",\n \"Compare seasonal flavors\",\n \"Draft flavor launch plan\",\n \"Check cold-chain status\",\n \"Audit sugar costs\",\n \"Retire low sellers\",\n];\n\nconst LABELS: SearchListLabels = {\n placeholder: \"Search flavors…\",\n ariaLabel: \"Search flavors\",\n emptyTitle: \"No results found\",\n emptyHint: \"Adjust your search to try again\",\n};\n\nexport default function SearchList({\n items = ITEMS,\n labels = LABELS,\n}: {\n items?: SearchItem[];\n labels?: SearchListLabels;\n variant?: string;\n} = {}) {\n const [query, setQuery] = useState(\"\");\n const results = query\n ? items.filter((i) => i.toLowerCase().includes(query.toLowerCase()))\n : items.slice(0, 5);\n const empty = query.length > 2 && results.length === 0;\n\n return (\n <div className=\"flex min-h-[248px] w-full max-w-72 flex-col items-stretch\">\n <div className=\"w-full self-start overflow-hidden rounded-card bg-surface shadow-raised\">\n {/* input row */}\n <div className=\"flex h-10 items-center gap-2 border-b border-line px-3 transition-colors duration-100 hover:bg-hover\">\n <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"var(--ink-3)\" strokeWidth=\"2\" strokeLinecap=\"round\" className=\"shrink-0\">\n <circle cx=\"11\" cy=\"11\" r=\"7\" />\n <path d=\"M21 21l-4.3-4.3\" />\n </svg>\n <input\n value={query}\n onChange={(event) => setQuery(event.target.value)}\n placeholder={labels.placeholder}\n aria-label={labels.ariaLabel}\n className=\"min-w-0 flex-1 bg-transparent text-[13px] text-ink outline-none placeholder:text-ink-3\"\n />\n {query && (\n <button\n aria-label=\"Clear search\"\n type=\"button\"\n onClick={() => setQuery(\"\")}\n className=\"flex size-6 items-center justify-center rounded-full text-ink-3\n transition-colors duration-100 hover:bg-line/70 hover:text-ink\"\n style={{ animation: \"fade-in 150ms ease-out both\" }}\n >\n <svg width=\"11\" height=\"11\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2.2\" strokeLinecap=\"round\">\n <path d=\"M18 6L6 18M6 6l12 12\" />\n </svg>\n </button>\n )}\n </div>\n\n {/* results / empty state */}\n {empty ? (\n <div className=\"flex flex-col items-center justify-center gap-1 px-4 py-8\" style={{ animation: \"fade-in 250ms ease-out both\" }}>\n <span className=\"mb-1.5 flex size-8 items-center justify-center rounded-control bg-inset text-ink-3 shadow-hairline\">\n <svg width=\"15\" height=\"15\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.8\" strokeLinecap=\"round\">\n <circle cx=\"11\" cy=\"11\" r=\"7\" />\n <path d=\"M21 21l-4.3-4.3\" />\n </svg>\n </span>\n <span className=\"text-[13px] font-medium text-ink\">{labels.emptyTitle}</span>\n <span className=\"text-[12px] text-ink-3\">{labels.emptyHint}</span>\n </div>\n ) : (\n <div className=\"p-1\">\n <GlideMenu className=\"flex flex-col gap-px\" highlightClassName=\"inset-x-0 rounded-[6px] bg-hover\">\n {results.map((item) => (\n <button\n key={item}\n data-menu-row\n type=\"button\"\n onClick={() => setQuery(item)}\n className=\"relative z-10 flex h-8 w-full items-center rounded-[6px] px-2 text-left text-[13px] text-ink\"\n style={{ animation: \"fade-in 200ms ease-out both\" }}\n >\n {item}\n </button>\n ))}\n </GlideMenu>\n </div>\n )}\n </div>\n </div>\n );\n}\n",
"type": "registry:component",
"target": "components/primitives/SearchList.tsx"
}
]
}
File diff suppressed because one or more lines are too long
+18
View File
@@ -0,0 +1,18 @@
{
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
"name": "shimmer",
"type": "registry:ui",
"title": "Shimmer",
"description": "Shared building block used by other Beautiful UI components.",
"registryDependencies": [
"https://www.beautifului.dev/r/foundation.json"
],
"files": [
{
"path": "components/atoms/Shimmer.tsx",
"content": "/** Shimmering label — signals the agent is processing. */\nexport function Shimmer({\n children,\n className = \"\",\n}: {\n children: React.ReactNode;\n className?: string;\n}) {\n return (\n <span\n className={`inline-block bg-clip-text text-transparent ${className}`}\n style={{\n backgroundImage:\n \"linear-gradient(90deg, var(--ink-3) 35%, var(--ink) 50%, var(--ink-3) 65%)\",\n backgroundSize: \"200% 100%\",\n animation: \"shimmer-text 1.8s linear infinite\",\n }}\n >\n {children}\n </span>\n );\n}\n",
"type": "registry:ui",
"target": "components/atoms/Shimmer.tsx"
}
]
}
File diff suppressed because one or more lines are too long
+18
View File
@@ -0,0 +1,18 @@
{
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
"name": "stream-text",
"type": "registry:ui",
"title": "StreamText",
"description": "Shared building block used by other Beautiful UI components.",
"registryDependencies": [
"https://www.beautifului.dev/r/foundation.json"
],
"files": [
{
"path": "components/atoms/StreamText.tsx",
"content": "\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\n\n/* ─────────────────────────────────────────────────────────\n * STREAM TEXT — reusable streaming primitive\n * Reveals characters quickly (fast, like a real token stream);\n * the leading edge resolves out of a soft blur, and the caret\n * stays solid while streaming, then blinks once the text\n * settles. Inherits typography from its context, so it drops\n * into any text surface (Selection Actions, chat, etc.).\n * ───────────────────────────────────────────────────────── */\n\nexport function StreamText({\n text,\n charsPerTick = 2,\n tickMs = 9,\n blurTail = 6,\n caret = true,\n className,\n onProgress,\n onDone,\n}: {\n text: string;\n /** characters revealed per tick — higher is faster */\n charsPerTick?: number;\n /** interval between reveals, ms */\n tickMs?: number;\n /** how many trailing characters carry the soft blur edge */\n blurTail?: number;\n /** render the caret (solid while streaming, blinks once idle) */\n caret?: boolean;\n className?: string;\n /** fires each tick — useful for re-anchoring UI to reflowing text */\n onProgress?: () => void;\n /** fires once the full string is shown */\n onDone?: () => void;\n}) {\n const [count, setCount] = useState(0);\n const onProgressRef = useRef(onProgress);\n const onDoneRef = useRef(onDone);\n onProgressRef.current = onProgress;\n onDoneRef.current = onDone;\n\n useEffect(() => {\n setCount(0);\n let i = 0;\n const id = setInterval(() => {\n i = Math.min(i + charsPerTick, text.length);\n setCount(i);\n onProgressRef.current?.();\n if (i >= text.length) {\n clearInterval(id);\n onDoneRef.current?.();\n }\n }, tickMs);\n return () => clearInterval(id);\n }, [text, charsPerTick, tickMs]);\n\n const streaming = count < text.length;\n const shown = text.slice(0, count);\n const split = streaming ? Math.max(0, shown.length - blurTail) : shown.length;\n\n return (\n <span className={className}>\n {shown.slice(0, split)}\n {split < shown.length && (\n <span className=\"stream-tail\">{shown.slice(split)}</span>\n )}\n {caret && (\n <span\n aria-hidden\n className={`stream-caret${streaming ? \" is-streaming\" : \"\"}`}\n />\n )}\n </span>\n );\n}\n",
"type": "registry:ui",
"target": "components/atoms/StreamText.tsx"
}
]
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+18
View File
@@ -0,0 +1,18 @@
{
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
"name": "value-pill",
"type": "registry:ui",
"title": "ValuePill",
"description": "Shared building block used by other Beautiful UI components.",
"registryDependencies": [
"https://www.beautifului.dev/r/foundation.json"
],
"files": [
{
"path": "components/atoms/ValuePill.tsx",
"content": "type Tone = \"neutral\" | \"green\" | \"orange\" | \"red\" | \"accent\";\n\nconst TONES: Record<Tone, { cls: string; ring: string }> = {\n neutral: { cls: \"bg-field text-ink-2\", ring: \"var(--shadow-hairline)\" },\n green: { cls: \"bg-green-tint text-green\", ring: \"0 0 0 1px color-mix(in oklch, var(--green) 28%, transparent)\" },\n orange: { cls: \"bg-orange-tint text-orange\", ring: \"0 0 0 1px color-mix(in oklch, var(--orange) 28%, transparent)\" },\n red: { cls: \"bg-red-tint text-red\", ring: \"0 0 0 1px color-mix(in oklch, var(--red) 28%, transparent)\" },\n accent: { cls: \"bg-accent-tint text-accent-ink\", ring: \"0 0 0 1px color-mix(in oklch, var(--accent) 28%, transparent)\" },\n};\n\n/** Inline value badge — a plain value (a date, a name, a count) set off in\n * prose. Softer than a StatusPill (no dot) and not a mono token (see Chip). */\nexport function ValuePill({\n children,\n tone = \"neutral\",\n className = \"\",\n}: {\n children: React.ReactNode;\n tone?: Tone;\n className?: string;\n}) {\n const t = TONES[tone];\n return (\n <span\n className={`mx-0.5 inline-flex items-center rounded-full px-1.5 py-0\n align-middle text-[12px] font-medium ${t.cls} ${className}`}\n style={{ boxShadow: t.ring }}\n >\n {children}\n </span>\n );\n}\n",
"type": "registry:ui",
"target": "components/atoms/ValuePill.tsx"
}
]
}
+10 -8
View File
@@ -118,26 +118,28 @@ export function EmptyState({ title, description, action, className }: EmptyState
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
/** primary=强调实心 / ghost=描边(默认)/ danger=危险描边 */
variant?: 'primary' | 'ghost' | 'danger'
/** sm=辅助按钮 11px / md=标准 11.5px(默认) */
/** sm=辅助 pill(h-7 12px)/ md=标准 pill(13px)(默认) */
size?: 'sm' | 'md'
/** true 时禁用并显示旋转指示器(异步提交防重复点击) */
loading?: boolean
}
export function Button({ variant = 'ghost', size = 'md', loading = false, disabled, className, children, ...rest }: ButtonProps): ReactElement {
/* beautifului pill 体系:rounded-full + 150ms ease-out + 按压回缩 0.96;
* filled 变体带 1px 内高光(类已在宿主 Button.tsx 中生成,插件可直接复用)。 */
const variantCls =
variant === 'primary'
? 'bg-accent text-white hover:opacity-90'
? 'bg-ink text-canvas hover:opacity-90 shadow-[inset_0_1px_0_rgba(255,255,255,0.14)]'
: variant === 'danger'
? 'border border-line-strong text-red hover:bg-red/10'
: 'border border-line-strong text-ink hover:bg-hover'
const sizeCls = size === 'sm' ? 'px-2 py-1 text-[11px]' : 'px-3 py-1.5 text-[11.5px]'
: 'bg-surface text-ink shadow-btn hover:bg-inset'
const sizeCls = size === 'sm' ? 'h-7 px-2.5 text-[12px]' : 'h-[27px] px-3 text-[12.5px]'
return (
<button
type="button"
disabled={disabled || loading}
className={cn(
'inline-flex items-center justify-center gap-1.5 rounded-control font-medium transition-colors disabled:pointer-events-none disabled:opacity-50',
'inline-flex items-center justify-center gap-1.5 rounded-full font-medium leading-none transition-[transform,background-color,opacity] duration-150 ease-out active:scale-[0.96] disabled:pointer-events-none disabled:opacity-50',
variantCls,
sizeCls,
className
@@ -157,7 +159,7 @@ export function Input({ className, ...rest }: InputHTMLAttributes<HTMLInputEleme
return (
<input
className={cn(
'w-full rounded-control border border-line-strong bg-surface px-2.5 py-1.5 text-[12px] text-ink outline-none placeholder:text-ink-3 disabled:opacity-50',
'w-full rounded-control bg-field shadow-inset-field px-2.5 py-1.5 text-[12px] text-ink outline-none placeholder:text-ink-3 disabled:opacity-50',
className
)}
{...rest}
@@ -169,7 +171,7 @@ export function Textarea({ className, ...rest }: TextareaHTMLAttributes<HTMLText
return (
<textarea
className={cn(
'w-full resize-y rounded-control border border-line-strong bg-surface px-2.5 py-1.5 text-[12px] leading-4 text-ink outline-none placeholder:text-ink-3 disabled:opacity-50',
'w-full resize-y bg-field shadow-inset-field px-2.5 py-1.5 text-[12px] leading-4 text-ink outline-none placeholder:text-ink-3 disabled:opacity-50',
className
)}
{...rest}
@@ -186,7 +188,7 @@ export function Select({ options, className, ...rest }: SelectProps): ReactEleme
return (
<select
className={cn(
'w-full rounded-control border border-line-strong bg-surface px-2.5 py-1.5 text-[12px] text-ink outline-none disabled:opacity-50',
'w-full rounded-control bg-field shadow-inset-field px-2.5 py-1.5 text-[12px] text-ink outline-none disabled:opacity-50',
className
)}
{...rest}