diff --git a/frontend/web/src/admin/AdminApp.tsx b/frontend/web/src/admin/AdminApp.tsx index be00da3..b81f401 100644 --- a/frontend/web/src/admin/AdminApp.tsx +++ b/frontend/web/src/admin/AdminApp.tsx @@ -3,6 +3,7 @@ import { applyTheme, readTheme, type ThemeMode } from "../lib/theme"; import { getAuthToken, getStoredRole, health } from "../lib/api"; import { useAppStore } from "../store/useAppStore"; import type { WsStatus } from "../lib/types"; +import { UiModeSwitch } from "../design/UiModeSwitch"; import { ADMIN_GROUPS, ADMIN_SECTIONS } from "./sections"; import { navigateAdmin, type AdminSectionId } from "./router"; import { Button, Tag, type TagTone } from "../components/primitives"; @@ -226,6 +227,11 @@ function AdminTopBar({ section, onMenu }: { section: AdminSectionId; onMenu?: () ) : null} + {/* 双档 UI 档位(裁决 16.4.4):面板顶栏也吃档位,胶囊形态对齐其余 h-8 按钮。 + 与用户端顶栏同理:窄屏收起( + + + {/* 双档 UI 档位(裁决 16.4.4):胶囊形态,尺寸对齐本栏其余 h-8 按钮。 + 窄屏( + + + ); +} + +/** 分段控件:Miuix Switch/BottomNav 的弹簧滑块,两段各带说明 tooltip。 */ +function SegmentedUiModeSwitch({ + mode, + onPick, + className, +}: { + mode: UiMode; + onPick: (next: UiMode) => void; + className: string; +}): React.ReactNode { + const index = UI_MODE_ORDER.indexOf(mode); + return ( +
+
+ ); +} diff --git a/frontend/web/src/design/tokens.css b/frontend/web/src/design/tokens.css index 55522a6..eb04bf3 100644 --- a/frontend/web/src/design/tokens.css +++ b/frontend/web/src/design/tokens.css @@ -8,6 +8,14 @@ * 4. 深色优先 —— 默认深色,Light/Dark 无感切换(--mu-light 覆盖) * 5. 毛玻璃 —— backdrop-filter(--mu-glass-*) * 6. Liquid Glass —— 液体玻璃寓意(折射/内高光) + * + * 双档 UI 模式(裁决 16.4.4 / design/07 §0.1): + * - 默认 = **UI 性能模式**:即上面这套 token 的近似档(Smooth Corner 走 border-radius + * 近似、Liquid Glass 走单向高光),渲染开销最低; + * - **华丽档** = `:root.ui-mode-fancy` 下的覆盖(本文末):连续曲率角型 + * (corner-shape,k=0.55)+ 完整 Liquid Glass(4 折射 / RGB 色差 / 鼠标跟踪 / 双层动态边框)。 + * 档位由 `src/design/uiMode.ts` 打在 上;本文新增的 fancy 专用 token 在 + * `:root` 里都有中性默认值,**性能档下不产生任何视觉差异**。 */ :root { @@ -97,3 +105,100 @@ --mu-liquid-highlight: rgba(255, 255, 255, 0.6); --mu-liquid-rim: rgba(255, 255, 255, 0.7); } + +/* ─────────────────────────────────────────────────────────────────────────── + 双档 UI 模式的 fancy 专用 token(性能档 = 中性默认值,等于「不生效」) + ─────────────────────────────────────────────────────────────────────────── */ + +:root { + --mu-corner-shape: round; /* 性能档:普通圆角(border-radius 近似) */ + --mu-glass-saturate: 1.4; + --mu-edge-light: rgba(255, 255, 255, 0.05); + --mu-liquid-specular: transparent; /* 鼠标跟踪的镜面高光(fancy 才着色) */ + --mu-liquid-caustic: transparent; /* 玻璃下方的焦散光斑 */ + --mu-liquid-chroma-r: transparent; /* RGB 色差分离(红/蓝偏移) */ + --mu-liquid-chroma-b: transparent; + --mu-liquid-ring: var(--mu-liquid-rim); /* 双层动态边框 */ + --mu-liquid-ring-dur: 0s; + --mu-ambient: none; /* 环境光(多层 Surface 深度) */ + --mu-elev-1: var(--mu-shadow-1); /* 分层阴影(性能档 = 现有单层阴影) */ + --mu-elev-2: var(--mu-shadow-2); + --mu-elev-3: var(--mu-shadow-3); + --mu-spring-slow: var(--mu-spring); +} + +/* 华丽档:连续曲率 + 完整 Liquid Glass + 分层深度 + 更弹的动效。 + 只放大半径与视觉层,**不改任何几何尺寸**,所以切档不重排布局。 */ +:root.ui-mode-fancy { + /* 1. Smooth Corner —— k=0.55 连续曲率:改用 squircle 角型,半径同步放大一档 */ + --mu-corner-shape: squircle; + --mu-radius-sm: 0.5625rem; + --mu-radius: 0.6875rem; + --mu-radius-lg: 1.125rem; + --mu-radius-xl: 1.75rem; + + /* 5. 毛玻璃 —— 更厚、更透、更饱和 */ + --mu-glass-blur: 28px; + --mu-glass-alpha: 0.54; + --mu-glass-saturate: 1.75; + --mu-glass-border: rgba(255, 255, 255, 0.12); + + /* 6. Liquid Glass —— 高光 / 折射描边 / 焦散 / 色差 / 双层动态边框 */ + --mu-liquid-highlight: rgba(255, 255, 255, 0.24); + --mu-liquid-rim: rgba(255, 255, 255, 0.14); + --mu-liquid-specular: rgba(255, 255, 255, 0.42); + --mu-liquid-caustic: rgba(120, 180, 255, 0.18); + --mu-liquid-chroma-r: rgba(255, 90, 90, 0.42); + --mu-liquid-chroma-b: rgba(80, 150, 255, 0.42); + --mu-liquid-ring: rgba(255, 255, 255, 0.28); + --mu-liquid-ring-dur: 7s; + --mu-edge-light: rgba(255, 255, 255, 0.1); + + /* 2. 多层 Surface —— 环境光 + 分层堆叠阴影制造深度 */ + --mu-ambient: + radial-gradient(120% 90% at 12% -10%, rgba(61, 107, 255, 0.16), transparent 60%), + radial-gradient(100% 80% at 100% 0%, rgba(157, 90, 255, 0.12), transparent 55%), + radial-gradient(90% 70% at 50% 120%, rgba(0, 200, 180, 0.08), transparent 60%); + --mu-elev-1: + 0 1px 1px rgba(0, 0, 0, 0.35), + 0 2px 6px rgba(0, 0, 0, 0.3), + inset 0 1px 0 var(--mu-liquid-rim); + --mu-elev-2: + 0 2px 4px rgba(0, 0, 0, 0.35), + 0 12px 28px rgba(0, 0, 0, 0.42), + 0 24px 64px rgba(0, 0, 0, 0.28), + inset 0 1px 0 var(--mu-liquid-rim); + --mu-elev-3: + 0 8px 20px rgba(0, 0, 0, 0.4), + 0 32px 80px rgba(0, 0, 0, 0.5), + inset 0 1px 0 var(--mu-liquid-rim); + + /* 3. 弹簧动效 —— 华丽档把弹簧用到更多交互,时长略长、过冲可见 */ + --mu-dur: 320ms; + --mu-dur-slow: 520ms; + --mu-spring-slow: cubic-bezier(0.34, 1.56, 0.64, 1); +} + +/* 深色优先:浅色主题下华丽档改用亮色玻璃(白边会糊在浅底上,必须换色) */ +:root.ui-mode-fancy.theme-light { + --mu-glass-border: rgba(0, 0, 0, 0.08); + --mu-liquid-highlight: rgba(255, 255, 255, 0.72); + --mu-liquid-rim: rgba(255, 255, 255, 0.8); + --mu-liquid-specular: rgba(255, 255, 255, 0.85); + --mu-liquid-caustic: rgba(80, 140, 255, 0.14); + --mu-liquid-chroma-r: rgba(220, 60, 60, 0.28); + --mu-liquid-chroma-b: rgba(60, 120, 255, 0.28); + --mu-liquid-ring: rgba(0, 0, 0, 0.16); + --mu-edge-light: rgba(255, 255, 255, 0.9); + --mu-ambient: + radial-gradient(120% 90% at 12% -10%, rgba(61, 107, 255, 0.12), transparent 60%), + radial-gradient(100% 80% at 100% 0%, rgba(157, 90, 255, 0.1), transparent 55%), + radial-gradient(90% 70% at 50% 120%, rgba(0, 180, 170, 0.08), transparent 60%); +} + +/* 双层动态边框要跑动画就得让角度可插值(不支持 @property 的浏览器退化为静态双层边框) */ +@property --mu-ring-angle { + syntax: ""; + inherits: false; + initial-value: 0deg; +} diff --git a/frontend/web/src/design/uiMode.test.ts b/frontend/web/src/design/uiMode.test.ts new file mode 100644 index 0000000..e1672fc --- /dev/null +++ b/frontend/web/src/design/uiMode.test.ts @@ -0,0 +1,150 @@ +// Logic test for the Miuix dual-tier UI mode (裁决 16.4.4 / design/07 §0.1): +// 默认档 = UI 性能模式、未知值回落、持久化往返、存储不可用降级、类名/属性落地。 +// +// The project has no frontend test runner, so this is a dependency-free plain-node +// check that drives the real module and throws on the first failure. Run it with +// the esbuild that already ships with vite (no new dependency, no package.json +// change): +// +// cd frontend/web \ +// && ./node_modules/.bin/esbuild src/design/uiMode.test.ts \ +// --bundle --platform=node --format=esm \ +// --outfile=/tmp/cyrene-uimode.test.mjs \ +// && node /tmp/cyrene-uimode.test.mjs +// +// `tsc --noEmit` (part of `npm run build`) type-checks this file, which is why it +// imports nothing from node:*. + +import { + DEFAULT_UI_MODE, + UI_MODE_ATTR, + UI_MODE_CLASS, + UI_MODE_HINT, + UI_MODE_LABEL, + UI_MODE_ORDER, + UI_MODE_STORAGE_KEY, + applyUiMode, + isUiMode, + normalizeUiMode, + readUiMode, + setUiMode, + toggleUiMode, + writeUiMode, + type UiMode, + type UiModeStorage, +} from "./uiMode"; + +let checks = 0; + +function ok(cond: boolean, what: string): void { + checks += 1; + if (!cond) throw new Error(`FAILED: ${what}`); +} + +function eq(got: string, want: string, what: string): void { + ok(got === want, `${what} — got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`); +} + +/** 内存版 localStorage。 */ +function memoryStorage(seed: Record = {}): UiModeStorage & { raw: Map } { + const raw = new Map(Object.entries(seed)); + return { + raw, + getItem: (key: string) => raw.get(key) ?? null, + setItem: (key: string, value: string) => { + raw.set(key, value); + }, + }; +} + +/** 隐私模式 / 禁用存储:读写都抛。 */ +const throwingStorage: UiModeStorage = { + getItem: () => { + throw new Error("storage disabled"); + }, + setItem: () => { + throw new Error("storage disabled"); + }, +}; + +/** 最小 DOM 替身:applyUiMode 只用 classList.add/remove + setAttribute。 */ +function fakeRoot(): { classes: Set; attrs: Map; el: HTMLElement } { + const classes = new Set(); + const attrs = new Map(); + const el = { + classList: { + add: (name: string) => classes.add(name), + remove: (name: string) => classes.delete(name), + contains: (name: string) => classes.has(name), + }, + setAttribute: (key: string, value: string) => attrs.set(key, value), + getAttribute: (key: string) => attrs.get(key) ?? null, + } as unknown as HTMLElement; + return { classes, attrs, el }; +} + +// 默认档 = UI 性能模式(裁决 16.4.4 / design/07 §0.1「默认 = UI 性能档」)。 +eq(DEFAULT_UI_MODE, "performance", "默认档是 UI 性能模式"); +eq(UI_MODE_ORDER[0], DEFAULT_UI_MODE, "档位顺序首项 = 默认档"); +eq(String(UI_MODE_ORDER.length), "2", "只有两档"); + +// 空存储 → 默认档。 +eq(readUiMode(memoryStorage()), "performance", "空存储读默认档"); + +// 未知 / 损坏 / 大小写不符的值一律回落默认档(前向兼容,不抛)。 +const junk: Array = ["", " ", "FANCY", "Fancy", "fancyx", "performance ", "2", null, undefined]; +for (const value of junk) { + eq(normalizeUiMode(value), DEFAULT_UI_MODE, `未知值 ${JSON.stringify(value)} 回落默认档`); +} +eq(normalizeUiMode("fancy"), "fancy", "合法值 fancy 保留"); +eq(normalizeUiMode("performance"), "performance", "合法值 performance 保留"); +ok(isUiMode("fancy") && isUiMode("performance"), "isUiMode 认两档"); +ok(!isUiMode("FANCY") && !isUiMode("") && !isUiMode(null), "isUiMode 拒绝非法值"); + +// 持久化往返:写进去必须读得回来(这是「开关状态存 localStorage」的核心断言)。 +const store = memoryStorage(); +writeUiMode("fancy", store); +eq(readUiMode(store), "fancy", "fancy 持久化往返"); +eq(store.raw.get(UI_MODE_STORAGE_KEY) ?? "", "fancy", "落盘键名与值"); +writeUiMode("performance", store); +eq(readUiMode(store), "performance", "回切 performance 也往返"); + +// setUiMode(切档入口)同样落盘;无 DOM 环境下仍不抛。 +setUiMode("fancy", store); +eq(readUiMode(store), "fancy", "setUiMode 落盘"); +setUiMode("bogus" as UiMode, store); +eq(readUiMode(store), "performance", "setUiMode 非法值回落默认档并落盘"); + +// 存储不可用(隐私模式)→ 读默认档、写不抛(退化为仅本次会话生效)。 +eq(readUiMode(throwingStorage), "performance", "存储抛错时读默认档"); +writeUiMode("fancy", throwingStorage); +eq(readUiMode(throwingStorage), "performance", "存储抛错时不崩且仍读默认档"); +eq(readUiMode(null), "performance", "无存储对象时读默认档"); + +// 两档互切。 +eq(toggleUiMode("performance"), "fancy", "performance → fancy"); +eq(toggleUiMode("fancy"), "performance", "fancy → performance"); + +// 类名/属性落地:切档只改 (不碰组件树 ⇒ 不丢会话状态)。 +const root = fakeRoot(); +applyUiMode("fancy", root.el); +ok(root.classes.has(UI_MODE_CLASS.fancy), "华丽档加 ui-mode-fancy 类"); +ok(!root.classes.has(UI_MODE_CLASS.performance), "华丽档移除 ui-mode-performance 类"); +eq(root.attrs.get(UI_MODE_ATTR) ?? "", "fancy", "data-ui-mode = fancy"); +applyUiMode("performance", root.el); +ok(root.classes.has(UI_MODE_CLASS.performance), "性能档加 ui-mode-performance 类"); +ok(!root.classes.has(UI_MODE_CLASS.fancy), "性能档移除 ui-mode-fancy 类"); +eq(root.attrs.get(UI_MODE_ATTR) ?? "", "performance", "data-ui-mode = performance"); +applyUiMode("nonsense" as UiMode, root.el); +ok(root.classes.has(UI_MODE_CLASS.performance), "非法档位回落到性能档类名"); + +// 无 root(SSR / 无 DOM)时不抛。 +applyUiMode("fancy", null); + +// 开关文案齐全(两档都有标签与说明,UI 不会出现空档位)。 +for (const id of UI_MODE_ORDER) { + ok(UI_MODE_LABEL[id].length > 0, `${id} 有标签`); + ok(UI_MODE_HINT[id].length > 0, `${id} 有说明`); +} + +console.log(`uiMode.test.ts OK — ${checks} checks`); diff --git a/frontend/web/src/design/uiMode.ts b/frontend/web/src/design/uiMode.ts new file mode 100644 index 0000000..526fdda --- /dev/null +++ b/frontend/web/src/design/uiMode.ts @@ -0,0 +1,360 @@ +/** + * Cyrene v2 —— Miuix 双档 UI 模式(裁决 16.4.4;规格 design/07 §0.1) + * + * 两档(用户可切;状态持久化在前端 localStorage,不改后端契约 —— 裁决 Q12): + * - `performance`:**UI 性能模式**(默认档)= 现状 token 近似档 + * (Smooth Corner 走 `border-radius` 近似 + 单向高光),渲染开销最低。 + * - `fancy`:**华丽档** = Smooth Corner 连续曲率(k=0.55,`corner-shape: squircle`) + * + 完整 Liquid Glass(4 折射模式 / RGB 色差分离 / 鼠标跟踪弹性变形 / 双层动态边框)。 + * + * 落地方式:只改 `` 上的类名 + 属性(视觉全交给 CSS),**不触碰 React 树**, + * 因此切换不重挂载、不丢会话状态、几何尺寸不变(只变视觉层)⇒ 无明显闪烁。 + * + * 本模块**不导入 React**(纯逻辑 + 受保护的 DOM 访问),可用无依赖 node 脚本直测; + * React 侧入口见同目录 `UiModeSwitch.tsx`(`useUiMode()` / ``)。 + */ + +export type UiMode = "performance" | "fancy"; + +/** localStorage 键:档位偏好(仅前端本地,不进后端契约)。 */ +export const UI_MODE_STORAGE_KEY = "cyrene.ui-mode"; +/** `` 上的档位属性(值即档位名;CSS/调试用,与类名等价)。 */ +export const UI_MODE_ATTR = "data-ui-mode"; +/** `` 上的档位类名(CSS 的唯一开关)。 */ +export const UI_MODE_CLASS: Record = { + performance: "ui-mode-performance", + fancy: "ui-mode-fancy", +}; +/** 档位顺序(开关按此顺序渲染;首项为默认档)。 */ +export const UI_MODE_ORDER: readonly UiMode[] = ["performance", "fancy"]; +/** 默认档 = UI 性能模式(裁决 16.4.4 + design/07 §0.1「默认 = UI 性能档」)。 */ +export const DEFAULT_UI_MODE: UiMode = "performance"; +/** 档位短标签(开关 UI)。 */ +export const UI_MODE_LABEL: Record = { + performance: "性能档", + fancy: "华丽档", +}; +/** 档位说明(开关的 title / 辅助文本)。 */ +export const UI_MODE_HINT: Record = { + performance: "UI 性能模式(默认):圆角与玻璃走 token 近似,渲染开销最低", + fancy: "华丽档:Smooth Corner 连续曲率 + 完整 Liquid Glass(4 折射 / RGB 色差 / 鼠标跟踪 / 双层动态边框)", +}; +/** 同页广播事件名:`storage` 只在其它标签页触发,本页切换要靠它同步所有开关实例。 */ +export const UI_MODE_EVENT = "cyrene:ui-mode"; + +/** 只用到读写两个方法,方便测试注入假存储。 */ +export interface UiModeStorage { + getItem(key: string): string | null; + setItem(key: string, value: string): void; +} + +export function isUiMode(value: unknown): value is UiMode { + return value === "performance" || value === "fancy"; +} + +/** + * 只认两档的契约值;**未知/损坏值一律回落默认档**(前向兼容:将来若新增第三档, + * 老客户端按默认档渲染而不是崩溃/空白)。 + */ +export function normalizeUiMode(value: string | null | undefined): UiMode { + return isUiMode(value) ? value : DEFAULT_UI_MODE; +} + +/** 取 localStorage;隐私模式/禁用存储时会抛,按「无存储」处理(默认档,仅本次会话生效)。 */ +function defaultStorage(): UiModeStorage | null { + try { + if (typeof localStorage === "undefined") return null; + return localStorage; + } catch { + return null; + } +} + +function documentRoot(): HTMLElement | null { + try { + return typeof document === "undefined" ? null : document.documentElement; + } catch { + return null; + } +} + +/** 读持久化档位(无存储/坏值 → 默认档)。 */ +export function readUiMode(storage: UiModeStorage | null = defaultStorage()): UiMode { + if (!storage) return DEFAULT_UI_MODE; + try { + return normalizeUiMode(storage.getItem(UI_MODE_STORAGE_KEY)); + } catch { + return DEFAULT_UI_MODE; + } +} + +/** 持久化档位(写失败不抛:退化为「仅本次会话生效」)。 */ +export function writeUiMode(mode: UiMode, storage: UiModeStorage | null = defaultStorage()): void { + if (!storage) return; + try { + storage.setItem(UI_MODE_STORAGE_KEY, normalizeUiMode(mode)); + } catch { + // 忽略:存储不可用不该让切换本身失败。 + } +} + +/** 两档互切。 */ +export function toggleUiMode(mode: UiMode): UiMode { + return mode === "fancy" ? "performance" : "fancy"; +} + +/** 把档位落到 ``:类名 + `data-ui-mode`;华丽档同时装载运行时(折射滤镜 + 鼠标跟踪)。 */ +export function applyUiMode(mode: UiMode, root: HTMLElement | null = documentRoot()): void { + if (!root) return; + const next = normalizeUiMode(mode); + // 先备好运行时(注入折射滤镜 + 指针跟踪),再上类名,保证滤镜引用一定已就位。 + if (next === "fancy") ensureLiquidGlassRuntime(root); + root.classList.remove(UI_MODE_CLASS[next === "fancy" ? "performance" : "fancy"]); + root.classList.add(UI_MODE_CLASS[next]); + root.setAttribute(UI_MODE_ATTR, next); + if (next !== "fancy") releaseLiquidGlassRuntime(root); +} + +function dispatchUiMode(mode: UiMode): void { + try { + if (typeof window === "undefined") return; + window.dispatchEvent(new CustomEvent(UI_MODE_EVENT, { detail: mode })); + } catch { + // 极端环境(无 CustomEvent)下静默:档位已生效,只是少一次广播。 + } +} + +/** 切档:应用 → 持久化 → 广播(顺序保证视觉即时,且不会因存储失败而回滚)。 */ +export function setUiMode(mode: UiMode, storage: UiModeStorage | null = defaultStorage()): void { + const next = normalizeUiMode(mode); + applyUiMode(next); + writeUiMode(next, storage); + dispatchUiMode(next); +} + +/** `useSyncExternalStore` 的取值快照(值就是 localStorage 里的档位,字符串比较天然稳定)。 */ +export function getUiModeSnapshot(): UiMode { + return readUiMode(); +} + +/** 订阅档位变化(本页广播 + 其它标签页的 storage),返回退订函数。 */ +export function subscribeUiMode(listener: () => void): () => void { + try { + if (typeof window === "undefined") return () => {}; + const onLocal = () => listener(); + window.addEventListener(UI_MODE_EVENT, onLocal); + window.addEventListener("storage", onLocal); + return () => { + window.removeEventListener(UI_MODE_EVENT, onLocal); + window.removeEventListener("storage", onLocal); + }; + } catch { + return () => {}; + } +} + +/** + * 启动装载:读持久化档位并应用,同时挂一次跨标签页同步(别的标签页改档 → 本页跟着变)。 + * 建议在 React 渲染**之前**调用(见回报里的接入点),这样持久化的华丽档不会先闪一帧性能档。 + */ +export function initUiMode(root: HTMLElement | null = documentRoot()): UiMode { + const mode = readUiMode(); + applyUiMode(mode, root); + attachCrossTabSync(root); + return mode; +} + +const SYNC_ATTR = "data-mu-ui-mode-sync"; + +function attachCrossTabSync(root: HTMLElement | null): void { + if (!root || typeof window === "undefined") return; + if (root.getAttribute(SYNC_ATTR) === "ready") return; + root.setAttribute(SYNC_ATTR, "ready"); + window.addEventListener("storage", (event) => { + if (event.key !== UI_MODE_STORAGE_KEY) return; + const mode = normalizeUiMode(event.newValue); + applyUiMode(mode, root); + dispatchUiMode(mode); + }); +} + +/* ── 华丽档运行时:Liquid Glass 的 4 折射模式 + 鼠标跟踪弹性变形 ────────────── + 全部按需装载:只在华丽档下工作,性能档完全不产生监听与滤镜,零额外开销。 + 运行时状态挂在 `` 的 DOM 属性上(不引入模块级可变全局)。 */ + +const RUNTIME_ATTR = "data-mu-liquid-runtime"; +const TRACKER_ATTR = "data-mu-liquid-tracker"; +const LENS_ACTIVE_ATTR = "data-mu-lens-active"; +const REFRACT_HOST_ID = "mu-liquid-refract"; +/** 参与鼠标跟踪的液态玻璃表面(与 index.css 的华丽档选择器一致)。 */ +const LENS_SELECTOR = ".liquid-glass,.liquid-glass-soft,.liquid-glass-accent"; +/** 形变上限(度)与弹性放大:刻意很小,只做「液体被压了一下」的暗示。 */ +const LENS_MAX_TILT_DEG = 1.6; +const LENS_SCALE = 1.008; + +function setRuntimeFlag(root: HTMLElement, on: boolean): void { + root.setAttribute(RUNTIME_ATTR, on ? "on" : "off"); +} + +function runtimeIsOn(): boolean { + const root = documentRoot(); + return root !== null && root.getAttribute(RUNTIME_ATTR) === "on"; +} + +function ensureLiquidGlassRuntime(root: HTMLElement): void { + if (typeof document === "undefined") return; + injectRefractionFilters(); + attachLensTracker(); + setRuntimeFlag(root, true); +} + +function releaseLiquidGlassRuntime(root: HTMLElement): void { + setRuntimeFlag(root, false); + if (typeof document === "undefined") return; + // 清掉指针留在各表面上的内联变量,避免下次回到华丽档时带着旧姿态。 + for (const el of Array.from(document.querySelectorAll(`[${LENS_ACTIVE_ATTR}]`))) { + clearLens(el); + } +} + +/* Liquid Glass 的 4 折射模式:4 个位移折射滤镜,元素用 `data-refract="1..4"` 选用。 */ +const REFRACT_PRESETS = 4; +/** 折射滤镜几何参数(预设 1 最克制,4 最强):噪声频率 / 种子步长 / 模糊 px / 位移 px。 */ +const REFRACT_BASE_FREQ = 0.006; +const REFRACT_FREQ_RATIO = 1.6; +const REFRACT_SEED_STEP = 7; +const REFRACT_BLUR = 1.5; +const REFRACT_DISPLACE_BASE = 2; +const REFRACT_DISPLACE_STEP = 2; + +/** 按预设序号注入 1..4 号滤镜。 */ +function injectRefractionFilters(): void { + if (typeof document === "undefined") return; + if (document.getElementById(REFRACT_HOST_ID)) return; + const host = document.body ?? documentRoot(); + if (!host) return; + const ns = "http://www.w3.org/2000/svg"; + const svg = document.createElementNS(ns, "svg"); + svg.setAttribute("id", REFRACT_HOST_ID); + svg.setAttribute("width", "0"); + svg.setAttribute("height", "0"); + svg.setAttribute("aria-hidden", "true"); + svg.setAttribute("focusable", "false"); + svg.setAttribute("style", "position:absolute;pointer-events:none"); + for (let preset = 1; preset <= REFRACT_PRESETS; preset += 1) { + svg.appendChild(refractionFilter(ns, preset)); + } + host.appendChild(svg); +} + +/** 单个折射滤镜:湍流噪声 → 模糊 → 位移映射(噪声越大,玻璃越「厚」)。 */ +function refractionFilter(ns: string, preset: number): Element { + const filter = document.createElementNS(ns, "filter"); + filter.setAttribute("id", `mu-refract-${preset}`); + filter.setAttribute("x", "-15%"); + filter.setAttribute("y", "-15%"); + filter.setAttribute("width", "130%"); + filter.setAttribute("height", "130%"); + filter.setAttribute("color-interpolation-filters", "sRGB"); + + const freq = REFRACT_BASE_FREQ * preset; + const noise = document.createElementNS(ns, "feTurbulence"); + noise.setAttribute("type", "fractalNoise"); + noise.setAttribute("baseFrequency", `${freq.toFixed(4)} ${(freq * REFRACT_FREQ_RATIO).toFixed(4)}`); + noise.setAttribute("numOctaves", "1"); + noise.setAttribute("seed", String(preset * REFRACT_SEED_STEP)); + noise.setAttribute("result", "noise"); + + const soften = document.createElementNS(ns, "feGaussianBlur"); + soften.setAttribute("in", "noise"); + soften.setAttribute("stdDeviation", String(REFRACT_BLUR)); + soften.setAttribute("result", "softNoise"); + + // 预设 1 最克制(内层玻璃)、4 最强(大块折射),保持「越华丽越夸张」的梯度。 + const displace = document.createElementNS(ns, "feDisplacementMap"); + displace.setAttribute("in", "SourceGraphic"); + displace.setAttribute("in2", "softNoise"); + displace.setAttribute("scale", String(REFRACT_DISPLACE_BASE + preset * REFRACT_DISPLACE_STEP)); + displace.setAttribute("xChannelSelector", "R"); + displace.setAttribute("yChannelSelector", "G"); + + filter.appendChild(noise); + filter.appendChild(soften); + filter.appendChild(displace); + return filter; +} + +function clearLens(el: HTMLElement): void { + el.removeAttribute(LENS_ACTIVE_ATTR); + el.style.removeProperty("--mu-lens-x"); + el.style.removeProperty("--mu-lens-y"); + el.style.removeProperty("--mu-lens-rotate"); + el.style.removeProperty("--mu-lens-scale"); +} + +function clamp01(value: number): number { + return value < 0 ? 0 : value > 1 ? 1 : value; +} + +/** 鼠标跟踪:一个 document 级 passive 监听 + rAF 合帧,只更新指针下的那一个表面。 */ +function attachLensTracker(): void { + const root = documentRoot(); + if (!root || typeof window === "undefined") return; + if (root.getAttribute(TRACKER_ATTR) === "ready") return; + root.setAttribute(TRACKER_ATTR, "ready"); + // 尊重「减弱动效」:不做跟踪形变,玻璃仍保留静态的折射/色差/边框。 + if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) return; + + let active: HTMLElement | null = null; + let frame = 0; + let pointerX = 0; + let pointerY = 0; + + const paint = () => { + frame = 0; + const el = active; + if (!el || !runtimeIsOn()) return; + const rect = el.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) return; + const nx = clamp01((pointerX - rect.left) / rect.width); + const ny = clamp01((pointerY - rect.top) / rect.height); + el.style.setProperty("--mu-lens-x", `${(nx * 100).toFixed(1)}%`); + el.style.setProperty("--mu-lens-y", `${(ny * 100).toFixed(1)}%`); + // rotate 用「轴 + 角度」写法:无透视的 3D 旋转只产生极轻微的横向压扁, + // 不会盖掉组件自身的 transform(两者按 translate→rotate→scale→transform 叠加)。 + const tilt = LENS_MAX_TILT_DEG; + el.style.setProperty("--mu-lens-rotate", `0.85 0.5 0 ${((0.5 - ny) * tilt).toFixed(2)}deg`); + el.style.setProperty("--mu-lens-scale", LENS_SCALE.toFixed(4)); + }; + + const schedule = () => { + if (!frame) frame = window.requestAnimationFrame(paint); + }; + + const onMove = (event: PointerEvent) => { + if (!runtimeIsOn()) { + if (active) clearLens(active); + active = null; + return; + } + const target = event.target instanceof Element ? event.target.closest(LENS_SELECTOR) : null; + if (target !== active) { + if (active) clearLens(active); + active = target; + if (active) active.setAttribute(LENS_ACTIVE_ATTR, ""); + } + if (!active) return; + pointerX = event.clientX; + pointerY = event.clientY; + schedule(); + }; + + const onLeave = () => { + if (active) clearLens(active); + active = null; + }; + + window.addEventListener("pointermove", onMove, { passive: true }); + window.addEventListener("blur", onLeave); + document.addEventListener("pointerleave", onLeave); +} diff --git a/frontend/web/src/main.tsx b/frontend/web/src/main.tsx index 2797481..f09a348 100644 --- a/frontend/web/src/main.tsx +++ b/frontend/web/src/main.tsx @@ -1,6 +1,7 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import App from "./App"; +import { initUiMode } from "./design/uiMode"; import "./styles/index.css"; // PWA: register the service worker (production only — the dev server's caching @@ -16,6 +17,10 @@ if ("serviceWorker" in navigator && import.meta.env.PROD) { const container = document.getElementById("root"); if (!container) throw new Error("#root not found"); +// 双档 UI 模式(裁决 16.4.4):必须在 React 渲染前装载,让持久化的「华丽档」 +// 在首帧就已生效——否则会先闪一帧性能档再切过去。 +initUiMode(); + createRoot(container).render( diff --git a/frontend/web/src/styles/index.css b/frontend/web/src/styles/index.css index ec3bb3d..958cd44 100644 --- a/frontend/web/src/styles/index.css +++ b/frontend/web/src/styles/index.css @@ -192,3 +192,204 @@ textarea { transform: translateX(0); } } + +/* ═══════════════════════════════════════════════════════════════════════════ + 双档 UI 模式(裁决 16.4.4 / design/07 §0.1) + 上面全部 =「UI 性能模式」(默认档,现状 token 近似,未改一行); + 本节全部以 `:root.ui-mode-fancy` 限定 ⇒ 性能档下**零生效、零开销**。 + 档位由 src/design/uiMode.ts 打在 上(类名 + data-ui-mode)。 + 切换只改 、不改几何尺寸 ⇒ 不丢会话状态、不明显闪烁。 + ═══════════════════════════════════════════════════════════════════════════ */ + +/* —— 1. Smooth Corner:连续曲率(k=0.55 的 squircle 角型,直线到圆弧丝滑过渡)—— + 只作用于「明确设计过圆角」的元素;`rounded-full` 的真圆(头像/指示点)不受影响。 + 不支持 corner-shape 的浏览器自动回落 border-radius 近似,无副作用。 */ +:root.ui-mode-fancy [class*="rounded"]:not([class*="rounded-full"]), +:root.ui-mode-fancy .glass, +:root.ui-mode-fancy .liquid-glass, +:root.ui-mode-fancy .liquid-glass-soft, +:root.ui-mode-fancy .liquid-glass-accent { + corner-shape: var(--mu-corner-shape); +} + +/* —— 2. 多层 Surface:整屏环境光,给所有玻璃表面一层被照亮的底 —— */ +:root.ui-mode-fancy body::before { + content: ""; + position: fixed; + inset: 0; + z-index: -1; + pointer-events: none; + background-image: var(--mu-ambient); +} + +/* —— 5. 毛玻璃:顶栏 / 侧栏 / 输入栏加「上缘受光 + 折射内描边」 —— */ +:root.ui-mode-fancy .glass { + backdrop-filter: blur(var(--mu-glass-blur)) saturate(var(--mu-glass-saturate)); + -webkit-backdrop-filter: blur(var(--mu-glass-blur)) saturate(var(--mu-glass-saturate)); + box-shadow: + inset 0 1px 0 var(--mu-edge-light), + inset 0 0 0 1px var(--mu-liquid-rim); +} + +/* —— 6. Liquid Glass(大件:卡片 / 弹层 / 下拉)—— + 真折射(默认 4 模式里的预设 1)+ 鼠标跟踪镜面高光 + RGB 色差分离 + 双层动态边框。 + 第二条 backdrop-filter 带 SVG 位移滤镜:不支持的浏览器在解析期即丢弃该声明, + 自动回落第一条的模糊链,不会把玻璃做成透明片。 */ +:root.ui-mode-fancy .liquid-glass { + isolation: isolate; + background: + radial-gradient(140% 120% at var(--mu-lens-x, 30%) var(--mu-lens-y, 0%), var(--mu-liquid-specular), transparent 45%), + radial-gradient(120% 90% at 50% 130%, var(--mu-liquid-caustic), transparent 62%), + linear-gradient(135deg, var(--mu-liquid-highlight), transparent 42%), + color-mix(in srgb, var(--mu-surface-4) 78%, transparent); + box-shadow: + var(--mu-elev-3), + inset 0 0 0 1px var(--mu-liquid-rim), + -1px 0 0 var(--mu-liquid-chroma-r), + 1px 0 0 var(--mu-liquid-chroma-b); + backdrop-filter: blur(var(--mu-glass-blur)) saturate(var(--mu-glass-saturate)); + -webkit-backdrop-filter: blur(var(--mu-glass-blur)) saturate(var(--mu-glass-saturate)); + backdrop-filter: blur(3px) saturate(var(--mu-glass-saturate)) url("#mu-refract-1"); + rotate: var(--mu-lens-rotate, 0deg); + scale: var(--mu-lens-scale, 1); + transition: + rotate 0.5s var(--mu-spring-slow), + scale 0.5s var(--mu-spring-slow), + transform 0.4s var(--mu-spring), + box-shadow 0.5s var(--mu-ease), + background 0.4s var(--mu-ease); +} +:root.ui-mode-fancy .liquid-glass:hover { + transform: translateY(-2px); + box-shadow: + var(--mu-elev-3), + 0 0 0 1px var(--mu-liquid-ring), + -1px 0 0 var(--mu-liquid-chroma-r), + 1px 0 0 var(--mu-liquid-chroma-b); +} + +/* 双层动态边框:内环(实线流光)+ 外环(色差光晕),各转各的、方向相反。 + 角度靠 @property 插值;不支持时自动退化为静态双层边框(仍有形、只是不转)。 */ +:root.ui-mode-fancy .liquid-glass::before, +:root.ui-mode-fancy .liquid-glass::after { + content: ""; + position: absolute; + inset: 0; + z-index: -1; + border-radius: inherit; + pointer-events: none; + padding: 1px; + background: conic-gradient( + from var(--mu-ring-angle, 0deg), + transparent 0deg, + var(--mu-liquid-ring) 70deg, + transparent 150deg + ); + -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); + mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); + -webkit-mask-composite: xor; + mask-composite: exclude; + animation: mu-ring-spin var(--mu-liquid-ring-dur) linear infinite; +} +:root.ui-mode-fancy .liquid-glass::after { + inset: -3px; + padding: 2px; + opacity: 0.6; + filter: blur(7px); + background: conic-gradient( + from var(--mu-ring-angle, 0deg), + var(--mu-liquid-chroma-r) 0deg, + transparent 90deg, + var(--mu-liquid-chroma-b) 220deg, + transparent 310deg + ); + animation-direction: reverse; +} + +@keyframes mu-ring-spin { + from { + --mu-ring-angle: 0deg; + } + to { + --mu-ring-angle: 360deg; + } +} + +/* —— 6b. Liquid Glass 的 4 折射模式:默认最克制(预设 1),元素可用 + `data-refract="2|3|4"` 自选更夸张的折射(预设在 uiMode.ts 里注入) —— */ +:root.ui-mode-fancy :is(.liquid-glass, .liquid-glass-soft, .liquid-glass-accent)[data-refract="1"] { + backdrop-filter: blur(3px) saturate(1.7) url("#mu-refract-1"); +} +:root.ui-mode-fancy :is(.liquid-glass, .liquid-glass-soft, .liquid-glass-accent)[data-refract="2"] { + backdrop-filter: blur(4px) saturate(1.8) url("#mu-refract-2"); +} +:root.ui-mode-fancy :is(.liquid-glass, .liquid-glass-soft, .liquid-glass-accent)[data-refract="3"] { + backdrop-filter: blur(5px) saturate(1.9) url("#mu-refract-3"); +} +:root.ui-mode-fancy :is(.liquid-glass, .liquid-glass-soft, .liquid-glass-accent)[data-refract="4"] { + backdrop-filter: blur(6px) saturate(2) url("#mu-refract-4"); +} + +/* —— 聊天气泡(轻量液态玻璃):镜面高光 + 色差分离 + 鼠标跟踪的弹性形变 —— */ +:root.ui-mode-fancy .liquid-glass-soft, +:root.ui-mode-fancy .liquid-glass-accent { + rotate: var(--mu-lens-rotate, 0deg); + scale: var(--mu-lens-scale, 1); + transition: + rotate 0.5s var(--mu-spring-slow), + scale 0.5s var(--mu-spring-slow), + box-shadow 0.5s var(--mu-ease), + background 0.4s var(--mu-ease); +} +:root.ui-mode-fancy .liquid-glass-soft { + background: + radial-gradient(130% 160% at var(--mu-lens-x, 30%) var(--mu-lens-y, 0%), color-mix(in srgb, var(--mu-liquid-specular) 62%, transparent), transparent 42%), + linear-gradient(135deg, var(--mu-liquid-highlight), transparent 45%), + color-mix(in srgb, var(--mu-surface-3) 62%, transparent); + box-shadow: + var(--mu-elev-1), + inset 0 0 0 1px var(--mu-liquid-rim), + -0.5px 0 0 var(--mu-liquid-chroma-r), + 0.5px 0 0 var(--mu-liquid-chroma-b); + backdrop-filter: blur(14px) saturate(1.6); + -webkit-backdrop-filter: blur(14px) saturate(1.6); +} +:root.ui-mode-fancy .liquid-glass-accent { + background: + radial-gradient(130% 160% at var(--mu-lens-x, 30%) var(--mu-lens-y, 0%), color-mix(in srgb, var(--mu-liquid-specular) 62%, transparent), transparent 42%), + linear-gradient(135deg, var(--mu-liquid-highlight), transparent 45%), + linear-gradient( + 180deg, + color-mix(in srgb, var(--mu-accent) 70%, transparent), + color-mix(in srgb, var(--mu-accent) 64%, transparent) + ); + box-shadow: + 0 8px 22px color-mix(in srgb, var(--mu-accent) 26%, transparent), + inset 0 0 0 1px var(--mu-liquid-rim), + -0.5px 0 0 var(--mu-liquid-chroma-r), + 0.5px 0 0 var(--mu-liquid-chroma-b); + backdrop-filter: blur(14px) saturate(1.6); + -webkit-backdrop-filter: blur(14px) saturate(1.6); +} + +/* —— 3. 弹簧动效:华丽档的入场也走弹簧,过冲可见(仍只用于入场/交互,不常态抖动) —— */ +:root.ui-mode-fancy .mu-rise-in { + animation-duration: 0.52s; +} + +/* 尊重系统「减弱动效」:华丽档保留静态的曲率/玻璃/色差,但停掉旋转与跟踪形变 */ +@media (prefers-reduced-motion: reduce) { + :root.ui-mode-fancy .liquid-glass::before, + :root.ui-mode-fancy .liquid-glass::after { + animation: none; + } + :root.ui-mode-fancy .liquid-glass, + :root.ui-mode-fancy .liquid-glass-soft, + :root.ui-mode-fancy .liquid-glass-accent { + rotate: none; + scale: none; + } + :root.ui-mode-fancy .mu-rise-in { + animation-duration: 0.01ms; + } +}