Initial homepage

This commit is contained in:
mid
2026-08-18 09:57:55 +08:00
commit 1660bb9f65
25 changed files with 3019 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
* text=auto eol=lf
*.png binary
*.jpg binary
*.jpeg binary
*.woff2 binary
*.zip binary
+20
View File
@@ -0,0 +1,20 @@
name: Verify
on:
push:
pull_request:
permissions:
contents: read
jobs:
production-checks:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Run production checks
shell: pwsh
run: .\scripts\verify-production.ps1
+33
View File
@@ -0,0 +1,33 @@
# Local and release artifacts
*.zip
*.sha256
*.log
# Secrets and certificates
.env
.env.*
!.env.example
*.pem
*.key
*.crt
*.cer
*.p12
*.pfx
# Host-specific files
backups/
certificate-check/
work/
outputs/
# Editors and operating systems
.vscode/
.idea/
.DS_Store
Thumbs.db
desktop.ini
# Runtime caches
node_modules/
__pycache__/
*.pyc
+37
View File
@@ -0,0 +1,37 @@
# AGENTS.md
本文件适用于整个仓库。
## 开始工作前
1. 阅读 `README.md` 和 `docs/AI-HANDOFF.md`。
2. 运行 `powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\verify-production.ps1`。
3. 检查工作区已有改动,不要覆盖不属于当前任务的修改。
## 必须保持的行为
- Sakura 保持 1,600 粒子、五次花瓣绘制、四次 Bloom 和 120 FPS 上限,除非用户明确批准降低效果。
- 动画使用 `requestAnimationFrame`,页面隐藏时暂停,页面恢复时只能存在一个动画循环。
- 保留 cold-start、`pagehide/pageshow`、WebGL context recovery 和 Render Target fallback 测试。
- Blog 和 Tools 默认在新标签页打开。
- `site/assets/js/site-config.json` 失败时必须保留 HTML 后备链接。
- reduced-motion 当前会让 WebGL 使用静态帧;改变该策略前先与用户确认。
- `site/404.html` 使用根绝对资源路径,避免嵌套 URL 下资源失效。
## 修改规则
- 网站源文件位于 `site/`,不要创建第二份重复的运行副本。
- 修改 CSS 或 JavaScript 后同步更新 `site/index.html` 和 `site/404.html` 的资源查询版本。
- 修改字体文件后更改字体 URL 版本或文件名,因为生产环境使用长期缓存。
- 不要将密钥、证书私钥、访问令牌、主机 IP、私有代理路径或服务器备份写入仓库。
- `nginx/midctrl.com.example.conf` 只能保持为脱敏示例;部署时合并现有主机私有 location,不要在公开仓库记录它们。
- 不要将测试工具、备份或发布 ZIP 放进 `site/`。
## 完成标准
- 生产检查全部通过。
- 首页和 404 页面控制台无错误。
- 桌面端和移动端无横向溢出或元素重叠。
- 连续画面采样确认 Sakura 在非 reduced-motion 的前台页面持续更新。
- 若生成发布包,ZIP 只能包含 `site/` 文件,并生成 SHA-256。
- 未经用户明确授权,不连接生产服务器、不上传文件、不 reload Nginx。
+78
View File
@@ -0,0 +1,78 @@
# 中枢首页
`midctrl.com` 的纯静态导航首页。页面不需要构建工具、后端运行时或数据库,Nginx 可以直接提供 `site/` 中的文件。
## 功能
- WebGL Sakura 背景,1,600 个粒子、五次花瓣绘制、四次 Bloom,最高 120 FPS。
- DOM 就绪后立即初始化,不等待字体或图片完成加载。
- 页面进入后台时暂停,通过 `visibilitychange` 和 `pageshow` 恢复。
- WebGL context 丢失、Render Target resize 和显存分配失败恢复。
- 自托管 Noto Serif SC 字体、圆形头像框、标题与文案效果。
- 外链由 JSON 配置,并在新标签页打开。
- 响应式首页和 404 页面。
## 本地预览
```powershell
python -m http.server 9010 --bind 127.0.0.1 --directory site
```
打开 `http://127.0.0.1:9010/`。
## 修改站点与导航
编辑 `site/assets/js/site-config.json`。站点名称、描述、规范 URL 和导航链接都集中在这里:
```json
{
"site": {
"name": "中枢",
"description": "你也可以变成光",
"url": "https://midctrl.com"
},
"links": [
{ "name": "Blog", "url": "https://wp.midctrl.com" },
{ "name": "Tools", "url": "https://tools.midctrl.com" }
]
}
```
HTML 中保留了同样的后备链接,这是 JSON 请求失败、JavaScript 被禁用或网络异常时的故障降级;修改 `links` 后需同步更新 `site/index.html` 中的后备锚点。JSON 请求失败或超过三秒时,后备链接仍可使用,WebGL 初始化不受影响。
页面运行时会从同一份配置更新标题、canonical 和 Open Graph URL,避免在 HTML 与 JavaScript 中重复维护站点域名。`robots.txt` 和 `sitemap.xml` 中的绝对 URL 是搜索引擎协议要求的静态发布文件;更换域名时应同步更新这两个文件。
## 验证
Windows PowerShell:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\verify-production.ps1
```
检查内容包括 JavaScript 语法、冷启动、页面恢复、单动画循环、WebGL context 恢复、Render Target resize、渲染质量参数、字体许可、Nginx 示例和公开目录文件白名单。GitHub Actions 会在 push 和 pull request 时运行相同检查。
## 目录
- `site/`:可直接部署的网站文件。
- `tests/`:WebGL 生命周期和 Render Target 测试。
- `scripts/`:生产检查脚本。
- `nginx/`:不包含主机私有 location 的公开配置示例。
- `docs/`:部署和 AI 交接说明。
- `AGENTS.md`:后续 AI 会话应遵守的项目约束。
## 部署
参见 [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md)。部署时只复制 `site/` 的内容,不要把仓库文档、测试或 Git 元数据放入网站根目录。
## 前端代码与安全
浏览器必须下载前端代码和资源才能渲染,因此无法阻止访问者检查或复制 HTML、CSS、JavaScript、Shader 和图片。混淆只能增加阅读成本,不是安全边界。仓库中不得提交密钥、令牌、源站证书私钥或主机私有代理配置。
## 许可
项目当前没有声明整体开源许可证;公开仓库默认不向第三方授予复制、修改或再发布权利。自托管 Noto Serif SC 子集仍受 `site/assets/fonts/OFL.txt` 中的 SIL Open Font License 约束。若计划把项目作为可复用开源项目发布,应先明确选择项目级许可证。
## AI 继续工作
新的 AI 会话应先阅读 [AGENTS.md](AGENTS.md) 和 [docs/AI-HANDOFF.md](docs/AI-HANDOFF.md),再运行生产检查,避免丢失渲染质量和部署边界。
+63
View File
@@ -0,0 +1,63 @@
# AI 交接说明
更新日期:2026-08-18
## 当前状态
项目是 `midctrl.com` 的纯静态首页,无数据库、API 或构建步骤。网站运行文件位于 `site/`。当前版本已完成代码复审、冷启动修复、移动端检查和公开文件白名单验证,但本仓库版本尚未上传生产服务器。
## 最近完成的修复
- WebGL 从等待 `window.load` 改为 DOM 就绪后初始化,字体或图片请求不会阻塞 Sakura。
- 初始化函数幂等,避免重复创建场景。
- 初始化时重新读取页面可见性和 reduced-motion 状态。
- `pagehide` 取消待执行帧,`pageshow` 重新同步并恢复。
- generation guard 隔离浏览器队列中未及时移除的旧 rAF 回调。
- `site-config.json` 请求超时为三秒,失败时使用 HTML 中的链接。
- 资源查询版本为 `20260818-3`。
## 渲染约束
- 粒子数:1,600。
- 花瓣 draw call:五次。
- Bloom directional blur draw:四次。
- 帧率上限:120 FPS,跟随显示器 `requestAnimationFrame`。
- 内部像素预算:普通设备 1,000 万像素,浏览器报告内存不高于 4 GB 时为 600 万像素。
- resize 稳定窗口:140 ms。
- reduced-motion:WebGL 静态帧,头像框使用静态图片。
不要在没有用户明确批准的情况下通过降低粒子、Bloom 或绘制次数解决性能问题。
## 关键文件
- `site/assets/js/main.js`:链接配置、Shader、Sakura、Render Target 和生命周期。
- `site/assets/css/main.css`:布局、字体、文字和头像效果。
- `site/assets/js/site-config.json`:站点名称、描述、规范 URL、外链名称和 URL。
- `tests/test-render-target-resize.js`:无第三方依赖的 VM 测试。
- `scripts/verify-production.ps1`:生产检查和公开目录白名单。
- `nginx/midctrl.com.example.conf`:脱敏示例,不是生产服务器完整配置。
## 推荐工作流程
```powershell
python -m http.server 9010 --bind 127.0.0.1 --directory site
powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\verify-production.ps1
```
修改后先运行自动检查,再用真实浏览器检查首页、移动端和 `/404.html`。检查控制台并对纯 Sakura 区域间隔采样,确认画面确实变化。
## 生产边界
- Cloudflare SSL/TLS 使用 `Full (strict)`。
- 源站为 AlmaLinux 9 和 Nginx。
- 生产主机还有不属于本仓库的私有 location,部署时必须从现有服务器配置中保留。
- 证书私钥、服务器地址、私有 location、备份和上传凭据不得进入仓库。
- 用户授权部署前,只能在本地修改和验证。
## 新会话启动提示
可以在新 AI 会话中直接说明:“请先阅读 `AGENTS.md` 和 `docs/AI-HANDOFF.md`,运行生产检查,再继续处理我的需求。”
## 域名配置边界
运行时站点身份从 `site/assets/js/site-config.json` 读取,首页的标题、canonical 和 Open Graph 元数据不会再分别写死域名。`site/robots.txt` 与 `site/sitemap.xml` 是需要绝对 URL 的静态 SEO 文件;更换站点域名时必须一并更新它们,并运行生产检查。
+72
View File
@@ -0,0 +1,72 @@
# 部署说明
## 环境
- AlmaLinux 9
- Nginx
- Cloudflare CDN
- Cloudflare SSL/TLS:`Full (strict)`
网站是纯静态文件,不需要数据库、Node.js 服务或常驻应用进程。
## 生成发布包
在仓库根目录执行:
```powershell
Compress-Archive -Path .\site\* -DestinationPath .\midctrl-homepage-public.zip -CompressionLevel Optimal
Get-FileHash .\midctrl-homepage-public.zip -Algorithm SHA256
```
发布 ZIP 不应提交到 Git。
## 部署网站文件
将 ZIP 上传后解压到独立网站目录:
```bash
mkdir -p /var/www/midctrl.com
unzip -o /root/midctrl-homepage-public.zip -d /var/www/midctrl.com
chown -R root:nginx /var/www/midctrl.com
find /var/www/midctrl.com -type d -exec chmod 755 {} \;
find /var/www/midctrl.com -type f -exec chmod 644 {} \;
```
如果 SELinux 已启用,再执行:
```bash
restorecon -Rv /var/www/midctrl.com
```
## Nginx
`nginx/midctrl.com.example.conf` 是公开、脱敏的参考模板。不要直接覆盖生产配置。部署时需要:
1. 将现有站点 `root` 指向 `/var/www/midctrl.com`。
2. 合并安全响应头、缓存和 gzip 规则。
3. 保留服务器现有的所有私有 location 和代理请求头。
4. 保留有效的 Cloudflare Origin Certificate 路径。
5. 确认字体使用 `font/woff2` 或兼容的 MIME 类型。
生效前必须执行:
```bash
nginx -t
systemctl reload nginx
```
只有 `nginx -t` 成功后才能 reload。
## 部署后检查
- 首页和 404 页面正常。
- Blog 与 Tools 在新标签页打开。
- Noto Serif SC 字体返回 200。
- 控制台无 JavaScript、WebGL 或资源错误。
- Sakura 在前台页面持续运动,页面进入后台后暂停并能恢复。
- 服务器原有私有服务仍正常。
- 仓库 README、测试和 Nginx 示例没有出现在网站根目录。
## 回滚
部署前备份当前网站目录和 Nginx 配置。发生问题时恢复备份,运行 `nginx -t`,成功后 reload。不要把生产备份提交到 Git。
+76
View File
@@ -0,0 +1,76 @@
server {
listen 80;
listen [::]:80;
server_name midctrl.com www.midctrl.com;
return 301 https://midctrl.com$request_uri;
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name www.midctrl.com;
ssl_certificate /etc/ssl/cloudflare/cert.pem;
ssl_certificate_key /etc/ssl/cloudflare/key.pem;
return 301 https://midctrl.com$request_uri;
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name midctrl.com;
root /var/www/midctrl.com;
index index.html;
ssl_certificate /etc/ssl/cloudflare/cert.pem;
ssl_certificate_key /etc/ssl/cloudflare/key.pem;
charset utf-8;
server_tokens off;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'none'; upgrade-insecure-requests" always;
location = /index.html {
expires -1;
try_files $uri =404;
}
location ~* ^/assets/(?:css|js)/.+\.(?:css|js|json)$ {
expires -1;
try_files $uri =404;
}
location ^~ /assets/images/ {
expires 7d;
try_files $uri =404;
}
location ^~ /assets/fonts/ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
# Merge host-private application locations from the existing server
# configuration here. Do not store their paths or upstreams in Git.
location / {
try_files $uri $uri/ =404;
}
error_page 404 /404.html;
location = /404.html {
internal;
}
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css application/javascript application/json application/manifest+json image/svg+xml application/xml;
}
+151
View File
@@ -0,0 +1,151 @@
param(
[string]$Root = (Join-Path $PSScriptRoot "..\site"),
[string]$PublicRoot = "",
[string]$PublicArchive = ""
)
$ErrorActionPreference = "Stop"
$Root = (Resolve-Path -LiteralPath $Root).Path
function Assert-Check {
param([bool]$Condition, [string]$Message)
if (-not $Condition) { throw "FAIL: $Message" }
Write-Host "PASS: $Message"
}
$requiredFiles = @(
"index.html",
"404.html",
"robots.txt",
"sitemap.xml",
"site.webmanifest",
"assets/css/main.css",
"assets/js/main.js",
"assets/js/site-config.json",
"assets/fonts/NotoSerifSC-subset.woff2",
"assets/fonts/OFL.txt",
"assets/images/avatar.jpg",
"assets/images/avatar-frame-animated.png",
"assets/images/avatar-frame-mask.svg",
"assets/images/avatar-frame-static.png",
"assets/images/favicon.svg"
)
foreach ($relative in $requiredFiles) {
Assert-Check (Test-Path -LiteralPath (Join-Path $Root $relative) -PathType Leaf) "required file exists: $relative"
}
$index = Get-Content -Raw -Encoding UTF8 (Join-Path $Root "index.html")
$notFound = Get-Content -Raw -Encoding UTF8 (Join-Path $Root "404.html")
$manifestPath = Join-Path $Root "site.webmanifest"
$manifest = Get-Content -Raw -Encoding UTF8 $manifestPath | ConvertFrom-Json
$siteConfig = Get-Content -Raw -Encoding UTF8 (Join-Path $Root "assets/js/site-config.json") | ConvertFrom-Json
[Uri]$siteUri = $siteConfig.site.url
[string]$siteRoot = $siteUri.AbsoluteUri.TrimEnd('/') + '/'
$robots = Get-Content -Raw -Encoding UTF8 (Join-Path $Root "robots.txt")
[xml]$sitemap = Get-Content -Raw -Encoding UTF8 (Join-Path $Root "sitemap.xml")
[xml]$favicon = Get-Content -Raw -Encoding UTF8 (Join-Path $Root "assets/images/favicon.svg")
$nginxPath = Join-Path $PSScriptRoot "..\nginx\midctrl.com.example.conf"
Assert-Check (Test-Path -LiteralPath $nginxPath -PathType Leaf) "sanitized Nginx example exists"
$nginx = Get-Content -Raw -Encoding UTF8 $nginxPath
$css = Get-Content -Raw -Encoding UTF8 (Join-Path $Root "assets/css/main.css")
$jsPath = Join-Path $Root "assets/js/main.js"
$js = Get-Content -Raw -Encoding UTF8 $jsPath
$indexScriptVersion = [regex]::Match($index, 'main\.js\?v=([^"'']+)').Groups[1].Value
$notFoundScriptVersion = [regex]::Match($notFound, 'main\.js\?v=([^"'']+)').Groups[1].Value
$fontVersion = [regex]::Match($css, 'NotoSerifSC-subset\.woff2\?v=([^"'']+)').Groups[1].Value
Assert-Check ($notFound -match 'href="/assets/images/avatar\.jpg"' -and $notFound -match 'href="/assets/css/main\.css(?:\?[^" ]*)?"' -and $notFound -match 'src="/assets/js/main\.js(?:\?[^" ]*)?"' -and $notFound -match 'src="/assets/images/avatar\.jpg"') "404 assets use root-absolute URLs"
Assert-Check ($notFound -match 'class="portrait-image"' -and $notFound -match 'avatar-frame-animated\.png' -and $notFound -notmatch 'axis-line') "404 page uses the current avatar structure"
Assert-Check ($manifest.icons[0].src -eq "/assets/images/avatar.jpg") "manifest icon uses root-absolute URL"
Assert-Check ($manifest.icons[0].sizes -eq "512x512") "manifest icon size matches the avatar"
Assert-Check ([string]$manifest.start_url -eq "/") "manifest starts at the site root"
Assert-Check ($siteConfig.site.name -and $siteConfig.site.description -and $siteUri.IsAbsoluteUri -and $siteUri.Scheme -eq "https") "site identity and HTTPS URL are centralized in site-config.json"
Assert-Check ($siteConfig.links.Count -gt 0 -and @($siteConfig.links | Where-Object { -not $_.name -or $_.url -notmatch '^https://' }).Count -eq 0) "navigation configuration contains named HTTPS links"
Assert-Check ([string]$sitemap.urlset.url.loc -eq $siteRoot -and $robots -match [regex]::Escape("Sitemap: ${siteRoot}sitemap.xml")) "robots and sitemap use the configured canonical homepage"
Assert-Check ($index -match 'rel="canonical" href="/"' -and $index -match 'property="og:url" content="/"' -and $js -match 'applySiteMetadata') "HTML keeps portable metadata fallbacks and applies the configured site URL"
Assert-Check ([string]$favicon.svg.viewBox -eq "0 0 64 64") "favicon is valid SVG XML"
Add-Type -AssemblyName System.Drawing
$avatar = [System.Drawing.Image]::FromFile((Join-Path $Root "assets/images/avatar.jpg"))
try {
Assert-Check ($avatar.Width -eq 512 -and $avatar.Height -eq 512) "avatar is 512x512"
} finally {
$avatar.Dispose()
}
$node = Get-Command node -ErrorAction SilentlyContinue
Assert-Check ($null -ne $node) "Node.js is available for JavaScript syntax validation"
& $node.Source --check $jsPath
Assert-Check ($LASTEXITCODE -eq 0) "main.js passes node --check"
$renderTargetTest = Join-Path $PSScriptRoot "..\tests\test-render-target-resize.js"
& $node.Source $renderTargetTest $jsPath
Assert-Check ($LASTEXITCODE -eq 0) "Render Target resize and recovery tests pass"
Assert-Check ($js -match 'pointFlower\.numFlowers\s*=\s*1600') "particle count remains 1600"
Assert-Check (([regex]::Matches($js, 'gl\.drawArrays\(gl\.POINTS')).Count -eq 5) "flower draw call count remains 5"
Assert-Check ($js -match 'for\(var i = 0; i < 2; i\+\+\)' -and ([regex]::Matches($js, 'drawEffect\(effectLib\.dirBlur\)').Count -eq 2)) "Bloom retains four directional blur draws"
Assert-Check ($js -match 'maximumFrameRate\s*=\s*120') "animation cap remains 120 FPS"
Assert-Check ($js -match 'requestAnimationFrame' -and $js -match 'matchMedia\(' -and $js -match 'prefers-reduced-motion') "refresh scheduling and reduced-motion handling are present"
Assert-Check ($js -match 'DOMContentLoaded' -and $js -match 'initializeSakura' -and $js -notmatch "window\.addEventListener\('load'") "WebGL starts when the DOM is ready without waiting for fonts or images"
Assert-Check ($js -match "addEventListener\('pagehide'" -and $js -match "addEventListener\('pageshow'" -and $js -match 'synchronizeAnimationState') "page lifecycle recovery re-synchronizes the animation loop"
Assert-Check ($js -match 'request\.timeout\s*=\s*3000') "navigation configuration has a bounded network timeout"
Assert-Check ($indexScriptVersion -and $indexScriptVersion -eq $notFoundScriptVersion -and $indexScriptVersion -eq $fontVersion) "homepage, 404 page, and font use one cache version"
Assert-Check ($js -match 'createRenderTargetSet' -and $js -match 'deleteRenderTargetSet') "render target sets are swapped atomically"
Assert-Check ($js -match 'normalRenderPixelBudget\s*=\s*10000000' -and $js -match 'constrainedRenderPixelBudget\s*=\s*6000000') "render target pixel budgets are configured"
Assert-Check ($js -match 'resizeDebounceMs\s*=\s*140' -and $js -match 'measureCanvasSize' -and $js -match 'commitCanvasSize') "resize measurement and delayed commit are present"
Assert-Check ($js -match 'shouldReleaseBeforeAllocate' -and $js -match 'gl\.finish\(\)' -and $js -match 'getResizeCandidates') "high-memory resize and allocation fallback paths are present"
Assert-Check ($js -notmatch 'gl\.enable\(gl\.TEXTURE_2D\)' -and $js -notmatch 'gl\.disable\(gl\.TEXTURE_2D\)' -and $js -notmatch 'gl\.POINT\b') "invalid WebGL capability enums are absent"
Assert-Check ($nginx -match 'root /var/www/midctrl\.com;' -and $nginx -match 'try_files \$uri \$uri/ =404') "Nginx serves only the dedicated static web root"
Assert-Check ($nginx -match 'location ~\* \^/assets/\(\?:css\|js\)/' -and $nginx -match 'expires -1') "unversioned CSS and JavaScript revalidate"
Assert-Check ($nginx -match 'location \^~ /assets/fonts/' -and $nginx -match 'Cache-Control "public, immutable"') "self-hosted fonts use long-lived immutable caching"
Assert-Check ($nginx -notmatch 'location\s+/[a-f0-9]{16,}' -and $nginx -notmatch 'proxy_pass\s+http://127\.0\.0\.1:\d+') "public Nginx example omits host-private proxy details"
Assert-Check ($js -match 'frameRequestGeneration' -and $js -match 'cancelScheduledAnimationFrame') "animation loop guards stale callbacks after context loss"
Assert-Check ((Get-Item -LiteralPath (Join-Path $Root "assets/fonts/NotoSerifSC-subset.woff2")).Length -gt 1000 -and (Get-Content -Raw -Encoding UTF8 (Join-Path $Root "assets/fonts/OFL.txt")) -match 'SIL Open Font License') "Noto Serif SC subset and license are present"
$allowedPublic = @(
"index.html",
"404.html",
"robots.txt",
"sitemap.xml",
"site.webmanifest",
"assets/css/main.css",
"assets/js/main.js",
"assets/js/site-config.json",
"assets/fonts/NotoSerifSC-subset.woff2",
"assets/fonts/OFL.txt",
"assets/images/avatar.jpg",
"assets/images/avatar-frame-animated.png",
"assets/images/avatar-frame-mask.svg",
"assets/images/avatar-frame-static.png",
"assets/images/favicon.svg"
) | Sort-Object
if (-not $PublicRoot) {
$PublicRoot = $Root
}
if ($PublicRoot) {
$PublicRoot = (Resolve-Path -LiteralPath $PublicRoot).Path
$actualPublic = @(Get-ChildItem -LiteralPath $PublicRoot -Recurse -File | ForEach-Object {
$_.FullName.Substring($PublicRoot.TrimEnd('\').Length + 1).Replace('\', '/')
} | Sort-Object)
Assert-Check (($actualPublic -join "`n") -eq ($allowedPublic -join "`n")) "public bundle contains only web-root files"
}
if ($PublicArchive) {
$PublicArchive = (Resolve-Path -LiteralPath $PublicArchive).Path
Add-Type -AssemblyName System.IO.Compression.FileSystem
$archive = [System.IO.Compression.ZipFile]::OpenRead($PublicArchive)
try {
$archiveFiles = @($archive.Entries | Where-Object { $_.Name } | ForEach-Object {
$_.FullName.Replace('\', '/').TrimStart('/')
} | Sort-Object)
Assert-Check (($archiveFiles -join "`n") -eq ($allowedPublic -join "`n")) "public ZIP contains only web-root files"
} finally {
$archive.Dispose()
}
}
Write-Host "Production checks complete."
+41
View File
@@ -0,0 +1,41 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark">
<meta name="theme-color" content="#77747f">
<title>未找到页面 | 中枢</title>
<link rel="icon" href="/assets/images/avatar.jpg" type="image/jpeg">
<link rel="stylesheet" href="/assets/css/main.css?v=20260818-3">
<script src="/assets/js/main.js?v=20260818-3" defer></script>
</head>
<body>
<canvas id="sakura" aria-hidden="true"></canvas>
<main class="landing">
<section class="composition" aria-labelledby="error-title">
<div class="portrait-stage">
<div class="portrait-ring">
<img class="portrait-image" src="/assets/images/avatar.jpg" alt="中枢头像">
<div class="portrait-frame" aria-hidden="true">
<picture class="portrait-frame-slice">
<source media="(prefers-reduced-motion: reduce)" srcset="/assets/images/avatar-frame-static.png">
<img src="/assets/images/avatar-frame-animated.png" alt="">
</picture>
</div>
</div>
</div>
<div class="intro">
<p class="eyebrow">Error 404</p>
<h1 id="error-title" aria-label="迷路了"><span>迷</span><span>路</span><span>了</span></h1>
<p class="tagline">这里没有光,但可以回到入口。</p>
</div>
<nav class="destinations destinations-single" aria-label="返回导航">
<a class="destination" href="/">
<span>返回首页</span>
</a>
</nav>
</section>
</main>
</body>
</html>
+483
View File
@@ -0,0 +1,483 @@
@font-face {
font-family: "Noto Serif SC";
src: url("../fonts/NotoSerifSC-subset.woff2?v=20260818-3") format("woff2");
font-style: normal;
font-weight: 200 900;
font-display: swap;
}
:root {
color-scheme: dark;
--ink: #ffffff;
--ink-muted: rgba(255, 255, 255, 0.78);
--line: rgba(255, 255, 255, 0.82);
--line-soft: rgba(255, 255, 255, 0.42);
--rose: #f38caa;
--font-display: "Noto Serif SC", "Source Han Serif SC", "Songti SC", STSong, serif;
font-family: "Microsoft YaHei UI", "PingFang SC", "Source Han Sans SC", "Noto Sans CJK SC", sans-serif;
font-synthesis: none;
}
* {
box-sizing: border-box;
}
html {
min-width: 320px;
min-height: 100%;
background: #77747f;
}
body {
min-width: 320px;
min-height: 100vh;
min-height: 100svh;
margin: 0;
overflow-x: hidden;
color: var(--ink);
background: #77747f;
}
a {
color: inherit;
text-decoration: none;
}
#sakura {
position: fixed;
z-index: 0;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
background: linear-gradient(180deg, #d8d7dc 0%, #aaa8b2 45%, #66636f 100%);
}
.landing {
position: relative;
z-index: 2;
display: grid;
min-height: 100vh;
min-height: 100svh;
place-items: start center;
padding: clamp(34px, 6vh, 64px) 24px 28px;
}
.composition {
position: relative;
display: flex;
width: min(100%, 620px);
flex-direction: column;
align-items: center;
justify-content: flex-start;
text-align: center;
/* Preserve the 100px composition on normal screens and reclaim space on short ones. */
transform: translateY(clamp(0px, calc(125vh - 800px), 100px));
}
.portrait-stage {
position: relative;
z-index: 2;
display: grid;
width: 124px;
height: 124px;
flex: 0 0 auto;
place-items: center;
}
.portrait-ring {
position: relative;
width: 116px;
height: 116px;
overflow: visible;
border: 3px solid rgba(255, 255, 255, 0.92);
border-radius: 50%;
background: #d9d8dc;
box-shadow: 0 12px 34px rgba(49, 42, 53, 0.24);
transform: rotate(0deg);
transition: transform 720ms ease-in-out;
}
.portrait-stage:hover .portrait-ring {
transform: rotate(360deg);
}
.portrait-image {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
object-position: 50% 43%;
border-radius: 50%;
}
/* The supplied frame is square. Keep only its central glitch slices so the
circular avatar remains the dominant shape. */
.portrait-frame {
position: absolute;
z-index: 2;
inset: 0;
overflow: hidden;
pointer-events: none;
border-radius: 50%;
clip-path: circle(50% at 50% 50%);
-webkit-mask-image: url("../images/avatar-frame-mask.svg?v=20260817-2");
-webkit-mask-mode: alpha;
-webkit-mask-repeat: no-repeat;
-webkit-mask-size: 100% 100%;
mask-image: url("../images/avatar-frame-mask.svg?v=20260817-2");
mask-mode: alpha;
mask-repeat: no-repeat;
mask-size: 100% 100%;
}
.portrait-frame-slice {
position: absolute;
display: block;
inset: 0;
}
.portrait-frame-slice img {
display: block;
width: 100%;
height: 100%;
object-fit: fill;
}
.intro {
position: relative;
width: 100%;
margin-top: 83px;
margin-bottom: 33px;
padding: 66px 24px 60px;
}
.eyebrow {
margin: 0 0 14px;
color: rgba(255, 255, 255, 0.68);
font-family: "Segoe UI", Arial, sans-serif;
font-size: 0.66rem;
font-weight: 500;
letter-spacing: 0;
text-transform: uppercase;
}
h1 {
display: inline-flex;
margin: 0;
align-items: center;
justify-content: center;
gap: 0.3em;
font-family: var(--font-display);
font-size: 3rem;
font-weight: 300;
line-height: 1.16;
letter-spacing: 0;
text-shadow: 0 3px 18px rgba(47, 41, 51, 0.18);
}
#site-title {
transform-origin: center;
animation: title-glow 2.8s ease-in-out infinite;
will-change: transform, text-shadow;
}
@keyframes title-glow {
0%,
100% {
color: rgba(255, 255, 255, 0.92);
transform: scale(1);
text-shadow:
0 3px 18px rgba(47, 41, 51, 0.18),
0 0 2px rgba(255, 255, 255, 0.24),
0 0 0 rgba(243, 140, 170, 0);
}
50% {
color: #fff8fb;
transform: scale(1.06);
text-shadow:
0 3px 18px rgba(47, 41, 51, 0.18),
0 0 7px rgba(255, 255, 255, 1),
0 0 24px rgba(243, 140, 170, 0.95),
0 0 54px rgba(243, 140, 170, 0.68);
}
}
.tagline {
margin: 30px 0 0;
color: var(--ink-muted);
font-family: var(--font-display);
font-size: 1.2rem;
font-weight: 300;
letter-spacing: 0;
text-shadow: 0 2px 14px rgba(47, 41, 51, 0.24);
}
.tagline-chromatic {
position: relative;
color: #ffffff;
font-weight: 500;
mix-blend-mode: lighten;
text-shadow: 0 3px 14px rgba(47, 41, 51, 0.2);
}
.tagline-chromatic::before,
.tagline-chromatic::after {
position: absolute;
top: 0;
width: 100%;
content: attr(data-text);
background: transparent;
clip: rect(0, 0, 0, 0);
pointer-events: none;
}
.tagline-chromatic::before {
left: -0.0125em;
text-shadow: 0.05em 0 #ff3f1a;
animation: glitch-loop-1 0.8s infinite ease-in-out alternate-reverse;
}
.tagline-chromatic::after {
left: 0.0125em;
text-shadow: -0.0625em 0 #00a7e0;
animation: glitch-loop-2 0.8s infinite ease-in-out alternate-reverse;
}
@keyframes glitch-loop-1 {
0% {
clip: rect(0.45em, 9999px, 0.1125em, 0);
}
25% {
clip: rect(0.3125em, 9999px, 1.2375em, 0);
}
50% {
clip: rect(0.625em, 9999px, 1.275em, 0);
}
75% {
clip: rect(0.375em, 9999px, 1.15em, 0);
}
100% {
clip: rect(1.1375em, 9999px, 1.225em, 0);
}
}
@keyframes glitch-loop-2 {
0% {
top: -0.0125em;
left: 0.0125em;
clip: rect(0.8125em, 9999px, 1.4875em, 0);
}
25% {
top: -0.075em;
left: 0.05em;
clip: rect(0.9875em, 9999px, 0.2375em, 0);
}
50% {
top: -0.0375em;
left: 0.025em;
clip: rect(0.85em, 9999px, 0.1375em, 0);
}
75% {
top: 0;
left: -0.05em;
clip: rect(1.1875em, 9999px, 0.6625em, 0);
}
100% {
top: -0.0125em;
left: -0.0125em;
clip: rect(0.3875em, 9999px, 1.8625em, 0);
}
}
.destinations {
position: relative;
z-index: 2;
display: flex;
width: min(100%, 420px);
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 18px;
overflow: visible;
}
.destinations-single {
width: min(100%, 240px);
}
.destination {
position: relative;
display: flex;
width: 82px;
height: 82px;
flex: 0 0 82px;
align-items: center;
justify-content: center;
gap: 7px;
min-width: 0;
min-height: 0;
color: rgba(255, 255, 255, 0.92);
font-family: "Segoe UI", Arial, sans-serif;
font-size: 0.78rem;
font-weight: 400;
letter-spacing: 0;
text-transform: uppercase;
border: 1px solid rgba(255, 255, 255, 0.88);
border-radius: 50%;
background: rgba(75, 70, 81, 0.16);
box-shadow: 0 12px 32px rgba(50, 43, 55, 0.13);
backdrop-filter: blur(4px);
transition: background-color 180ms ease, border-color 180ms ease, color 180ms ease, transform 180ms ease;
}
.destination:hover {
color: #4e4854;
background: rgba(255, 255, 255, 0.82);
border-color: rgba(255, 255, 255, 0.98);
transform: translateY(-3px);
}
.destination:active {
transform: translateY(-1px);
}
.destination-label {
line-height: 1;
}
.external-icon {
font-family: "Segoe UI Symbol", "Arial Unicode MS", sans-serif;
font-size: 0.76rem;
line-height: 1;
}
.destination:focus-visible {
z-index: 2;
outline: 2px solid #ffffff;
outline-offset: -5px;
}
.site-footer {
position: fixed;
z-index: 3;
right: 18px;
bottom: 14px;
color: rgba(255, 255, 255, 0.48);
font-family: "Segoe UI", Arial, sans-serif;
font-size: 0.58rem;
letter-spacing: 0;
text-transform: uppercase;
}
@media (max-width: 720px) {
.landing {
padding: 30px 20px 38px;
}
.composition {
min-height: 610px;
transform: translateY(clamp(0px, calc(75vh - 480px), 100px));
}
.portrait-stage {
width: 106px;
height: 106px;
}
.portrait-ring {
width: 100px;
height: 100px;
}
.intro {
margin-top: 65px;
margin-bottom: 29px;
padding: 54px 12px 50px;
}
h1 {
gap: 0.28em;
font-size: 2.55rem;
}
.tagline {
margin-top: 24px;
font-size: 1.08rem;
}
.destinations {
width: min(100%, 330px);
gap: 14px;
}
.destination {
width: 76px;
height: 76px;
flex-basis: 76px;
font-size: 0.74rem;
}
.site-footer {
display: none;
}
}
@media (max-width: 380px) {
.landing {
padding-right: 16px;
padding-left: 16px;
}
h1 {
gap: 0.24em;
font-size: 2.3rem;
}
}
@media (max-height: 720px) and (min-width: 721px) {
.landing {
padding-top: 44px;
padding-bottom: 18px;
}
}
@media (max-width: 720px) and (max-height: 720px) {
.landing {
padding-bottom: 0;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
}
.tagline-chromatic::before,
.tagline-chromatic::after {
animation: none;
}
.portrait-ring {
transition: none;
}
#site-title {
animation: none;
}
}
Binary file not shown.
+94
View File
@@ -0,0 +1,94 @@
Copyright 2012 Google Inc. All Rights Reserved.
This Font Software is licensed under the SIL Open Font License,
Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font
creation efforts of academic and linguistic communities, and to
provide a free and open framework in which fonts may be shared and
improved in partnership with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply to
any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software
components as distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to,
deleting, or substituting -- in part or in whole -- any of the
components of the Original Version, by changing formats or by porting
the Font Software to a new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed,
modify, redistribute, and sell modified and unmodified copies of the
Font Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components, in
Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the
corresponding Copyright Holder. This restriction only applies to the
primary font name as presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created using
the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.

After

Width:  |  Height:  |  Size: 633 KiB

+14
View File
@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" width="224" height="224" viewBox="0 0 224 224">
<defs>
<mask id="remove-frame" maskUnits="userSpaceOnUse" mask-type="luminance">
<rect width="224" height="224" fill="white"/>
<g fill="black">
<rect x="14" y="0" width="18" height="224"/>
<rect x="192" y="0" width="22" height="224"/>
<rect x="0" y="14" width="224" height="18"/>
<rect x="0" y="193" width="224" height="22"/>
</g>
</mask>
</defs>
<rect width="224" height="224" fill="white" mask="url(#remove-frame)"/>
</svg>

After

Width:  |  Height:  |  Size: 578 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="8" fill="#080a0d"/>
<path d="M32 10v44M10 32h44" stroke="#f4f7f8" stroke-width="2"/>
<rect x="23" y="23" width="18" height="18" transform="rotate(45 32 32)" fill="none" stroke="#f0c87a" stroke-width="2"/>
<circle cx="32" cy="32" r="3" fill="#9ee7cf"/>
</svg>

After

Width:  |  Height:  |  Size: 361 B

File diff suppressed because it is too large Load Diff
+11
View File
@@ -0,0 +1,11 @@
{
"site": {
"name": "中枢",
"description": "你也可以变成光",
"url": "https://midctrl.com"
},
"links": [
{ "name": "Blog", "url": "https://wp.midctrl.com" },
{ "name": "Tools", "url": "https://tools.midctrl.com" }
]
}
+66
View File
@@ -0,0 +1,66 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark">
<meta name="theme-color" content="#77747f">
<meta name="description" content="中枢 - 你也可以变成光">
<meta property="og:type" content="website">
<meta property="og:site_name" content="中枢">
<meta property="og:title" content="中枢">
<meta property="og:description" content="你也可以变成光">
<meta property="og:url" content="/">
<meta property="og:image" content="assets/images/avatar.jpg">
<title>中枢</title>
<link rel="canonical" href="/">
<link rel="icon" href="assets/images/avatar.jpg" type="image/jpeg">
<link rel="manifest" href="site.webmanifest">
<link rel="stylesheet" href="assets/css/main.css?v=20260818-3">
<script src="assets/js/main.js?v=20260818-3" defer></script>
</head>
<body>
<canvas id="sakura" aria-hidden="true"></canvas>
<main class="landing">
<section class="composition" aria-labelledby="site-title">
<div class="portrait-stage">
<div class="portrait-ring">
<img class="portrait-image" src="assets/images/avatar.jpg" alt="中枢头像">
<div class="portrait-frame" aria-hidden="true">
<picture class="portrait-frame-slice">
<source media="(prefers-reduced-motion: reduce)" srcset="assets/images/avatar-frame-static.png">
<img src="assets/images/avatar-frame-animated.png" alt="">
</picture>
</div>
</div>
</div>
<div class="intro">
<h1 id="site-title" aria-label="中枢"><span>中</span><span>枢</span></h1>
<p class="tagline tagline-chromatic" data-text="你也可以变成光">你也可以变成光</p>
</div>
<!-- Static destinations keep the page usable when JSON or JavaScript is unavailable. -->
<nav id="site-navigation" class="destinations" aria-label="站点导航">
<a class="destination" href="https://wp.midctrl.com" target="_blank" rel="noopener noreferrer">
<span class="destination-label">Blog</span>
<span class="external-icon" aria-hidden="true">↗</span>
</a>
<a class="destination" href="https://tools.midctrl.com" target="_blank" rel="noopener noreferrer">
<span class="destination-label">Tools</span>
<span class="external-icon" aria-hidden="true">↗</span>
</a>
</nav>
</section>
</main>
<footer class="site-footer">
<span>&copy; <span id="year">2026</span> midctrl</span>
</footer>
</body>
</html>
+4
View File
@@ -0,0 +1,4 @@
User-agent: *
Allow: /
Sitemap: https://midctrl.com/sitemap.xml
+17
View File
@@ -0,0 +1,17 @@
{
"name": "中枢",
"short_name": "中枢",
"description": "你也可以变成光",
"lang": "zh-CN",
"start_url": "/",
"display": "standalone",
"background_color": "#77747f",
"theme_color": "#77747f",
"icons": [
{
"src": "/assets/images/avatar.jpg",
"sizes": "512x512",
"type": "image/jpeg"
}
]
}
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://midctrl.com/</loc>
</url>
</urlset>
+337
View File
@@ -0,0 +1,337 @@
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const vm = require("node:vm");
const mainPath = process.argv[2]
? path.resolve(process.argv[2])
: path.join(__dirname, "midctrl-homepage-optimized", "assets", "js", "main.js");
const source = fs.readFileSync(mainPath, "utf8");
const events = [];
const consoleErrors = [];
const windowListeners = new Map();
const documentListeners = new Map();
const canvasListeners = new Map();
const timers = new Map();
let nextTimerId = 1;
let rafQueue = [];
let rafTime = 0;
let nextObjectId = 1;
let reducedMotionChangeListener = null;
let documentHidden = true;
let documentReadyState = "loading";
function addListener(store, type, callback) {
if (!store.has(type)) store.set(type, []);
store.get(type).push(callback);
}
function emit(store, type, event = {}) {
for (const callback of store.get(type) || []) callback(event);
}
function flushTimers() {
const pending = [...timers.entries()];
timers.clear();
for (const [, callback] of pending) callback();
}
function flushAnimationFrames() {
const pending = rafQueue;
rafQueue = [];
rafTime += 16.667;
for (const callback of pending) callback(rafTime);
}
function makeObject(type) {
return { type, id: nextObjectId++ };
}
const constants = {
NO_ERROR: 0,
FRAMEBUFFER_COMPLETE: 0x8cd5,
MAX_TEXTURE_SIZE: 0x0d33,
MAX_RENDERBUFFER_SIZE: 0x84e8,
ALIASED_POINT_SIZE_RANGE: 0x846d,
COMPILE_STATUS: 0x8b81,
LINK_STATUS: 0x8b82,
VERTEX_SHADER: 0x8b31,
FRAGMENT_SHADER: 0x8b30,
HIGH_FLOAT: 0x8df2,
TEXTURE0: 0x84c0,
TEXTURE1: 0x84c1,
};
const glTarget = {
...constants,
failTexturePixelsAbove: Infinity,
createFramebuffer() {
events.push("createFramebuffer");
return makeObject("framebuffer");
},
deleteFramebuffer() {
events.push("deleteFramebuffer");
},
createTexture() {
return makeObject("texture");
},
deleteTexture() {},
createRenderbuffer() {
return makeObject("renderbuffer");
},
deleteRenderbuffer() {},
createShader() {
return makeObject("shader");
},
createProgram() {
return makeObject("program");
},
createBuffer() {
return makeObject("buffer");
},
texImage2D(target, level, internalFormat, width, height) {
if (width * height > this.failTexturePixelsAbove) {
events.push("textureAllocationFailed");
throw new Error("Synthetic texture allocation failure.");
}
},
getParameter(parameter) {
if (parameter === this.MAX_TEXTURE_SIZE || parameter === this.MAX_RENDERBUFFER_SIZE) return 16384;
if (parameter === this.ALIASED_POINT_SIZE_RANGE) return [1, 64];
return null;
},
getShaderPrecisionFormat() {
return { precision: 23 };
},
getShaderParameter() {
return true;
},
getProgramParameter() {
return true;
},
getShaderInfoLog() {
return "";
},
getProgramInfoLog() {
return "";
},
getUniformLocation() {
return makeObject("uniform");
},
getAttribLocation() {
return 0;
},
checkFramebufferStatus() {
return this.FRAMEBUFFER_COMPLETE;
},
getError() {
return this.NO_ERROR;
},
isContextLost() {
return false;
},
finish() {
events.push("finish");
},
};
let generatedConstant = 0x10000;
const gl = new Proxy(glTarget, {
get(target, property) {
if (property in target) return target[property];
if (typeof property === "string" && /^[A-Z0-9_]+$/.test(property)) {
target[property] = generatedConstant++;
return target[property];
}
if (typeof property === "string") {
target[property] = function noop() {};
return target[property];
}
return undefined;
},
});
const canvas = {
width: 300,
height: 150,
hidden: false,
getContext() {
return gl;
},
addEventListener(type, callback) {
addListener(canvasListeners, type, callback);
},
};
gl.canvas = canvas;
const year = { textContent: "" };
const mediaQuery = {
matches: false,
addEventListener(type, callback) {
if (type === "change") reducedMotionChangeListener = callback;
},
addListener(callback) {
reducedMotionChangeListener = callback;
},
};
const context = {
console: {
log() {},
warn() {},
error(...args) {
consoleErrors.push(args.map(String).join(" "));
},
},
document: {
get hidden() {
return documentHidden;
},
get visibilityState() {
return documentHidden ? "hidden" : "visible";
},
get readyState() {
return documentReadyState;
},
documentElement: { clientWidth: 1280, clientHeight: 720 },
querySelector(selector) {
if (selector === "#year") return year;
return null;
},
getElementById(id) {
return id === "sakura" ? canvas : null;
},
addEventListener(type, callback) {
addListener(documentListeners, type, callback);
},
},
navigator: { deviceMemory: 8 },
innerWidth: 1280,
innerHeight: 720,
devicePixelRatio: 1,
performance: { now: () => rafTime },
matchMedia() {
return mediaQuery;
},
addEventListener(type, callback) {
addListener(windowListeners, type, callback);
},
setTimeout(callback) {
const id = nextTimerId++;
timers.set(id, callback);
return id;
},
clearTimeout(id) {
timers.delete(id);
},
requestAnimationFrame(callback) {
rafQueue.push(callback);
return rafQueue.length;
},
cancelAnimationFrame() {
// Deliberately leave the callback queued to verify generation guards in
// browsers where cancellation races with a frame already being dispatched.
events.push("cancelAnimationFrame");
},
Date,
Math,
Number,
Object,
Array,
Float32Array,
Uint8Array,
isFinite,
};
context.window = context;
context.globalThis = context;
vm.runInNewContext(source, context, { filename: "main.js" });
assert.equal(events.filter((event) => event === "createFramebuffer").length, 0, "WebGL must wait only for the DOM");
documentHidden = false;
documentReadyState = "interactive";
emit(documentListeners, "DOMContentLoaded");
assert.equal(consoleErrors.length, 0, "initialization must not log errors");
assert.deepEqual([canvas.width, canvas.height], [1280, 720]);
assert.equal(events.filter((event) => event === "createFramebuffer").length, 3);
assert.equal(rafQueue.length, 1, "a missed pre-DOM visibility transition must still start animation");
function resize(width, height, dpr, burstCount = 1) {
context.innerWidth = width;
context.innerHeight = height;
context.devicePixelRatio = dpr;
context.document.documentElement.clientWidth = width;
context.document.documentElement.clientHeight = height;
for (let i = 0; i < burstCount; i++) emit(windowListeners, "resize");
assert.equal(timers.size, 1, "resize bursts must collapse to one timer");
flushTimers();
flushAnimationFrames();
}
events.length = 0;
resize(1366, 768, 1, 25);
assert.equal(events.filter((event) => event === "createFramebuffer").length, 3);
assert.equal(events.filter((event) => event === "deleteFramebuffer").length, 3);
assert.ok(events.indexOf("createFramebuffer") < events.indexOf("deleteFramebuffer"));
events.length = 0;
resize(3000, 1800, 2);
assert.ok(canvas.width * canvas.height <= 10000000, "normal pixel budget must be enforced");
events.length = 0;
resize(3200, 1800, 2);
assert.equal(events.filter((event) => event === "finish").length, 1);
assert.equal(events.filter((event) => event === "deleteFramebuffer").length, 3);
assert.equal(events.filter((event) => event === "createFramebuffer").length, 3);
assert.ok(events.indexOf("deleteFramebuffer") < events.indexOf("createFramebuffer"));
events.length = 0;
gl.failTexturePixelsAbove = 5000000;
resize(3600, 2000, 2);
assert.ok(events.filter((event) => event === "textureAllocationFailed").length >= 2);
assert.ok(canvas.width * canvas.height <= 5000000, "failed allocations must fall back to a smaller target");
assert.equal(consoleErrors.length, 0, "successful fallback must not log a terminal error");
events.length = 0;
gl.failTexturePixelsAbove = Infinity;
let contextLossPrevented = false;
documentHidden = true;
emit(windowListeners, "pagehide", { persisted: true });
assert.equal(events.filter((event) => event === "cancelAnimationFrame").length, 1);
assert.equal(rafQueue.length, 1, "the mock retains the stale frame after pagehide");
documentHidden = false;
emit(windowListeners, "pageshow", { persisted: true });
assert.equal(rafQueue.length, 2, "pageshow must queue a replacement frame");
emit(windowListeners, "pageshow", { persisted: true });
assert.equal(rafQueue.length, 3, "a repeated pageshow replaces the pending frame in the mock queue");
flushAnimationFrames();
assert.equal(rafQueue.length, 1, "only the newest pageshow loop may continue");
events.length = 0;
mediaQuery.matches = true;
reducedMotionChangeListener({ matches: true });
assert.equal(events.filter((event) => event === "cancelAnimationFrame").length, 1);
flushAnimationFrames();
assert.equal(rafQueue.length, 0, "reduced motion must leave no live animation callback");
mediaQuery.matches = false;
reducedMotionChangeListener({ matches: false });
assert.equal(rafQueue.length, 1, "animation must have one pending frame before context loss");
events.length = 0;
emit(canvasListeners, "webglcontextlost", {
preventDefault() {
contextLossPrevented = true;
},
});
emit(canvasListeners, "webglcontextrestored");
assert.equal(contextLossPrevented, true);
assert.equal(events.filter((event) => event === "createFramebuffer").length, 3);
assert.equal(events.filter((event) => event === "cancelAnimationFrame").length, 1);
assert.equal(rafQueue.length, 2, "the mock keeps both the stale and restored callbacks queued");
flushAnimationFrames();
assert.equal(rafQueue.length, 1, "only the restored animation loop may schedule the next frame");
assert.equal(consoleErrors.length, 0, "context restoration must not log errors");
console.log("PASS: cold start, page recovery, Render Target resize, fallback, and context restoration");