feat(display): pointer-sway background with a dark variant; acrylic glass tokens [CI]
ci/woodpecker/push/main-amd64-build Pipeline was successful
ci/woodpecker/push/main-amd64-build Pipeline was successful
This commit is contained in:
1 parent
70f20d38d0
commit
f8e8bb8acb
7 files changed
+212
-31
No files matched your search
@@ -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()
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user