set(toggleUiMode(mode))} className={className} />;
+ return ;
+}
+
+/** 顶栏小胶囊:一行放下,点击在性能档 / 华丽档之间切换。 */
+function CompactUiModeSwitch({
+ mode,
+ onToggle,
+ className,
+}: {
+ mode: UiMode;
+ onToggle: () => void;
+ className: string;
+}): React.ReactNode {
+ const fancy = mode === "fancy";
+ return (
+
+ );
+}
+
+/** 分段控件: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 (
+
+
+ {UI_MODE_ORDER.map((id) => (
+
+ ))}
+
+ );
+}
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;
+ }
+}