diff --git a/apps/desktop/src/renderer/src/components/atoms/Button.tsx b/apps/desktop/src/renderer/src/components/atoms/Button.tsx index 02ae2db..da2cd26 100644 --- a/apps/desktop/src/renderer/src/components/atoms/Button.tsx +++ b/apps/desktop/src/renderer/src/components/atoms/Button.tsx @@ -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" }, diff --git a/apps/desktop/src/renderer/src/styles/beautifului.css b/apps/desktop/src/renderer/src/styles/beautifului.css index 6b4f19c..83ec508 100644 --- a/apps/desktop/src/renderer/src/styles/beautifului.css +++ b/apps/desktop/src/renderer/src/styles/beautifului.css @@ -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 - * 由 \n );\n}\n", + "type": "registry:component", + "target": "components/primitives/SidebarNav.tsx" + }, + { + "path": "app/beautifui/sidebar-nav.css", + "content": "/* ══════════════════════════════════════════════════════════\n * COMPONENT-SPECIFIC — SidebarNav\n * The 52↔224px collapsible sidebar tree.\n * Copy this block only alongside SidebarNav; it is not part of the\n * generic foundation above.\n * ══════════════════════════════════════════════════════════ */\n.sidebar-row {\n width: 208px;\n transition-duration: var(--sidebar-copy-duration);\n transition-timing-function: var(--sidebar-easing);\n}\n\n.sidebar-copy {\n opacity: 1;\n transform: translateX(0);\n transition:\n opacity var(--sidebar-copy-duration) ease-out,\n transform var(--sidebar-copy-duration) var(--sidebar-easing);\n}\n\n.sidebar-logo,\n.sidebar-collapse-control,\n.sidebar-expand-control {\n transition-duration: var(--sidebar-copy-duration);\n transition-timing-function: ease-out;\n}\n\n.sidebar-expand-control {\n pointer-events: none;\n opacity: 0;\n}\n\n.sidebar-glide-highlight {\n right: 8px;\n left: 8px;\n}\n\n[data-sidebar-collapsed=\"true\"] .sidebar-row {\n width: 36px;\n}\n\n[data-sidebar-collapsed=\"true\"] .sidebar-copy {\n pointer-events: none;\n opacity: 0;\n transform: translateX(calc(var(--sidebar-copy-offset) * -1));\n}\n\n[data-sidebar-collapsed=\"true\"] .sidebar-workspace-control {\n pointer-events: none;\n}\n\n[data-sidebar-collapsed=\"true\"] .sidebar-logo,\n[data-sidebar-collapsed=\"true\"] .sidebar-collapse-control {\n pointer-events: none;\n opacity: 0;\n}\n\n[data-sidebar-collapsed=\"true\"] .sidebar-expand-control {\n pointer-events: auto;\n opacity: 1;\n}\n\n[data-sidebar-collapsed=\"true\"] .sidebar-glide-highlight {\n right: auto;\n width: 36px;\n}\n\n@media (prefers-reduced-motion: reduce) {\n .home-reveal {\n opacity: 1 !important;\n transform: none !important;\n filter: none !important;\n }\n}\n\n/* ── records table ────────────────────────────────────────\n * The grid is deliberately more architectural than card-like:\n * one quiet surface, precise rules, and color reserved for\n * meaning. The first column remains an anchor while both axes\n * can move independently inside the table viewport.\n */\n", + "type": "registry:file", + "target": "app/beautifui/sidebar-nav.css" + } + ] +} \ No newline at end of file diff --git a/docs/ui-ref/beautifului/stream-text.json b/docs/ui-ref/beautifului/stream-text.json new file mode 100644 index 0000000..2439d28 --- /dev/null +++ b/docs/ui-ref/beautifului/stream-text.json @@ -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 \n {shown.slice(0, split)}\n {split < shown.length && (\n {shown.slice(split)}\n )}\n {caret && (\n \n )}\n \n );\n}\n", + "type": "registry:ui", + "target": "components/atoms/StreamText.tsx" + } + ] +} \ No newline at end of file diff --git a/docs/ui-ref/beautifului/streaming-text.json b/docs/ui-ref/beautifului/streaming-text.json new file mode 100644 index 0000000..05be034 --- /dev/null +++ b/docs/ui-ref/beautifului/streaming-text.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "streaming-text", + "type": "registry:component", + "title": "Streaming Text", + "description": "Streamed answer with inline sources, actions, and follow-ups.", + "registryDependencies": [ + "https://www.beautifului.dev/r/foundation.json" + ], + "files": [ + { + "path": "components/primitives/StreamingText.tsx", + "content": "\"use client\";\n\nimport { useEffect, useState } from \"react\";\n\n/* ─────────────────────────────────────────────────────────\n * STREAMING TEXT\n * Words resolve out of blur, inline citations appear in\n * context, then actions and follow-up prompts become usable.\n * ───────────────────────────────────────────────────────── */\n\nconst WORD_MS = 55;\nconst HOLD_MS = 3400;\n\n/* one streamed word, or a `cite` placeholder that renders an inline source chip */\nexport type StreamingToken = { text: string; cite?: boolean };\n\nconst TOKENS: StreamingToken[] = [\n ...\"Pistachio is your fastest-growing flavor — sales are up 23% this month and margins beat vanilla by 8 points.\"\n .split(\" \")\n .map((text) => ({ text })),\n { text: \"\", cite: true },\n ...\"Stone-fruit flavors are trending in the same range.\"\n .split(\" \")\n .map((text) => ({ text })),\n];\n\nconst FOLLOW_UPS = [\n \"Which flavors sell best in winter\",\n \"Compare gelato and soft serve margins\",\n];\n\nconst SOURCE_IMAGES = {\n scoop:\n \"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='16' fill='%231f7a5f'/%3E%3Cpath d='M20 36c0 7 5.4 12 12 12s12-5 12-12H20Z' fill='%23fff'/%3E%3Ccircle cx='32' cy='25' r='11' fill='%23bff3dd'/%3E%3Cpath d='M24 24c4-7 13-7 17 0' fill='none' stroke='%231f7a5f' stroke-width='4' stroke-linecap='round'/%3E%3C/svg%3E\",\n trends:\n \"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='16' fill='%232f6fec'/%3E%3Cpath d='M15 43 27 31l8 7 14-18' fill='none' stroke='%23fff' stroke-width='7' stroke-linecap='round' stroke-linejoin='round'/%3E%3Ccircle cx='49' cy='20' r='5' fill='%23bfe0ff'/%3E%3C/svg%3E\",\n market:\n \"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='16' fill='%23e56d24'/%3E%3Cpath d='M17 45V25h8v20h-8Zm11 0V16h8v29h-8Zm11 0V30h8v15h-8Z' fill='%23fff'/%3E%3Cpath d='M16 49h32' stroke='%23ffd6b8' stroke-width='4' stroke-linecap='round'/%3E%3C/svg%3E\",\n};\n\n/* one cited source rendered as an inline chip and in the sources list */\nexport type StreamingSource = { name: string; domain: string; href: string; image: string };\n\nconst SOURCES: StreamingSource[] = [\n { name: \"Scoop Data\", domain: \"scoopdata.io\", href: \"https://scoopdata.io/\", image: SOURCE_IMAGES.scoop },\n { name: \"Trends Index\", domain: \"trends.google.com\", href: \"https://trends.google.com/trends/\", image: SOURCE_IMAGES.trends },\n { name: \"Market Basket\", domain: \"marketbasket.io\", href: \"https://marketbasket.io/\", image: SOURCE_IMAGES.market },\n];\n\nfunction sourceImage(source: StreamingSource) {\n return source.image;\n}\n\nfunction SourceChip({ source }: { source?: StreamingSource }) {\n if (!source) return null;\n return (\n \n \"\"\n {source.domain}\n \n );\n}\n\nconst ACTION_ICONS: React.ReactNode[] = [\n ,\n ,\n ,\n ,\n];\n\nexport type StreamingLabels = {\n /** label on the collapsed sources toggle */\n sources: string;\n /** heading above the follow-up prompts */\n followUps: string;\n};\n\nconst DEFAULT_LABELS: StreamingLabels = {\n sources: \"10 sources\",\n followUps: \"Follow-ups\",\n};\n\nexport default function StreamingText({\n content = TOKENS,\n sources = SOURCES,\n followUps = FOLLOW_UPS,\n labels,\n loop = true,\n fill = false,\n onDone,\n onFollowUp,\n}: {\n variant?: string;\n /** the streamed tokens; `cite` tokens render an inline source chip */\n content?: StreamingToken[];\n /** cited sources shown in the chip, avatar stack, and expanded list */\n sources?: StreamingSource[];\n /** follow-up prompt suggestions shown once the stream completes */\n followUps?: string[];\n /** prominent copy strings */\n labels?: Partial;\n /** restart the stream after a hold; turn off when embedding in a real thread */\n loop?: boolean;\n /** fill the parent width instead of the gallery's fixed measure */\n fill?: boolean;\n onDone?: () => void;\n /** fired when a follow-up prompt is chosen */\n onFollowUp?: (text: string, index: number) => void;\n} = {}) {\n const l = { ...DEFAULT_LABELS, ...labels };\n const [count, setCount] = useState(0);\n const [sourcesOpen, setSourcesOpen] = useState(false);\n const done = count >= content.length;\n\n useEffect(() => {\n if (done && !loop) {\n onDone?.();\n return;\n }\n const t = setTimeout(\n () => setCount((c) => (c >= content.length ? 0 : c + 1)),\n done ? HOLD_MS : WORD_MS,\n );\n return () => clearTimeout(t);\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [count, done, loop]);\n\n return (\n
\n

