feat(display): pointer-sway background with a dark variant; acrylic glass tokens [CI]
ci/woodpecker/push/main-amd64-build Pipeline was successful

This commit is contained in:
justme committed 2026-10-09 03:09:14 +08:00
1 parent 70f20d38d0
commit f8e8bb8acb
7 files changed
+212 -31

No files matched your search

+8 -4
View File
@@ -6,6 +6,7 @@ import { SourceButton, ThemeToggle } from '@shared/ui'
import { Compare } from './pages/Compare'
import { Line } from './pages/Line'
import { Overview } from './pages/Overview'
import { useBackgroundParallax } from './parallax'
function LineRoute() {
const { id } = useParams()
@@ -13,21 +14,24 @@ function LineRoute() {
}
export function App() {
const background = useRef<HTMLDivElement>(null)
useBackgroundParallax(background)
const location = useLocation()
const ipv6 = location.pathname.endsWith('~ipv6') || new URLSearchParams(location.search).get('ip_version') === 'ipv6'
const familyQuery = ipv6 ? '?ip_version=ipv6' : ''
return (
<>
<div ref={background} className="bg-layer" aria-hidden="true" />
<header className="topbar">
<div className="inner">
<nav className="topnav">
<NavLink to={`/${familyQuery}`} end className={({ isActive }) => (isActive ? 'active' : '')}>
总览
</NavLink>
<NavLink to={`/${familyQuery}`} className="brand">
<img className="brand-logo" src={avatar} alt="" width={36} height={36} />
<span>MoShiro</span>
</NavLink>
<nav className="topnav">
<NavLink to={`/${familyQuery}`} end className={({ isActive }) => (isActive ? 'active' : '')}>
总览
</NavLink>
<NavLink to={`/compare${familyQuery}`} className={({ isActive }) => (isActive ? 'active' : '')}>
对比
</NavLink>
Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 156 KiB

After

Width:  |  Height:  |  Size: 184 KiB

+73 -27
View File
@@ -1000,37 +1000,83 @@
/* Agent 卡片:国旗开头,名字下面是「运营商 / 系统」两行(左边名字右边内容,和下面的流量、网速一个风格) */
/* 亚克力:整张卡片半透明 + 背景模糊,细亮边,大一点的投影;标题栏是稍深的一条,底下垫放大模糊的国旗 */
/* 背景图固定在最底下,自己模糊一点(边缘放大一点,免得模糊后露出白边);上面盖一层页面底色,亮色盖得浅一点,暗色盖得深 */
body::before {
content: '';
/* 背景图固定在最底下:图本身已经是预先模糊过的(web/display/scripts/prepare-bg.py),所以移动它只是搬一块贴图,不用每次让浏览器重新模糊;
上面盖一层页面底色,亮色盖得浅一点,暗色盖得深。背景层会随鼠标移动(见 parallax.ts,直接改它的 transform),向外多留一圈边距免得露边。 */
.bg-layer {
position: fixed;
inset: -24px;
inset: -130px;
z-index: -1;
background-image:
linear-gradient(color-mix(in srgb, var(--app-bg) var(--bg-veil), transparent), color-mix(in srgb, var(--app-bg) var(--bg-veil), transparent)),
linear-gradient(color-mix(in srgb, var(--glass-veil) var(--bg-veil), transparent), color-mix(in srgb, var(--glass-veil) var(--bg-veil), transparent)),
url('./assets/bg.jpg');
background-size: cover;
background-position: center;
filter: blur(var(--bg-blur));
pointer-events: none;
}
:root {
--bg-veil: 50%;
--bg-blur: 10px;
}
:root[data-theme="dark"] {
--bg-veil: 82%;
--bg-veil: 62%;
}
/* 暗色模式换一张背景图 */
:root[data-theme="dark"] .bg-layer {
background-image:
linear-gradient(color-mix(in srgb, var(--glass-veil) var(--bg-veil), transparent), color-mix(in srgb, var(--glass-veil) var(--bg-veil), transparent)),
url('./assets/bg-dark.jpg');
}
/* ── 亚克力(毛玻璃)规范:所有玻璃面(卡片、线路面板、顶部栏、时间轴)只用下面这组变量;颜色来自 tokens.css 的 --glass-*,换风格改那里 ──
--glass-bg 玻璃底色(半透明) --glass-filter 背景模糊和饱和度
--glass-ring 1px 的细描边 --glass-top 上沿的一条高光(玻璃边缘的反光)
--glass-drop 投影(带一点底色的近黑,不用纯黑;暗色用一大一小两层,贴着一层细的、散开一层虚的)
--glass-head 卡片标题栏的叠层 --glass-head-line 标题栏和身体之间的分隔线
--glass-bar-* 顶部栏专用(更薄、更透) */
:root {
--glass-bg: color-mix(in srgb, var(--glass-tint) 76%, transparent);
--glass-filter: blur(28px) saturate(1.6);
--glass-ring: color-mix(in srgb, var(--glass-line) 9%, transparent);
--glass-top: color-mix(in srgb, var(--glass-light) 70%, transparent);
--glass-drop: 0 14px 32px -12px color-mix(in srgb, var(--glass-shade) 45%, transparent);
--glass-head: color-mix(in srgb, var(--glass-line) 5%, transparent);
--glass-head-line: color-mix(in srgb, var(--glass-line) 8%, transparent);
--glass-bar-bg: color-mix(in srgb, var(--glass-tint) 62%, transparent);
--glass-bar-filter: blur(20px) saturate(1.5);
--glass-bar-line: color-mix(in srgb, var(--glass-line) 7%, transparent);
--glass-bar-drop: 0 4px 14px -8px color-mix(in srgb, var(--glass-shade) 28%, transparent);
}
/* 暗色:玻璃更透一点(58%),模糊和饱和度比亮色低(背景图本来就暗,饱和度太高会发艳);
边缘靠 10% 的白色细描边和 12% 的白色上沿高光撑出来;投影更深、分两层 */
:root[data-theme="dark"] {
/* 玻璃上的小控件(分段按钮、标签、进度条底、时间轴的格子):用半透明的白色叠层,不再是不透明的暖灰,免得在冷色玻璃上发褐 */
--app-card-inset: color-mix(in srgb, var(--glass-line) 7%, transparent);
--app-card-hover: color-mix(in srgb, var(--glass-line) 11%, transparent);
--glass-bg: color-mix(in srgb, var(--glass-tint) 58%, transparent);
--glass-filter: blur(24px) saturate(1.35);
--glass-ring: color-mix(in srgb, var(--glass-line) 10%, transparent);
--glass-top: color-mix(in srgb, var(--glass-light) 12%, transparent);
--glass-drop:
0 18px 40px -14px color-mix(in srgb, var(--glass-shade) 60%, transparent),
0 2px 6px -2px color-mix(in srgb, var(--glass-shade) 40%, transparent);
--glass-head: color-mix(in srgb, var(--glass-line) 4.5%, transparent);
--glass-head-line: color-mix(in srgb, var(--glass-line) 7%, transparent);
--glass-bar-bg: color-mix(in srgb, var(--glass-tint) 52%, transparent);
--glass-bar-filter: blur(20px) saturate(1.3);
--glass-bar-line: color-mix(in srgb, var(--glass-line) 8%, transparent);
--glass-bar-drop: 0 6px 20px -10px color-mix(in srgb, var(--glass-shade) 55%, transparent);
}
.agent-card {
background: color-mix(in srgb, var(--app-card) 76%, transparent);
backdrop-filter: blur(28px) saturate(1.6);
background: var(--glass-bg);
backdrop-filter: var(--glass-filter);
box-shadow:
inset 0 0 0 1px color-mix(in srgb, var(--app-ink) 9%, transparent),
inset 0 1px 0 color-mix(in srgb, var(--app-card) 70%, transparent),
0 14px 32px -12px color-mix(in srgb, var(--app-shadow) 45%, transparent);
inset 0 0 0 1px var(--glass-ring),
inset 0 1px 0 var(--glass-top),
var(--glass-drop);
}
.agent-head {
@@ -1039,8 +1085,8 @@ body::before {
margin: -20px -20px 0;
padding: 16px 20px;
border-radius: var(--r-card) var(--r-card) 0 0;
background: color-mix(in srgb, var(--app-ink) 5%, transparent);
border-bottom: 1px solid color-mix(in srgb, var(--app-ink) 8%, transparent);
background: var(--glass-head);
border-bottom: 1px solid var(--glass-head-line);
}
.agent-head .flag {
@@ -1159,12 +1205,12 @@ body::before {
.card:not(.agent-card),
.tiles > .stat,
.tiles > .tile {
background: color-mix(in srgb, var(--app-card) 76%, transparent);
backdrop-filter: blur(28px) saturate(1.6);
background: var(--glass-bg);
backdrop-filter: var(--glass-filter);
box-shadow:
inset 0 0 0 1px color-mix(in srgb, var(--app-ink) 9%, transparent),
inset 0 1px 0 color-mix(in srgb, var(--app-card) 70%, transparent),
0 14px 32px -12px color-mix(in srgb, var(--app-shadow) 45%, transparent);
inset 0 0 0 1px var(--glass-ring),
inset 0 1px 0 var(--glass-top),
var(--glass-drop);
}
/* 小的指标块浮得低一点:阴影收小 */
@@ -1176,19 +1222,19 @@ body::before {
}
.topbar {
background: color-mix(in srgb, var(--app-card) 62%, transparent);
backdrop-filter: blur(20px) saturate(1.5);
border-bottom-color: color-mix(in srgb, var(--app-ink) 7%, transparent);
box-shadow: 0 4px 14px -8px color-mix(in srgb, var(--app-shadow) 28%, transparent);
background: var(--glass-bar-bg);
backdrop-filter: var(--glass-bar-filter);
border-bottom-color: var(--glass-bar-line);
box-shadow: var(--glass-bar-drop);
}
/* 时间轴:同样的亚克力。有数据的段是半透明的亮底,没数据的段是淡淡的斜纹,整条有细描边和投影 */
.minimap .track {
background: repeating-linear-gradient(135deg, color-mix(in srgb, var(--app-card) 55%, var(--app-idle)) 0 5px, color-mix(in srgb, var(--app-card) 70%, var(--app-idle)) 5px 10px);
backdrop-filter: blur(20px) saturate(1.6);
backdrop-filter: var(--glass-bar-filter);
box-shadow:
inset 0 0 0 1px color-mix(in srgb, var(--app-ink) 9%, transparent),
0 10px 24px -12px color-mix(in srgb, var(--app-shadow) 45%, transparent);
inset 0 0 0 1px var(--glass-ring),
var(--glass-drop);
}
.minimap svg rect.has {
+81
View File
@@ -0,0 +1,81 @@
import { useEffect, type RefObject } from 'react'
/** 指针在窗口里的位置带来的偏移上限(像素):鼠标停在窗口边缘时背景偏这么多。 */
const POSITION_SHIFT = 30
/** 鼠标移动时额外的晃动上限(像素):移得越快晃得越多,停下来就慢慢回到位置对应的偏移。两项加起来不能超过 display.css 里背景层向外多出来的边距(130px)。 */
const SWAY_MAX = 90
/** 每移动 1 像素,晃动增加多少像素。 */
const SWAY_GAIN = 0.8
/** 晃动每一帧衰减到多少:越接近 1,晃得越久。 */
const SWAY_DECAY = 0.94
/** 每一帧追上目标的比例:越小越慢、拖拽感越强。 */
const EASE = 0.05
/**
* 背景跟着鼠标(或手指)移动,带一点拖拽的惯性:
* - 位置:指针在窗口里的位置对应一个小的偏移(最多 POSITION_SHIFT),背景每帧只追上一小段;
* - 晃动:鼠标移动的时候,移动的距离再叠加一份额外的偏移(移得越快越大,最多 SWAY_MAX),停下来之后逐渐衰减,所以移动时背景「晃」得更明显。
* 指针离开窗口,背景慢慢回到中间。直接改背景层自己的 transform(不经过 CSS 变量:变量会让整页的样式重新计算,卡片又多);
* 追上并且晃动衰减完之后就停止动画帧,不空转。用户开了「减少动态效果」就什么都不做。
*/
export function useBackgroundParallax(layer: RefObject<HTMLElement | null>) {
useEffect(() => {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
const element = layer.current
if (!element) return
let baseX = 0
let baseY = 0
let swayX = 0
let swayY = 0
let x = 0
let y = 0
let lastX: number | null = null
let lastY: number | null = null
let frame = 0
const step = () => {
swayX *= SWAY_DECAY
swayY *= SWAY_DECAY
const targetX = baseX + swayX
const targetY = baseY + swayY
x += (targetX - x) * EASE
y += (targetY - y) * EASE
element.style.transform = `translate3d(${x.toFixed(2)}px, ${y.toFixed(2)}px, 0)`
const moving = Math.abs(targetX - x) > 0.05 || Math.abs(targetY - y) > 0.05 || Math.abs(swayX) > 0.05 || Math.abs(swayY) > 0.05
frame = moving ? requestAnimationFrame(step) : 0
}
const schedule = () => {
if (!frame) frame = requestAnimationFrame(step)
}
const clamp = (value: number) => Math.max(-SWAY_MAX, Math.min(SWAY_MAX, value))
const move = (event: PointerEvent) => {
// 位置:指针在窗口里的位置换成 -1 到 1(背景朝同一个方向偏移,想要反方向就把符号取反)
baseX = ((event.clientX / window.innerWidth) * 2 - 1) * POSITION_SHIFT
baseY = ((event.clientY / window.innerHeight) * 2 - 1) * POSITION_SHIFT
// 晃动:这一次移动了多远,就再推背景一把
if (lastX !== null && lastY !== null) {
swayX = clamp(swayX + (event.clientX - lastX) * SWAY_GAIN)
swayY = clamp(swayY + (event.clientY - lastY) * SWAY_GAIN)
}
lastX = event.clientX
lastY = event.clientY
schedule()
}
const leave = () => {
baseX = 0
baseY = 0
lastX = null
lastY = null
schedule()
}
window.addEventListener('pointermove', move, { passive: true })
document.documentElement.addEventListener('mouseleave', leave)
return () => {
window.removeEventListener('pointermove', move)
document.documentElement.removeEventListener('mouseleave', leave)
cancelAnimationFrame(frame)
element.style.transform = ''
}
}, [layer])
}