feat(web): 设计规范与 uhd_web 同步(截至 2026-10-08)

- 材质统一边光:slab / float / pill / pill-selected / seg-thumb / chrome-side 改为左上亮右下暗的 1px 边光 + 外侧暗线;新增 lens / field / chip 参数与 pg-float-modal
- 主按钮:转动光谱环改为静态左上光谱高光,悬停沿边扫过一小段;次按钮同为 lens 玻璃
- Input / Textarea / SelectTrigger 挂 .field-glass,原生下拉与实体选择器同步;对话框按钮行 gap-3
- 提示条改 pg-float(状态色只在图标、展开不叠放);新增 GlassTooltip 接管 title 提示
- 触屏判断 input-modality.ts、touch / hover 自定义变体;animate-pg-pop 改 backwards;scroll-fade-end
- 控制台外壳:侧栏无硬分隔线、折叠开关进标志行;顶栏去磨砂,右侧控件收进玻璃胶囊
- 登录页居中单卡 auth-card;详情头图「订阅」改回普通主按钮,删除 pill-solid;发现页海报角标改 glass-chip-media
- 引入 simple-liquid-glass(liquid-surface.tsx);更新 docs/design 三份规范与 CHANGELOG

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-08 02:29:19 +08:00
1 parent d842bb7fb4
commit 09a2adb637
38 files changed
+941 -356

No files matched your search

