feat(ui-refactor 1/4): token 基座对齐 beautifului.dev foundation 原版——蓝色 accent、ring+分层阴影、圆角体系 6/8/10/14、强缓动曲线、暗色专属 ring;Button 恢复 pill 体系

This commit is contained in:
Rick Ye
2026-09-11 11:58:27 +08:00
parent 665f848c93
commit 60fc9b6f4f
31 changed files with 1397 additions and 198 deletions
@@ -4,29 +4,33 @@ import { ButtonHTMLAttributes } from "react";
import { cva, type VariantProps } from "class-variance-authority"; import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
/* 设计规范:矩形控件统一 5px 圆角(rounded-control),rounded-full 仅限 /* beautifului.dev 原版 Button:Pill 胶囊形是应用的核心按钮语言。显式对称
* icon 圆钮/状态点/头像。动效走全局默认 400ms + ease(@theme 注入)。 */ * 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( export const buttonVariants = cva(
`inline-flex items-center justify-center font-medium select-none `inline-flex items-center justify-center font-medium select-none
transition-[transform,background-color,opacity] transition-[transform,background-color,opacity] duration-150 ease-out
active:scale-[0.98] disabled:opacity-50 disabled:pointer-events-none`, active:scale-[0.96] disabled:opacity-50 disabled:pointer-events-none`,
{ {
variants: { variants: {
variant: { 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", secondary: "bg-surface text-ink shadow-btn hover:bg-inset aria-expanded:bg-hover",
ghost: "bg-hover-2 text-ink hover:bg-line-strong", ghost: "bg-hover-2 text-ink hover:bg-line-strong",
accent: "bg-accent text-white hover:bg-accent-ink", accent: `bg-accent text-white hover:bg-accent-ink ${filledShadow}`,
success: "bg-green text-white hover:brightness-95", success: `bg-green text-white hover:brightness-95 ${filledShadow}`,
/* transparent until hovered — for dense toolbars/action rows */ /* transparent until hovered — for dense toolbars/action rows */
quiet: "text-ink hover:bg-hover", quiet: "text-ink hover:bg-hover",
}, },
size: { size: {
/* compact toolbar control — fixed height, lighter weight */ /* compact toolbar pill — fixed height, lighter weight */
xs: "h-7 rounded-control px-2.5 text-[12px] font-normal leading-none gap-1", xs: "h-7 rounded-full px-2.5 text-[12px] font-normal leading-none gap-1",
/* canonical action button */ /* canonical action pill — 27px tall, roomy sides */
sm: "h-[27px] px-3 text-[13px] leading-none rounded-control gap-1.5", sm: "h-[27px] px-3 text-[13px] leading-none rounded-full gap-1.5",
md: "px-4 py-[9px] text-sm leading-none rounded-control gap-2", md: "px-4 py-[9px] text-sm leading-none rounded-full gap-2",
}, },
}, },
defaultVariants: { variant: "secondary", size: "md" }, defaultVariants: { variant: "secondary", size: "md" },
@@ -1,115 +1,127 @@
/* Beautiful UI foundation — design tokens 按项目设计规范(OpenAI 浅色体系)重映射。 /* Beautiful UI foundation — design tokens 与 beautifului.dev 原版 foundation.css
* 规范要点:浅色界面;accent #8e8ea0(muted purple-gray);正文近黑;8px 间距网格; * 对齐(MIT):冷调近白画布、白色卡片、hairline 边框、「ring + 柔和分层阴影」
* 圆角统一 5px(圆形仅限 icon 按钮/状态点/头像);动效统一 400ms + ease; * 的层级体系、蓝色 accent、克制语义色。亮暗两套完整 token,切换 = 一次重绘。
* 平面层级:surface 之间用 hairline 边框分层,仅浮层(modal/popover/审批卡)允许一层中性软阴影。 * 圆角体系:chip 6 / control 8 / card 10 / window 14 / pill 999。
* 层级语义:--accent 负责焦点环/选区/选中态/装饰 tint;--accent-ink(加深版)负责 * 动效:强缓动曲线(--ease-out-strong 等)+ pill 控件 150ms ease-out。 */
* 链接与可交互文本(保证 AA 对比度);--ink-3 是弱化文本的 AA 安全灰。 */
@import "shadow-plugin/unprefixed"; @import "shadow-plugin/unprefixed";
@custom-variant dark (&:where(.dark, .dark *)); @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 { :root {
/* motion — 规范统一值 */ /* motion — pill 控件/悬停反馈走 150ms ease-out;大位移动效用强曲线变量 */
--duration-fast: 400ms; --duration-fast: 150ms;
--duration-base: 400ms; --duration-base: 220ms;
--duration-slow: 400ms; --duration-slow: 360ms;
--ease-standard: ease; --ease-standard: ease-out;
/* surfaces — 中性灰阶,白底为主 */ /* surfaces — cool, blue-tinted neutrals */
--page: #ffffff; --page: oklch(0.985 0.001 286.376);
--canvas: #ffffff; --canvas: oklch(0.961 0.002 247.84);
--surface: #ffffff; --surface: oklch(1 0 0);
--inset: #f4f4f5; --inset: oklch(0.979 0.002 247.839);
--hover: #f0f0f1; --hover: oklch(0.97 0.002 247.839);
--hover-2: #e6e6e8; --hover-2: oklch(0.933 0.003 247.86);
/* ink ramp — 正文近黑(规范 #000 的 a11y 修正版),弱化层为 AA 安全灰 */ /* ink ramp */
--ink: #0d0d0d; --ink: oklch(0.247 0.006 258.361);
--ink-2: #5d5d5d; --ink-2: oklch(0.506 0.01 264.477);
--ink-3: #75757f; --ink-3: oklch(0.695 0.009 264.505);
/* borders — 实色 hairline,不用 alpha */ /* borders — solid and crisp, not alpha */
--line: #ececec; --line: oklch(0.946 0.003 264.542);
--line-strong: #dcdce0; --line-strong: oklch(0.912 0.005 258.326);
--line-soft: #f4f4f5; --line-soft: oklch(0.966 0.002 264.542);
--grid-line: color-mix(in srgb, var(--line) 78%, transparent); --grid-line: color-mix(in srgb, var(--line) 78%, transparent);
--field: #f4f4f5; --field: oklch(0.961 0.001 286.375);
--stripe: rgba(13, 13, 13, 0.04); --stripe: oklch(0.405 0 0 / 0.075);
--stripe-bg: #f7f7f8; --stripe-bg: oklch(0.97 0 0);
/* accent — 规范主色 #8e8ea0 及其加深可交互版 */ /* accent */
--accent: #8e8ea0; --accent: oklch(0.626 0.205 254.947);
--accent-ink: #707084; --accent-ink: oklch(0.556 0.187 255.617);
--accent-tint: rgba(142, 142, 160, 0.12); --accent-tint: oklch(0.96 0.019 252.878);
/* semantic — 唯一允许的彩色例外(状态语义),已降饱和融入中性界面 */ /* semantic */
--green: #2e7d5b; --green: oklch(0.603 0.155 150.883);
--green-tint: #e9f3ee; --green-tint: oklch(0.958 0.017 159.118);
--orange: #b45309; --orange: oklch(0.689 0.179 49.902);
--orange-tint: #faf1e3; --orange-tint: oklch(0.964 0.021 67.581);
--red: #b91c1c; --red: oklch(0.621 0.192 23.042);
--red-tint: #fbecec; --red-tint: oklch(0.956 0.017 17.462);
/* chart overlays */ /* chart overlays — neutral system colors with enough contrast on either theme */
--tooltip-bg: #2a2a2e; --tooltip-bg: oklch(0.272 0.008 264.435);
--tooltip-fg: #f7f7f8; --tooltip-fg: oklch(0.976 0.002 247.839);
--tooltip-muted: #a5a5af; --tooltip-muted: oklch(0.731 0.008 260.731);
--tooltip-border: #454549; --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-hairline: 0 0 0 1px var(--line);
--shadow-btn: 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); --shadow-card: 0 0 0 1px var(--line), var(--shadow-sm);
--shadow-raised: 0 0 0 1px var(--line-strong); --shadow-raised: 0 0 0 1px var(--line), var(--shadow-md);
--shadow-overlay: 0 0 0 1px var(--line-strong), 0 12px 32px rgba(13, 13, 13, 0.08); --shadow-overlay: 0 0 0 1px var(--line), var(--shadow-lg);
--shadow-inset-field: inset 0 0 0 1px var(--line-strong); --shadow-inset-field: inset 0 1px 2px oklch(0 0 0 / 0.12);
} }
.dark { .dark {
--page: #212121; --page: oklch(0.209 0.004 264.477);
--canvas: #212121; --canvas: oklch(0.231 0.004 264.487);
--surface: #2f2f2f; --surface: oklch(0.26 0.006 271.191);
--inset: #181818; --inset: oklch(0.243 0.004 264.492);
--hover: #2f2f2f; --hover: oklch(0.289 0.006 271.22);
--hover-2: #424242; --hover-2: oklch(0.318 0.007 274.747);
--ink: #ececec; --ink: oklch(0.964 0.002 247.839);
--ink-2: #b4b4b4; --ink-2: oklch(0.731 0.008 260.731);
--ink-3: #8e8ea0; --ink-3: oklch(0.541 0.01 264.484);
--line: #383838; --line: oklch(0.308 0.006 258.354);
--line-strong: #4a4a4a; --line-strong: oklch(0.356 0.007 264.474);
--line-soft: #2a2a2a; --line-soft: oklch(0.278 0.006 258.354);
--field: #2f2f2f; --field: oklch(0.293 0.006 271.223);
--stripe: rgba(255, 255, 255, 0.05); --stripe: oklch(1 0 0 / 0.055);
--stripe-bg: #262626; --stripe-bg: oklch(0.226 0.004 264.485);
--accent: #a3a3b5; --accent: oklch(0.68 0.173 253.301);
--accent-ink: #bcbccc; --accent-ink: oklch(0.788 0.113 248.33);
--accent-tint: rgba(142, 142, 160, 0.18); --accent-tint: oklch(0.68 0.173 253.301 / 0.16);
--green: #6fbf95; --green: oklch(0.705 0.154 153.814);
--green-tint: rgba(111, 191, 149, 0.15); --green-tint: oklch(0.705 0.154 153.814 / 0.14);
--orange: #d19a55; --orange: oklch(0.746 0.156 55.642);
--orange-tint: rgba(209, 154, 85, 0.15); --orange-tint: oklch(0.746 0.156 55.642 / 0.14);
--red: #e07a70; --red: oklch(0.666 0.18 21.433);
--red-tint: rgba(224, 122, 112, 0.15); --red-tint: oklch(0.666 0.18 21.433 / 0.14);
--tooltip-bg: #171717; --tooltip-bg: oklch(0.182 0.004 264.459);
--tooltip-fg: #ececec; --tooltip-fg: oklch(0.964 0.002 247.839);
--tooltip-muted: #8e8ea0; --tooltip-muted: oklch(0.731 0.008 260.731);
--tooltip-border: #383838; --tooltip-border: oklch(0.308 0.006 258.354);
--shadow-hairline: 0 0 0 1px var(--line); --shadow-hairline: 0 0 0 1px var(--line);
--shadow-btn: 0 0 0 1px var(--line-strong); /* Elevation rings go to a subtle *light* hairline in dark mode: the ring
--shadow-card: 0 0 0 1px var(--line); * paints over the surface behind the element, so a low-alpha white edge
--shadow-raised: 0 0 0 1px var(--line-strong); * dissolves into the shadow as one continuous stroke (per smooth-shadow-ring),
--shadow-overlay: 0 0 0 1px var(--line-strong), 0 16px 40px rgba(0, 0, 0, 0.45); * graduated by how far the surface floats. */
--shadow-inset-field: inset 0 0 0 1px var(--line-strong); --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 { @theme inline {
@@ -146,20 +158,20 @@
/* type 沿用 globals.css 中的 --font-sans / --font-mono,不覆盖 */ /* type 沿用 globals.css 中的 --font-sans / --font-mono,不覆盖 */
/* radii — 规范统一 5px;rounded-full 仅限 icon 圆钮/状态点/头像 */ /* radii — chips 6, controls 8, cards 10, windows 14, pills 999 */
--radius-chip: 5px; --radius-chip: 6px;
--radius-control: 5px; --radius-control: 8px;
--radius-card: 5px; --radius-card: 10px;
--radius-window: 5px; --radius-window: 14px;
/* easing — 规范统一 ease;保留旧变量名以兼容 primitives */ /* easing — strong curves; built-ins are too weak */
--ease-out-strong: ease; --ease-out-strong: cubic-bezier(0.23, 1, 0.32, 1);
--ease-in-out-strong: ease; --ease-in-out-strong: cubic-bezier(0.77, 0, 0.175, 1);
--ease-link: ease; --ease-link: cubic-bezier(0.16, 1, 0.3, 1);
/* motion — Tailwind transition-* 工具类的全局默认:400ms + ease */ /* motion — pill 控件/悬停反馈的全局默认:150ms ease-out(原版手感) */
--default-transition-duration: 400ms; --default-transition-duration: 150ms;
--default-transition-timing-function: ease; --default-transition-timing-function: ease-out;
} }
/* ── base ─────────────────────────────────────────────── */ /* ── base ─────────────────────────────────────────────── */
@@ -170,6 +182,43 @@ html {
text-rendering: optimizeLegibility; 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 /* Tailwind v4 resets buttons to cursor:default — anything
* clickable must invite the click */ * clickable must invite the click */
button:not(:disabled), button:not(:disabled),
@@ -207,7 +256,7 @@ select:focus-visible,
background: currentColor; background: currentColor;
transform: scaleX(0); transform: scaleX(0);
transform-origin: left center; transform-origin: left center;
transition: transform var(--duration-base) var(--ease-standard); transition: transform 280ms var(--ease-link);
} }
a:hover .animated-underline::after, a:hover .animated-underline::after,
@@ -216,23 +265,24 @@ a:focus-visible .animated-underline::after {
} }
/* ── primitive spacing system ───────────────────────────── /* ── primitive spacing system ─────────────────────────────
* 8px 网格上的共享间距。 * Shared optical offsets for small cards. Use these
* instead of hand-tuning 10/12/14px per component.
*/ */
.primitive-card-pad { .primitive-card-pad {
padding: 8px; padding: 12px;
} }
.primitive-card-bar { .primitive-card-bar {
padding: 8px; padding: 10px 12px;
} }
.primitive-card-footer { .primitive-card-footer {
padding: 8px; padding: 10px;
} }
.primitive-table-cell { .primitive-table-cell {
padding: 8px; padding: 10px 12px;
} }
.primitive-icon-button { .primitive-icon-button {
@@ -244,83 +294,6 @@ a:focus-visible .animated-underline::after {
border-radius: var(--radius-control); 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 ─────────────────────────────────── */ /* ── shared keyframes ─────────────────────────────────── */
@keyframes shimmer-text { @keyframes shimmer-text {
@@ -391,3 +364,61 @@ a:focus-visible .animated-underline::after {
transition-duration: 0.01ms !important; 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-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 { *::-webkit-scrollbar {
@@ -24,21 +24,19 @@ body,
} }
*::-webkit-scrollbar-thumb { *::-webkit-scrollbar-thumb {
background: rgba(142, 142, 160, 0.35); background: color-mix(in srgb, var(--ink-3) 45%, transparent);
border-radius: 4px; border-radius: 4px;
} }
*::-webkit-scrollbar-thumb:hover { *::-webkit-scrollbar-thumb:hover {
background: rgba(142, 142, 160, 0.55); background: color-mix(in srgb, var(--ink-3) 70%, transparent);
} }
*::-webkit-scrollbar-track { *::-webkit-scrollbar-track {
background: transparent; background: transparent;
} }
::selection { /* ::selection 在 beautifului.css 基座(accent-tint + ink) */
background: rgba(142, 142, 160, 0.28);
}
/* 流式输出光标 */ /* 流式输出光标 */
@keyframes caret-blink { @keyframes caret-blink {
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"
}
]
}
+643
View File
@@ -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;
}
}