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

+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env python3
"""把一张背景图处理成展示页用的背景:缩到最长边不超过 3840 像素、预先模糊、存成渐进式 JPEG。
用法:python web/display/scripts/prepare-bg.py <原图> <输出,例如 web/display/src/assets/bg.jpg> [--sigma 10]
为什么预先模糊:背景层会随鼠标移动,如果让浏览器用 CSS filter 现场模糊这么大的一层,每次移动都要重新算,
和卡片自己的 backdrop-filter 叠在一起很吃 GPU(低配机器上会出现闪烁、层叠错乱)。图片本身已经是模糊的,移动就只是搬一块贴图。
模糊半径默认按宽度的 0.6% 算(1672 像素宽约 10),和原来 CSS 里 blur(10px) 的效果相近;想更糊或更清楚用 --sigma。
"""
import argparse
import os
from PIL import Image, ImageFilter
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("source")
parser.add_argument("target")
parser.add_argument("--sigma", type=float, default=None, help="模糊半径(像素,按处理后的图);默认宽度的 0.6%%")
parser.add_argument("--max-side", type=int, default=3840)
parser.add_argument("--quality", type=int, default=92)
args = parser.parse_args()
image = Image.open(args.source).convert("RGB")
longest = max(image.size)
if longest > args.max_side:
scale = args.max_side / longest
image = image.resize((round(image.width * scale), round(image.height * scale)), Image.LANCZOS)
sigma = args.sigma if args.sigma is not None else image.width * 0.006
image = image.filter(ImageFilter.GaussianBlur(sigma))
image.save(args.target, quality=args.quality, optimize=True, progressive=True, subsampling=0)
print(f"{args.target}: {image.width}x{image.height}, blur sigma {sigma:.1f}, {os.path.getsize(args.target) // 1024} KB")
if __name__ == "__main__":
main()
+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])
}
+14
View File
@@ -41,6 +41,13 @@
--app-scrim: #1f1d18;
--app-shadow: #1f1d18;
/* 亚克力(毛玻璃)用色:展示端所有玻璃面都从这五个取色,比例、模糊和阴影在 display.css 的「亚克力规范」里 */
--glass-tint: var(--app-card); /* 玻璃底色 */
--glass-light: var(--app-card); /* 上沿的反光色 */
--glass-line: var(--app-ink); /* 细描边、标题栏的分隔线 */
--glass-shade: var(--app-shadow); /* 投影 */
--glass-veil: var(--app-bg); /* 盖在背景图上的那层底色 */
--chart-1: #2a5da8;
--chart-2: #b9770e;
--chart-3: #a23e2c;
@@ -70,6 +77,13 @@
--app-shadow: #000000;
--app-scrim: #000000;
/* 暗色的玻璃:偏蓝紫的冷色深蓝灰(和背景插画的色调一致),描边和上沿反光用白色,投影用带一点蓝的近黑 */
--glass-tint: #1b1e2d;
--glass-light: #ffffff;
--glass-line: #ffffff;
--glass-shade: #04061a;
--glass-veil: #12141d;
--app-bg: #181714;
--app-card: #211f1b;
--app-card-inset: #2b2924;