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

+274 -221
View File
@@ -5,6 +5,23 @@
/* ───────────────────────── Tailwind configuration ───────────────────────── */
@custom-variant dark (&:is(.dark *), &:is([data-force-dark] *));
/* 悬停只给真鼠标:触屏(含被报告成鼠标的车机)点按后不留悬停态。覆盖 Tailwind 内置的 hover,group-hover / peer-hover 一并跟随 */
@custom-variant hover {
@media (hover: hover) {
&:not(:where([data-input="touch"] *)):hover {
@slot;
}
}
}
/* 触屏:系统报告粗指针,或 <html data-input="touch">(特斯拉车机等报告成鼠标的触屏,见 input-modality.ts) */
@custom-variant touch {
@media (pointer: coarse) {
@slot;
}
&:where([data-input="touch"] *) {
@slot;
}
}
@theme {
--font-sans:
@@ -38,7 +55,9 @@
--shadow-card-hover: 0 26px 54px -24px rgba(0, 0, 0, 1);
--animate-fade-in: fade-in 520ms ease-out both;
--animate-pg-pop: pg-pop 200ms var(--ease-pop) both;
/* backwards 而不是 both:动画播完要撤掉。透明度动画一直挂着时,Chromium 把这个元素当成背景根,
里面的玻璃 / 磨砂只能采样到它自己,菜单就不糊了(账户菜单、播放器菜单都踩过) */
--animate-pg-pop: pg-pop 200ms var(--ease-pop) backwards;
/* 播放器加载提示的滚动字幕:新一行从下方滚入,上一行向上滚出 */
--animate-ticker-in: ticker-in 360ms var(--ease-standard) both;
--animate-ticker-out: ticker-out 360ms var(--ease-standard) both;
@@ -79,7 +98,6 @@
--text-notfound-code: clamp(7rem, 18vw, 11rem);
--text-pricing-hero: clamp(2rem, 4.4vw, 4rem);
--text-pricing-title: clamp(1.6rem, 2.4vw, 2.2rem);
--text-auth-title: clamp(2rem, 3.4vw, 3.4rem);
--text-landing-hero: clamp(2.1rem, 4.1vw, 3.9rem);
--text-landing-platforms-title: clamp(1.8rem, 3.4vw, 2.8rem);
--text-landing-immersive-title: clamp(2.25rem, 4.8vw, 5.4rem);
@@ -116,6 +134,12 @@
padding: 0.75rem;
}
/* 横滚标签条右端淡出:手机上分类标签滚到排序按钮跟前不再被硬切,也提示还能往右滑 */
@utility scroll-fade-end {
-webkit-mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent);
mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent);
}
@utility duration-micro { --tw-duration: var(--duration-micro); transition-duration: var(--duration-micro); }
@utility duration-base { --tw-duration: var(--duration-base); transition-duration: var(--duration-base); }
@utility duration-layout { --tw-duration: var(--duration-layout); transition-duration: var(--duration-layout); }
@@ -298,19 +322,21 @@
--mat-surface-highlight: rgba(255, 255, 255, 0.07);
--mat-surface-solid: rgba(12, 13, 17, 0.92);
/* pill:药丸 HUD */
--mat-pill-bg: rgba(16, 17, 23, 0.68);
--mat-pill-bg: rgba(16, 17, 23, 0.58);
--mat-pill-sheen: linear-gradient(180deg, rgba(255, 255, 255, 0.12) 0%, rgba(255, 255, 255, 0.04) 40%, rgba(255, 255, 255, 0) 70%);
--mat-pill-blur: blur(16px) saturate(160%);
--mat-pill-border: rgba(255, 255, 255, 0.16);
--mat-pill-highlight: rgba(255, 255, 255, 0.18);
--mat-pill-shade: rgba(0, 0, 0, 0.25);
--mat-pill-border: rgba(255, 255, 255, 0.06);
--mat-pill-highlight: rgba(255, 255, 255, 0.34);
--mat-pill-edge: rgba(0, 0, 0, 0.4);
--mat-pill-shade: rgba(255, 255, 255, 0.1);
--mat-pill-shadow: 0 2px 6px -2px rgba(0, 0, 0, 0.2), 0 12px 32px -14px rgba(0, 0, 0, 0.42);
--mat-pill-solid: rgba(16, 17, 23, 0.88);
/* slab:门户外层内容卡(浅色值;深色在 .dark 块) */
--mat-slab-bg: linear-gradient(155deg, rgba(255, 255, 255, 0.74) 0%, rgba(255, 255, 255, 0.58) 40%, rgba(255, 255, 255, 0.52) 100%);
--mat-slab-border: rgba(255, 255, 255, 0.7);
--mat-slab-border: rgba(255, 255, 255, 0.3);
--mat-slab-blur: blur(20px) saturate(160%);
--mat-slab-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.85), 0 0 0 1px rgba(20, 22, 28, 0.04), 0 1px 2px rgba(20, 22, 28, 0.05), 0 12px 28px -18px rgba(20, 22, 28, 0.16);
/* 内容卡的边与全站玻璃同一套:左上亮、右下弱的 1px 边光 + 外侧 1px 暗线;面本身不变 */
--mat-slab-shadow: inset 1px 1px 0 -0.25px rgba(255, 255, 255, 0.95), inset -1px -1px 0 -0.25px rgba(255, 255, 255, 0.45), 0 0 0 1px rgba(20, 22, 28, 0.07), 0 1px 2px rgba(20, 22, 28, 0.05), 0 12px 28px -18px rgba(20, 22, 28, 0.16);
/* 满高工作面板(文件管理的目录树 + 主面板)的实底与粘性表头:两块共用,背后的光斑不再让一块比另一块亮 */
--mat-slab-base: color-mix(in oklab, var(--background) 35%, transparent);
--mat-slab-head: color-mix(in oklab, var(--background) 25%, white);
@@ -326,15 +352,19 @@
--mat-chrome-blur: blur(24px) saturate(160%);
--mat-chrome-border: color-mix(in oklch, var(--primary) 11%, transparent);
--mat-chrome-shadow: 0 8px 24px -20px rgba(20, 22, 28, 0.25);
--mat-chrome-side-shadow: 8px 0 24px -20px rgba(20, 22, 28, 0.25);
/* 竖直侧栏的边:内侧细亮边 + 外侧细暗线 + 很淡的投影,与玻璃同一套,不画硬分隔线 */
--mat-chrome-side-shadow: inset -1px 0 0 rgba(255, 255, 255, 0.9), 1px 0 0 rgba(20, 22, 28, 0.06), 10px 0 28px -22px rgba(20, 22, 28, 0.2);
--mat-chrome-solid: rgba(255, 255, 255, 0.95);
/* float:门户小型悬浮面板。SEARCH-4(2026-09-30)起不透明实底 = popover token,不做背景模糊:
半透明弹层压在资源列表上文字看不清(用户反馈) */
--mat-float-bg: var(--popover);
--mat-float-blur: none;
--mat-float-border: rgba(20, 22, 28, 0.1);
--mat-float-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.9), 0 0 0 1px rgba(20, 22, 28, 0.05), 0 2px 6px -2px rgba(20, 22, 28, 0.08), 0 20px 40px -20px rgba(20, 22, 28, 0.2);
--mat-float-border: rgba(255, 255, 255, 0.3);
/* 边与全站玻璃同一套(uhd_web 2026-10-07):左上亮、右下弱的 1px 边光 + 外侧 1px 暗线。底仍是 SEARCH-4 的实底 */
--mat-float-shadow: inset 1px 1px 0 -0.25px rgba(255, 255, 255, 0.95), inset -1px -1px 0 -0.25px rgba(255, 255, 255, 0.5), 0 0 0 1px rgba(20, 22, 28, 0.08), 0 2px 6px -2px rgba(20, 22, 28, 0.08), 0 20px 40px -20px rgba(20, 22, 28, 0.2);
--mat-float-solid: rgb(251, 251, 252);
/* 对话框 / 抽屉的底:uhd_web 的菜单是通透玻璃、对话框才换实底;本项目菜单本来就是实底(SEARCH-4),两者同值,留着这一层供换皮 */
--mat-float-modal-bg: var(--popover);
/* 胶囊按钮:主操作是有填充的薄玻璃,次操作只描边。日夜对称——夜间白色薄玻璃配白字,
日间墨色薄玻璃配深字;不做实心黑 / 实心白 */
--mat-pill-primary-bg: linear-gradient(180deg, rgba(20, 22, 28, 0.02), rgba(20, 22, 28, 0.05));
@@ -344,44 +374,43 @@
/* 主按钮的棱镜环(方案 A):亮度、彩度、环与内光的强度 */
--prism-l: 0.66;
--prism-c: 0.15;
--prism-ring: 0.9;
--prism-glow: 0.16;
--prism-ring: 1;
--prism-glow: 0.14;
/* 选中态(分页当前页、角标、选中的选项):静态半透明 + 稍深描边,不转动、不实心 */
--mat-pill-selected-bg: rgba(20, 22, 28, 0.07);
--mat-pill-selected-border: rgba(20, 22, 28, 0.28);
--mat-pill-selected-bg: rgba(20, 22, 28, 0.08);
--mat-pill-selected-border: rgba(20, 22, 28, 0.22);
--mat-pill-secondary-bg: rgba(20, 22, 28, 0.03);
--mat-pill-secondary-bg-hover: rgba(20, 22, 28, 0.07);
--mat-pill-secondary-border: rgba(20, 22, 28, 0.12);
/* pill-solid:详情页头图区的唯一主操作(订阅),主色实心;只用在 MediaHero 的操作行 */
--mat-pill-solid-bg: var(--primary);
--mat-pill-solid-bg-hover: color-mix(in oklab, var(--primary) 84%, var(--background));
--mat-pill-solid-fg: var(--primary-foreground);
--mat-pill-solid-shadow: 0 1px 2px rgba(20, 22, 28, 0.14), 0 6px 16px -8px rgba(20, 22, 28, 0.4);
/* lens:液态玻璃小控件(次按钮、图标键;主按钮只用 edge / sheen)。meniscus 的 lens 档:
本体几乎透明,靠左上亮右下暗的 1px 边光(内阴影)和外侧 1px 暗线成形 */
--mat-lens-fill: rgba(255, 255, 255, 0.08);
--mat-lens-fill-hover: rgba(255, 255, 255, 0.3);
--mat-lens-rim: inset 1px 1px 0 -0.25px rgba(255, 255, 255, 0.95), inset -1px -1px 0 -0.25px rgba(255, 255, 255, 0.55), inset 0 0 0 1px rgba(255, 255, 255, 0.25);
--mat-lens-edge: 0 0 0 1px rgba(20, 22, 28, 0.1);
--mat-lens-sheen: inset 1px 1px 0 -0.5px rgba(255, 255, 255, 0.7);
--mat-lens-shadow: 0 6px 16px -10px rgba(20, 22, 28, 0.28);
--mat-lens-blur: blur(2px) saturate(160%);
/* field:输入框 / 下拉选择的触发器,与按钮同一套边光;底比按钮实一点,保证字和占位符好读 */
/* 输入框是填充型:靠比卡片略深的底色区分,边线按 WCAG 非文本对比 3:1 给(压在白卡片上) */
--mat-field-bg: rgba(20, 22, 28, 0.045);
--mat-field-edge: rgba(20, 22, 28, 0.46);
--mat-field-edge-focus: rgba(20, 22, 28, 0.78);
/* 登录页的表单卡片:实在的底,先把卡片和页面分开 */
--mat-auth-card-bg: rgba(255, 255, 255, 0.84);
--mat-field-ring: rgba(20, 22, 28, 0.06);
--mat-field-highlight: rgba(255, 255, 255, 0.08);
/* 小玻璃圆片的烟色玻璃:白色图标坐在上面。日间烟墨、夜间淡白;压在图片上的恒用 media 档 */
--mat-chip-glass: rgba(20, 22, 28, 0.56);
/* media-hero:详情页头图。剧照铺在内容区顶部、渐隐到页面底色;左侧文字区压近乎不透明的底色,右侧露出剧照。
窄屏海报与文字上下排,改为自上而下压底色 */
--mat-hero-image-opacity: 1;
--mat-hero-scrim-side: linear-gradient(90deg, var(--background) 0%, color-mix(in oklab, var(--background) 96%, transparent) 38%, color-mix(in oklab, var(--background) 82%, transparent) 55%, color-mix(in oklab, var(--background) 40%, transparent) 72%, transparent 92%);
--mat-hero-scrim-stack: linear-gradient(180deg, color-mix(in oklab, var(--background) 30%, transparent) 0%, color-mix(in oklab, var(--background) 72%, transparent) 45%, color-mix(in oklab, var(--background) 92%, transparent) 75%, var(--background) 100%);
/* 分段控件的滑块(侧栏当前项、SegmentedTabs):日间是托起的白片,夜间是薄白玻璃 */
--mat-seg-thumb-bg: rgba(255, 255, 255, 0.8);
--mat-seg-thumb-border: rgba(20, 22, 28, 0.06);
--mat-seg-thumb-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.95), 0 3px 12px -3px rgba(20, 22, 28, 0.18);
/* notice:更新提示胶囊 */
--mat-notice-fg: var(--foreground);
--mat-notice-bg: rgba(255, 255, 255, 0.78);
--mat-notice-blur: blur(24px) saturate(160%);
--mat-notice-border: rgba(255, 255, 255, 0.85);
--mat-notice-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.95), 0 0 0 1px color-mix(in oklch, var(--primary) 12%, transparent), 0 14px 36px -14px rgba(20, 22, 28, 0.35), 0 3px 10px -4px rgba(20, 22, 28, 0.12);
--mat-notice-sub-fg: var(--muted-foreground);
--mat-notice-icon-fg: var(--primary);
--mat-notice-icon-bg: color-mix(in oklch, var(--primary) 12%, transparent);
--mat-notice-ring: 0 0 0 0 color-mix(in oklch, var(--primary) 45%, transparent);
--mat-notice-cta-fg: var(--primary-foreground);
--mat-notice-cta-bg: var(--primary);
--mat-notice-cta-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25), 0 6px 16px -8px color-mix(in oklch, var(--primary) 70%, transparent);
--mat-notice-close-fg: var(--muted-foreground);
--mat-notice-close-hover-fg: var(--foreground);
--mat-notice-close-hover-bg: rgba(0, 0, 0, 0.06);
--mat-seg-thumb-bg: rgba(255, 255, 255, 0.62);
--mat-seg-thumb-border: transparent;
--mat-seg-thumb-shadow: inset 1px 1px 0 -0.25px rgba(255, 255, 255, 1), inset -1px -1px 0 -0.25px rgba(255, 255, 255, 0.6), 0 0 0 1px rgba(20, 22, 28, 0.08), 0 3px 12px -3px rgba(20, 22, 28, 0.18);
/* Scrollbars */
--scrollbar-thumb: color-mix(
@@ -470,8 +499,8 @@
/* 材质参数:深色门户 */
--mat-slab-bg: linear-gradient(155deg, rgba(255, 255, 255, 0.08) 0%, rgba(255, 255, 255, 0.04) 30%, rgba(255, 255, 255, 0.03) 75%, rgba(255, 255, 255, 0.04) 100%);
--mat-slab-border: rgba(255, 255, 255, 0.1);
--mat-slab-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.12), inset 0 -1px 0 rgba(255, 255, 255, 0.03), 0 1px 2px rgba(0, 0, 0, 0.2), 0 12px 28px -18px rgba(0, 0, 0, 0.4);
--mat-slab-border: rgba(255, 255, 255, 0.04);
--mat-slab-shadow: inset 1px 1px 0 -0.25px rgba(255, 255, 255, 0.22), inset -1px -1px 0 -0.25px rgba(255, 255, 255, 0.06), 0 0 0 1px rgba(0, 0, 0, 0.4), 0 1px 2px rgba(0, 0, 0, 0.2), 0 12px 28px -18px rgba(0, 0, 0, 0.4);
--mat-slab-base: color-mix(in oklab, var(--background) 78%, transparent);
--mat-slab-head: color-mix(in oklab, var(--background) 94%, white);
--mat-slab-reflection: linear-gradient(105deg, transparent 0%, rgba(255, 255, 255, 0.006) 36%, rgba(255, 255, 255, 0.013) 44%, rgba(255, 255, 255, 0.003) 51%, transparent 62%);
@@ -483,12 +512,13 @@
--mat-chrome-bg: color-mix(in oklch, var(--card-solid) 48%, transparent);
--mat-chrome-border: color-mix(in oklch, var(--primary) 13%, transparent);
--mat-chrome-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
--mat-chrome-side-shadow: inset -1px 0 0 rgba(255, 255, 255, 0.04);
--mat-chrome-side-shadow: inset -1px 0 0 rgba(255, 255, 255, 0.08), 1px 0 0 rgba(0, 0, 0, 0.45), 10px 0 28px -22px rgba(0, 0, 0, 0.55);
--mat-chrome-solid: rgba(12, 13, 17, 0.92);
--mat-float-bg: linear-gradient(180deg, rgba(255, 255, 255, 0.05) 0%, rgba(255, 255, 255, 0) 40%), var(--popover);
--mat-float-solid: rgb(22, 23, 27);
--mat-float-modal-bg: linear-gradient(180deg, rgba(255, 255, 255, 0.06) 0%, rgba(255, 255, 255, 0) 40%), var(--popover);
--mat-float-blur: none;
--mat-float-border: rgba(255, 255, 255, 0.14);
--mat-float-border: rgba(255, 255, 255, 0.06);
/* 胶囊按钮:夜间主操作是白色玻璃 */
--mat-pill-primary-bg: rgba(255, 255, 255, 0.05);
--mat-pill-primary-bg-hover: rgba(255, 255, 255, 0.09);
@@ -496,28 +526,33 @@
--mat-pill-primary-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.14), 0 1px 2px rgba(0, 0, 0, 0.2);
--prism-l: 0.82;
--prism-c: 0.13;
--prism-ring: 0.85;
--prism-glow: 0.22;
--mat-pill-selected-bg: rgba(255, 255, 255, 0.1);
--mat-pill-selected-border: rgba(255, 255, 255, 0.32);
--prism-ring: 1;
--prism-glow: 0.2;
--mat-pill-selected-bg: rgba(255, 255, 255, 0.12);
--mat-pill-selected-border: rgba(0, 0, 0, 0.45);
--mat-pill-secondary-bg: rgba(255, 255, 255, 0.04);
--mat-pill-secondary-bg-hover: rgba(255, 255, 255, 0.1);
--mat-pill-secondary-border: rgba(255, 255, 255, 0.15);
--mat-pill-solid-bg: var(--primary);
--mat-pill-solid-bg-hover: color-mix(in oklab, var(--primary) 84%, var(--background));
--mat-pill-solid-fg: var(--primary-foreground);
--mat-pill-solid-shadow: 0 1px 2px rgba(0, 0, 0, 0.3), 0 6px 16px -8px rgba(0, 0, 0, 0.6);
--mat-lens-fill: rgba(0, 0, 0, 0.14);
--mat-lens-fill-hover: rgba(255, 255, 255, 0.08);
--mat-lens-rim: inset 1px 1px 0 -0.25px rgba(255, 255, 255, 0.42), inset -1px -1px 0 -0.25px rgba(255, 255, 255, 0.16), inset 0 0 0 1px rgba(255, 255, 255, 0.06);
--mat-lens-edge: 0 0 0 1px rgba(0, 0, 0, 0.35);
--mat-lens-sheen: inset 1px 1px 0 -0.5px rgba(255, 255, 255, 0.28);
--mat-lens-shadow: 0 6px 16px -10px rgba(0, 0, 0, 0.55);
/* 夜间:底比卡片略亮,外线是浅色且对比 3:1(黑线压在深色卡片上看不见) */
--mat-field-bg: rgba(255, 255, 255, 0.075);
--mat-field-edge: rgba(255, 255, 255, 0.36);
--mat-field-edge-focus: rgba(255, 255, 255, 0.78);
--mat-auth-card-bg: rgba(26, 27, 32, 0.74);
--mat-field-ring: rgba(255, 255, 255, 0.06);
--mat-chip-glass: rgba(255, 255, 255, 0.12);
--mat-hero-image-opacity: 0.85;
--mat-hero-scrim-side: linear-gradient(90deg, var(--background) 0%, color-mix(in oklab, var(--background) 94%, transparent) 38%, color-mix(in oklab, var(--background) 78%, transparent) 55%, color-mix(in oklab, var(--background) 36%, transparent) 72%, transparent 92%);
--mat-hero-scrim-stack: linear-gradient(180deg, color-mix(in oklab, var(--background) 30%, transparent) 0%, color-mix(in oklab, var(--background) 72%, transparent) 45%, color-mix(in oklab, var(--background) 92%, transparent) 75%, var(--background) 100%);
--mat-seg-thumb-bg: rgba(255, 255, 255, 0.08);
--mat-seg-thumb-border: rgba(255, 255, 255, 0.15);
--mat-seg-thumb-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.18), 0 4px 18px -6px rgba(0, 0, 0, 0.4);
--mat-float-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.16), inset 0 -1px 0 rgba(255, 255, 255, 0.03), 0 2px 6px -2px rgba(0, 0, 0, 0.22), 0 18px 40px -20px rgba(0, 0, 0, 0.4);
--mat-notice-bg: rgba(255, 255, 255, 0.08);
--mat-notice-border: rgba(255, 255, 255, 0.14);
--mat-notice-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.16), inset 0 -1px 0 rgba(0, 0, 0, 0.25), 0 16px 40px -16px rgba(0, 0, 0, 0.55);
--mat-notice-close-hover-bg: rgba(255, 255, 255, 0.08);
--mat-seg-thumb-border: transparent;
--mat-seg-thumb-shadow: inset 1px 1px 0 -0.25px rgba(255, 255, 255, 0.42), inset -1px -1px 0 -0.25px rgba(255, 255, 255, 0.14), 0 0 0 1px rgba(0, 0, 0, 0.35), 0 4px 18px -6px rgba(0, 0, 0, 0.4);
--mat-float-shadow: inset 1px 1px 0 -0.25px rgba(255, 255, 255, 0.32), inset -1px -1px 0 -0.25px rgba(255, 255, 255, 0.1), 0 0 0 1px rgba(0, 0, 0, 0.45), 0 2px 6px -2px rgba(0, 0, 0, 0.22), 0 18px 40px -20px rgba(0, 0, 0, 0.4);
}
/* ───────────────────────────── Base styles ───────────────────────────── */
@@ -566,14 +601,6 @@
-webkit-tap-highlight-color: transparent;
}
/* 流媒体手机端有 3.5rem 的底部 Tab Bar:全局右下角浮层(更新提示)按这个
变量再垫高,免得压在 Tab Bar 上。播放页不渲染 Tab Bar,不设。 */
@media (max-width: 767px) {
:root:has([data-stream-shell="page"]) {
--uhd-bottom-dock: 3.5rem;
}
}
a {
@apply transition-colors;
}
@@ -801,69 +828,15 @@
backdrop-filter: var(--mat-pill-blur);
-webkit-backdrop-filter: var(--mat-pill-blur);
border: 1px solid var(--mat-pill-border);
/* 液态玻璃语言:左上亮、右下暗的边光 + 外侧 1px 暗线 */
box-shadow:
inset 0 1px 0 var(--mat-pill-highlight),
inset 0 -1px 0 var(--mat-pill-shade),
inset 1px 1px 0 -0.25px var(--mat-pill-highlight),
inset -1px -1px 0 -0.25px var(--mat-pill-shade),
0 0 0 1px var(--mat-pill-edge),
var(--mat-pill-shadow);
}
/* ─── 更新提示胶囊(app/components/update-notice.tsx)──────────────
三处材质各归各:浅色门户 = 白磨砂玻璃 + 蓝调投影(pg-slab 浅色配方的
简化版);深色门户 = 白透玻璃(侧栏药丸同款);流媒体壳 = 同深色配方,
动作钮换金色 —— 那边的强调色是金不是蓝。 */
.update-pill {
color: var(--mat-notice-fg);
background: var(--mat-notice-bg);
backdrop-filter: var(--mat-notice-blur);
-webkit-backdrop-filter: var(--mat-notice-blur);
border: 1px solid var(--mat-notice-border);
box-shadow: var(--mat-notice-shadow);
}
.update-pill__sub {
color: var(--mat-notice-sub-fg);
}
.update-pill__icon {
color: var(--mat-notice-icon-fg);
background: var(--mat-notice-icon-bg);
}
.update-pill__icon--stale {
color: var(--warning);
background: color-mix(in oklch, var(--warning) 14%, transparent);
}
/* 一圈慢呼吸的光环,提醒但不催 */
.update-pill__ring {
box-shadow: var(--mat-notice-ring);
animation: update-pill-ring 2.4s ease-out infinite;
}
.update-pill__cta {
color: var(--mat-notice-cta-fg);
background: var(--mat-notice-cta-bg);
box-shadow: var(--mat-notice-cta-shadow);
}
.update-pill__close {
color: var(--mat-notice-close-fg);
}
.update-pill__close:hover {
color: var(--mat-notice-close-hover-fg);
background: var(--mat-notice-close-hover-bg);
}
@media (prefers-reduced-motion: reduce) {
.update-pill__ring {
animation: none;
}
}
/* 第四档:内容卡片,双主题(门户与 /speed 共用)。薄白/薄亮底 + 亮棱边 +
顶部内高光 + 斜反光,背景模糊由 --mat-slab-blur 给;/speed 的卡片贴着
高频重绘的实时区,在 .spd-bg 里把它关掉,零合成开销优先。 */
@@ -912,7 +885,7 @@
/* 2026-08-02 统一校准:① 暗色底从硬编码墨黑(rgba 12,13,17,流媒体
继承)改为 token 混色 —— 门户暗色是 271° 蓝紫调,chrome 必须同色相
否则读成另一种材质;② 边框掺 primary,与卡片"蓝棱线"同语言;
③ 竖直侧栏用 .pg-chrome-side 把投影转向右侧(向下投影是横栏的光学)。 */
③ 竖直侧栏用 .pg-chrome-side:内侧细亮边 + 外侧细暗线 + 投影转向右侧,不画硬分隔线。 */
.pg-chrome {
background: var(--mat-chrome-bg);
backdrop-filter: var(--mat-chrome-blur);
@@ -938,6 +911,11 @@
box-shadow: var(--mat-float-shadow);
}
/* 对话框、确认框、抽屉:要专心阅读和填写,底比菜单实。压在半透明遮罩上时,通透的白底会变成脏灰 */
.pg-float-modal {
--mat-float-bg: var(--mat-float-modal-bg);
}
/* 嵌在另一层磨砂里的菜单(页头、筛选栏、卡片内的下拉):外层磨砂是 backdrop 根,
里面的菜单糊不到页面,半透明底会透出没糊过的内容 —— 这类一律用近实底(动漫站同一做法)。
挂在 body 上的对话框、shadcn 弹出框不需要 */
@@ -1018,27 +996,6 @@
}
}
/* 吸顶筛选条与顶栏之间的接缝:一条无彩细线,两端淡出,随日夜取 --wash */
.chrome-seam-top {
position: relative;
}
.chrome-seam-top::before {
content: "";
position: absolute;
inset: 0 0 auto 0;
height: 1px;
background: linear-gradient(
90deg,
transparent 0%,
color-mix(in oklch, var(--wash) 8%, transparent) 12%,
color-mix(in oklch, var(--wash) 12%, transparent) 50%,
color-mix(in oklch, var(--wash) 8%, transparent) 88%,
transparent 100%
);
pointer-events: none;
}
/* Stream library layout */
/* 手机三列:两列时单张海报 190px,信息密度只有主流客户端的一半;三列
约 120px,一屏能看 9 张,标题字号在 MovieCard 里随之收到 15px */
@@ -1142,18 +1099,20 @@
只管底、边、字色与投影,形状尺寸(rounded-full、内边距、字号)由调用处给。
日夜差别全在 --mat-pill-* 参数里;以后要换回实底或调透明度只改参数。 */
.pill-secondary {
background: var(--mat-pill-secondary-bg);
border: 1px solid var(--mat-pill-secondary-border);
/* 液态玻璃 lens:几乎透明的填充 + 内阴影画的方向性边光 + 外侧暗线 */
background: var(--mat-lens-fill);
border: 1px solid transparent;
box-shadow: var(--mat-lens-rim), var(--mat-lens-edge), var(--mat-lens-shadow);
color: var(--content);
-webkit-backdrop-filter: blur(20px) saturate(160%);
backdrop-filter: blur(20px) saturate(160%);
-webkit-backdrop-filter: var(--mat-lens-blur);
backdrop-filter: var(--mat-lens-blur);
transition:
background-color var(--duration-base) var(--ease-standard),
transform 220ms var(--ease-spring);
}
.pill-secondary:hover {
background: var(--mat-pill-secondary-bg-hover);
background: var(--mat-lens-fill-hover);
}
/* 主操作(方案 A,2026-09-26):和其他按钮一样是半透明胶囊,不做实心;靠沿边框缓慢流转的
@@ -1164,12 +1123,13 @@
position: relative;
isolation: isolate;
overflow: visible;
background: var(--mat-pill-primary-bg);
background: var(--mat-lens-fill);
border: 1px solid transparent;
color: var(--mat-pill-primary-fg);
box-shadow: var(--mat-pill-primary-shadow);
-webkit-backdrop-filter: blur(20px) saturate(160%);
backdrop-filter: blur(20px) saturate(160%);
/* 液态玻璃:彩虹环就是它的边光,这里只补左上内高光和外侧暗线 */
box-shadow: var(--mat-lens-sheen), var(--mat-lens-edge), var(--mat-lens-shadow);
-webkit-backdrop-filter: var(--mat-lens-blur);
backdrop-filter: var(--mat-lens-blur);
transition:
background-color var(--duration-base) var(--ease-standard),
transform 220ms var(--ease-spring);
@@ -1177,9 +1137,13 @@
.pill-primary:hover,
.btn-glass.btn-glass-primary:hover {
background: var(--mat-pill-primary-bg-hover);
background: var(--mat-lens-fill-hover);
}
/* 棱镜高光(2026-10-07 液态玻璃方案 2,替代整圈转动的光谱环):光从左上照来,在玻璃棱边上
分出一段光谱,右下是一段更淡的回光。静止不转,悬停时高光沿边扫过一小段。
::before = 棱边上的光谱弧;::after = 同一段光谱的柔光,透进玻璃本体、略洒出边外。
渐变直接写在伪元素上:--prism-angle 不继承,写进 :root 的变量会在根上就被算死 */
.pill-primary::before,
.btn-glass.btn-glass-primary::before,
.prism-ring::before {
@@ -1187,18 +1151,23 @@
position: absolute;
inset: -1px;
border-radius: inherit;
padding: 1.5px;
padding: 1.75px;
pointer-events: none;
background: conic-gradient(
from var(--prism-angle),
oklch(var(--prism-l) var(--prism-c) 25),
oklch(var(--prism-l) var(--prism-c) 70),
oklch(calc(var(--prism-l) + 0.06) var(--prism-c) 105),
oklch(var(--prism-l) var(--prism-c) 155),
oklch(var(--prism-l) var(--prism-c) 205),
oklch(calc(var(--prism-l) - 0.04) var(--prism-c) 265),
oklch(calc(var(--prism-l) - 0.04) var(--prism-c) 305),
oklch(var(--prism-l) var(--prism-c) 25)
from calc(var(--prism-angle) + 225deg),
transparent 0deg,
oklch(var(--prism-l) var(--prism-c) 25) 22deg,
oklch(var(--prism-l) var(--prism-c) 70) 40deg,
oklch(calc(var(--prism-l) + 0.06) var(--prism-c) 105) 58deg,
oklch(var(--prism-l) var(--prism-c) 155) 76deg,
oklch(var(--prism-l) var(--prism-c) 205) 94deg,
oklch(calc(var(--prism-l) - 0.04) var(--prism-c) 265) 112deg,
oklch(calc(var(--prism-l) - 0.04) var(--prism-c) 305) 128deg,
transparent 152deg,
transparent 196deg,
oklch(var(--prism-l) var(--prism-c) 205 / 0.4) 226deg,
oklch(calc(var(--prism-l) - 0.04) var(--prism-c) 305 / 0.4) 254deg,
transparent 290deg
);
-webkit-mask:
linear-gradient(#fff 0 0) content-box,
@@ -1209,8 +1178,9 @@
linear-gradient(#fff 0 0);
mask-composite: exclude;
opacity: var(--prism-ring);
transition: opacity var(--duration-base) var(--ease-standard);
animation: prism-spin 6s linear infinite;
transition:
--prism-angle 700ms var(--ease-pop),
opacity var(--duration-base) var(--ease-standard);
}
.pill-primary::after,
@@ -1223,23 +1193,33 @@
border-radius: inherit;
pointer-events: none;
background: conic-gradient(
from var(--prism-angle),
oklch(0.8 0.14 25),
oklch(0.85 0.13 105),
oklch(0.8 0.13 205),
oklch(0.72 0.15 305),
oklch(0.8 0.14 25)
from calc(var(--prism-angle) + 225deg),
transparent 0deg,
oklch(var(--prism-l) var(--prism-c) 25) 22deg,
oklch(var(--prism-l) var(--prism-c) 70) 40deg,
oklch(calc(var(--prism-l) + 0.06) var(--prism-c) 105) 58deg,
oklch(var(--prism-l) var(--prism-c) 155) 76deg,
oklch(var(--prism-l) var(--prism-c) 205) 94deg,
oklch(calc(var(--prism-l) - 0.04) var(--prism-c) 265) 112deg,
oklch(calc(var(--prism-l) - 0.04) var(--prism-c) 305) 128deg,
transparent 152deg,
transparent 196deg,
oklch(var(--prism-l) var(--prism-c) 205 / 0.4) 226deg,
oklch(calc(var(--prism-l) - 0.04) var(--prism-c) 305 / 0.4) 254deg,
transparent 290deg
);
filter: blur(10px);
filter: blur(5px);
opacity: var(--prism-glow);
transition: opacity var(--duration-base) var(--ease-standard);
animation: prism-spin 6s linear infinite;
transition:
--prism-angle 700ms var(--ease-pop),
opacity var(--duration-base) var(--ease-standard);
}
.pill-primary:hover::before,
.btn-glass.btn-glass-primary:hover::before,
.group:hover .prism-ring::before,
.prism-ring:hover::before {
--prism-angle: 32deg;
opacity: 1;
}
@@ -1247,14 +1227,16 @@
.btn-glass.btn-glass-primary:hover::after,
.group:hover .prism-ring::after,
.prism-ring:hover::after {
opacity: calc(var(--prism-glow) * 1.6);
--prism-angle: 32deg;
opacity: calc(var(--prism-glow) * 1.8);
}
.pill-primary:disabled::before,
.pill-primary:disabled::after,
.btn-glass.btn-glass-primary:disabled::before,
.btn-glass.btn-glass-primary:disabled::after {
animation: none;
--prism-angle: 0deg;
filter: saturate(0.4);
}
@media (prefers-reduced-motion: reduce) {
@@ -1264,7 +1246,7 @@
.btn-glass.btn-glass-primary::after,
.prism-ring::before,
.prism-ring::after {
animation: none;
transition: opacity var(--duration-base) var(--ease-standard);
}
}
@@ -1324,26 +1306,12 @@
}
/* 选中态:分页当前页、角标、选中的选项等"不是按钮但要标出来"的地方。静态、不实心 */
/* 选中态 = 有填充的 lens 玻璃:比次按钮多一层填充,边由方向性边光 + 加深的外侧暗线给 */
.pill-selected {
background: var(--mat-pill-selected-bg);
border: 1px solid var(--mat-pill-selected-border);
color: var(--content-strong);
}
/* 实心主操作:只给详情页头图区的唯一主操作(订阅)用(2026-10-07 用户定的例外),
其余页面主操作仍是 pill-primary;hover 只微调明度,不换色 */
.pill-solid {
background: var(--mat-pill-solid-bg);
border: 1px solid transparent;
color: var(--mat-pill-solid-fg);
box-shadow: var(--mat-pill-solid-shadow);
transition:
background-color var(--duration-base) var(--ease-standard),
transform 220ms var(--ease-spring);
}
.pill-solid:hover {
background: var(--mat-pill-solid-bg-hover);
box-shadow: var(--mat-lens-rim), 0 0 0 1px var(--mat-pill-selected-border);
color: var(--content-strong);
}
/* 详情页头图(MediaHero):剧照层上下、右缘渐隐成透明,露出页面底色与光斑,不留硬边;
@@ -1392,8 +1360,7 @@
/* 按下回弹,与 .btn-glass 同一手感 */
.pill-primary:active,
.pill-secondary:active,
.pill-solid:active {
.pill-secondary:active {
transform: scale(0.97);
}
@@ -1432,11 +1399,12 @@
.btn-glass,
.btn-glass-icon {
background: var(--mat-pill-secondary-bg);
border: 1px solid var(--mat-pill-secondary-border);
background: var(--mat-lens-fill);
border: 1px solid transparent;
box-shadow: var(--mat-lens-rim), var(--mat-lens-edge), var(--mat-lens-shadow);
color: var(--content-strong);
-webkit-backdrop-filter: blur(20px) saturate(160%);
backdrop-filter: blur(20px) saturate(160%);
-webkit-backdrop-filter: var(--mat-lens-blur);
backdrop-filter: var(--mat-lens-blur);
transition:
background-color var(--duration-base) var(--ease-standard),
transform 220ms var(--ease-spring);
@@ -1444,7 +1412,109 @@
.btn-glass:hover,
.btn-glass-icon:hover {
background: var(--mat-pill-secondary-bg-hover);
background: var(--mat-lens-fill-hover);
}
/* LiquidSurface 只模糊不折射时(触屏、测速页、frost 面板):包在玻璃层顶部画的 50% 白光泽太亮,
夜间会压住第一行字。普通档收到很淡,frost(字多的面板)去掉 */
.liquid-surface-blur > :first-child {
background-image: linear-gradient(180deg, rgba(255, 255, 255, 0.06) 0%, rgba(255, 255, 255, 0) 35%) !important;
}
.liquid-surface-blur.liquid-surface-frost > :first-child {
background-image: none !important;
}
/* 输入框 / 下拉选择触发器:淡底 + 1px 实边框,聚焦时边框加深并带一圈淡光晕;
调用处只给尺寸与内边距,不再各写底色、边框、阴影(由 Input / SelectTrigger 挂上) */
.field-glass {
background: var(--mat-field-bg);
/* 实打实的 1px 边框(不用阴影画线):阴影线太细,部分屏幕和浏览器上看不清输入框在哪 */
border: 1px solid var(--mat-field-edge);
box-shadow: inset 0 1px 0 var(--mat-field-highlight);
transition:
box-shadow var(--duration-base) var(--ease-standard),
background-color var(--duration-base) var(--ease-standard);
}
.field-glass:focus-visible,
.field-glass:focus-within,
.field-glass[data-state="open"] {
outline: none;
border-color: var(--mat-field-edge-focus);
box-shadow: 0 0 0 3px var(--mat-field-ring);
}
.field-glass[aria-invalid="true"] {
border-color: color-mix(in oklch, var(--destructive) 65%, transparent);
}
/* 登录类页面的表单卡片:内容卡的配方,但底换成实在的磨砂(夜间内容卡几乎透明,卡片、输入框、页面三层挤在一起分不开) */
.auth-card {
--mat-slab-bg: var(--mat-auth-card-bg);
--mat-slab-blur: blur(24px) saturate(160%);
}
/* 同 btn-glass 的 lens 玻璃外观,不带布局:尺寸会动画变化、不适合套 LiquidLens 折射的自定义控件 */
.lens-glass {
background: var(--mat-lens-fill);
box-shadow: var(--mat-lens-rim), var(--mat-lens-edge), var(--mat-lens-shadow);
-webkit-backdrop-filter: var(--mat-lens-blur);
backdrop-filter: var(--mat-lens-blur);
}
/* 小玻璃圆片 + 白色图标:glass-chip 跟随日夜(页面底上);glass-chip-media 恒为深烟色,
用于一切压在图片上的小控件(海报 / 缩略图的角标、状态圆片、悬停出现的操作键) */
.glass-chip,
.glass-chip-media {
display: grid;
place-items: center;
border-radius: 9999px;
color: #ffffff;
background: var(--mat-chip-glass);
-webkit-backdrop-filter: blur(6px) saturate(160%);
backdrop-filter: blur(6px) saturate(160%);
box-shadow:
inset 1px 1px 0 -0.25px rgba(255, 255, 255, 0.42),
inset -1px -1px 0 -0.25px rgba(255, 255, 255, 0.12),
0 0 0 1px rgba(0, 0, 0, 0.22);
}
.glass-chip-media {
background: rgba(0, 0, 0, 0.32);
}
/* 套在 LiquidLens 里的按钮:玻璃由外层画(真折射),按钮自己不再铺底、不再模糊、不画边光 */
.btn-glass.btn-liquid,
.btn-glass-icon.btn-liquid,
.pill-primary.btn-liquid,
.pill-secondary.btn-liquid {
background: transparent;
border-color: transparent;
box-shadow: none;
-webkit-backdrop-filter: none;
backdrop-filter: none;
}
.btn-glass.btn-liquid:hover,
.btn-glass-icon.btn-liquid:hover,
.btn-glass.btn-glass-primary.btn-liquid,
.btn-glass.btn-glass-primary.btn-liquid:hover,
.pill-primary.btn-liquid,
.pill-primary.btn-liquid:hover,
.pill-secondary.btn-liquid:hover {
background: transparent;
box-shadow: none;
-webkit-backdrop-filter: none;
backdrop-filter: none;
}
.btn-glass.btn-liquid:hover,
.btn-glass-icon.btn-liquid:hover,
.btn-glass.btn-glass-primary.btn-liquid:hover,
.pill-primary.btn-liquid:hover,
.pill-secondary.btn-liquid:hover {
background: rgba(255, 255, 255, 0.06);
}
.btn-glass:active,
@@ -1692,20 +1762,3 @@
animation-duration: 420ms;
animation-timing-function: var(--ease-standard);
}
@keyframes update-pill-ring {
0% {
box-shadow: 0 0 0 0 color-mix(in oklch, var(--primary) 45%, transparent);
opacity: 1;
}
70% {
box-shadow: 0 0 0 10px color-mix(in oklch, var(--primary) 0%, transparent);
opacity: 0;
}
100% {
box-shadow: 0 0 0 0 color-mix(in oklch, var(--primary) 0%, transparent);
opacity: 0;
}
}
@@ -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}
@@ -29,7 +29,6 @@ export const DESIGN_TOKENS = {
"notfound-code",
"pricing-hero",
"pricing-title",
"auth-title",
"landing-hero",
"landing-platforms-title",
"landing-immersive-title",
+171
View File
@@ -0,0 +1,171 @@
import { useSyncExternalStore } from "react";
/**
* 当前主输入是触屏还是鼠标,写在 <html data-input="touch|mouse"> 上,CSS 用 `touch:` 变体读它,
* React 用 useTouchInput() 读它。
*
* 只看 (pointer: coarse) 不够:车机、触屏电视盒、一体机这类设备常跑桌面 Linux / 桌面版 Chromium,
* 报告的是精细指针和可悬停(按 PC 处理),实际只有触屏,悬停展开、音量簇、点按呼出控制条就全错了。
* 通用判断分三层,前一层认不出时后一层兜底:
*
* 1. 系统 / 浏览器报告:粗指针,或有触点却没有能悬停的指针 → 触屏。UA 只作提示(已知车机直接从触屏起步)。
* 2. 实际输入:
* - 手指 / 笔按下(pointerType touch / pen)→ 触屏;
* - 触屏被系统模拟成鼠标时(Linux 车机常见),按下前没有移动轨迹、光标「瞬移」到按下位置;
* 连续两次瞬移按下 → 触屏;
* - 真鼠标连续移动一段 → 回到鼠标(带触屏的笔记本、平板接鼠标)。
* 3. 手动:地址带 ?input=touch|mouse|auto,记在 localStorage,优先于自动判断。只作排障口子,界面上不给用户选。
*
* 迁自 uhd_web app/lib/input-modality.ts(2026-10-08)。
*/
export type InputMode = "touch" | "mouse";
export type InputPreference = "auto" | InputMode;
const STORAGE_KEY = "umt-input-override";
// 已知跑桌面浏览器内核的车机 / 车载系统 UA 片段(只作初始提示,不锁定)
const TOUCH_UA_HINTS =
/\b(Tesla|QtCarBrowser|Automotive|AAOS|MBUX|Rivian|Lucid|Polestar|NIO|XPENG|LiAuto|HarmonyOS.*Car)\b/i;
// 真鼠标点击前的移动是连续的小步。瞬移判定:按下点离上一次移动位置超过这个距离,
// 或按下前紧挨着的那次移动本身就是一次大跳(触屏模拟的鼠标会先把光标跳到按下点再按下)。
// 原地不动再点不算:电脑用户手放着连点两下不能被误判成触屏
const TELEPORT_PX = 24;
const JUMP_BEFORE_DOWN_MS = 120;
const TELEPORTS_TO_TOUCH = 2;
// 连续这么多次小步移动才算真鼠标回来了(模拟鼠标在按下前通常只有 0~1 次移动)
const STEADY_MOVES_TO_MOUSE = 6;
let auto: InputMode = "mouse";
let pref: InputPreference = "auto";
let started = false;
const listeners = new Set<() => void>();
function effective(): InputMode {
return pref === "auto" ? auto : pref;
}
function publish() {
document.documentElement.dataset.input = effective();
for (const fn of listeners) fn();
}
function setAuto(next: InputMode) {
if (next === auto) return;
const before = effective();
auto = next;
if (effective() !== before) publish();
}
function mq(q: string) {
return window.matchMedia?.(q).matches ?? false;
}
function detect(): InputMode {
if (mq("(pointer: coarse)")) return "touch";
if (navigator.maxTouchPoints > 0 && !mq("(any-hover: hover)")) return "touch";
if (TOUCH_UA_HINTS.test(navigator.userAgent)) return "touch";
return "mouse";
}
function readPreference(): InputPreference {
try {
const fromUrl = new URLSearchParams(window.location.search).get("input");
if (fromUrl === "auto" || fromUrl === "touch" || fromUrl === "mouse") {
localStorage.setItem(STORAGE_KEY, fromUrl);
return fromUrl;
}
const saved = localStorage.getItem(STORAGE_KEY);
if (saved === "touch" || saved === "mouse") return saved;
} catch {
// 隐私模式等拿不到存储时按自动处理
}
return "auto";
}
/** 在客户端入口调用一次;重复调用无副作用 */
export function initInputModality() {
if (started || typeof window === "undefined") return;
started = true;
pref = readPreference();
auto = detect();
document.documentElement.dataset.input = effective();
let lastMove: { x: number; y: number; t: number; jumped: boolean } | null = null;
let teleports = 0;
let steadyMoves = 0;
window.addEventListener(
"pointerdown",
(event) => {
if (event.pointerType === "touch" || event.pointerType === "pen") {
teleports = 0;
steadyMoves = 0;
setAuto("touch");
return;
}
if (event.pointerType !== "mouse") return;
const jumped =
!lastMove ||
Math.hypot(event.clientX - lastMove.x, event.clientY - lastMove.y) > TELEPORT_PX ||
(lastMove.jumped && event.timeStamp - lastMove.t < JUMP_BEFORE_DOWN_MS);
steadyMoves = 0;
if (jumped) {
teleports += 1;
if (teleports >= TELEPORTS_TO_TOUCH) setAuto("touch");
} else {
teleports = 0;
}
},
{ capture: true, passive: true },
);
window.addEventListener(
"pointermove",
(event) => {
if (event.pointerType !== "mouse") return;
const prev = lastMove;
const step = prev ? Math.hypot(event.clientX - prev.x, event.clientY - prev.y) : Infinity;
lastMove = { x: event.clientX, y: event.clientY, t: event.timeStamp, jumped: step > TELEPORT_PX };
// 按住拖动(滑动进度条、触屏拖拽被模拟成鼠标)不算「真鼠标回来了」
if (event.buttons !== 0 || !prev) return;
if (step > 0 && step <= TELEPORT_PX && event.timeStamp - prev.t < 200) {
steadyMoves += 1;
if (steadyMoves >= STEADY_MOVES_TO_MOUSE) {
teleports = 0;
setAuto("mouse");
}
} else {
steadyMoves = 0;
}
},
{ capture: true, passive: true },
);
window.matchMedia?.("(pointer: coarse)").addEventListener("change", () => setAuto(detect()));
}
export function getInputMode(): InputMode {
return effective();
}
/**
* 这次指针事件按触屏还是鼠标处理。手指 / 笔一律算触屏;鼠标类型的事件在全站判定为触屏模式时
* 也算触屏 —— 车机等把触屏报告成鼠标的设备,播放器手势、进度条、点按显隐靠它才能生效。
*/
export function pointerKind(event: { pointerType: string }): InputMode {
if (event.pointerType === "touch" || event.pointerType === "pen") return "touch";
return effective();
}
function subscribe(fn: () => void) {
listeners.add(fn);
return () => listeners.delete(fn);
}
/** 主输入是否为触屏。SSR 阶段返回 false,水合后按实际校正 */
export function useTouchInput(): boolean {
return useSyncExternalStore(
subscribe,
() => effective() === "touch",
() => false,
);
}
+8 -1
View File
@@ -2,13 +2,18 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ThemeProvider } from "next-themes";
import { useState } from "react";
import { isRouteErrorResponse, Links, Meta, Outlet, Scripts, ScrollRestoration } from "react-router";
import { GlassTooltip } from "~/components/glass-tooltip.tsx";
import { Toaster } from "~/components/ui/sonner.tsx";
import { initInputModality } from "~/lib/input-modality.ts";
import type { Route } from "./+types/root";
import "@fontsource-variable/inter-tight";
import "@fontsource-variable/space-grotesk";
import "./app.css";
// 触屏 / 鼠标判断要在首屏渲染前定下来(写到 <html data-input>),见 input-modality.ts
if (typeof window !== "undefined") initInputModality();
export const meta: Route.MetaFunction = () => [{ title: "UHD 媒体自动化控制台" }];
export function Layout({ children }: { children: React.ReactNode }) {
@@ -31,7 +36,9 @@ export function Layout({ children }: { children: React.ReactNode }) {
{children}
<ScrollRestoration />
<Scripts />
<Toaster position="top-center" duration={1500} richColors />
{/* 玻璃提示条不叠放(叠起来后面的字会透出来):展开排列、间隔 8px;状态色只在图标上,不开 richColors */}
<Toaster position="top-center" duration={1500} expand gap={8} />
<GlassTooltip />
</ThemeProvider>
</body>
</html>
+1 -1
View File
@@ -843,7 +843,7 @@ export default function FilesPage() {
aria-label="排序"
value={sort}
onChange={(e) => patch({ sort: e.target.value === "name" ? null : e.target.value })}
className="h-8 rounded-lg border border-input bg-transparent px-2 text-caption"
className="field-glass h-8 rounded-xl px-2 text-caption"
>
{SORT_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
+50 -29
View File
@@ -1,5 +1,5 @@
import type { SessionInfo } from "@umt/contracts";
import { ChevronLeft, ChevronRight, Clapperboard, LoaderCircle, Menu } from "lucide-react";
import { Clapperboard, LoaderCircle, Menu, PanelLeftClose, PanelLeftOpen } from "lucide-react";
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { Link, Outlet, useLocation, useMatches } from "react-router";
import { useSession } from "~/api/auth.ts";
@@ -10,6 +10,7 @@ import { AgentLauncher, AgentNavEntry } from "~/components/console/agent-launche
import { ErrorState } from "~/components/console/empty-state.tsx";
import { InboxBell } from "~/components/console/inbox-bell.tsx";
import { findNavItem, NAV_GROUPS, type NavGroup, pageTitle } from "~/components/console/nav.ts";
import { LiquidSurface } from "~/components/liquid-surface.tsx";
import PrismLeak from "~/components/prism-leak.tsx";
import { CommandPalette } from "~/components/search/command-palette.tsx";
import { SearchTrigger } from "~/components/search/search-trigger.tsx";
@@ -129,19 +130,13 @@ function CollapsedNav() {
);
}
function Brand({ collapsed = false }: { collapsed?: boolean }) {
function Brand({ className }: { className?: string }) {
return (
<Link
to="/console"
className={cn(
"flex items-center transition-opacity hover:opacity-80",
collapsed ? "w-full justify-center" : "gap-2",
)}
>
<span className="grid h-7 w-7 place-items-center rounded-lg pill-selected">
<Link to="/console" className={cn("flex items-center gap-2 transition-opacity hover:opacity-80", className)}>
<span className="grid h-7 w-7 shrink-0 place-items-center rounded-lg pill-selected">
<Clapperboard className="h-4 w-4" />
</span>
{!collapsed && <p className="text-xs font-bold tracking-wide text-foreground">UHD Media Tool</p>}
<p className="truncate text-xs font-bold tracking-wide text-foreground">UHD Media Tool</p>
</Link>
);
}
@@ -186,27 +181,47 @@ function ConsoleShell({ session }: { session: SessionInfo }) {
<PrismLeak className="-z-10" />
{/* 桌面端侧栏 */}
{/* 侧栏贴边,不做浮起卡片;右边不画硬分隔线,由 pg-chrome-side 给内侧细亮边、外侧细暗线和淡投影 */}
<aside
className={cn(
"pg-chrome pg-chrome-side fixed inset-y-0 left-0 z-30 hidden flex-col border-r transition-all duration-layout select-none xl:flex",
"pg-chrome pg-chrome-side fixed inset-y-0 left-0 z-30 hidden flex-col transition-all duration-layout select-none xl:flex",
collapsed ? "w-[60px]" : "w-60",
)}
>
<button
type="button"
onClick={() => setCollapsed((v) => !v)}
className="absolute top-3 -right-4 z-40 flex h-8 w-8 cursor-pointer items-center justify-center rounded-full border border-[var(--shell-border)] bg-background text-muted-foreground shadow-md transition-all duration-base hover:scale-110 hover:bg-foreground/[0.04] hover:text-foreground"
title={collapsed ? "展开侧边栏" : "收起侧边栏"}
>
{collapsed ? <ChevronRight className="h-4 w-4" /> : <ChevronLeft className="h-4 w-4" />}
</button>
{/* 标志行 + 折叠开关:展开时开关在名称右侧;收起后只剩标志,悬停换成展开图标,点一下展开 */}
<div
className={cn(
"flex h-14 shrink-0 items-center border-b border-[var(--shell-border)]",
collapsed ? "justify-center px-0" : "px-4",
"flex h-14 shrink-0 items-center border-b border-[var(--shell-border)] transition-all duration-layout",
collapsed ? "justify-center px-0" : "gap-2 pr-2 pl-4",
)}
>
<Brand collapsed={collapsed} />
{collapsed ? (
<button
type="button"
onClick={() => setCollapsed(false)}
title="展开侧边栏"
aria-label="展开侧边栏"
className="group/logo relative grid h-9 w-9 cursor-pointer place-items-center rounded-full transition-colors duration-base hover:bg-foreground/[0.06]"
>
<span className="grid h-7 w-7 place-items-center rounded-lg pill-selected transition-opacity duration-base group-hover/logo:opacity-0">
<Clapperboard className="h-4 w-4" />
</span>
<PanelLeftOpen className="absolute h-[18px] w-[18px] text-muted-foreground opacity-0 transition-opacity duration-base group-hover/logo:opacity-100" />
</button>
) : (
<>
<Brand className="min-w-0 flex-1" />
<button
type="button"
onClick={() => setCollapsed(true)}
title="收起侧边栏"
aria-label="收起侧边栏"
className="grid h-8 w-8 shrink-0 cursor-pointer place-items-center rounded-full text-muted-foreground transition-colors duration-base hover:bg-foreground/[0.06] hover:text-foreground"
>
<PanelLeftClose className="h-[18px] w-[18px]" />
</button>
</>
)}
</div>
{collapsed ? <CollapsedNav /> : <ExpandedNav groups={NAV_GROUPS} />}
</aside>
@@ -218,7 +233,9 @@ function ConsoleShell({ session }: { session: SessionInfo }) {
collapsed ? "xl:ml-[60px]" : "xl:ml-60",
)}
>
<header className="pg-chrome sticky top-0 z-20 flex h-14 items-center border-b px-4 md:px-6">
{/* 顶栏不铺磨砂(磨砂栏会成为背景根,里面的玻璃糊不到页面):页面底色渐隐托住标题,
右侧通知、智能体、日夜切换与账户收进一枚玻璃胶囊 */}
<header className="sticky top-0 z-20 flex h-14 items-center bg-gradient-to-b from-background/90 via-background/60 to-transparent px-4 md:px-6">
<div className="flex min-w-0 items-center gap-3">
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
<SheetTrigger asChild>
@@ -239,11 +256,15 @@ function ConsoleShell({ session }: { session: SessionInfo }) {
</div>
<div className="ml-auto flex items-center gap-2">
<SearchTrigger />
{/* 桌面有侧栏主入口与右下角悬浮按钮,顶栏小按钮只在窄屏保留 */}
<InboxBell />
<AgentDrawerTrigger className="xl:hidden" />
<ThemeToggle />
<AccountMenu session={session} />
<LiquidSurface tier="regular" radius={20} noRefraction style={{ width: "auto", height: "auto" }}>
<div className="flex items-center gap-0.5 p-1">
<InboxBell bare />
{/* 桌面有侧栏主入口与右下角悬浮按钮,顶栏的智能体按钮只在窄屏保留 */}
<AgentDrawerTrigger className="xl:hidden" />
<ThemeToggle bare />
<AccountMenu session={session} bare />
</div>
</LiquidSurface>
</div>
</header>
+2 -2
View File
@@ -1179,8 +1179,8 @@ export default function LibraryDetailPage() {
<>
{e.status !== "merged" ? (
<>
{/* 头图区唯一的主操作:实心;其余保持描边 */}
<Button variant="solid" size="sm" onClick={() => setSubscribing(true)}>
{/* 头图区唯一的主操作(棱镜高光);其余是次操作 */}
<Button size="sm" onClick={() => setSubscribing(true)}>
<CalendarPlus />
订阅
</Button>
@@ -133,8 +133,8 @@ function Detail({ d }: { d: TmdbMediaDetail }) {
badges={<InventoryBadges inv={d.inventory} />}
actions={
<>
{/* 头图区唯一的主操作:实心;其余保持描边 */}
<Button variant="solid" size="sm" onClick={() => setSubscribing(true)}>
{/* 头图区唯一的主操作(棱镜高光);其余是次操作 */}
<Button size="sm" onClick={() => setSubscribing(true)}>
<CalendarPlus />
订阅
</Button>
+9 -29
View File
@@ -77,10 +77,7 @@ function IconInput({
return (
<div className="relative">
<Icon className="pointer-events-none absolute top-1/2 left-3.5 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
className={cn("h-12 rounded-lg pl-10 text-control md:text-control", trailing && "pr-11", className)}
{...props}
/>
<Input className={cn("h-12 pl-10 text-control md:text-control", trailing && "pr-11", className)} {...props} />
{trailing ? <div className="absolute top-1/2 right-1.5 -translate-y-1/2">{trailing}</div> : null}
</div>
);
@@ -229,14 +226,15 @@ export default function LoginPage() {
? useRecovery
? "输入一个没用过的恢复码,用过即作废。"
: "输入身份验证器里的 6 位验证码。"
: "使用管理员账号登录,会话 12 小时有效。";
: null;
return (
<div className="relative isolate flex min-h-screen flex-col overflow-x-hidden bg-background">
<div className="pointer-events-none fixed inset-0 -z-10 bg-page-glow" />
<PrismLeak className="-z-10" />
<header className="pg-chrome sticky top-0 z-20 flex h-14 items-center border-b px-4 md:px-6">
{/* 顶栏不铺磨砂:页面底上只有标志和日夜切换(单颗按钮不套胶囊) */}
<header className="sticky top-0 z-20 flex h-14 items-center bg-gradient-to-b from-background/90 via-background/60 to-transparent px-4 md:px-6">
<div className="flex items-center gap-2">
<span className="grid h-7 w-7 place-items-center rounded-lg pill-selected">
<Clapperboard className="h-4 w-4" />
@@ -248,29 +246,11 @@ export default function LoginPage() {
</div>
</header>
<main className="mx-auto grid w-full max-w-6xl flex-1 items-center gap-10 px-4 py-10 md:px-6 lg:grid-cols-[minmax(0,1fr)_23.5rem] lg:py-16">
{/* 左侧品牌区(纯装饰,仅桌面) */}
<section className="hidden lg:block">
<p className="text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">媒体自动化</p>
<h1 className="mt-3 max-w-xl font-display text-display-sm leading-tight font-semibold text-foreground">
订阅、下载、整理
</h1>
<p className="mt-4 max-w-lg text-lead text-muted-foreground">每一步都有记录。拿不准的会停下来等你决定。</p>
<ul className="mt-8 space-y-3 text-body text-muted-foreground">
<li className="flex items-center gap-2.5">
<ShieldCheck className="h-4 w-4 text-foreground/70" />
单管理员登录,外部程序走 API Key
</li>
<li className="flex items-center gap-2.5">
<LockKeyhole className="h-4 w-4 text-foreground/70" />
凭证加密存储,页面不回显
</li>
</ul>
</section>
<section className="pg-slab w-full rounded-2xl p-6 sm:p-7">
<h2 className="text-title font-semibold tracking-tight text-foreground">{title}</h2>
<p className="mt-1 text-caption text-muted-foreground">{subtitle}</p>
{/* 整页居中一张卡,只放标题和表单,桌面与手机同一套;手机卡片常比屏幕高,从顶栏下方往下排,宽屏才居中 */}
<main className="mx-auto flex w-full max-w-md flex-1 flex-col justify-start px-4 pt-6 pb-10 sm:px-0 md:justify-center md:pb-16">
<section className="pg-slab auth-card w-full rounded-3xl p-7 motion-safe:animate-blur-in sm:p-8">
<h1 className="text-headline-xs font-semibold tracking-tight text-foreground">{title}</h1>
{subtitle ? <p className="mt-1 text-caption text-muted-foreground">{subtitle}</p> : null}
{mode === "setup" ? (
<div className="mt-4 flex gap-2.5 rounded-lg border border-border bg-foreground/[0.03] p-3 text-meta text-muted-foreground">
+1
View File
@@ -32,6 +32,7 @@
"react-dom": "^19.3.0",
"react-router": "8.4.0",
"recharts": "^3.10.1",
"simple-liquid-glass": "5.3.0",
"sonner": "^2.0.8",
"tailwind-merge": "^3.7.0",
"zod": "^4.6.5"