@@ -17,7 +17,7 @@ import {
import { Input } from "~/components/ui/input.tsx";
import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover.tsx";
import { errorMessage, LOGIN_PATH } from "~/lib/api.ts";
import { formatDateTime } from "~/lib/utils.ts";
import { cn, formatDateTime } from "~/lib/utils.ts";
import { FormField, zodFieldErrors } from "./form-field.tsx";
const PasswordForm = PasswordChangeRequest.extend({ confirm: z.string() }).refine((v) => v.newPassword === v.confirm, {
@@ -107,7 +107,8 @@ function ChangePasswordDialog({ open, onOpenChange }: { open: boolean; onOpenCha
}
/** 右上角账户弹层:当前身份、会话到期、改密、二次验证、登出 */
export function AccountMenu({ session }: { session: SessionInfo }) {
/** bare:放在顶栏的玻璃胶囊里时触发器不自带玻璃 */
export function AccountMenu({ session, bare = false }: { session: SessionInfo; bare?: boolean }) {
const [open, setOpen] = useState(false);
const [pwdOpen, setPwdOpen] = useState(false);
const navigate = useNavigate();
@@ -128,7 +129,15 @@ export function AccountMenu({ session }: { session: SessionInfo }) {
<>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="max-w-40" aria-label="账户">
<Button
variant={bare ? "ghost" : "outline"}
size="sm"
className={cn(
"max-w-40",
bare && "data-[state=open]:bg-foreground/[0.08] data-[state=open]:text-foreground",
)}
aria-label="账户"
>
<UserRound />
<span className="hidden truncate sm:inline">{actor?.name ?? "账户"}</span>
</Button>
@@ -71,7 +71,8 @@ function Group({
);
}
export function InboxBell() {
/** bare:放在顶栏的玻璃胶囊里时触发器不自带玻璃 */
export function InboxBell({ bare = false }: { bare?: boolean }) {
const q = useAttention();
const [open, setOpen] = useState(false);
const navigate = useNavigate();
@@ -83,7 +84,13 @@ export function InboxBell() {
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="outline" size="icon-sm" className="relative" aria-label={label} title={label}>
<Button
variant={bare ? "ghost" : "outline"}
size="icon-sm"
className={cn("relative", bare && "data-[state=open]:bg-foreground/[0.08] data-[state=open]:text-foreground")}
aria-label={label}
title={label}
>
<Bell className="h-4 w-4" />
{total ? (
<span className="absolute -top-0.5 -right-0.5 min-w-4 rounded-full bg-primary px-1 text-center text-micro leading-4 font-semibold text-primary-foreground tabular-nums">
@@ -14,10 +14,13 @@ const TONE: Record<StatusTone, { box: string; dot: string }> = {
/** 状态徽章:文字必填,不只靠颜色传达状态 */
export function StatusBadge({
tone = "neutral",
onMedia = false,
children,
className,
}: {
tone?: StatusTone;
/** 压在海报 / 剧照上:换成烟色小玻璃(glass-chip-media)白字,状态色只留在圆点上 */
onMedia?: boolean;
children: ReactNode;
className?: string;
}) {
@@ -25,12 +28,15 @@ export function StatusBadge({
return (
<span
className={cn(
"inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-meta font-medium whitespace-nowrap",
t.box,
"inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-meta font-medium whitespace-nowrap",
onMedia ? "glass-chip-media" : ["border", t.box],
className,
)}
>
<span aria-hidden className={cn("h-1.5 w-1.5 rounded-full", t.dot)} />
<span
aria-hidden
className={cn("h-1.5 w-1.5 rounded-full", onMedia && tone === "neutral" ? "bg-current opacity-70" : t.dot)}
/>
{children}
</span>
);
@@ -54,7 +54,7 @@ export function DiscoverCard({
</span>
)}
{item.rating ? (
<span className="absolute top-1.5 left-1.5 inline-flex items-center gap-0.5 rounded-full bg-background/80 px-1.5 py-0.5 text-micro font-medium text-foreground backdrop-blur">
<span className="glass-chip-media absolute top-1.5 left-1.5 inline-flex items-center gap-0.5 px-1.5 py-0.5 text-micro font-medium">
<Star className="h-3 w-3" aria-hidden />
{item.rating.toFixed(1)}
</span>
@@ -62,7 +62,7 @@ export function DiscoverCard({
{badges.length ? (
<span className="absolute inset-x-1.5 bottom-1.5 flex flex-wrap gap-1">
{badges.map((b) => (
<StatusBadge key={b.label} tone={b.tone} className="bg-background/85 backdrop-blur">
<StatusBadge key={b.label} tone={b.tone} onMedia>
{b.label}
</StatusBadge>
))}
+1 -1
View File
@@ -616,7 +616,7 @@ function HoverOps({
return (
<span
className={cn(
"pointer-events-none absolute top-1/2 right-9 flex -translate-y-1/2 items-center gap-0.5 rounded-full bg-background/95 p-0.5 opacity-0 backdrop-blur shadow-sm ring-1 ring-border transition-opacity duration-micro",
"pg-float pointer-events-none absolute top-1/2 right-9 flex -translate-y-1/2 items-center gap-0.5 rounded-full p-0.5 opacity-0 transition-opacity duration-micro",
"group-hover/row:pointer-events-auto group-hover/row:opacity-100 focus-within:pointer-events-auto focus-within:opacity-100",
active && "group-focus-visible/grid:pointer-events-auto group-focus-visible/grid:opacity-100",
)}
+126
View File
@@ -0,0 +1,126 @@
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { getInputMode } from "~/lib/input-modality.ts";
/**
* 全站悬停提示:把浏览器原生的 title 提示换成站里的玻璃气泡(pg-float),样式与其他悬浮层一致。
* 在根组件挂一次,所有带 title 的元素自动生效,调用处照旧写 title。
*
* 做法:鼠标停在带 title 的元素上 500ms 后显示;显示期间把 title 暂存到 data-glass-title,
* 避免原生提示同时弹出,离开时还原。只处理鼠标(触屏没有悬停)。
* 播放器全屏时 body 上的节点看不见,所以气泡挂在 document.fullscreenElement 里。
*/
const SHOW_DELAY = 500;
const GAP = 8;
const EDGE = 8;
type Tip = { text: string; x: number; y: number; below: boolean };
export function GlassTooltip() {
const [tip, setTip] = useState<Tip | null>(null);
const targetRef = useRef<HTMLElement | null>(null);
const timerRef = useRef<number | undefined>(undefined);
const bubbleRef = useRef<HTMLDivElement>(null);
// 按下过的元素:直到鼠标离开它都不再弹提示(点完按钮不该冒出气泡,尤其是点击后提示文字变了的切换类按钮)
const pressedRef = useRef<HTMLElement | null>(null);
useEffect(() => {
const restore = () => {
const el = targetRef.current;
if (el?.dataset.glassTitle !== undefined) {
// 显示期间组件自己换了提示文字(如 暂停 ↔ 播放)时,React 已经写回了新的 title,不能用旧文字覆盖
if (!el.hasAttribute("title")) el.setAttribute("title", el.dataset.glassTitle);
delete el.dataset.glassTitle;
}
targetRef.current = null;
};
const hide = () => {
window.clearTimeout(timerRef.current);
restore();
setTip(null);
};
const onOver = (event: PointerEvent) => {
// 只给鼠标:车机这类触屏点按时可能报成 mouse 指针,按全站的触屏判断再拦一次
if (event.pointerType !== "mouse" || getInputMode() === "touch") return;
const el = (event.target as Element | null)?.closest<HTMLElement>("[title], [data-glass-title]");
if (!el || el === targetRef.current || el === pressedRef.current) return;
hide();
const text = el.getAttribute("title")?.trim();
if (!text) return;
targetRef.current = el;
el.dataset.glassTitle = text;
el.removeAttribute("title");
timerRef.current = window.setTimeout(() => {
if (targetRef.current !== el || !el.isConnected) return;
const r = el.getBoundingClientRect();
// 元素在一块液态玻璃里(底栏、顶栏胶囊)时,上下以那块玻璃的边为准,气泡不压在玻璃上
const body = el.parentElement?.closest<HTMLElement>("[data-liquid-glass]")?.getBoundingClientRect();
const top = body?.top ?? r.top;
const bottom = body?.bottom ?? r.bottom;
const below = top < 48;
setTip({ text, x: r.left + r.width / 2, y: below ? bottom + GAP : top - GAP, below });
}, SHOW_DELAY);
};
const onOut = (event: PointerEvent) => {
const next = event.relatedTarget as Node | null;
const pressed = pressedRef.current;
if (pressed && !(next && pressed.contains(next))) pressedRef.current = null;
const el = targetRef.current;
if (!el) return;
if (next && el.contains(next)) return;
hide();
};
const onDown = (event: PointerEvent) => {
pressedRef.current =
(event.target as Element | null)?.closest<HTMLElement>("[title], [data-glass-title]") ?? null;
hide();
};
document.addEventListener("pointerover", onOver, true);
document.addEventListener("pointerout", onOut, true);
document.addEventListener("pointerdown", onDown, true);
document.addEventListener("keydown", hide, true);
window.addEventListener("scroll", hide, true);
window.addEventListener("blur", hide);
return () => {
hide();
document.removeEventListener("pointerover", onOver, true);
document.removeEventListener("pointerout", onOut, true);
document.removeEventListener("pointerdown", onDown, true);
document.removeEventListener("keydown", hide, true);
window.removeEventListener("scroll", hide, true);
window.removeEventListener("blur", hide);
};
}, []);
// 量出气泡宽度后夹在视口内,不贴边出屏
useEffect(() => {
const node = bubbleRef.current;
if (!tip || !node) return;
const half = node.offsetWidth / 2;
const x = Math.min(Math.max(tip.x, half + EDGE), window.innerWidth - half - EDGE);
node.style.left = `${x}px`;
}, [tip]);
if (!tip || typeof document === "undefined") return null;
return createPortal(
// 外层只管定位(transform 居中),内层放材质与弹出动画:动画的 transform 会盖掉同一元素上的定位
<div
ref={bubbleRef}
role="tooltip"
className="pointer-events-none fixed z-[300] w-max max-w-xs"
style={{
left: tip.x,
top: tip.y,
transform: tip.below ? "translateX(-50%)" : "translate(-50%, -100%)",
}}
>
<div className="pg-float rounded-xl px-2.5 py-1.5 text-caption text-popover-foreground animate-pg-pop">
{tip.text}
</div>
</div>,
document.fullscreenElement ?? document.body,
);
}
+137
View File
@@ -0,0 +1,137 @@
import { useTheme } from "next-themes";
import { LiquidGlass, type LiquidGlassProps } from "simple-liquid-glass";
import { useTouchInput } from "~/lib/input-modality.ts";
import { cn } from "~/lib/utils.ts";
/**
* 液态玻璃面:simple-liquid-glass 的薄封装,材质按 meniscus 的尺寸分档。
* - lens:小控件(图标键、头像),几乎不糊,只在边带折射;
* - regular:栏(顶栏胶囊、播放器底栏);
* - frost:大块、字多的悬浮层(菜单)。只有真模糊,不做折射(见下方 effectMode)。
* 折射只有 Chromium 桌面有(SVG 位移进 backdrop-filter);Safari / Firefox / 触屏设备走包自带的磨砂。
* 只放在压着图片、视频、海报的层上;背后没细节的内容卡继续用 pg-slab。
*/
export type LiquidTier = "lens" | "regular" | "frost";
const TIERS: Record<
LiquidTier,
{
blur: number;
displacement: number;
fill: { dark: string; light: string };
}
> = {
lens: { blur: 1, displacement: 6, fill: { dark: "rgba(0,0,0,0.14)", light: "rgba(255,255,255,0.08)" } },
regular: { blur: 5, displacement: 10, fill: { dark: "rgba(10,11,14,0.30)", light: "rgba(255,255,255,0.16)" } },
frost: { blur: 20, displacement: 8, fill: { dark: "rgba(14,15,19,0.58)", light: "rgba(255,255,255,0.5)" } },
};
const FROST_BLUR = "blur(24px) saturate(160%)";
// 模糊之上再压一层底色,浅色字(次要说明、退出登录)压在暖色画面上也读得清
const FROST_FILL = { dark: "rgba(14,15,19,0.55)", light: "rgba(255,255,255,0.6)" };
interface Props extends Omit<LiquidGlassProps, "radius"> {
tier?: LiquidTier;
radius: number;
/** 播放器等恒暗区域固定用夜间参数 */
forceDark?: boolean;
/** 不画包自带的边光(主按钮的棱镜高光就是它的边光) */
rimless?: boolean;
/** 折射边缘带色散:背后画面在棱边被拆成彩色(只在 Chromium 桌面、背后有画面时看得到) */
dispersive?: boolean;
/** 只要玻璃外观与模糊,不做折射(测速页:实时曲线在重绘,不加 SVG 滤镜开销) */
noRefraction?: boolean;
}
export function LiquidSurface({
tier = "regular",
radius,
forceDark,
rimless,
dispersive,
noRefraction,
className,
style,
children,
...rest
}: Props) {
const { resolvedTheme } = useTheme();
const dark = forceDark || resolvedTheme !== "light";
// 触屏设备(手机、平板、车机)不跑 SVG 折射,退回同参数的磨砂:车机这类 Linux 触屏会被包当成桌面
const touch = useTouchInput();
// 不折射、只模糊的情况:包在这个模式下不铺 glassColor,还会在顶部画一道 50% 白的光泽(夜间压住第一行字),
// 底色由这里铺,光泽在 app.css 里按档收淡 / 去掉
const blurOnly = touch || noRefraction || tier === "frost";
const t = TIERS[tier];
return (
<LiquidGlass
mode="custom"
refraction="lens"
lensProfile="material"
quality={dispersive ? "high" : "standard"}
radius={radius}
blur={t.blur}
displacementScale={dispersive ? t.displacement + 4 : t.displacement}
saturation={150}
dispersion={50}
aberrationIntensity={dispersive ? 0.4 : 0.06}
frost={0}
// frost 档(字多的悬浮层)不做折射:Chromium 里同一个 backdrop-filter 同时有 blur 和 SVG 位移时,
// 模糊不生效,菜单底下的画面原样透出来、字看不清。frost 只要真模糊,折射留给栏和按钮
effectMode={blurOnly ? "blur" : "auto"}
glassColor={dark ? t.fill.dark : t.fill.light}
borderColor={rimless ? "rgba(255,255,255,0)" : dark ? "rgba(255,255,255,0.22)" : "rgba(255,255,255,0.65)"}
className={cn("liquid-surface", `liquid-surface-${tier}`, blurOnly && "liquid-surface-blur", className)}
style={{
// frost 档(菜单、搜索结果等字多的悬浮层)自己垫一层固定模糊:包退回磨砂时只给 8~11px,
// 菜单压在画面上看不清,这里统一保证 24px
...(tier === "frost"
? {
backdropFilter: FROST_BLUR,
WebkitBackdropFilter: FROST_BLUR,
backgroundColor: dark ? FROST_FILL.dark : FROST_FILL.light,
}
: blurOnly
? { backgroundColor: dark ? t.fill.dark : t.fill.light }
: null),
// 1px 暗边线在亮棱外侧,投影只留贴边一层
boxShadow: dark
? "0 0 0 1px rgba(0,0,0,0.28), 0 8px 24px -14px rgba(0,0,0,0.5)"
: "0 0 0 1px rgba(20,22,28,0.08), 0 8px 24px -16px rgba(20,22,28,0.22)",
...style,
}}
{...rest}
>
{children}
</LiquidGlass>
);
}
/**
* 给压在大图上的按钮加真折射:外面套一层 lens 玻璃,按钮本身挂 .btn-liquid 去掉自己的底和模糊,
* 只留字、图标和主按钮的彩虹环。radius 取按钮高度的一半。
*/
export function LiquidLens({
radius,
primary,
className,
children,
}: {
radius: number;
primary?: boolean;
className?: string;
children: React.ReactNode;
}) {
return (
<LiquidSurface
tier="lens"
radius={radius}
rimless={primary}
dispersive={primary}
className={className}
style={{ width: "auto", height: "auto" }}
>
{children}
</LiquidSurface>
);
}
@@ -70,13 +70,13 @@ describe("MediaHero 头图", () => {
expect(row).toContain("正常");
});
it("操作按钮并进头图区、在外链之后;只有订阅是实心主操作,其余是描边次操作,没有棱镜描边", () => {
it("操作按钮并进头图区、在外链之后;只有订阅是主操作,其余是次操作,没有实心按钮", () => {
const html = render({
facts: facts(),
actions: createElement(
Fragment,
null,
createElement(Button, { variant: "solid", size: "sm" }, "订阅"),
createElement(Button, { size: "sm" }, "订阅"),
createElement(Button, { variant: "outline", size: "sm" }, "搜索资源"),
createElement(Button, { variant: "outline", size: "sm" }, "查看订阅"),
),
@@ -84,10 +84,10 @@ describe("MediaHero 头图", () => {
const actions = slotHtml(html, "media-hero-actions");
expect(actions).not.toBeNull();
expect(html.indexOf('data-slot="media-hero-actions"')).toBeGreaterThan(html.indexOf("TMDB"));
expect(actions!.match(/pill-solid/g)).toHaveLength(1);
expect(actions!.match(/pill-primary/g)).toHaveLength(1);
expect(actions!.match(/pill-secondary/g)).toHaveLength(2);
expect(actions).not.toContain("pill-primary");
expect(actions!.indexOf("pill-solid")).toBeLessThan(actions!.indexOf("订阅"));
expect(actions).not.toContain("pill-solid");
expect(actions!.indexOf("pill-primary")).toBeLessThan(actions!.indexOf("订阅"));
});
it("没有操作时不渲染操作行;标语有值才显示", () => {
@@ -144,8 +144,9 @@ export function EntityPicker({
type="button"
aria-invalid={invalid || undefined}
className={cn(
"flex h-8 w-full min-w-0 items-center gap-1.5 rounded-lg border px-2.5 text-left text-caption",
value ? "border-input" : invalid ? "border-warning/60 text-warning" : "border-input text-muted-foreground",
// 长得像下拉选择:材质用 .field-glass(出错红线由 aria-invalid 给)
"field-glass flex h-8 w-full min-w-0 items-center gap-1.5 rounded-xl px-2.5 text-left text-caption",
!value && (invalid ? "text-warning" : "text-muted-foreground"),
className,
)}
>
@@ -17,7 +17,8 @@ export function SearchTrigger({ className }: { className?: string }) {
aria-label="搜索"
aria-keyshortcuts="Control+K Meta+K"
className={cn(
"hidden h-8 w-56 cursor-pointer items-center gap-2 rounded-full border border-border/80 bg-foreground/[0.03] px-3 text-caption text-muted-foreground transition-colors duration-micro outline-none hover:bg-foreground/[0.06] hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 md:flex lg:w-72",
// 长得像输入框,材质与输入框同一套(.field-glass)
"field-glass hidden h-8 w-56 cursor-pointer items-center gap-2 rounded-full px-3 text-caption text-muted-foreground outline-none hover:text-foreground md:flex lg:w-72",
className,
)}
>
@@ -25,7 +25,8 @@ import { Textarea } from "~/components/ui/textarea.tsx";
import { formatDateTime } from "~/lib/utils.ts";
import { SaveBar, SettingsBlock } from "./settings-block.tsx";
const selectClass = "w-full rounded-lg border border-border bg-background px-3 py-2 text-caption";
// 原生下拉:材质与输入框同一套(.field-glass),这里只给尺寸
const selectClass = "field-glass w-full rounded-xl px-3 py-2 text-caption";
function Field({ label, children }: { label: string; children: ReactElement<{ id?: string }> }) {
const id = useId();
return (
@@ -85,7 +85,7 @@ export function SettingsSearch({ className }: { className?: string }) {
id={listId}
role="listbox"
aria-label="搜索结果"
className="pg-float absolute inset-x-0 top-full z-30 mt-1.5 max-h-80 animate-pg-pop overflow-y-auto rounded-xl p-1"
className="pg-float absolute inset-x-0 top-full z-30 mt-1.5 max-h-80 animate-pg-pop overflow-y-auto rounded-2xl p-1"
>
{hits.length === 0 ? (
<p className="px-3 py-2 text-caption text-muted-foreground">没有匹配的设置</p>
@@ -81,9 +81,11 @@ export function HistoryChart({
<Tooltip
labelFormatter={(v) => new Date(String(v)).toLocaleString("zh-CN", { hour12: false })}
formatter={(v) => [`${Math.round(Number(v) * 10) / 10} ${unit}`]}
// 悬浮层材质与 pg-float 同一组参数
contentStyle={{
background: "var(--color-popover)",
border: "1px solid var(--color-border)",
background: "var(--mat-float-bg)",
border: "1px solid var(--mat-float-border)",
boxShadow: "var(--mat-float-shadow)",
borderRadius: 12,
fontSize: 12,
color: "var(--color-foreground)",
@@ -65,9 +65,11 @@ export function Sparkline({
<Tooltip
labelFormatter={(_, p) => (p?.[0]?.payload?.at ? clock(String(p[0].payload.at)) : "")}
formatter={(v, name) => [format(Number(v)), String(name)]}
// 悬浮层材质与 pg-float 同一组参数
contentStyle={{
background: "var(--color-popover)",
border: "1px solid var(--color-border)",
background: "var(--mat-float-bg)",
border: "1px solid var(--mat-float-border)",
boxShadow: "var(--mat-float-shadow)",
borderRadius: 12,
fontSize: 12,
color: "var(--color-foreground)",
+3 -2
View File
@@ -6,7 +6,8 @@ import { flushSync } from "react-dom";
import { Button } from "~/components/ui/button.tsx";
import { cn } from "~/lib/utils.ts";
export function ThemeToggle({ className }: { className?: string }) {
/** bare:放在顶栏的玻璃胶囊里时不自带玻璃,只留图标与悬停淡底 */
export function ThemeToggle({ className, bare = false }: { className?: string; bare?: boolean }) {
const [mounted, setMounted] = useState(false);
const { resolvedTheme, setTheme } = useTheme();
@@ -41,7 +42,7 @@ export function ThemeToggle({ className }: { className?: string }) {
return (
<Button
type="button"
variant="outline"
variant={bare ? "ghost" : "outline"}
size="icon-sm"
aria-label={isDark ? "切换到亮色主题" : "切换到暗色主题"}
title={isDark ? "切换到亮色主题" : "切换到暗色主题"}
+2 -2
View File
@@ -42,7 +42,7 @@ function AlertDialogContent({
data-slot="alert-dialog-content"
data-size={size}
className={cn(
"group/alert-dialog-content fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-3xl pg-float p-6 duration-base data-[size=sm]:max-w-xs data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[size=default]:sm:max-w-lg",
"group/alert-dialog-content fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-3xl pg-float pg-float-modal p-6 duration-base data-[size=sm]:max-w-xs data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[size=default]:sm:max-w-lg",
className,
)}
{...props}
@@ -69,7 +69,7 @@ function AlertDialogFooter({ className, ...props }: React.ComponentProps<"div">)
<div
data-slot="alert-dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
"flex flex-col-reverse gap-3 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
className,
)}
{...props}
+2 -4
View File
@@ -12,11 +12,9 @@ const buttonVariants = cva(
{
variants: {
variant: {
// 主操作:半透明胶囊 + 沿边框流转的棱镜光谱。样式见 app.css 的 .pill-primary(--mat-pill-primary-*、--prism-*)
// 主操作:lens 玻璃胶囊 + 左上棱边的光谱高光(静止,悬停沿边扫过一小段)。样式见 app.css 的 .pill-primary(--mat-lens-*、--prism-*)
default: "pill-primary",
// 实心主操作:只给详情页头图区的唯一主操作(订阅)用,见 app.css 的 .pill-solid(--mat-pill-solid-*)
solid: "pill-solid",
// 次操作:描边胶囊。outline 与 secondary 是同一个样子,保留两个名字只为兼容旧调用
// 次操作:lens 玻璃胶囊(边光 + 外侧细线)。outline 与 secondary 是同一个样子,保留两个名字只为兼容旧调用
outline: "pill-secondary",
secondary: "pill-secondary",
// 危险操作:同一胶囊,淡红底红字
+6 -2
View File
@@ -47,7 +47,7 @@ function DialogContent({
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-3xl pg-float p-6 duration-base outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
"fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-3xl pg-float pg-float-modal p-6 duration-base outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
className,
)}
{...props}
@@ -88,7 +88,11 @@ function DialogFooter({
return (
<div
data-slot="dialog-footer"
className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
className={cn(
// 12px:按钮带外侧细线与光谱高光,8px 会挤在一起
"flex flex-col-reverse gap-3 sm:flex-row sm:justify-end",
className,
)}
{...props}
>
{children}
+2 -3
View File
@@ -8,9 +8,8 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
// 材质由 .field-glass 统一给(底、边、聚焦光晕、出错红线),调用处只给尺寸与内边距
"field-glass file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground h-9 w-full min-w-0 rounded-xl px-3 py-1 text-base outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className,
)}
{...props}
+2 -1
View File
@@ -29,7 +29,8 @@ function SelectTrigger({
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
// 材质由 .field-glass 统一给(底、边、聚焦 / 展开光晕、出错红线),调用处只给尺寸与内边距
"field-glass flex w-fit items-center justify-between gap-2 rounded-xl px-3 py-2 text-sm whitespace-nowrap outline-none disabled:cursor-not-allowed disabled:opacity-50 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className,
)}
{...props}
+2 -2
View File
@@ -49,7 +49,7 @@ function SheetContent({
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"pg-float data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-layout data-[state=open]:duration-slow",
"pg-float pg-float-modal data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 transition ease-in-out data-[state=closed]:duration-layout data-[state=open]:duration-slow",
side === "right" &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
side === "left" &&
@@ -79,7 +79,7 @@ function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="sheet-footer" className={cn("mt-auto flex flex-col gap-2 p-4", className)} {...props} />;
return <div data-slot="sheet-footer" className={cn("mt-auto flex flex-col gap-3 p-4", className)} {...props} />;
}
function SheetTitle({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Title>) {
+16
View File
@@ -1,6 +1,7 @@
import { CircleCheckIcon, InfoIcon, Loader2Icon, OctagonXIcon, TriangleAlertIcon } from "lucide-react";
import { useTheme } from "next-themes";
import { Toaster as Sonner, type ToasterProps } from "sonner";
import { buttonVariants } from "~/components/ui/button.tsx";
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme();
@@ -26,6 +27,21 @@ const Toaster = ({ ...props }: ToasterProps) => {
} as React.CSSProperties
}
toastOptions={{
// 提示条是 pg-float 玻璃,状态色只在图标上(不开 richColors)
unstyled: true,
classNames: {
toast:
"pg-float rounded-2xl w-full flex items-start gap-2.5 px-3.5 py-2.5 text-caption text-popover-foreground",
title: "font-medium leading-relaxed",
description: "mt-1 text-caption text-muted-foreground",
content: "min-w-0 flex-1",
icon: "mt-0.5 shrink-0 flex items-center justify-center",
error: "[&_[data-icon]]:text-destructive",
warning: "[&_[data-icon]]:text-warning",
success: "[&_[data-icon]]:text-success",
actionButton: buttonVariants({ size: "sm" }),
cancelButton: buttonVariants({ variant: "outline", size: "sm" }),
},
style: { pointerEvents: "auto" },
}}
{...props}
+2 -3
View File
@@ -7,9 +7,8 @@ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
<textarea
data-slot="textarea"
className={cn(
"placeholder:text-muted-foreground dark:bg-input/30 border-input flex min-h-16 w-full min-w-0 rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
// 材质同 Input,由 .field-glass 统一给
"field-glass placeholder:text-muted-foreground flex min-h-16 w-full min-w-0 rounded-xl px-3 py-2 text-base outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className,
)}
{...props}