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

+9
View File
@@ -4,6 +4,15 @@
## [Unreleased]
### 调整
- 界面材质与 uhd_web 同步(截至 2026-10-08):内容卡、悬浮层、选中态、分段滑块、药丸改用同一套边光(左上亮、右下暗的细边加外侧暗线);主按钮的转动光谱环改为静止的左上光谱高光,悬停时沿边扫过一小段,减少动态效果时只变亮;次按钮与主按钮同为 lens 玻璃。
- 输入框、多行输入、下拉选择统一由 `.field-glass` 给底、边、聚焦光晕和出错红线,去掉各处自带的边框和底色;对话框、确认框、抽屉的底更实,按钮行间距加大。
- 提示条改成玻璃浮层,状态色只在图标上,多条时展开排列不叠放;原生 `title` 悬停提示换成同款玻璃气泡。
- 控制台外壳:侧栏不画硬分隔线,折叠开关移到标志行;顶栏不铺磨砂,标题下用页面底色渐隐,通知、智能体、日夜切换和账户收进一枚玻璃胶囊。分组小标题保留。
- 登录页改为整页居中一张卡,只留标题,去掉左侧宣传语。
- 作品详情头图的「订阅」改回普通主按钮(静态高光在剧照上看得清),去掉实心按钮例外 `pill-solid`;发现页海报上的评分与状态角标改成烟色小玻璃。
- 触屏判断改为通用方案(`<html data-input>`、`touch:` 变体、`useTouchInput()`):车机等把触屏报告成鼠标的设备也按触屏处理,悬停效果只给真鼠标。
## [0.3.37] - 2026-10-07
### 调整
+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"
Generated
+13
View File
@@ -54,6 +54,7 @@
"npm:[email protected]": "[email protected][email protected]",
"npm:react@^19.3.0": "19.3.0",
"npm:recharts@^3.10.1": "[email protected][email protected][email protected]_@[email protected][email protected]",
"npm:[email protected]": "[email protected][email protected]",
"npm:sonner@^2.0.8": "2.0.8_@[email protected][email protected][email protected]",
"npm:tailwind-merge@^3.7.0": "3.7.0",
"npm:tailwindcss@^4.3.3": "4.3.3",
@@ -4299,6 +4300,17 @@
"side-channel-weakmap"
]
},
"[email protected][email protected][email protected]": {
"integrity": "sha512-VgUgdnsKAIYezJijrJMV8YTZ+Ei5KEh4/q48YD/0aG+pCIgSET8rHBYC4i5M0Mrd3C6PNAmgWy92t7QdogjTLA==",
"dependencies": [
"react",
"react-dom"
],
"optionalPeers": [
"react",
"react-dom"
]
},
"[email protected]": {
"integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==",
"dependencies": [
@@ -4700,6 +4712,7 @@
"npm:[email protected]",
"npm:react@^19.3.0",
"npm:recharts@^3.10.1",
"npm:[email protected]",
"npm:sonner@^2.0.8",
"npm:tailwind-merge@^3.7.0",
"npm:tailwindcss@^4.3.3",
+3 -3
View File
@@ -1,6 +1,6 @@
# 设计 token
> 迁入自 /opt/uhd_web `docs/features/`(2026-09-27)。文中流媒体、播放器、落地页、套餐等段落是 uhd_web 的原始上下文,本项目没有这些页面,只作材质与 token 用法参考;以本目录 README 的约定为准。
> 迁入自 /opt/uhd_web `docs/features/`(2026-09-27),2026-10-08 与 uhd_web 同步。文中流媒体、播放器、落地页、套餐等段落是 uhd_web 的原始上下文,本项目没有这些页面,只作材质与 token 用法参考;以本目录 README 的约定为准。
样式入口是 [app.css](../../apps/web/app/app.css)。本说明定义颜色类 token 的分层、命名与使用规则,目的是让主站之外的皮肤(如动漫版)只改 token 值就能换皮,组件源码两边保持一致。材质类的说明见[界面材质](interface-materials.md)。
@@ -37,7 +37,7 @@
|---|---|---|
| 正文 | `micro` / `label` / `meta` / `caption` / `body` / `control` / `lead` / `lead-lg` | 10 / 11 / 12 / 13 / 14 / 15 / 16 / 17 px |
| 标题 | `title-xs` / `title-sm` / `title` / `title-lg` / `headline-xs` / `headline-sm` / `headline` / `headline-lg` / `display-xs` / `display-sm` / `display` / `display-lg` / `display-xl` | 18 / 20 / 22 / 24 / 26 / 28 / 30 / 32 / 34 / 42 / 44 / 52 / 56 px |
| 流式 | `hero-stream(-sm/-md)`、`detail-title`、`notfound-code`、`pricing-hero`、`pricing-title`、`auth-title`、`landing-*` | 各处 clamp 原值,按用途命名 |
| 流式 | `hero-stream(-sm/-md)`、`detail-title`、`notfound-code`、`pricing-hero`、`pricing-title`、`landing-*` | 各处 clamp 原值,按用途命名 |
2026-09-12 迁移时零散字号并入最近一档(10.5 → 11、13.5 → 14、0.72rem → 11、1.05rem → 17 等,最大偏差 0.6px,约 120 处),经页面级截图复验;标题阶逐档保留现状值,流式标题各处原值保留,收敛是设计决定另议。
@@ -66,7 +66,7 @@ tailwind-merge 不认识的 `text-*` 会被当成文字颜色,`text-control text
## 材质参数与播放器白天模式
所有材质(播放器的 surface / pill,按钮的 pill-primary / pill-secondary / pill-selected,分段滑块 seg-thumb,门户的 slab / chrome / float,更新提示 notice)都是"固定配方 + `--mat-<材质>-<部位>` 参数",class 只引用参数,壳与主题只覆盖参数;结构见[界面材质](interface-materials.md)。
所有材质(播放器的 surface / pill,按钮的 pill-primary / pill-secondary / pill-selected,分段滑块 seg-thumb,门户的 slab / chrome / float,小控件玻璃 lens,输入框 field,小玻璃圆片 chip)都是"固定配方 + `--mat-<材质>-<部位>` 参数",class 只引用参数,壳与主题只覆盖参数;结构见[界面材质](interface-materials.md)。
播放器整体压在画面上,默认只有夜间,但白天模式已经留出位置:主题在播放页作用域(`:root:has([data-stream-shell="play"])`)覆盖两组东西即可,不需要改播放器组件。
+33 -15
View File
@@ -1,6 +1,6 @@
# 界面材质
> 迁入自 /opt/uhd_web `docs/features/`(2026-09-27)。文中流媒体、播放器、落地页、套餐等段落是 uhd_web 的原始上下文,本项目没有这些页面,只作材质与 token 用法参考;以本目录 README 的约定为准。
> 迁入自 /opt/uhd_web `docs/features/`(2026-09-27),2026-10-08 与 uhd_web 同步(液态玻璃、边光、输入框、提示条、触屏判断)。文中流媒体、播放器、落地页、套餐等段落是 uhd_web 的原始上下文,本项目没有这些页面,只作材质与 token 用法参考;以本目录 README 的约定为准。
样式入口是 [app.css](../../apps/web/app/app.css),具体色值与透明度只在 CSS 中维护。颜色 token 见[设计 token](design-tokens.md),按钮与控件的用法见[控件使用标准](ui-controls.md),三块壳下的实物陈列在开发态 `/dev/ui`。
@@ -9,26 +9,26 @@
全站(用户中心、公共页、测速、流媒体)是同一套审美,改样式先对照这几条:
1. **主色无彩**。夜间是白、日间是墨。彩色只来自内容(海报、剧照),外加一种很淡的棱镜光。状态色 `success` / `warning` / `destructive` 只表示状态,不做装饰。支付方式保留品牌色。
2. **棱镜光只出现在三处**:左上角的漏光 `PrismLeak`、主操作按钮沿边框转动的光谱环、流媒体入口(侧栏的播放键 `prism-ring` 与菜单里的三角 `prism-icon`)。不再往别处加彩虹。
2. **棱镜光只出现在三处**:左上角的漏光 `PrismLeak`、主操作按钮左上棱边的光谱高光、流媒体入口(侧栏的播放键 `prism-ring` 与菜单里的三角 `prism-icon`)。不再往别处加彩虹。
3. **材质按层级只有三种**:
- 贴边的用 `pg-surface` / `pg-chrome`;
- 内容卡用 `pg-slab`,是磨砂面,好读优先;
- 悬浮层用 `pg-float`,只有它用较深的投影。
不做边缘折射,也不在玻璃上再叠玻璃。
压在大图、海报、视频上的控件与悬浮层另用液态玻璃(见下文),只有它们做边缘折射;玻璃里的子元素只用平涂,不再叠玻璃。
4. **一个角色一种外观**:
- 主操作是半透明胶囊加棱镜环,一屏只放一个;
- 主操作是玻璃胶囊加左上棱边的光谱高光,一屏只放一个;
- 次操作是描边胶囊;
- 选中态是静态的 `pill-selected`;
- 分段控件的滑块是 `seg-thumb`。
全站没有实心按钮,也没有实心的选中块。唯一例外是作品详情头图区(`MediaHero`)的「订阅」:压在剧照上时棱镜环看不清,改用主色实心 `pill-solid`(2026-10-07 定),其余页面不用。
全站没有实心按钮,也没有实心的选中块。
5. **日夜两套,画面跟随页面**。大图、横向滚动行两侧的渐隐和压暗层都取页面底色,不硬切成黑色。播放页永远是夜间,落地页暂时固定夜间。
6. **同一种信息用同一种结构**:
- 套餐卡的零件在公共定价页和订阅服务页共用;
- 电影和剧集详情页共用 `DetailExtras`;
- 流媒体页面的内容区统一用 `max-w-[1800px]` 加 `px-4 sm:px-6 lg:px-10`,标题、海报行和顶栏对齐。
7. **动效只做一次性的**。入场、按压回弹、悬停反馈可以做;持续运动的装饰不做。主按钮的光谱环是唯一的例外,而且在减少动态效果时停转。
7. **动效只做一次性的**。入场、按压回弹、悬停反馈可以做;持续运动的装饰不做。主按钮的光谱高光静止,悬停时沿边扫过一小段。
这些规则里能机器检查的,由 [check-design-tokens.sh](../../apps/web/scripts/check-design-tokens.sh) 在 `deno task ci` 中守住(见[设计 token](design-tokens.md#规则))。
@@ -49,16 +49,16 @@
材质分三组:
- 播放器与媒体区:`surface`、`pill`;
- 门户:`slab`、`chrome`、`float`,以及按钮用的 `pill-primary` / `pill-secondary` / `pill-selected` 和分段滑块 `seg-thumb`;
- 更新提示胶囊:`notice`。
- 门户:`slab`、`chrome`、`float`,以及按钮用的 `pill-primary` / `pill-secondary` / `pill-selected`、分段滑块 `seg-thumb`、小控件玻璃 `lens`、输入框 `field`、小玻璃圆片 `chip`。
浅色门户、深色门户与流媒体的差别都只是参数覆盖,不各自重写声明。换皮做平面材质时,把 blur 设成 none、光泽设成透明、投影收掉即可,class 不动。
| 场景 | 样式 |
|---|---|
| 贴边的 header、筛选栏、播放器底栏 | `pg-surface`,模糊与底色,不叠加悬浮面板的泛光亮线 |
| 悬浮层:下拉菜单、弹出框、下拉选择、对话框、抽屉、错误卡 | `pg-float`,全站唯一。菜单类 `rounded-2xl`,对话框 `rounded-3xl`,调用处不改底色、边框、投影。菜单里再弹出的子菜单、手机底部抽屉加 `pg-float-solid`(不透明),避免透出下面一层 |
| 悬浮层:下拉菜单、弹出框、下拉选择、对话框、抽屉、错误卡、提示条、图表提示 | `pg-float`,全站唯一。菜单类 `rounded-2xl`,对话框 `rounded-3xl`,调用处不改底色、边框、投影。对话框、确认框、抽屉再加 `pg-float-modal`(底取 `--mat-float-modal-bg`,由 `Dialog` / `AlertDialog` / `Sheet` 挂上)。菜单里再弹出的子菜单、手机底部抽屉加 `pg-float-solid`(不透明),避免透出下面一层。本项目的 `pg-float` 底是不透明的 popover 实底、不做背景模糊(SEARCH-4,2026-09-30:半透明弹层压在资源列表上看不清),立体感只靠边光与投影 |
| 药丸 HUD、徽章等小型悬浮元素 | `pg-pill` |
| 悬停提示 | 照旧写 `title`,根组件里的 [GlassTooltip](../../apps/web/app/components/glass-tooltip.tsx) 把原生提示换成 `pg-float` 气泡;元素在液态玻璃里时,气泡以那块玻璃的边为准,不压在玻璃上 |
新组件先确认它是贴边、内容还是悬浮,再复用对应样式。滚动行卡片本体和高频跟随鼠标的进度气泡不加背景模糊。
@@ -70,11 +70,11 @@
主操作 `pill-primary`:
- 一圈光谱沿边框转动,6 秒一圈,内侧透一层很淡的同色光;
- 悬停时变亮,禁用或减少动态效果时停转;
- 参数是 `--mat-pill-primary-*` 与 `--prism-*`。
- 光从左上照来:左上棱边是一段光谱高光,右下是一段更淡的回光,内侧透一层很淡的同色光;本体与次按钮同为 lens 玻璃;
- 静止不转,悬停时变亮并沿边扫过一小段,减少动态效果时只变亮;
- 参数是 `--mat-lens-*`(本体)与 `--prism-*`(高光)。
次操作 `pill-secondary` 与选中态 `pill-selected` 分别由 `--mat-pill-secondary-*`、`--mat-pill-selected-*` 控制。按下回弹 `scale(0.96~0.97)`。
次操作 `pill-secondary` 是 lens 玻璃(`--mat-lens-*`),选中态 `pill-selected` 比它多一层填充(`--mat-pill-selected-*`)。按下回弹 `scale(0.96~0.97)`。
动效:
@@ -82,6 +82,20 @@
- 需要鼠标反馈的卡片(套餐卡)挂 `.pg-spotlight`,并用 `rippleMove` 写入 `--mx/--my`。光在卡面底色之上、文字之下,亮度参数是 `--mat-spotlight`,只在能悬停的设备上出现。
- `.pg-beam` 巡边光只留给少数常驻小徽标。
## 液态玻璃
压在大图、海报、视频上的控件用液态玻璃:边缘一圈把背后的画面弯折放大,中间不变形。组件是 [liquid-surface.tsx](../../apps/web/app/components/liquid-surface.tsx)(封装 `simple-liquid-glass`),按尺寸分三档:
- `lens`:小控件。按钮本身的材质参数是 `--mat-lens-*`(几乎透明的填充、左上亮右下暗的内阴影边光、外侧 1px 暗线);压在大图上的主按钮和图标键外面再套 `LiquidLens` 加真折射,按钮挂 `.btn-liquid` 去掉自己的底。主按钮的折射边缘带少量色散;尺寸会动画变化、不适合套折射的自定义控件用 `.lens-glass`;
- `regular`:栏与胶囊。控制台顶栏右侧的胶囊(通知、智能体、日夜切换、账户)用它的只模糊档(`noRefraction`);
- `frost`:字多的悬浮面板,只做真模糊(24px)加一层底色,不折射:以可读为先。
全站其余玻璃不折射,但用同一套边光语言:左上亮、右下暗的 1px 内阴影边光,加外侧 1px 暗线。包括内容卡 `pg-slab`、悬浮层 `pg-float`(提示条也走它:状态色只在图标上,不开 `richColors`;展开排列不叠放)、选中态 `pill-selected`、分段滑块 `seg-thumb`、药丸 `pg-pill`。激活态(菜单展开)在玻璃里多一层淡填充,不改描边。压在图片上的小控件(海报角标、状态圆片)统一用烟色小玻璃 `glass-chip-media`,白字,状态色只留在圆点上(`StatusBadge onMedia`);页面底上的同类圆片用 `glass-chip`,参数是 `--mat-chip-glass`。贴边的 `pg-surface` / `pg-chrome` 不加边光。
折射只有 Chromium 桌面可见,其他浏览器与触屏设备由组件退回磨砂。背后没有画面的地方(内容卡、表单)不用液态玻璃,折射看不出来。玻璃之间至少留 8px,不叠放。含玻璃的弹层外壳不能挂一直生效的透明度动画(`animate-pg-pop` 用 `backwards`):Chromium 会把它当成背景根,里面的模糊采样不到页面。
本项目里压在画面上的只有作品详情头图(见下文)和发现页海报的角标;头图的操作按钮坐在近乎不透明的底色渐变上,不在剧照像素上,所以用普通按钮、不套 `LiquidLens`。
## 流媒体大图
流媒体首页与详情页的大图通栏显示,跟随日夜:
@@ -94,7 +108,7 @@
画面层下面垫一层页面底色,加载占位 `BackdropSkeleton` 用同一个盒子、同样的渐隐。这样图片没到、数据没到的时候,顶栏下面也不会先露出后面的棱镜漏光再被盖掉,切换首页和详情页时顶栏颜色不跳。
列表页(电影、剧集、收藏、合集、演职人员)不做色带:标题、筛选条和内容坐在同一张页面底上。吸顶筛选条 `StickyFilterBar` 平时透明,滚到顶栏下面吸住之后才出现磨砂底,和顶栏连成一条。
列表页(电影、剧集、收藏、合集、演职人员)不做色带:标题、筛选条和内容坐在同一张页面底上。筛选条 `StickyFilterBar` 平时透明。桌面吸顶,吸住后磨砂底向上延伸盖住顶栏区域(顶栏本身不铺底),顶栏胶囊浮在这块磨砂上。手机不吸顶,随正文滚走,滚过后由顶栏 Logo 右侧的筛选胶囊 `HeaderFilterCapsule` 接替,点开选分类和排序;不做常驻吸顶,也不做上滑弹出,底部栏不再加入口。
## 控制台作品详情头图
@@ -103,11 +117,15 @@
- 剧照铺在内容区顶部(左右伸到内容区的内边距,向上伸到返回链接后面),`.media-hero-backdrop` 让剧照上下和右缘渐隐成透明,露出页面底色与光斑,不做圆角卡片;
- 剧照上压一层页面底色的渐变(`--mat-hero-scrim-side`):左侧文字区近乎不透明、右侧露出剧照;窄屏海报与文字上下排,改为自上而下压底色(`--mat-hero-scrim-stack`);
- 文字用常规前景色 token,日夜同一结构,只是 `--background` 不同;没有背景图时不渲染剧照层,就是普通头部;
- 播出状态与作品状态 / Emby 徽标在同一行;操作按钮在外链行下方,「订阅」是唯一的实心主操作,其余描边。
- 播出状态与作品状态 / Emby 徽标在同一行;操作按钮在外链行下方,「订阅」是唯一的主操作(普通 `pill-primary`),其余是次操作。2026-10-07 曾因转动的光谱环在剧照上看不清改用主色实心 `pill-solid`;静态的左上光谱高光在剧照上看得清,2026-10-08 起取消这个例外。
## 其他
- 侧栏的选中项由滑块(`seg-thumb`)承担主要形态,导航文字与 hover 保持清晰;分组展开、滚动和路由变化都会影响滑块位置。
- 控制台侧栏贴边,不做浮起卡片;右边不画硬分隔线,由 `pg-chrome-side` 给内侧细亮边、外侧细暗线和淡投影。分组小标题(概览、获取、整理、系统)保留:它们和菜单项不同名,是导航的分区。折叠开关在标志行:展开时在名称右侧,收起后标志悬停变为展开图标。
- 控制台顶栏与登录页顶栏不铺磨砂(磨砂栏会成为背景根,里面的玻璃糊不到页面),用页面底色渐隐托住标题;控制台右侧通知、智能体、日夜切换与账户收进一枚玻璃胶囊,胶囊里的按钮是平的(`bare`);登录页右侧只有日夜切换一颗,不套胶囊。
- 登录页只留标题(初始化和二次验证保留一句操作说明),整页居中一张卡,桌面与手机同一套;卡片是 `pg-slab auth-card`(底换成实在的磨砂 `--mat-auth-card-bg`,卡片、输入框、页面三层分得开)。
- 输入框、多行输入与下拉选择的触发器统一用 `.field-glass`(淡底 + 1px 实边,聚焦 / 展开时外线加深并带淡光晕,出错时红线;参数 `--mat-field-*`),由 `Input` / `Textarea` / `SelectTrigger` 挂上;原生 `<select>` 与长得像下拉的按钮也挂它。调用处只给尺寸和内边距,不再各写底色、边框、阴影。
- 改支付界面的样式时,要覆盖确认、二维码、错误与返回路径。套餐的优惠金额从价格推导,不写死。交易摘要用现有的 RemarkText 展示,不为排版改写后端摘要的含义。
主动使用 Taste 的触发条件和项目适配见前端规范。
+11 -8
View File
@@ -1,6 +1,6 @@
# 控件使用标准
> 迁入自 /opt/uhd_web `docs/features/`(2026-09-27)。文中流媒体、播放器、落地页、套餐等段落是 uhd_web 的原始上下文,本项目没有这些页面,只作材质与 token 用法参考;以本目录 README 的约定为准。
> 迁入自 /opt/uhd_web `docs/features/`(2026-09-27),2026-10-08 与 uhd_web 同步。文中流媒体、播放器、落地页、套餐等段落是 uhd_web 的原始上下文,本项目没有这些页面,只作材质与 token 用法参考;以本目录 README 的约定为准。
配合[设计 token](design-tokens.md)与[界面材质](interface-materials.md)。开发态的陈列页 `/dev/ui`(只在开发构建注册,[routes.ts](../../apps/web/app/routes.ts))把三块壳下的 token、材质和控件排在一页,换皮或改 token 先看它。
@@ -10,16 +10,15 @@
| 角色 | 门户写法 | 流媒体写法 | 外观 |
|---|---|---|---|
| 主操作 | `<Button>` | `btn-glass btn-glass-primary` | 半透明胶囊 + 流转的棱镜光谱边框,不做实心 |
| 次操作 | `<Button variant="outline">`(`secondary` 同样子) | `btn-glass` | 描边胶囊 |
| 图标钮 | `<Button variant="outline" size="icon*">` | `btn-glass-icon` | 描边正圆 |
| 主操作 | `<Button>` | `btn-glass btn-glass-primary` | lens 玻璃胶囊 + 左上棱边的光谱高光(静止,悬停沿边扫过一小段),不做实心 |
| 次操作 | `<Button variant="outline">`(`secondary` 同样子) | `btn-glass` | lens 玻璃胶囊(边光 + 外侧细线) |
| 图标钮 | `<Button variant="outline" size="icon*">` | `btn-glass-icon` | lens 玻璃正圆 |
| 只有文字 / 图标 | `<Button variant="ghost">` | 各自的 ghost 行 | 悬停出淡底 |
| 危险操作 | `<Button variant="destructive">` | — | 淡红底红字胶囊 |
| 头图区主操作(例外) | `<Button variant="solid">` | — | 主色实心胶囊(`pill-solid`),只用于作品详情头图区的「订阅」 |
一屏(或一张卡片)只放一个主操作,并列的其余动作退为描边;前置条件没满足时,真正该做的那一步是主操作(例如团队页没有团队套餐时,「创建团队套餐」是主操作,「添加成员」退为描边)。
两边的底、边、字色、投影都引用 `--mat-pill-*`,改外观只改参数。调用处只给布局(宽度、高度、间距、定位),不改圆角、底色、边框、投影和字色;不是 `<button>` 的元素(`Link`、`a`)用 `buttonVariants()` 拿同一套类名。分段标签、菜单行、列表行不算按钮,按各自控件规范;分页当前页、角标、选中的季 / 集 / 选项、日历选中日用静态的 `pill-selected`,不借主按钮样式;分段控件(侧栏、SegmentedTabs、求片页的分段)的滑块用 `seg-thumb`。放胶囊或分段控件的横向滚动容器加 `scroll-bleed`:`overflow-x-auto` 会连带裁掉纵向溢出,不加的话胶囊的阴影和描边会被切成直边。
两边的底、边、字色、投影都引用 `--mat-lens-*` / `--mat-pill-*`,改外观只改参数。弹窗底部的按钮行间距 12px(`gap-3`):按钮带外侧细线与光谱高光,8px 会挤在一起。放进玻璃胶囊里的按钮(控制台顶栏的通知、日夜切换、账户)用 `ghost`,不再自带玻璃。调用处只给布局(宽度、高度、间距、定位),不改圆角、底色、边框、投影和字色;不是 `<button>` 的元素(`Link`、`a`)用 `buttonVariants()` 拿同一套类名。分段标签、菜单行、列表行不算按钮,按各自控件规范;分页当前页、角标、选中的季 / 集 / 选项、日历选中日用静态的 `pill-selected`,不借主按钮样式;分段控件(侧栏、SegmentedTabs、求片页的分段)的滑块用 `seg-thumb`。放胶囊或分段控件的横向滚动容器加 `scroll-bleed`:`overflow-x-auto` 会连带裁掉纵向溢出,不加的话胶囊的阴影和描边会被切成直边。
右上角账户模块全站共用 `account-popover.tsx`:用户中心用头像 + 名字的胶囊,流媒体与测速用圆头像;菜单里是另外两块的跳转(当前所在的不列,流媒体与测速需有生效订阅)与退出登录。
@@ -27,8 +26,8 @@
## 尺寸与圆角
- 图标钮:桌面 36px,粗指针 40px(`[@media(pointer:coarse)]`);播放键等主钮 44 / 48。
- 圆角角色:控件 `rounded-md`、输入与小按钮 `rounded-lg`、卡片 `rounded-xl`、面板 `rounded-2xl`、抽屉与大卡 `rounded-3xl`、药丸 `rounded-full`、徽章与骨架屏 `rounded-tight`。
- 图标钮:桌面 36px,触屏 40px(`touch:` 变体,见下文「触屏判断」);播放键等主钮 44 / 48。
- 圆角角色:控件 `rounded-md`、小按钮 `rounded-lg`、输入框与下拉选择 `rounded-xl`(由 `.field-glass` 的原语给)、卡片 `rounded-xl`、面板 `rounded-2xl`、抽屉与大卡 `rounded-3xl`、药丸 `rounded-full`、徽章与骨架屏 `rounded-tight`。
- 字号:控件文字 `text-control`(15),菜单项 `text-body` / `text-caption`,面板小标签 `text-label` 大写 `tracking-[0.18em]`。
## 状态反馈
@@ -61,3 +60,7 @@
- 参数只到必要:尺寸档、是否激活、标签、图标;不把整页状态传给控件。
- 只有一处用的局部组件留在页面文件里,不为了"以后复用"提前抽。
- 两个页面展示同一种信息时共用零件:套餐卡用 plan-card-parts.tsx(名称价格、规格表、线路提醒、共有权益),电影与剧集详情用 DetailExtras.tsx(规格徽章、影片信息、可播放版本、更多推荐)。
## 触屏判断
触屏还是鼠标由 [input-modality.ts](../../apps/web/app/lib/input-modality.ts) 统一判断,写在 `<html data-input>` 上:样式用 `touch:` 变体(代替 `[@media(pointer:coarse)]:`),组件用 `useTouchInput()`,处理指针事件用 `pointerKind(e)`,不要直接比较 `pointerType`。车机、触屏一体机常把触屏报告成鼠标,只看 `(pointer: coarse)` 会按电脑处理。`hover:` 在触屏模式下不生效,触屏上必须能用的操作不要只靠悬停出现。界面上不给用户选;排障时可在地址加 `?input=touch|mouse|auto` 手动指定。