feat(web): 双档 UI 模式(性能档 = 现状 / Smooth Corner 华丽档)+ 开关挂载(裁决 16.4.4)
- design/uiMode.ts:档位纯逻辑(localStorage `cyrene.ui-mode` 持久化、跨标签页同步、未知值回落默认档), 只改 <html> 类名与 data 属性 ⇒ 不碰 React 树、不重挂载、几何尺寸不变 - 性能档=**现状**:tokens.css / styles/index.css 的改动**全为新增(0 删除)**,且全部限定在 `:root.ui-mode-fancy` 下;75 元素签名比对证明性能档渲染与改动前逐元素一致 - 华丽档对齐六大核心:连续曲率 corner-shape / 多层 Surface 环境光 / 弹簧动效 / 深色优先(浅色单列补偿)/ 毛玻璃升级 / Liquid Glass(4 折射 + RGB 色差 + 鼠标跟踪 + 双层动态边框) - 开关挂载:main.tsx 在 createRoot 之前 initUiMode(零闪烁);TopBar/Nav/AdminTopBar 三处挂载, 窄屏(<640px)按既有约定收起(实测否则 375px 角色块被压到 27px、面板溢出 73px),移动端由侧栏抽屉覆盖 - 复核:tsc 0 错 + vite build 成功;uiMode 38 项 node 测试 + headless Chrome 真渲染 30 项(真点击/真持久化/ 真指针事件);变异反证(去掉读回 → 持久化往返必红)
This commit is contained in:
1 parent
c1a98d7c01
commit
8d70f6d331
9 files changed
+961
No files matched your search
@@ -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?: ()
|
||||
</Tag>
|
||||
) : null}
|
||||
<BackendStatusTag />
|
||||
{/* 双档 UI 档位(裁决 16.4.4):面板顶栏也吃档位,胶囊形态对齐其余 h-8 按钮。
|
||||
与用户端顶栏同理:窄屏收起(<sm),避免与「返回用户端」等既有控件争宽。 */}
|
||||
<span className="hidden shrink-0 sm:inline-flex">
|
||||
<UiModeSwitch variant="compact" />
|
||||
</span>
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
aria-label="切换主题"
|
||||
|
||||
@@ -5,6 +5,7 @@ import { labelFor } from "../lib/enums";
|
||||
import { personaAvatar } from "../lib/persona";
|
||||
import { useAppStore } from "../store/useAppStore";
|
||||
import { useCurrentMood, useCurrentPersona } from "../store/personaSelectors";
|
||||
import { UiModeSwitch } from "../design/UiModeSwitch";
|
||||
import { Icon } from "./Icon";
|
||||
import { NavItem } from "./NavItem";
|
||||
import { SessionRail } from "./SessionRail";
|
||||
@@ -96,6 +97,8 @@ function NavContent({ onNavigate }: { onNavigate: () => void }): React.ReactNode
|
||||
label={theme === "dark" ? "浅色" : "深色"}
|
||||
onClick={toggleTheme}
|
||||
/>
|
||||
{/* 双档 UI 档位(裁决 16.4.4):分段形态;桌面栏与移动抽屉共用 NavContent,抽屉里同样可切 */}
|
||||
<UiModeSwitch />
|
||||
{!hasToken ? <Tag tone="warn">未登录</Tag> : null}
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { applyTheme, readTheme, type ThemeMode } from "../lib/theme";
|
||||
import { personaAvatar } from "../lib/persona";
|
||||
import { labelFor } from "../lib/enums";
|
||||
import type { Mode, WsStatus } from "../lib/types";
|
||||
import { UiModeSwitch } from "../design/UiModeSwitch";
|
||||
import { Tag } from "./primitives";
|
||||
import { Icon, type IconName } from "./Icon";
|
||||
import { AwakenBadge } from "./status";
|
||||
@@ -89,6 +90,12 @@ export function TopBar({ onMenu }: { onMenu: () => void }): React.ReactNode {
|
||||
>
|
||||
<Icon name="shield" size={18} />
|
||||
</button>
|
||||
{/* 双档 UI 档位(裁决 16.4.4):胶囊形态,尺寸对齐本栏其余 h-8 按钮。
|
||||
窄屏(<sm)本栏已被头像/状态位占满,再塞 88px 会把角色名挤没甚至横向溢出,
|
||||
故窄屏收起——移动端改由侧栏抽屉里的分段开关切档(Nav.tsx)。 */}
|
||||
<span className="hidden shrink-0 sm:inline-flex">
|
||||
<UiModeSwitch variant="compact" />
|
||||
</span>
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
aria-label="切换主题"
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { useCallback, useSyncExternalStore } from "react";
|
||||
import {
|
||||
DEFAULT_UI_MODE,
|
||||
UI_MODE_HINT,
|
||||
UI_MODE_LABEL,
|
||||
UI_MODE_ORDER,
|
||||
getUiModeSnapshot,
|
||||
setUiMode,
|
||||
subscribeUiMode,
|
||||
toggleUiMode,
|
||||
type UiMode,
|
||||
} from "./uiMode";
|
||||
|
||||
/**
|
||||
* 双档 UI 模式的 React 入口(裁决 16.4.4)。别处只需:
|
||||
*
|
||||
* import { UiModeSwitch } from "../design/UiModeSwitch";
|
||||
* <UiModeSwitch variant="compact" /> // 顶栏小胶囊(点击即切)
|
||||
* <UiModeSwitch /> // 分段控件(设置/抽屉里)
|
||||
*
|
||||
* 也提供 `useUiMode()` 给想自绘开关的调用方。切换只改 `<html>` 的类名,
|
||||
* 不重挂载组件树 ⇒ 不丢会话状态、无明显闪烁。
|
||||
*/
|
||||
|
||||
/** 读当前档位 + 一个 setter(跨标签页同步,同页多实例同步)。 */
|
||||
export function useUiMode(): [UiMode, (next: UiMode) => void] {
|
||||
const mode = useSyncExternalStore(subscribeUiMode, getUiModeSnapshot, () => DEFAULT_UI_MODE);
|
||||
const set = useCallback((next: UiMode) => setUiMode(next), []);
|
||||
return [mode, set];
|
||||
}
|
||||
|
||||
export interface UiModeSwitchProps {
|
||||
/** `segmented`(默认)= 两段分段控件;`compact` = 顶栏图标胶囊,点击即切。 */
|
||||
variant?: "segmented" | "compact";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const BOLT_PATH = "M13 2 4.5 13.5H11l-1 8.5L19.5 10H13l1-8Z";
|
||||
const SPARKLE_PATH =
|
||||
"M12 2.5 13.9 8 19.4 9.9 13.9 11.8 12 17.3 10.1 11.8 4.6 9.9 10.1 8 12 2.5Zm6.8 11.2 1 2.9 2.9 1-2.9 1-1 2.9-1-2.9-2.9-1 2.9-1 1-2.9Z";
|
||||
|
||||
function ModeIcon({ name, size = 14 }: { name: UiMode; size?: number }): React.ReactNode {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="currentColor" aria-hidden="true" focusable="false">
|
||||
<path d={name === "fancy" ? SPARKLE_PATH : BOLT_PATH} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function UiModeSwitch({ variant = "segmented", className = "" }: UiModeSwitchProps): React.ReactNode {
|
||||
const [mode, set] = useUiMode();
|
||||
if (variant === "compact") return <CompactUiModeSwitch mode={mode} onToggle={() => set(toggleUiMode(mode))} className={className} />;
|
||||
return <SegmentedUiModeSwitch mode={mode} onPick={set} className={className} />;
|
||||
}
|
||||
|
||||
/** 顶栏小胶囊:一行放下,点击在性能档 / 华丽档之间切换。 */
|
||||
function CompactUiModeSwitch({
|
||||
mode,
|
||||
onToggle,
|
||||
className,
|
||||
}: {
|
||||
mode: UiMode;
|
||||
onToggle: () => void;
|
||||
className: string;
|
||||
}): React.ReactNode {
|
||||
const fancy = mode === "fancy";
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
aria-pressed={fancy}
|
||||
aria-label={`UI 档位:${UI_MODE_LABEL[mode]}(点击切换)`}
|
||||
title={`当前 ${UI_MODE_LABEL[mode]} · ${UI_MODE_HINT[mode]}`}
|
||||
className={`inline-flex h-8 shrink-0 items-center gap-1.5 rounded-[var(--mu-radius-pill)] border border-[var(--mu-glass-border)] px-2.5 text-xs font-medium transition-all duration-300 ease-[var(--mu-spring)] active:scale-95 ${
|
||||
fancy ? "bg-[var(--mu-accent)] text-white" : "bg-[var(--mu-surface-4)] text-[var(--mu-text-mid)] hover:bg-[var(--mu-surface-5)]"
|
||||
} ${className}`}
|
||||
>
|
||||
<ModeIcon name={mode} />
|
||||
{UI_MODE_LABEL[mode]}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** 分段控件: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 (
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label="UI 档位"
|
||||
className={`relative inline-flex w-full items-center rounded-[var(--mu-radius-pill)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-4)] p-0.5 ${className}`}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute top-0.5 bottom-0.5 left-0.5 w-[calc(50%-0.125rem)] rounded-[var(--mu-radius-pill)] bg-[var(--mu-accent)] shadow-[var(--mu-shadow-1)]"
|
||||
style={{ translate: `${index * 100}%`, transition: "translate 0.32s var(--mu-spring)" }}
|
||||
/>
|
||||
{UI_MODE_ORDER.map((id) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={mode === id}
|
||||
title={UI_MODE_HINT[id]}
|
||||
onClick={() => onPick(id)}
|
||||
className={`relative z-10 inline-flex flex-1 items-center justify-center gap-1.5 rounded-[var(--mu-radius-pill)] px-2 py-1 text-xs font-medium transition-colors duration-200 ${
|
||||
mode === id ? "text-white" : "text-[var(--mu-text-mid)] hover:text-[var(--mu-text-hi)]"
|
||||
}`}
|
||||
>
|
||||
<ModeIcon name={id} />
|
||||
{UI_MODE_LABEL[id]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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` 打在 <html> 上;本文新增的 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: "<angle>";
|
||||
inherits: false;
|
||||
initial-value: 0deg;
|
||||
}
|
||||
@@ -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<string, string> = {}): UiModeStorage & { raw: Map<string, string> } {
|
||||
const raw = new Map<string, string>(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<string>; attrs: Map<string, string>; el: HTMLElement } {
|
||||
const classes = new Set<string>();
|
||||
const attrs = new Map<string, string>();
|
||||
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<string | null | undefined> = ["", " ", "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");
|
||||
|
||||
// 类名/属性落地:切档只改 <html>(不碰组件树 ⇒ 不丢会话状态)。
|
||||
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`);
|
||||
@@ -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 色差分离 / 鼠标跟踪弹性变形 / 双层动态边框)。
|
||||
*
|
||||
* 落地方式:只改 `<html>` 上的类名 + 属性(视觉全交给 CSS),**不触碰 React 树**,
|
||||
* 因此切换不重挂载、不丢会话状态、几何尺寸不变(只变视觉层)⇒ 无明显闪烁。
|
||||
*
|
||||
* 本模块**不导入 React**(纯逻辑 + 受保护的 DOM 访问),可用无依赖 node 脚本直测;
|
||||
* React 侧入口见同目录 `UiModeSwitch.tsx`(`useUiMode()` / `<UiModeSwitch />`)。
|
||||
*/
|
||||
|
||||
export type UiMode = "performance" | "fancy";
|
||||
|
||||
/** localStorage 键:档位偏好(仅前端本地,不进后端契约)。 */
|
||||
export const UI_MODE_STORAGE_KEY = "cyrene.ui-mode";
|
||||
/** `<html>` 上的档位属性(值即档位名;CSS/调试用,与类名等价)。 */
|
||||
export const UI_MODE_ATTR = "data-ui-mode";
|
||||
/** `<html>` 上的档位类名(CSS 的唯一开关)。 */
|
||||
export const UI_MODE_CLASS: Record<UiMode, string> = {
|
||||
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<UiMode, string> = {
|
||||
performance: "性能档",
|
||||
fancy: "华丽档",
|
||||
};
|
||||
/** 档位说明(开关的 title / 辅助文本)。 */
|
||||
export const UI_MODE_HINT: Record<UiMode, string> = {
|
||||
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";
|
||||
}
|
||||
|
||||
/** 把档位落到 `<html>`:类名 + `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 折射模式 + 鼠标跟踪弹性变形 ──────────────
|
||||
全部按需装载:只在华丽档下工作,性能档完全不产生监听与滤镜,零额外开销。
|
||||
运行时状态挂在 `<html>` 的 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<HTMLElement>(`[${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<HTMLElement>(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);
|
||||
}
|
||||
@@ -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(
|
||||
<StrictMode>
|
||||
<App />
|
||||
|
||||
@@ -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 打在 <html> 上(类名 + data-ui-mode)。
|
||||
切换只改 <html>、不改几何尺寸 ⇒ 不丢会话状态、不明显闪烁。
|
||||
═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
/* —— 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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user