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

@@ -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,
);
}