v0.4.1: OSS skill videos, site version footer, and mail contact.

Host ability demos on Aliyun OSS instead of Pages bundles; add patches-page version label and top-right mailto link; improve spirit bear portrait export.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
voson
2026-07-27 20:26:30 +08:00
co-authored by Cursor
parent adb8f303c2
commit 124bdbb7c7
11 changed files with 289 additions and 22 deletions
+7 -5
View File
@@ -29,8 +29,8 @@ Windows 上的 Dota 2 **天梯选将识别**工具:用 GSI 感知选将阶段
| `audit_relations.py` | OpenDota+STRATZ 交叉审计 `relations.json`,列出缺失英雄 |
| `recommend.py` | 分路过滤;按克制/被克制/搭档边打分,输出 Top-3 |
| `serve_relations.py` | 游戏外关系/物品只读预览(`web/relations/`;改 `data/relations.json` |
| `export_relations_site.py` | 导出关系预览为纯静态站点 → `dist/relations/`data.json 快照 + 前端 + 图片;`--with-videos` 含视频 |
| `deploy_relations.py` | 一键部署关系预览静态站点到 Cloudflare Pages(导出 + 资产预检 + `wrangler` 直传 + 绑域名;凭据经 keyzoo 注入或 env |
| `export_relations_site.py` | 导出关系预览为纯静态站点 → `dist/relations/`data.json 快照 + 前端 + 图片;`SITE_VERSION` 常量与 `web/relations/config.js` 同步;`--ability-video-base``config.js` 指向 OSS 技能视频;`--with-videos` 可选本地拷贝视频,生产部署勿用 |
| `deploy_relations.py` | 一键部署关系预览静态站点到 Cloudflare Pages(导出 + 资产预检 + `wrangler` 直传 + 绑域名;默认 `--ability-video-base` 指向 `climperor` OSS凭据经 keyzoo 注入或 env |
| `_cf_status.py` | 只读查询 Cloudflare Pages 项目 / 部署 / 自定义域名状态(凭据经 keyzoo 注入) |
| `fetch_hero_portraits.py` | 拉取官网横版头像 → `assets/hero_portraits/`(关系预览) |
| `fetch_hero_items.py` | 拉取 OpenDota 热门装备 → `data/hero_items.json` + `assets/item_icons/` |
@@ -57,7 +57,7 @@ Windows 上的 Dota 2 **天梯选将识别**工具:用 GSI 感知选将阶段
| `data/hero_fear_overrides.json` | 英雄→害怕装备手工加减(合并进 item_fears |
| `data/hero_abilities.json` | 英雄技能与机制汇总(由 `fetch_hero_abilities.py` 生成) |
| `data/hero_item_fears.json` | 英雄怕的装备(规则推导;预览「怕」行) |
| `web/relations/` | 关系只读预览页静态资源(`index.html` / `app.js` / `style.css` / `router.js` |
| `web/relations/` | 关系只读预览页静态资源(`index.html` / `config.js` / `app.js` / `style.css` / `router.js``config.js``SITE_VERSION``ABILITY_VIDEO_BASE`;版本页底部显示 `v{SITE_VERSION}` |
| `web/relations/router.js` | Hash 路由:`parseHash` / `serializeHash` / `installRouter` / `syncStateToUrl`;状态↔URL 双向同步(顶层标签 / 英雄 + 子标签 / 物品 / 版本 / 标签筛选 / 搜索) |
| `templates/cdn/` | 全英雄 CDN 模板(需提交或由脚本生成) |
| `templates/roles/` / `templates/modes/` | 位置 / 模式辅助模板 |
@@ -68,7 +68,7 @@ Windows 上的 Dota 2 **天梯选将识别**工具:用 GSI 感知选将阶段
| `assets/item_cat_icons/` | 官网商店分类图标(`itemcat_*.png`,物品页列头) |
| `assets/ability_icons/` | Steam CDN 技能图标(关系预览;按需缓存);`innate.png` 先天共用图标;`talent_tree.png` 天赋树触发图标 |
| `assets/ui_icons/` | dota2.com.cn 通用 UI 图标(`cooldown.png` 等,技能详情底栏冷却图标) |
| `assets/ability_videos/` | 官网技能演示视频(webm/mp4;勿提交,由脚本拉取) |
| `assets/ability_videos/` | 官网技能演示视频(webm/mp4;勿提交,由脚本拉取;线上由阿里云 OSS `climperor``ability-video/` 前缀托管 |
运行时产物(**勿提交**,见 `.gitignore`):
@@ -122,7 +122,8 @@ python item_fears.py # 英雄怕的装备
python fetch_patches.py # 近一年版本日志 + 详情(版本页)
python serve_relations.py # 浏览器只读预览 英雄关系 / 物品商店
python export_relations_site.py # 导出静态站点到 dist/relations/(可部署 Pages
python deploy_relations.py # 部署 dist/relations 到 Cloudflare Pages(凭据经 keyzoo 注入或 env;默认 dota2.refining.dev
python export_relations_site.py --ability-video-base https://climperor.oss-cn-shanghai.aliyuncs.com
python deploy_relations.py # 部署 dist/relations 到 Cloudflare Pages(凭据经 keyzoo 注入或 env;默认 dota2.refining.dev;技能视频走 OSS
python _cf_status.py # 只读查 CF Pages 部署 / 自定义域名状态
python gsi_setup.py --check
python gsi_watch.py --once
@@ -138,6 +139,7 @@ python evaluate.py
- 改 GSI cfg 时同步核对 `gsi_setup.py` 与 Dota `gamestate_integration` 目录。
- 改预览路由形态(URL 段 / query 参数 / 默认值)时同步 `web/relations/router.js``parseHash` / `serializeHash`)与 `app.js``applyPatch` 校验;新增可路由状态维度时在两处都加,并在 `syncStateToUrl` 调用点(含搜索 debounce)接好。Hash 路由不命中后端,`serve_relations.py` 无需改;`export_relations_site.py` 的导出文件列表须含 `router.js`
-`data/heroes.json` 结构时同步 `grid.py`(依赖 `attr` / `name_loc`);`roles``overlay.py` 使用;`aliases` 为中文口语/俗称(勿与 `name_loc` 重复),重跑 `fetch_cdn_templates.py` 会按 `key` 合并保留;`tags` 为中文定位(核心/辅助/…/幻象,由 `roles`+幻想系推导),仅关系预览筛选用;基础属性/血蓝等数值字段供关系预览详情条,勿塞机制文案。
- 发版关系预览时同步 bump `export_relations_site.py``SITE_VERSION``web/relations/config.js` 的同名变量,以及 `index.html``config.js`/`router.js`/`app.js``?v=` 缓存戳;并写 `CHANGELOG.md`
- 不要重新引入 real 模板双层库、`cdn_penalty``build_library.py`
## 文档分工
+18
View File
@@ -4,6 +4,24 @@
## [Unreleased]
## [0.4.1] - 2026-07-27
### Changed
- 线上关系预览技能演示视频改由阿里云 OSS 托管(桶 `climperor`
`https://climperor.oss-cn-shanghai.aliyuncs.com/ability-video/...`),不再打进
Cloudflare Pages;本地 `serve_relations.py` 仍读 `assets/ability_videos/`
导出写入 `config.js``ABILITY_VIDEO_BASE``export_relations_site.py
--ability-video-base` / `deploy_relations.py` 默认 OSS 地址)。
### Added
- 版本页底部居中显示站点版本号(`config.js``SITE_VERSION`,如 `v0.4.1`)。
### Fixed
- 版本页熊灵(spirit bear)头像:静态导出包含 `portrait/spirit_bear.png`
## [0.4.0] - 2026-07-27
### Added
+1
View File
@@ -51,6 +51,7 @@ python deploy_relations.py
# 凭据经 keyzoo 注入(refining/cloudflare 的 Global API Key);
# 或手动设 $env:CLOUDFLARE_EMAIL / $env:CLOUDFLARE_API_KEY 后运行
# 默认项目 climperor-relations、域名 dota2.refining.dev(可用 --project-name / --domain 覆盖)
# 技能演示视频默认走阿里云 OSS 桶 climperorPages 只部署 ~42MB 静态资源)
python _cf_status.py # 部署后只读核对(项目 / 部署 / 域名状态)
```
+19 -6
View File
@@ -148,14 +148,21 @@ def ensure_project(email: str, api_key: str, account_id: str, project: str) -> N
raise SystemExit(f"create project failed: {d.get('errors')}")
def run_export() -> None:
def run_export(ability_video_base: str | None = None) -> None:
"""Re-run export_relations_site.py to refresh dist/relations."""
print("exporting static site ...")
subprocess.run(
[sys.executable, str(ROOT / "export_relations_site.py")],
cwd=str(ROOT),
check=True,
cmd = [sys.executable, str(ROOT / "export_relations_site.py")]
# Production Pages should load ability demos from OSS, not bundle GB of video.
base = ability_video_base
if base is None:
base = os.environ.get(
"ABILITY_VIDEO_BASE",
"https://climperor.oss-cn-shanghai.aliyuncs.com",
)
if base:
cmd.extend(["--ability-video-base", base])
print(f"ABILITY_VIDEO_BASE={base}")
subprocess.run(cmd, cwd=str(ROOT), check=True)
def check_integrity(dist: Path) -> None:
@@ -309,6 +316,12 @@ def main() -> None:
ap.add_argument("--project-name", default=DEFAULT_PROJECT)
ap.add_argument("--domain", default=DEFAULT_DOMAIN)
ap.add_argument("--dist", default=str(DEFAULT_DIST))
ap.add_argument(
"--ability-video-base",
default=None,
help="passed to export as --ability-video-base (default: OSS climperor endpoint "
"or ABILITY_VIDEO_BASE env)",
)
args = ap.parse_args()
dist = Path(args.dist).resolve()
@@ -320,7 +333,7 @@ def main() -> None:
ensure_project(email, api_key, account_id, args.project_name)
if not args.no_export:
run_export()
run_export(args.ability_video_base)
else:
print("--no-export: using existing dist")
+40 -3
View File
@@ -2,6 +2,7 @@
Usage:
python export_relations_site.py [--out dist/relations] [--with-videos]
[--ability-video-base URL]
Copies web/relations/ + a snapshot of the /api/data payload (data.json) +
the referenced image assets into one directory, ready for any static host
@@ -13,13 +14,15 @@ Notes:
- For patch-notes names/icons on the 版本 page, run `python fetch_patches.py`
first (downloads referenced item + ability icons into assets/).
- Ability videos (several GB locally) are skipped unless --with-videos is
given; the UI degrades quietly when a video is missing.
given; production deploys point the UI at OSS via --ability-video-base
(or ABILITY_VIDEO_BASE env) instead of bundling videos into Pages.
"""
from __future__ import annotations
import argparse
import json
import os
import shutil
from pathlib import Path
@@ -36,6 +39,8 @@ from common import (
)
from serve_relations import WEB_DIR, build_payload
SITE_VERSION = "0.4.1"
def copy_glob(src: Path, dst: Path, pattern: str = "*.png") -> int:
if not src.is_dir():
@@ -49,6 +54,21 @@ def copy_glob(src: Path, dst: Path, pattern: str = "*.png") -> int:
return n
def write_config_js(out: Path, ability_video_base: str, site_version: str) -> None:
"""Write config.js consumed by app.js (SITE_VERSION, ABILITY_VIDEO_BASE)."""
base = (ability_video_base or "").strip().rstrip("/")
ver = (site_version or "").strip()
# JSON-encode so values are safe JS string literals.
ver_lit = json.dumps(ver, ensure_ascii=False)
base_lit = json.dumps(base, ensure_ascii=False)
(out / "config.js").write_text(
f"/* generated by export_relations_site.py — do not edit */\n"
f"var SITE_VERSION = {ver_lit};\n"
f"var ABILITY_VIDEO_BASE = {base_lit};\n",
encoding="utf-8",
)
def main() -> None:
ap = argparse.ArgumentParser(
description="Export relations preview as a static site"
@@ -59,8 +79,20 @@ def main() -> None:
action="store_true",
help="also copy assets/ability_videos (several GB)",
)
ap.add_argument(
"--ability-video-base",
default=None,
help="public base URL for ability demos (writes config.js); "
"falls back to ABILITY_VIDEO_BASE env, else empty (same-origin)",
)
args = ap.parse_args()
video_base = (
args.ability_video_base
if args.ability_video_base is not None
else os.environ.get("ABILITY_VIDEO_BASE", "")
)
out = Path(args.out).resolve()
if out == ROOT.resolve() or out.parent == out:
raise SystemExit(f"refusing unsafe --out: {out}")
@@ -69,8 +101,12 @@ def main() -> None:
out.mkdir(parents=True)
# Frontend (uses relative paths — works from any sub-path).
for name in ("index.html", "router.js", "app.js", "style.css"):
shutil.copy2(WEB_DIR / name, out / name)
# `_headers` is a Cloudflare Pages config file (cache TTLs for HTML/JSON/JS).
for name in ("index.html", "router.js", "app.js", "style.css", "_headers"):
src = WEB_DIR / name
if src.is_file():
shutil.copy2(src, out / name)
write_config_js(out, video_base, SITE_VERSION)
# Data snapshot (same payload as serve_relations /api/data).
payload = build_payload()
@@ -124,6 +160,7 @@ def main() -> None:
print(f" {name}/: {n} files")
if args.with_videos:
print(f" ability-video/: {n_videos} files")
print(f" config.js SITE_VERSION={SITE_VERSION!r} ABILITY_VIDEO_BASE={video_base!r}")
print(f" total: {total / 1e6:.1f} MB")
print(f"local check: python -m http.server -d {out} 8080")
print("deploy: upload the directory to any static host (Pages / nginx / ...)")
+93 -2
View File
@@ -33,8 +33,8 @@ import urllib.error
from datetime import datetime, timedelta, timezone
from pathlib import Path
from common import ABILITY_ICONS, DATA, ITEM_ICONS
from http_utils import http_json
from common import ABILITY_ICONS, DATA, HERO_PORTRAITS, ITEM_ICONS
from http_utils import http_bytes, http_json
PATCHES_LIST_URL = "https://www.dota2.com/datafeed/patchnoteslist?language=schinese"
PATCH_NOTES_URL = "https://www.dota2.com/datafeed/patchnotes?version={version}&language=schinese"
@@ -44,6 +44,13 @@ ABILITY_IDS_URL = "https://raw.githubusercontent.com/odota/dotaconstants/master/
ABILITIES_URL = "https://raw.githubusercontent.com/odota/dotaconstants/master/build/abilities.json"
ITEM_ICON_URL = "https://cdn.cloudflare.steamstatic.com/apps/dota2/images/dota_react/items/{key}.png"
ABILITY_ICON_URL = "https://cdn.cloudflare.steamstatic.com/apps/dota2/images/dota_react/abilities/{key}.png"
# Hero-card CDN (same as fetch_hero_portraits). Non-hero units may only exist at half res.
HERO_CARD_URL = (
"https://cdn.cloudflare.steamstatic.com/apps/dota2/images/dota_react/heroes/{key}.png"
)
# Prefer ability art when Valve's hero-card for a unit is tiny (spirit_bear is 128x72).
UNIT_PORTRAIT_FALLBACK_ABILITY = {"spirit_bear": "lone_druid_spirit_bear"}
PORTRAIT_TARGET_SIZE = (256, 144)
OUT = DATA / "patches.json"
HERO_ABILITIES_PATH = DATA / "hero_abilities.json"
@@ -242,6 +249,86 @@ def build_lookup(
return {"items": items, "abilities": abilities, "heroes": heroes}
def _png_size(data: bytes) -> tuple[int, int] | None:
if len(data) < 24 or data[:8] != b"\x89PNG\r\n\x1a\n":
return None
import struct
return struct.unpack(">II", data[16:24])
def _cover_resize_png(data: bytes, size: tuple[int, int] = PORTRAIT_TARGET_SIZE) -> bytes:
"""Center-crop / scale image bytes to a 16:9 hero-card PNG."""
import cv2
import numpy as np
arr = np.frombuffer(data, dtype=np.uint8)
img = cv2.imdecode(arr, cv2.IMREAD_UNCHANGED)
if img is None:
raise ValueError("cv2 could not decode image")
if img.ndim == 2:
img = cv2.cvtColor(img, cv2.COLOR_GRAY2BGR)
elif img.shape[2] == 4:
img = cv2.cvtColor(img, cv2.COLOR_BGRA2BGR)
h, w = img.shape[:2]
tw, th = size
scale = max(tw / w, th / h)
nw, nh = max(tw, int(round(w * scale))), max(th, int(round(h * scale)))
resized = cv2.resize(img, (nw, nh), interpolation=cv2.INTER_CUBIC)
x0 = max(0, (nw - tw) // 2)
y0 = max(0, (nh - th) // 2)
crop = resized[y0 : y0 + th, x0 : x0 + tw]
ok, buf = cv2.imencode(".png", crop)
if not ok:
raise ValueError("cv2 could not encode png")
return buf.tobytes()
def download_unit_portraits(*, delay: float) -> None:
"""Fetch portraits for UNIT_NAMES into hero_portraits (not in heroes.json).
Valve ships some unit cards at half resolution (spirit_bear 128x72). When a
fallback ability icon is configured, use that art cover-cropped to 256x144
so the patch page matches normal hero cards.
"""
HERO_PORTRAITS.mkdir(parents=True, exist_ok=True)
ok = skip = fail = 0
for unit in UNIT_NAMES.values():
key = unit["key"]
out = HERO_PORTRAITS / f"{key}.png"
if out.is_file():
dims = _png_size(out.read_bytes())
if dims == PORTRAIT_TARGET_SIZE:
skip += 1
continue
try:
fb = UNIT_PORTRAIT_FALLBACK_ABILITY.get(key)
if fb:
raw = http_bytes(ABILITY_ICON_URL.format(key=fb), timeout=30)
data = _cover_resize_png(raw)
src = f"ability:{fb}"
else:
raw = http_bytes(HERO_CARD_URL.format(key=key), timeout=30)
dims = _png_size(raw)
data = (
_cover_resize_png(raw)
if dims and dims != PORTRAIT_TARGET_SIZE
else raw
)
src = "hero-card"
out.write_bytes(data)
ok += 1
print(f" unit portrait {key} <- {src} ({len(data)} bytes)", flush=True)
time.sleep(max(delay, 0.05))
except Exception as e: # noqa: BLE001
fail += 1
print(f" FAIL unit portrait {key}: {e}", flush=True)
print(
f" unit portraits saved={ok} skipped={skip} fail={fail} -> {HERO_PORTRAITS}",
flush=True,
)
def download_referenced_icons(lookup: dict, *, delay: float) -> None:
"""Pull referenced item + ability icons into assets/ (skip existing)."""
from http_utils import download_icons
@@ -266,6 +353,10 @@ def download_referenced_icons(lookup: dict, *, delay: float) -> None:
)
print(f" ability icons saved={saved} skipped={skipped} fail={fail}", flush=True)
# Pseudo-heroes in patchnotes (e.g. 熊灵) are absent from herolist / heroes.json.
print("downloading unit portraits for patch lookup ...", flush=True)
download_unit_portraits(delay=delay)
def load_existing_details() -> dict[str, dict]:
if not OUT.is_file():
+20
View File
@@ -0,0 +1,20 @@
/*
X-Content-Type-Options: nosniff
/index.html
Cache-Control: public, max-age=0, must-revalidate
/data.json
Cache-Control: public, max-age=0, must-revalidate
/app.js
Cache-Control: public, max-age=60, must-revalidate
/config.js
Cache-Control: public, max-age=0, must-revalidate
/router.js
Cache-Control: public, max-age=60, must-revalidate
/style.css
Cache-Control: public, max-age=300, must-revalidate
+34 -3
View File
@@ -1,4 +1,30 @@
/* global fetch, document */
/* global fetch, document, ABILITY_VIDEO_BASE, SITE_VERSION */
/** OSS / CDN base for ability demos; empty => same-origin `/ability-video/...`. */
function abilityVideoBase() {
const raw =
typeof ABILITY_VIDEO_BASE === "string" ? ABILITY_VIDEO_BASE.trim() : "";
return raw.replace(/\/+$/, "");
}
function abilityVideoUrl(heroKey, abilityKey) {
const rel = `/ability-video/${heroKey}/${abilityKey}.webm`;
const base = abilityVideoBase();
return base ? `${base}${rel}` : rel;
}
function siteVersionLabel() {
const v = typeof SITE_VERSION === "string" ? SITE_VERSION.trim() : "";
return v ? `v${v}` : "";
}
function renderPatchesSiteVersion() {
const el = $("#patches-site-version");
if (!el) return;
const label = siteVersionLabel();
el.textContent = label;
el.setAttribute("aria-hidden", label ? "false" : "true");
}
const state = {
data: null,
@@ -724,15 +750,17 @@ function mountSkillInspect(root, skillEnt, heroKey) {
// Official-page layout: demo clip on the left, name/desc column on the right.
const body = document.createElement("div");
body.className = "skill-info-body";
// Official demo clip (assets/ability_videos); hidden when not cached locally.
// Official demo clip: local `/ability-video/...` (serve_relations) or OSS base
// from config.js (static export / Pages). Hidden when the file is missing.
if (heroKey && skillEnt.ability_key) {
const video = document.createElement("video");
video.className = "skill-info-video";
video.src = `/ability-video/${heroKey}/${skillEnt.ability_key}.webm`;
video.src = abilityVideoUrl(heroKey, skillEnt.ability_key);
video.muted = true;
video.loop = true;
video.autoplay = true;
video.playsInline = true;
video.crossOrigin = "anonymous";
video.addEventListener("error", () => video.remove());
body.appendChild(video);
}
@@ -1723,6 +1751,7 @@ function renderPatches() {
if (select) select.innerHTML = "";
if (verEl) verEl.textContent = "";
root.innerHTML = '<div class="patches-empty">暂无版本数据,请运行 python fetch_patches.py</div>';
renderPatchesSiteVersion();
return;
}
@@ -1753,6 +1782,7 @@ function renderPatches() {
const det = details[state.selectedPatch];
if (!det) {
root.innerHTML = '<div class="patches-empty">该版本详情暂未抓取,请重跑 python fetch_patches.py</div>';
renderPatchesSiteVersion();
return;
}
@@ -1785,6 +1815,7 @@ function renderPatches() {
.map((h) => renderHeroEntry(h, lookup))
.join("");
renderPatchSection(root, "英雄改动", heroesHtml);
renderPatchesSiteVersion();
}
function escapeHtml(s) {
+3
View File
@@ -0,0 +1,3 @@
/* Local defaults; production export overwrites via export_relations_site.py. */
var SITE_VERSION = "0.4.1";
var ABILITY_VIDEO_BASE = "";
+14 -2
View File
@@ -12,6 +12,16 @@
<img class="brand-logo" src="ui-icon/dota2_logo.png" alt="Dota 2" />
<span class="brand-title">上分帝</span>
</div>
<a
class="contact-mail"
href="mailto:c9mhs8vfmuyv@outlook.com"
aria-label="邮件联系"
title="c9mhs8vfmuyv@outlook.com"
>
<svg class="contact-mail-icon" viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" focusable="false">
<path fill="currentColor" d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4-8 5-8-5V6l8 5 8-5v2z"/>
</svg>
</a>
<nav class="main-tabs" aria-label="主分页">
<button type="button" class="main-tab active" data-page="heroes">英雄</button>
<button type="button" class="main-tab" data-page="items">物品</button>
@@ -55,11 +65,13 @@
<select id="patch-select" aria-label="选择版本"></select>
</div>
<div class="patches-detail" id="patches-detail"></div>
<footer class="patches-site-version" id="patches-site-version" aria-hidden="true"></footer>
</main>
<section class="detail" id="detail" aria-live="polite"></section>
<script src="/router.js"></script>
<script src="/app.js"></script>
<script src="/config.js?v=0.4.1"></script>
<script src="/router.js?v=0.4.1"></script>
<script src="/app.js?v=0.4.1"></script>
</body>
</html>
+39
View File
@@ -75,6 +75,34 @@ body {
@media (max-width: 620px) {
.brand { display: none; }
}
.contact-mail {
position: absolute;
right: 20px;
top: 12px;
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 8px;
border: none;
background: transparent;
color: var(--muted);
text-decoration: none;
transition: color 0.15s, background 0.15s;
}
.contact-mail:hover {
color: var(--text);
background: rgba(255, 255, 255, 0.06);
}
.contact-mail:focus-visible {
outline: 2px solid var(--sel);
outline-offset: 2px;
}
.contact-mail-icon {
display: block;
}
.main-tabs {
display: flex;
justify-content: center;
@@ -1609,6 +1637,17 @@ body:has(#items-view:not(.hidden)) {
color: var(--muted);
font-size: 14px;
}
.patches-site-version {
width: min(820px, 92%);
padding: 4px 0 28px;
text-align: center;
font-size: 11px;
font-weight: 500;
color: var(--muted);
letter-spacing: 0.08em;
opacity: 0.55;
user-select: none;
}
.patch-section {
margin-top: 26px;
}