\n {content.slice(0, count).map((token, i) =>\n token.cite ? (\n \n ) : (\n \n {token.text}{\" \"}\n \n ),\n )}\n {!done && (\n \n )}\n

\n\n {/* action icons row */}\n \n {ACTION_ICONS.map((icon, i) => (\n \n \n {icon}\n \n \n ))}\n setSourcesOpen((current) => !current)}\n className=\"ml-1.5 flex items-center gap-1.5 rounded-[6px] px-1 py-0.5 text-left transition-colors duration-150 hover:bg-hover\"\n >\n \n {sources.map((source) => (\n \n ))}\n \n {l.sources}\n \n
\n\n \n
\n
\n {sources.map((source) => (\n \n \"\"\n {source.name}\n {source.domain}\n \n ))}\n
\n
\n \n\n {/* follow-ups */}\n \n

{l.followUps}

\n
\n {followUps.map((text, i) => (\n onFollowUp?.(text, i)}\n className=\"-mx-1.5 flex items-center gap-2 rounded-[7px] border-b border-line\n px-1.5 py-1.5 text-left text-[12.5px] text-ink transition-colors\n duration-100 hover:bg-hover-2\"\n style={\n done\n ? { animation: `fade-up 350ms cubic-bezier(0.23,1,0.32,1) ${i * 90}ms both` }\n : { opacity: 0 }\n }\n >\n \n \n \n \n {text}\n \n ))}\n
\n \n \n );\n}\n", + "type": "registry:component", + "target": "components/primitives/StreamingText.tsx" + } + ] +} \ No newline at end of file diff --git a/docs/ui-ref/beautifului/task-rows.json b/docs/ui-ref/beautifului/task-rows.json new file mode 100644 index 0000000..f68b25d --- /dev/null +++ b/docs/ui-ref/beautifului/task-rows.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "task-rows", + "type": "registry:component", + "title": "Task Rows", + "description": "Live agent task status — running, failed, completed.", + "registryDependencies": [ + "https://www.beautifului.dev/r/foundation.json" + ], + "files": [ + { + "path": "components/primitives/TaskRows.tsx", + "content": "\"use client\";\n\nimport { useEffect, useState } from \"react\";\n\n/* ─────────────────────────────────────────────────────────\n * TASK ROWS\n *\n * 0ms rows enter staggered (80ms apart)\n * 600ms row 1 ring sweeps 0 → 66%\n * 1500ms row 1 expands — detail steps drop down\n * 3900ms row 1 collapses; row 2 flips to Failed + retry\n * 5300ms row 2 resolves to Completed\n * The status run completes once; task details stay clickable.\n * ───────────────────────────────────────────────────────── */\n\nconst TICKS = [600, 900, 2400, 1400, 2400, 600];\n\nfunction useTick(intervals: number[]) {\n const [tick, setTick] = useState(0);\n useEffect(() => {\n if (tick >= intervals.length - 1) return;\n const t = setTimeout(() => setTick((x) => x + 1), intervals[tick]);\n return () => clearTimeout(t);\n }, [tick, intervals]);\n return tick;\n}\n\nfunction SpinnerRing({ active, children }: { active?: boolean; children?: React.ReactNode }) {\n const size = 24, stroke = 2;\n const r = (size - stroke) / 2;\n const c = 2 * Math.PI * r;\n return (\n \n \n \n {active && (\n \n )}\n \n {children}\n \n );\n}\n\nfunction Badge({ tone, children }: { tone: \"red\" | \"green\"; children: React.ReactNode }) {\n return (\n \n {children}\n \n );\n}\n\nconst XIcon = (\n \n);\nconst CheckIcon = (\n \n);\nconst RetryIcon = (\n \n);\n\n/* One detail line shown when a task row is expanded. */\nexport type TaskDetail = { label: string; meta: string };\n\n/* A single task row.\n * - \"done\" → green check badge + completed pill (static)\n * - \"running\" → active spinner showing `step`, no pill (static)\n * - \"sequence\" → animation-driven: pending spinner → failed → completed\n */\nexport type TaskRow = {\n key: string;\n label: string;\n amount: string;\n status: \"done\" | \"running\" | \"sequence\";\n step?: number;\n details: TaskDetail[];\n};\n\nexport type TaskRowsLabels = {\n completed: string;\n failed: string;\n};\n\nconst DEFAULT_LABELS: TaskRowsLabels = {\n completed: \"Completed\",\n failed: \"Failed\",\n};\n\nconst TASK_ROWS: TaskRow[] = [\n {\n key: \"verify\",\n label: \"Verified vendor records\",\n amount: \"12 suppliers\",\n status: \"done\",\n details: [\n { label: \"Matched tax and contact IDs\", meta: \"12/12\" },\n { label: \"Flagged stale records\", meta: \"0\" },\n ],\n },\n {\n key: \"index\",\n label: \"Build reorder task list\",\n amount: \"7 SKUs\",\n status: \"running\",\n step: 2,\n details: [\n { label: \"Reading POS export\", meta: \"3 files\" },\n { label: \"Scoring stockout risk\", meta: \"68%\" },\n ],\n },\n {\n key: \"draft\",\n label: \"Draft supplier emails\",\n amount: \"2 messages\",\n status: \"sequence\",\n step: 3,\n details: [\n { label: \"Cone supplier follow-up\", meta: \"draft\" },\n { label: \"Pistachio reorder note\", meta: \"draft\" },\n ],\n },\n];\n\nexport default function TaskRows({\n variant = \"Capsules\",\n rows = TASK_ROWS,\n labels,\n className,\n onToggleRow,\n}: {\n variant?: string;\n rows?: TaskRow[];\n labels?: Partial;\n className?: string;\n onToggleRow?: (key: string, open: boolean) => void;\n}) {\n const tick = useTick(TICKS);\n const [manualOpen, setManualOpen] = useState>({});\n const row2: \"pending\" | \"failed\" | \"done\" = tick < 3 ? \"pending\" : tick === 3 ? \"failed\" : \"done\";\n const copy = { ...DEFAULT_LABELS, ...labels };\n\n const badgeFor = (row: TaskRow) => {\n if (row.status === \"done\") return {CheckIcon};\n if (row.status === \"running\") return {row.step};\n return row2 === \"pending\" ? (\n {row.step}\n ) : row2 === \"failed\" ? (\n {XIcon}\n ) : (\n {CheckIcon}\n );\n };\n\n const pillFor = (row: TaskRow) => {\n if (row.status === \"done\")\n return (\n \n {copy.completed}\n \n );\n if (row.status === \"running\") return null;\n return row2 === \"failed\" ? (\n \n {copy.failed} {RetryIcon}\n \n ) : row2 === \"done\" ? (\n \n {copy.completed}\n \n ) : null;\n };\n\n const list = variant === \"List\";\n return (\n \n {rows.map((row, i) => {\n const open = manualOpen[row.key] ?? (row.key === \"index\" && tick === 2);\n return (\n \n {\n setManualOpen((current) => ({ ...current, [row.key]: !open }));\n onToggleRow?.(row.key, !open);\n }}\n className=\"flex h-11 w-full items-center gap-2.5 px-2.5 text-left\"\n >\n \n {badgeFor(row)}\n \n \n {row.label}\n \n {row.amount}\n {pillFor(row)}\n \n \n \n \n \n \n\n {/* dropdown detail — same expandable grammar as Chain of Thought */}\n \n
\n
\n \n
\n {row.details.map((d, j) => (\n \n {d.label}\n \n {d.meta}\n \n
\n ))}\n
\n
\n \n \n \n );\n })}\n \n );\n}\n", + "type": "registry:component", + "target": "components/primitives/TaskRows.tsx" + } + ] +} \ No newline at end of file diff --git a/docs/ui-ref/beautifului/thinking-state.json b/docs/ui-ref/beautifului/thinking-state.json new file mode 100644 index 0000000..c478101 --- /dev/null +++ b/docs/ui-ref/beautifului/thinking-state.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "thinking-state", + "type": "registry:component", + "title": "Thinking", + "description": "Expandable traces — steps, reasoning, search, coding.", + "registryDependencies": [ + "https://www.beautifului.dev/r/foundation.json" + ], + "files": [ + { + "path": "components/primitives/ThinkingState.tsx", + "content": "\"use client\";\n\nimport { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from \"react\";\n\n/* ─────────────────────────────────────────────────────────\n * THINKING — expandable agent trace, four variants\n *\n * Steps step list with spinner → muted checks\n * Reasoning prose reasoning that expands, then settles\n * Search web-search trace: query + sources read\n * Coding tool trace: files read, edits, commands\n *\n * The trace runs once, settles, and remains expandable.\n * ───────────────────────────────────────────────────────── */\n\nconst STAGES = [800, 600, 1800, 2600, 1600];\n\nfunction useSequence(steps: number[]) {\n const [stage, setStage] = useState(0);\n useEffect(() => {\n if (stage >= steps.length - 1) return;\n const t = setTimeout(() => setStage((s) => s + 1), steps[stage]);\n return () => clearTimeout(t);\n }, [stage, steps]);\n return stage;\n}\n\ntype Row = {\n primary: string;\n secondary?: string;\n mono?: boolean;\n add?: number;\n del?: number;\n href?: string;\n};\n\nconst VARIANTS: Record<\n string,\n { active: string; done: string; rows: Row[]; query?: string }\n> = {\n Steps: {\n active: \"Thinking\",\n done: \"Thought for 4 seconds\",\n rows: [\n { primary: \"Reading flavor briefs\" },\n { primary: \"Scanning supplier lists\" },\n { primary: \"Comparing tasting notes\", secondary: \"6 flavors\" },\n { primary: \"Writing the scoop report\" },\n ],\n },\n Reasoning: {\n active: \"Thinking\",\n done: \"Thought for 4 seconds\",\n rows: [\n { primary: \"Summer demand spikes for stone-fruit flavors — peach and apricot lead.\" },\n { primary: \"I should check cone inventory before promoting a waffle-bowl special.\" },\n ],\n },\n Search: {\n active: \"Searching the web\",\n done: \"Searched the web\",\n query: \"best waffle cone supplier\",\n rows: [\n { primary: \"Joy Cone\", secondary: \"joycone.com\", href: \"https://joycone.com/fs_products/waffle-cones/\" },\n { primary: \"WebstaurantStore\", secondary: \"webstaurantstore.com\", href: \"https://www.webstaurantstore.com/ice-cream-shop-supplies.html\" },\n { primary: \"The Konery\", secondary: \"thekonery.com\", href: \"https://www.thekonery.com/\" },\n ],\n },\n Coding: {\n active: \"Running tools\",\n done: \"Ran 3 tools\",\n rows: [\n { primary: \"Read\", secondary: \"flavors.ts\", mono: true },\n { primary: \"Edit\", secondary: \"ChurnSchedule.tsx\", mono: true, add: 74, del: 41 },\n { primary: \"Run\", secondary: \"npm run freeze\", mono: true },\n ],\n },\n};\n\nfunction Dot({ tone }: { tone: string }) {\n return (\n \n \n \n \n \n \n );\n}\n\nconst TONES = [\"bg-accent\", \"bg-orange\", \"bg-green\"];\n\nexport default function ThinkingState({\n variant = \"Steps\",\n onSettled,\n rows,\n active,\n done,\n icon,\n}: {\n variant?: string;\n onSettled?: () => void;\n /** override the built-in trace content (keeps the primitive reusable) */\n rows?: Row[];\n active?: string;\n done?: string;\n /** override the header glyph (defaults to the sparkle) */\n icon?: ReactNode;\n}) {\n const stage = useSequence(STAGES);\n const [manualExpanded, setManualExpanded] = useState(null);\n const [selectedTool, setSelectedTool] = useState(null);\n const base = VARIANTS[variant] ?? VARIANTS.Steps;\n const v = {\n ...base,\n rows: rows ?? base.rows,\n active: active ?? base.active,\n done: done ?? base.done,\n };\n const autoExpanded = stage >= 1 && stage < 4;\n const expanded = manualExpanded ?? autoExpanded;\n const working = stage < 3;\n const visible = stage < 2 ? 0 : stage === 2 ? Math.min(2, v.rows.length) : v.rows.length;\n const traceRef = useRef(null);\n const [lineHeight, setLineHeight] = useState(0);\n useLayoutEffect(() => {\n if (traceRef.current) setLineHeight(traceRef.current.offsetHeight);\n }, [visible, expanded, variant, stage]);\n\n /* let embedders sequence content after the trace settles */\n const settledRef = useRef(false);\n useEffect(() => {\n if (working || settledRef.current) return;\n settledRef.current = true;\n onSettled?.();\n }, [working, onSettled]);\n\n return (\n \n {/* header — shared across variants */}\n setManualExpanded((current) => !(current ?? autoExpanded))}\n className=\"-mx-1.5 flex w-fit items-center gap-2 rounded-control px-1.5 py-1\n transition-colors duration-100 hover:bg-hover-2\"\n >\n {icon ? (\n \n {icon}\n \n ) : (\n \n \n \n )}\n \n {working ? (\n \n {v.active}\n \n ) : (\n \n {v.done}\n \n )}\n \n \n \n \n \n\n {/* expandable trace */}\n \n
\n
\n \n
\n {v.query && (\n
\n \n \n \n \n {v.query}\n
\n )}\n {v.rows.slice(0, visible).map((row, i) => {\n const content = (\n <>\n {variant === \"Search\" && }\n {variant === \"Steps\" && (\n i < visible - 1 || !working ? (\n \n \n \n ) : (\n \n )\n )}\n \n {row.primary}\n \n {row.secondary && (\n \n {row.secondary}\n \n )}\n {row.add !== undefined && (\n \n +{row.add}{\" \"}\n −{row.del}\n \n )}\n \n );\n const rowClass = \"flex min-h-7 w-full items-center gap-2 rounded-[6px] px-1.5 py-0.5 text-left\";\n const animation = { animation: `fade-up 320ms cubic-bezier(0.23,1,0.32,1) ${i * 120}ms both` };\n\n if (variant === \"Search\") {\n return (\n \n {content}\n \n );\n }\n\n if (variant === \"Coding\") {\n const selected = selectedTool === row.primary;\n return (\n setSelectedTool(selected ? null : row.primary)}\n className={`${rowClass} transition-colors duration-150 ${selected ? \"bg-inset\" : \"hover:bg-hover\"}`}\n style={animation}\n >\n {content}\n \n );\n }\n\n return (\n
\n {content}\n
\n );\n })}\n {variant === \"Search\" && stage >= 3 && (\n \n +7 more\n \n )}\n
\n
\n
\n \n \n );\n}\n", + "type": "registry:component", + "target": "components/primitives/ThinkingState.tsx" + } + ] +} \ No newline at end of file diff --git a/docs/ui-ref/beautifului/tool-chips.json b/docs/ui-ref/beautifului/tool-chips.json new file mode 100644 index 0000000..b962169 --- /dev/null +++ b/docs/ui-ref/beautifului/tool-chips.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "tool-chips", + "type": "registry:component", + "title": "Tool Chips", + "description": "Code edits and tool calls as compact chips.", + "registryDependencies": [ + "https://www.beautifului.dev/r/foundation.json" + ], + "files": [ + { + "path": "components/primitives/ToolChips.tsx", + "content": "\"use client\";\n\nimport { useEffect, useState } from \"react\";\nimport { createPortal } from \"react-dom\";\n\n/* ─────────────────────────────────────────────────────────\n * TOOL CHIPS\n * An agent run as compact rows: tool calls with inline\n * chips, then file-diff chips summarizing the edits.\n * Hover a row to reveal its chevron; every row expands\n * to show what the tool actually did.\n * ───────────────────────────────────────────────────────── */\n\nconst STEP_MS = 700;\n\nconst Icons: Record = {\n think: ,\n write: ,\n run: ,\n read: ,\n};\n\nexport type ToolDetailLine = { text: string; tone?: \"add\" };\n\nexport type ToolStep = {\n icon: string;\n label: string;\n chip: string;\n mono: boolean;\n detailMono: boolean;\n detail: ToolDetailLine[];\n};\n\nexport type ToolDiff = { file: string; add: number; del: number };\n\nexport type ToolDiffLine = { text: string; tone: \"add\" | \"del\" | \"ctx\" };\n\nexport type ToolChipsLabels = {\n header: string;\n more: string;\n};\n\nconst DEFAULT_LABELS: ToolChipsLabels = {\n header: \"4 tool calls, 2 messages\",\n more: \"+2 more\",\n};\n\nconst ROWS: ToolStep[] = [\n {\n icon: \"think\", label: \"Thinking\", chip: \"Planning the churn schedule…\", mono: false, detailMono: false,\n detail: [\n { text: \"Weekend demand carries pistachio, so it churns first.\" },\n { text: \"Batch capacity leaves two evening freezer windows.\" },\n ],\n },\n {\n icon: \"write\", label: \"Write 204 lines\", chip: \"ChurnSchedule.tsx\", mono: true, detailMono: true,\n detail: [\n { text: \"+ const windows = slots.filter((s) => s.temp <= -12)\", tone: \"add\" },\n { text: \"+ return schedule(windows, { hero: \\\"pistachio\\\" })\", tone: \"add\" },\n ],\n },\n {\n icon: \"run\", label: \"Rebuild and verify\", chip: \"npm run freeze\", mono: true, detailMono: true,\n detail: [\n { text: \"✓ built in 1.2s\" },\n { text: \"✓ 34 checks passed\" },\n ],\n },\n {\n icon: \"read\", label: \"Read image\", chip: \"flavor-chart.png\", mono: true, detailMono: false,\n detail: [\n { text: \"1280 × 720 · line chart, three summers.\" },\n { text: \"Mint chip trends up 12% through July.\" },\n ],\n },\n];\n\nconst DIFFS: ToolDiff[] = [\n { file: \"flavors.css\", add: 13, del: 0 },\n { file: \"ChurnSchedule.tsx\", add: 74, del: 41 },\n { file: \"menu.ts\", add: 8, del: 2 },\n];\n\n/* hovering a file chip opens its diff — green added, red removed */\nconst DIFF_LINES: Record = {\n \"flavors.css\": [\n { text: \".scoop-card {\", tone: \"ctx\" },\n { text: \" gap: 14px;\", tone: \"del\" },\n { text: \" gap: 12px;\", tone: \"add\" },\n { text: \" container-type: inline-size;\", tone: \"add\" },\n { text: \"}\", tone: \"ctx\" },\n ],\n \"ChurnSchedule.tsx\": [\n { text: \"const slots = coldSlots(week);\", tone: \"ctx\" },\n { text: \"const windows = slots;\", tone: \"del\" },\n { text: \"const windows = slots.filter(\", tone: \"add\" },\n { text: \" (s) => s.temp <= -12,\", tone: \"add\" },\n { text: \");\", tone: \"add\" },\n ],\n \"menu.ts\": [\n { text: \"export const hero = \\\"mint-chip\\\";\", tone: \"del\" },\n { text: \"export const hero = \\\"pistachio\\\";\", tone: \"add\" },\n ],\n};\n\nexport default function ToolChips({\n steps = ROWS,\n diffs = DIFFS,\n diffLines = DIFF_LINES,\n labels,\n className,\n onOpenChange,\n onToggleRow,\n}: {\n /** Accepted for gallery/registry parity; ToolChips has no visual variants. */\n variant?: string;\n steps?: ToolStep[];\n diffs?: ToolDiff[];\n diffLines?: Record;\n labels?: Partial;\n className?: string;\n onOpenChange?: (open: boolean) => void;\n onToggleRow?: (label: string, open: boolean) => void;\n} = {}) {\n const copy = { ...DEFAULT_LABELS, ...labels };\n const [step, setStep] = useState(0);\n const [open, setOpen] = useState(true);\n const [openRows, setOpenRows] = useState>(new Set());\n /* Rendered in a body portal so animated/translated reply wrappers cannot\n * redefine the fixed-position coordinate system. */\n const [preview, setPreview] = useState<{\n file: string;\n x: number;\n top?: number;\n bottom?: number;\n } | null>(null);\n const openPreview = (file: string) => (event: React.SyntheticEvent) => {\n const rect = (event.currentTarget as Element).closest(\"[data-diffchip]\")!.getBoundingClientRect();\n const previewHeight = 38 + (diffLines[file]?.length ?? 0) * 19;\n const fitsBelow = rect.bottom + 6 + previewHeight <= window.innerHeight - 12;\n setPreview({\n file,\n x: Math.max(12, Math.min(rect.left, window.innerWidth - 300)),\n ...(fitsBelow\n ? { top: rect.bottom + 6 }\n : { bottom: window.innerHeight - rect.top + 6 }),\n });\n };\n const closePreview = (file: string) => () =>\n setPreview((current) => (current?.file === file ? null : current));\n const total = steps.length + 1; // rows, then diff chips\n\n useEffect(() => {\n if (step >= total) return;\n const t = setTimeout(() => setStep((s) => s + 1), STEP_MS);\n return () => clearTimeout(t);\n }, [step, total]);\n\n const toggleRow = (label: string) =>\n setOpenRows((current) => {\n const next = new Set(current);\n next.has(label) ? next.delete(label) : next.add(label);\n onToggleRow?.(label, next.has(label));\n return next;\n });\n\n return (\n
\n {/* collapsed run header */}\n \n setOpen((current) => {\n onOpenChange?.(!current);\n return !current;\n })\n }\n className=\"-mx-1.5 flex w-fit items-center gap-1.5 rounded-control px-1.5 py-1 text-[12.5px] text-ink-2 transition-colors duration-100 hover:bg-hover-2\"\n >\n \n \n \n {copy.header}\n \n\n {/* tool call rows */}\n
\n {/* -mx-1 + px-1.5 keeps content at the same x while giving the\n row hover pills room inside this overflow-hidden clip box */}\n
\n
\n {steps.slice(0, step).map((row) => {\n const rowOpen = openRows.has(row.label);\n return (\n
\n toggleRow(row.label)}\n className=\"group/row -mx-[3px] flex h-7 w-[calc(100%+6px)] min-w-0 items-center gap-2 rounded-control px-[3px] text-left transition-colors duration-100 hover:bg-hover-2\"\n >\n \n \n {Icons[row.icon]}\n \n \n \n \n \n {row.label}\n \n {row.chip}\n \n \n\n {/* expanded detail */}\n \n
\n
\n {row.detail.map((line) => (\n \n {line.text}\n \n ))}\n
\n
\n
\n
\n );\n })}\n
\n\n {/* file-diff chips */}\n {step >= total && (\n
\n {diffs.map((d, i) => (\n \n \n {d.file}\n +{d.add}\n {d.del > 0 && −{d.del}}\n \n\n \n ))}\n \n {copy.more}\n \n
\n )}\n
\n
\n {preview && typeof document !== \"undefined\" && createPortal(\n \n
\n {preview.file}\n \n +{diffs.find((diff) => diff.file === preview.file)?.add}\n {(diffs.find((diff) => diff.file === preview.file)?.del ?? 0) > 0 && (\n −{diffs.find((diff) => diff.file === preview.file)?.del}\n )}\n \n
\n
\n {(diffLines[preview.file] ?? []).map((line, index) => (\n \n {line.tone === \"add\" ? \"+\" : line.tone === \"del\" ? \"−\" : \" \"}\n {line.text}\n
\n ))}\n \n ,\n document.body,\n )}\n \n );\n}\n", + "type": "registry:component", + "target": "components/primitives/ToolChips.tsx" + } + ] +} \ No newline at end of file diff --git a/docs/ui-ref/beautifului/value-pill.json b/docs/ui-ref/beautifului/value-pill.json new file mode 100644 index 0000000..227e403 --- /dev/null +++ b/docs/ui-ref/beautifului/value-pill.json @@ -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 = {\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 \n {children}\n \n );\n}\n", + "type": "registry:ui", + "target": "components/atoms/ValuePill.tsx" + } + ] +} \ No newline at end of file diff --git a/tmp-foundation.css b/tmp-foundation.css new file mode 100644 index 0000000..b4a38bc --- /dev/null +++ b/tmp-foundation.css @@ -0,0 +1,643 @@ +@import "tailwindcss"; +/* shadow-plugin (flornkm) — smooth, layered shadow stacks. The unprefixed + * entry maps them onto Tailwind's --shadow-xs…2xl scale; our semantic tokens + * below build on those values so the whole app picks up the smooth shadows. */ +@import "shadow-plugin/unprefixed"; + +@custom-variant dark (&:where(.dark, .dark *)); + +/* GENERIC FOUNDATION — tokens, @theme, base rules, the primitive-* + * spacing utilities, and shared keyframes below are needed by EVERY + * component. Blocks tagged COMPONENT-SPECIFIC further down belong to + * one primitive each. */ +/* ────────────────────────────────────────────────────────── + * 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 { + /* 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 */ + --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 — 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: oklch(0.961 0.001 286.375); + --stripe: oklch(0.405 0 0 / 0.075); + --stripe-bg: oklch(0.97 0 0); + + /* 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: 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 — 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 — our 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), 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: 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: 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: 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: 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: 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: 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); + /* 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 { + /* colors */ + --color-page: var(--page); + --color-canvas: var(--canvas); + --color-surface: var(--surface); + --color-inset: var(--inset); + --color-ink: var(--ink); + --color-ink-2: var(--ink-2); + --color-ink-3: var(--ink-3); + --color-hover: var(--hover); + --color-hover-2: var(--hover-2); + --color-field: var(--field); + --color-line: var(--line); + --color-line-strong: var(--line-strong); + --color-accent: var(--accent); + --color-accent-ink: var(--accent-ink); + --color-accent-tint: var(--accent-tint); + --color-green: var(--green); + --color-green-tint: var(--green-tint); + --color-orange: var(--orange); + --color-orange-tint: var(--orange-tint); + --color-red: var(--red); + --color-red-tint: var(--red-tint); + + /* shadows */ + --shadow-hairline: var(--shadow-hairline); + --shadow-btn: var(--shadow-btn); + --shadow-inset-field: var(--shadow-inset-field); + --shadow-card: var(--shadow-card); + --shadow-raised: var(--shadow-raised); + --shadow-overlay: var(--shadow-overlay); + + /* type */ + --font-sans: var(--font-inter), ui-sans-serif, system-ui, sans-serif; + --font-mono: var(--font-mono-face), ui-monospace, "SF Mono", monospace; + + /* radii — chips 6, controls 8, cards 10, windows 14, pills 999 */ + --radius-chip: 6px; + --radius-control: 8px; + --radius-card: 10px; + --radius-window: 14px; + + /* 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); +} + +/* ── base ─────────────────────────────────────────────── */ + +html { + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: optimizeLegibility; +} + +body { + background-color: var(--stripe-bg); + background-image: repeating-linear-gradient( + -45deg, + transparent 0, + transparent 7px, + var(--stripe) 7px, + var(--stripe) 8px + ); + /* Pin the stripe pattern to the viewport so it stays put on scroll. */ + background-attachment: fixed; + color: var(--ink); + font-feature-settings: "cv11", "ss01"; + font-size: 14px; + letter-spacing: -0.01em; + line-height: 1.5; +} + +::selection { + background: var(--accent-tint); + color: var(--ink); +} + +/* ── interface hygiene (interfaces.dev cheat sheet) ────── */ + +/* headings balance; descriptions avoid orphans (skip long-form — we have none) */ +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; +} + +/* touch: :hover sticks after a tap, so neutralize row/header hovers there */ +@media (hover: none) { + .records-row:hover > .records-cell { + background: transparent; + } + + .records-row:hover > td.is-colsel { + background: color-mix(in srgb, var(--accent) 4%, var(--surface)); + } + + .records-header-button:hover { + background: transparent; + color: var(--ink-2); + } +} + +/* Keep the component nav scrollable but hide its scrollbar. */ +.component-nav-scroll { + scrollbar-width: none; /* Firefox */ + -ms-overflow-style: none; /* legacy Edge */ +} +.component-nav-scroll::-webkit-scrollbar { + display: none; /* WebKit */ +} + +@media (min-width: 64rem) { + .component-nav-scroll { + -webkit-mask-image: linear-gradient( + to bottom, + oklch(0 0 0) 0, + oklch(0 0 0) calc(100% - 6rem), + transparent 100% + ); + mask-image: linear-gradient( + to bottom, + oklch(0 0 0) 0, + oklch(0 0 0) calc(100% - 6rem), + transparent 100% + ); + } +} + +/* Mobile demo gestures should operate controls and visualizations rather than + * selecting surrounding labels. Text entry and code remain intentionally + * selectable. */ +@media (hover: none) and (pointer: coarse) { + .primitive-showcase { + -webkit-user-select: none; + user-select: none; + -webkit-touch-callout: none; + } + + .primitive-showcase :is(input, textarea, [contenteditable="true"], pre, code) { + -webkit-user-select: text; + user-select: text; + -webkit-touch-callout: default; + } + + .primitive-showcase :is(a, button, [role="button"]) { + -webkit-tap-highlight-color: transparent; + } + + .primitive-showcase :is(a, button, [role="button"]):focus-visible { + outline: none; + } + + .primitive-showcase :is(img, svg, canvas) { + -webkit-user-drag: none; + } + + .insight-chart-stage { + touch-action: pan-y; + } +} + +/* Tailwind v4 resets buttons to cursor:default — anything + * clickable must invite the click */ +button:not(:disabled), +[role="button"]:not([aria-disabled="true"]), +label:has(input), +a { + cursor: pointer; +} + + +:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + border-radius: 4px; +} + +input:focus-visible, +textarea:focus-visible, +select:focus-visible, +[contenteditable]:focus-visible { + outline: none; +} + +.animated-underline { + position: relative; + display: inline-block; +} + +.animated-underline::after { + position: absolute; + right: 0; + bottom: -1px; + left: 0; + height: 1px; + content: ""; + background: currentColor; + transform: scaleX(0); + transform-origin: left center; + transition: transform 280ms var(--ease-link); +} + +a:hover .animated-underline::after, +a:focus-visible .animated-underline::after { + transform: scaleX(1); +} + +/* ══════════════════════════════════════════════════════════ + * COMPONENT-SPECIFIC — StreamingText / ContextCards + * Favicon source avatars. + * Copy this block only alongside StreamingText / ContextCards; it is not part of the + * generic foundation above. + * ══════════════════════════════════════════════════════════ */ +.source-avatar { + box-shadow: 0 0 0 1px oklch(0.21 0.034 263.436 / 0.1); +} + +.dark .source-avatar { + box-shadow: 0 0 0 1px oklch(1 0 0 / 0.08); +} + +/* ── primitive spacing system ───────────────────────────── + * Shared optical offsets for small demo cards. Use these + * instead of hand-tuning 10/12/14px per component. + */ + +.primitive-card-pad { + padding: 12px; +} + +.primitive-card-bar { + padding: 10px 12px; +} + +.primitive-card-footer { + padding: 10px; +} + +.primitive-table-cell { + padding: 10px 12px; +} + +.primitive-icon-button { + display: inline-flex; + width: 28px; + height: 28px; + align-items: center; + justify-content: center; + border-radius: var(--radius-control); +} + +/* ── shared keyframes ─────────────────────────────────── */ + +@keyframes shimmer-text { + from { + background-position: 150% center; + } + to { + background-position: -50% center; + } +} + +@keyframes fade-up { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.home-reveal { + will-change: opacity, transform, filter; +} + +/* The sidebar is one persistent 224px tree clipped by a 52px shell. + * Every rail glyph remains centered at x=26 in both states. */ + .records-footer-hint { + display: none; + } +} + +/* ── filter table electric status colors ─────────────────── */ + +/* ══════════════════════════════════════════════════════════ + * COMPONENT-SPECIFIC — FilterTable + * Electric status-pill colors. + * Copy this block only alongside FilterTable; it is not part of the + * generic foundation above. + * ══════════════════════════════════════════════════════════ */ +.filter-status-todo, +.filter-status-progress, +.filter-status-done { + border: 1px solid transparent; +} + +/* ══════════════════════════════════════════════════════════ + * COMPONENT-SPECIFIC — InsightCards + * Liveline chart tooltip + cursor. + * Copy this block only alongside InsightCards; it is not part of the + * generic foundation above. + * ══════════════════════════════════════════════════════════ */ +.insight-chart-stage { + overflow: hidden; +} + +.insight-chart-cursor { + position: absolute; + z-index: 4; + top: 0; + bottom: 0; + width: 1px; + background: var(--ink); + opacity: 0.26; + pointer-events: none; +} + +.insight-chart-tooltip-anchor { + position: absolute; + z-index: 5; + top: 8px; + transform: translateX(-50%); + pointer-events: none; +} + +.insight-chart-tooltip { + display: flex; + align-items: center; + gap: 12px; + padding: 5px 9px; + border: 1px solid var(--line-strong); + border-radius: 8px; + color: var(--tooltip-fg); + background: var(--tooltip-bg); + box-shadow: var(--shadow-overlay); + font-size: 11.5px; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.insight-chart-tooltip-item { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.insight-chart-tooltip-dot { + width: 8px; + height: 8px; + flex: 0 0 8px; + border-radius: 50%; +} + +/* Status pills share the same base-hue → color-mix derivation as .records-tag, + * so both components stay consistent across light and dark. */ +.filter-status-todo { + --tag-base: oklch(0.757 0.153 66.401); +} +.filter-status-progress { + --tag-base: oklch(0.671 0.118 219.351); +} +.filter-status-done { + --tag-base: oklch(0.652 0.131 162.865); +} + +.filter-status-todo, +.filter-status-progress, +.filter-status-done { + color: color-mix(in srgb, var(--tag-base) 92%, var(--ink)); + background: color-mix(in srgb, var(--tag-base) 20%, var(--surface)); + border-color: color-mix(in srgb, var(--tag-base) 34%, var(--surface)); +} + +/* Dark mode — a richer, saturated same-hue fill + * with bright same-hue text and no visible border (light mode keeps the soft + * tinted pastels above). Applies to both the records grid and status pills. */ +.dark .records-tag, +.dark .filter-status-todo, +.dark .filter-status-progress, +.dark .filter-status-done { + color: color-mix(in srgb, var(--tag-base) 86%, white); + background: color-mix(in srgb, var(--tag-base) 34%, var(--surface)); + border-color: color-mix(in srgb, var(--tag-base) 34%, var(--surface)); +} + +@keyframes fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes eq-bounce { + 0%, + 100% { + transform: scaleY(0.35); + } + 50% { + transform: scaleY(1); + } +} + +/* ── StreamText primitive ───────────────────────────────── + * Fast character reveal: the newest few characters carry a + * soft, masked blur so the leading edge resolves smoothly as + * text flows in. The caret holds solid while streaming, then + * blinks once the string settles. */ +/* ══════════════════════════════════════════════════════════ + * COMPONENT-SPECIFIC — StreamText + * The streaming caret + tail. + * Copy this block only alongside StreamText; it is not part of the + * generic foundation above. + * ══════════════════════════════════════════════════════════ */ +.stream-tail { + filter: blur(1.6px); + -webkit-mask-image: linear-gradient(to right, oklch(0 0 0) 20%, oklch(0 0 0 / 0.2)); + mask-image: linear-gradient(to right, oklch(0 0 0) 20%, oklch(0 0 0 / 0.2)); +} + +.stream-caret { + display: inline-block; + width: 2px; + height: 1.05em; + margin-left: 1.5px; + translate: 0 -0.5px; + border-radius: 1px; + background: var(--ink); + vertical-align: text-bottom; + animation: caret-blink 1s step-end infinite; +} + +.stream-caret.is-streaming { + animation: none; +} + +@keyframes caret-blink { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0; + } +} + +@media (prefers-reduced-motion: reduce) { + .stream-caret { + animation: none; + } + .stream-tail { + filter: none; + -webkit-mask-image: none; + mask-image: none; + } +} + +@keyframes pop-in { + from { + opacity: 0; + transform: scale(0.95); + } + to { + opacity: 1; + transform: scale(1); + } +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +/* pixel-grid loader — brighten plateau, staggered per cell */ +@keyframes pixel-on { + 0%, 100% { + opacity: 0.15; + } + 18%, 42% { + opacity: 1; + } + 62% { + opacity: 0.15; + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +}