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:
1 parent
d842bb7fb4
commit
09a2adb637
38 files changed
+941
-356
No files matched your search
+274
-221
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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",
|
||||
)}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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)",
|
||||
|
||||
@@ -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 ? "切换到亮色主题" : "切换到暗色主题"}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
// 危险操作:同一胶囊,淡红底红字
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>) {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user