diff --git a/.spec/specs/2026-11-02_blog-5g-pages/tasks.md b/.spec/specs/2026-11-02_blog-5g-pages/tasks.md
index 29173df..c3f9b33 100644
--- a/.spec/specs/2026-11-02_blog-5g-pages/tasks.md
+++ b/.spec/specs/2026-11-02_blog-5g-pages/tasks.md
@@ -16,7 +16,7 @@
- boundary: 新增 src/components/blocks/{Cards,Timeline,Quote,Figure,LinkList,Cta}.astro + 图标键白名单(复用 SocialLinks 注册表或新建 icons 注册表)、schema 块型补全(图片字段用 image(),禁止外链字符串)、content/site/assets/ 目录、near docs 图表说明
- verify: `E2E_BASE=http://127.0.0.1:18924 node tests/e2e.mjs 2>&1 | grep -q "37/37 PASS" && ls src/components/blocks/ | grep -qC1 "Cards\|Timeline" && grep -q "image()" src/content.config.ts`
-- [ ] T3 样式即数据(theme.yaml + 对比度闸门)
+- [x] T3 样式即数据(theme.yaml + 对比度闸门)
- claim: op-artalk
- boundary: 新增 content/site/theme.yaml(12 个白名单旋钮:prose-size/measure/radius/accent/leading 等)、新增 src/lib/theme.ts(getTheme + themeVarsCss 生成 :root:root{…})、BaseLayout.astro head 内联 theme 变量(构建期静态)、新增 scripts/build-perf/contrast-check.mjs(断言 theme 结果下正文对比度 ≥4.5)、public/admin/config.yml 加 theme 单例、build.sh 守卫挂载
- verify: `E2E_BASE=http://127.0.0.1:18924 node tests/e2e.mjs 2>&1 | grep -q "37/37 PASS" && test -f content/site/theme.yaml && node scripts/build-perf/contrast-check.mjs dist`
diff --git a/content/site/theme.yaml b/content/site/theme.yaml
new file mode 100644
index 0000000..4174228
--- /dev/null
+++ b/content/site/theme.yaml
@@ -0,0 +1,37 @@
+# 主题旋钮(样式即数据 · 层 4):只放白名单内的 9 组旋钮。
+# 红线:--measure(版心)与断点/纸墨/code 配色不在此列——它们牵动版面结构与分轨身份。
+# 全部留空 = 沿用 src/styles/tokens.css 的既有值(零配置零变化)。
+# 钳制与单调性在 src/lib/theme.ts 里做:越界值被夹取而非报错,构建不会因主题配置失败。
+
+# ① 强调色(明/暗两值;构建期闸门校验正文对比度 ≥4.5,不达标回落默认)
+accent_light: '#3454d1'
+accent_dark: '#8ea2ff'
+
+# ② 图片圆角(4–16;08 文档裁定上限 14)
+radius_img: 10
+
+# ③ 标题字号(22–32;必须 h1 > h2 > h3,否则整体回落默认)
+text_h1: 27
+text_h2: 22
+text_h3: 19
+
+# ④ 正文字号(16–22)
+prose_size_reading: 20
+prose_size_tech: 19
+
+# ⑤ 正文行高(1.5–2.0)
+leading_reading: 1.75
+leading_tech: 1.7
+
+# ⑥ 间距梯度(8–80;必须严格递增 3 < 4 < 5 < 6,否则回落默认)
+space_3: 16
+space_4: 24
+space_5: 40
+space_6: 64
+
+# ⑦ 段落流距(0–2em)
+flow_p: 0.9
+flow_h2_post: 0.6
+
+# ⑧ 过渡时长(150–600ms)
+dur: 350
diff --git a/infra/deploy/build.sh b/infra/deploy/build.sh
index 7d9d35b..c675fdd 100755
--- a/infra/deploy/build.sh
+++ b/infra/deploy/build.sh
@@ -33,6 +33,7 @@ pnpm astro build
# 按页字体 CSS 子集化(车道 A 任务 1):裁 @font-face 声明数,构建仓与本地同链;
# 存在性守卫:新文件未推送到 main 前构建仓无此脚本,绝不让发布链因车道在途文件断掉
[ -f scripts/build-perf/subset-font-css.mjs ] && node scripts/build-perf/subset-font-css.mjs dist || echo "[build] subset 脚本未就绪,跳过"
+[ -f scripts/build-perf/contrast-check.mjs ] && node scripts/build-perf/contrast-check.mjs dist || echo "[build] contrast 脚本未就绪,跳过"
# 首页阻塞 CSS 内联(车道 D):守卫先行同上——脚本未到 main 前绝不炸链
[ -f scripts/build-perf/gen-sw-precache.mjs ] && node scripts/build-perf/gen-sw-precache.mjs dist || echo "[build] sw-precache 生成器未就绪,跳过"
[ -f scripts/build-perf/inline-home-css.mjs ] && node scripts/build-perf/inline-home-css.mjs || echo "[build] inline-home 脚本未就绪,跳过"
diff --git a/public/admin/config.yml b/public/admin/config.yml
index b49b7dd..8d8a618 100644
--- a/public/admin/config.yml
+++ b/public/admin/config.yml
@@ -58,6 +58,29 @@ collections:
- { label: 草稿, name: draft, widget: boolean, required: false, default: false }
- { label: 正文, name: body, widget: markdown }
+ # 主题旋钮(样式即数据 · 层 4):只放白名单内的 9 组,越界值会被构建期钳制
+ - name: site_theme
+ label: 主题设置
+ file: content/site/theme.yaml
+ fields:
+ - { label: 强调色(亮), name: accent_light, widget: color, required: false, hint: '亮色模式强调色,如 #3454d1;构建期校验正文对比度 ≥4.5' }
+ - { label: 强调色(暗), name: accent_dark, widget: color, required: false, hint: '暗色模式强调色,如 #8ea2ff' }
+ - { label: 图片圆角, name: radius_img, widget: number, required: false, value_type: int, min: 4, max: 16, hint: '范围 4–16(建议 ≤14)' }
+ - { label: 一级标题字号, name: text_h1, widget: number, required: false, value_type: int, min: 22, max: 32, hint: '22–32,且必须大于 h2' }
+ - { label: 二级标题字号, name: text_h2, widget: number, required: false, value_type: int, min: 22, max: 32, hint: '22–32,必须小于 h1、大于 h3' }
+ - { label: 三级标题字号, name: text_h3, widget: number, required: false, value_type: int, min: 16, max: 32, hint: '16–32,必须小于 h2' }
+ - { label: 书评正文字号, name: prose_size_reading, widget: number, required: false, value_type: int, min: 16, max: 22, hint: '16–22' }
+ - { label: 技术文正文字号, name: prose_size_tech, widget: number, required: false, value_type: int, min: 16, max: 22, hint: '16–22' }
+ - { label: 书评行高, name: leading_reading, widget: number, required: false, value_type: float, min: 1.5, max: 2.0, step: 0.05, hint: '1.5–2.0' }
+ - { label: 技术文行高, name: leading_tech, widget: number, required: false, value_type: float, min: 1.5, max: 2.0, step: 0.05, hint: '1.5–2.0' }
+ - { label: 间距 3, name: space_3, widget: number, required: false, value_type: int, min: 8, max: 80, hint: '8–80,必须 < 间距 4' }
+ - { label: 间距 4, name: space_4, widget: number, required: false, value_type: int, min: 8, max: 80, hint: '8–80,必须 > 间距 3 且 < 间距 5' }
+ - { label: 间距 5, name: space_5, widget: number, required: false, value_type: int, min: 8, max: 80, hint: '8–80,必须 > 间距 4 且 < 间距 6' }
+ - { label: 间距 6, name: space_6, widget: number, required: false, value_type: int, min: 8, max: 80, hint: '8–80,必须 > 间距 5' }
+ - { label: 段间距, name: flow_p, widget: number, required: false, value_type: float, min: 0, max: 2, step: 0.05, hint: '0–2(em)' }
+ - { label: 标题下间距, name: flow_h2_post, widget: number, required: false, value_type: float, min: 0, max: 2, step: 0.05, hint: '0–2(em)' }
+ - { label: 过渡时长, name: dur, widget: number, required: false, value_type: int, min: 150, max: 600, hint: '150–600(毫秒)' }
+
# 瞬间:随手发(text/image 至少其一——Sveltia 无法表达条件必填,两者都设可选,
# 保存前自查;pubDate 必填)。图片走图库媒体库(content/photos 同源可复用)
- name: moments
@@ -140,6 +163,24 @@ collections:
fields:
- { label: 中文, name: zh, widget: string, required: false }
- { label: 英文, name: en, widget: string, required: false }
+ - label: 公告(可选,markdown)
+ name: announcement
+ widget: object
+ required: false
+ hint: 渲染在名片区下方;留空则不显示公告块
+ fields:
+ - { label: 中文, name: zh, widget: markdown, required: false }
+ - { label: 英文, name: en, widget: markdown, required: false }
+ - { label: 项目墙条数, name: projectsLimit, widget: number, required: false, value_type: int, min: 1, max: 99, hint: '首页项目墙显示几条(1–99,缺省 6)' }
+ - label: 轨细分开关(首页文章列表)
+ name: postsTracks
+ widget: object
+ required: false
+ hint: 关掉某轨则该轨文章不在首页列出(全开=现状)
+ fields:
+ - { label: 阅读轨, name: reading, widget: boolean, required: false, default: true }
+ - { label: 技术轨, name: tech, widget: boolean, required: false, default: true }
+ - { label: 照片轨, name: photo, widget: boolean, required: false, default: true }
- label: 副标题(可选,markdown 短文本)
name: subtitle
widget: object
diff --git a/scripts/build-perf/contrast-check.mjs b/scripts/build-perf/contrast-check.mjs
new file mode 100644
index 0000000..0d12406
--- /dev/null
+++ b/scripts/build-perf/contrast-check.mjs
@@ -0,0 +1,141 @@
+#!/usr/bin/env node
+/**
+ * 主题对比度闸门(样式即数据 · 层 4):从**构建产物**里读实际生效的 CSS 变量,
+ * 断言正文与强调色在两态下的对比度都 ≥4.5:1。构建期跑,失败即 exit 1 拦住发布。
+ *
+ * 为什么读产物而不是读 yaml:断言的对象必须是"用户最终看到的颜色"——
+ * theme.yaml 经 theme.ts 钳制/回落后的真值只存在于 dist 内联样式里。
+ *
+ * 用法:node scripts/build-perf/contrast-check.mjs [distRoot]
+ */
+import { readFileSync, existsSync, readdirSync } from 'node:fs';
+import { join } from 'node:path';
+
+const distRoot = process.argv[2] ?? 'dist';
+const MIN = 4.5;
+
+const read = (p) => readFileSync(p, 'utf8');
+
+function normalize(hex) {
+ const h = hex.replace('#', '');
+ return '#' + (h.length === 3 ? h.split('').map((c) => c + c).join('') : h);
+}
+function lum(hex) {
+ const h = normalize(hex).replace('#', '');
+ const [r, g, b] = [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16) / 255);
+ const f = (c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
+ return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b);
+}
+function ratio(a, b) {
+ const [la, lb] = [lum(a), lum(b)];
+ const [hi, lo] = [Math.max(la, lb), Math.min(la, lb)];
+ return (hi + 0.05) / (lo + 0.05);
+}
+
+// ── 1. 从产物取主题变量(内联 style 的 :root:root / [data-theme=dark] 块) ──
+let html = '';
+if (!existsSync(distRoot)) {
+ console.error(`[contrast] dist 不存在:${distRoot}(先 build)`);
+ process.exit(1);
+}
+const htmlFile = join(distRoot, 'index.html');
+if (existsSync(htmlFile)) html = read(htmlFile);
+else {
+ // 兜底:任取一个页面
+ const walk = (d) => {
+ for (const e of readdirSync(d, { withFileTypes: true })) {
+ const p = join(d, e.name);
+ if (e.isDirectory()) {
+ const r = walk(p);
+ if (r) return r;
+ } else if (e.name === 'index.html') return p;
+ }
+ return null;
+ };
+ const f = walk(distRoot);
+ if (f) html = read(f);
+}
+if (!html) {
+ console.error('[contrast] 产物里找不到任何页面');
+ process.exit(1);
+}
+
+const varOf = (scope, name) => {
+ const re = new RegExp(`${scope.replace(/[[\]]/g, '\\$&')}[^{]*\\{([^}]*)\\}`, 'g');
+ let m;
+ while ((m = re.exec(html))) {
+ const hit = new RegExp(`${name}\\s*:\\s*(#[0-9a-fA-F]{3,6})`).exec(m[1]);
+ if (hit) return hit[1];
+ }
+ return null;
+};
+
+// tokens.css 的底色与正文色(不在主题旋钮白名单内,故从产物 CSS 取)
+const cssFiles = [];
+const walkCss = (d) => {
+ for (const e of readdirSync(d, { withFileTypes: true })) {
+ const p = join(d, e.name);
+ if (e.isDirectory()) walkCss(p);
+ else if (e.name.endsWith('.css')) cssFiles.push(p);
+ }
+};
+walkCss(distRoot);
+let tokensCss = '';
+for (const f of cssFiles) {
+ const c = read(f);
+ // 必须是**定义**(--bg:#xxx),而不是 var(--bg-soft) 这类引用——
+ // 后者会误命中只用到变量名、并不定义 base token 的页面级 CSS
+ if (/--ink-2\s*:\s*#/.test(c) && /--bg\s*:\s*#/.test(c)) { tokensCss = c; break; }
+}
+const tokenVar = (name) => {
+ const m = new RegExp(`${name}\\s*:\\s*(#[0-9a-fA-F]{3,6})`).exec(tokensCss);
+ return m ? m[1] : null;
+};
+
+const checks = [];
+const bgLight = tokenVar('--bg');
+const bgDark = (() => {
+ // 暗态 --bg 在 [data-theme=dark] 块里
+ const m = /\[data-theme=["']?dark["']?\][^{]*\{([^}]*)\}/.exec(tokensCss);
+ if (!m) return null;
+ const h = /--bg\s*:\s*(#[0-9a-fA-F]{3,6})/.exec(m[1]);
+ return h ? h[1] : null;
+})();
+const ink2Light = tokenVar('--ink-2');
+const ink2Dark = (() => {
+ const m = /\[data-theme=["']?dark["']?\][^{]*\{([^}]*)\}/.exec(tokensCss);
+ if (!m) return null;
+ const h = /--ink-2\s*:\s*(#[0-9a-fA-F]{3,6})/.exec(m[1]);
+ return h ? h[1] : null;
+})();
+
+const accentLight = varOf(':root:root', '--accent');
+const accentDark = (() => {
+ const m = /\[data-theme=["']?dark["']?\]:root:root\{([^}]*)\}/.exec(html);
+ if (!m) return null;
+ const h = /--accent\s*:\s*(#[0-9a-fA-F]{3,6})/.exec(m[1]);
+ return h ? h[1] : null;
+})();
+
+if (bgLight && ink2Light) checks.push(['正文(亮)', ink2Light, bgLight]);
+if (bgDark && ink2Dark) checks.push(['正文(暗)', ink2Dark, bgDark]);
+if (accentLight && bgLight) checks.push(['强调色(亮)', accentLight, bgLight]);
+if (accentDark && bgDark) checks.push(['强调色(暗)', accentDark, bgDark]);
+
+if (checks.length === 0) {
+ console.error('[contrast] 未能从产物解析出主题/正文色变量,请检查注入是否生效');
+ process.exit(1);
+}
+
+let failed = 0;
+for (const [label, fg, bg] of checks) {
+ const r = ratio(fg, bg);
+ const ok = r >= MIN;
+ if (!ok) failed++;
+ console.log(`[contrast] ${ok ? 'PASS' : 'FAIL'} ${label} ${fg} on ${bg} = ${r.toFixed(2)}:1`);
+}
+if (failed > 0) {
+ console.error(`[contrast] ${failed} 项低于 ${MIN}:1 —— 主题配色不可读,请调亮/调暗后再构建`);
+ process.exit(1);
+}
+console.log(`[contrast] 全部通过(${checks.length} 项 ≥ ${MIN}:1)`);
diff --git a/src/components/pages/HomePage.astro b/src/components/pages/HomePage.astro
new file mode 100644
index 0000000..aad9192
--- /dev/null
+++ b/src/components/pages/HomePage.astro
@@ -0,0 +1,214 @@
+---
+// 首页(zh/en 共用):名片区 / 文章列表 / 最新影像 / 热力图 / 项目墙 / 心跳
+// 两个 index.astro 只是薄壳(传 locale),区块逻辑与样式集中在这里,避免双份漂移。
+import { Image } from 'astro:assets';
+import { getCollection } from 'astro:content';
+import BaseLayout from '../../layouts/BaseLayout.astro';
+import IdCard from '../IdCard.astro';
+import PostList from '../PostList.astro';
+import ProjectWall from '../geek/ProjectWall.astro';
+import Heatmap from '../geek/Heatmap.astro';
+import SocialLinks from '../SocialLinks.astro';
+import { t } from '../../i18n/ui';
+import type { Locale } from '../../lib/locale';
+import {
+ homeConfig,
+ homeTagline,
+ pageTypography,
+ renderMarkdown,
+ renderRich,
+} from '../../lib/site-content';
+import { fetchHeartbeat, agoZh } from '../../lib/heartbeat';
+
+interface Props {
+ locale?: Locale;
+}
+const { locale = 'zh' } = Astro.props;
+
+// 首页可配项(后台主页设置):副标题/影像张数/区块开关/每轨条数/项目墙条数/公告/轨细分
+const cfg = await homeConfig(locale);
+const subtitleHtml = cfg.subtitle ? await renderMarkdown(cfg.subtitle) : '';
+const announcementHtml = cfg.announcement ? await renderRich(cfg.announcement) : '';
+
+const posts = (
+ await getCollection('posts', ({ data }) => !data.draft && !data.tags.includes('hidden'))
+).sort((a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime());
+
+// en 轨列表标题吃 postsEn 译题(缺译回退 zh,与 /en/archives 同策略)
+const enTitle = locale === 'en'
+ ? Object.fromEntries((await getCollection('postsEn')).map((e) => [e.id, e.data.title]))
+ : undefined;
+
+// 最新影像取集合前 N 张;照片时间序由照片管线治理,此处只裁切
+const photos = (await getCollection('photos')).slice(0, Math.max(1, cfg.latestPhotos));
+
+const tagline = await homeTagline(locale);
+const taglineHtml = await renderRich(tagline, { inline: true });
+const typo = await pageTypography('siteHome', 'home');
+// 心跳只出现在中文首页(en 轨历史形态如此,保持零回归)
+const heartbeat = locale === 'zh' ? await fetchHeartbeat() : null;
+const galleryHref = locale === 'en' ? '/en/gallery/' : '/gallery/';
+
+// 轨细分开关:只保留开启的轨道(全开时等价于不过滤,零回归)
+const activeTracks = Object.entries(cfg.postsTracks)
+ .filter(([, on]) => on)
+ .map(([k]) => k);
+const allTracksOn = activeTracks.length === Object.keys(cfg.postsTracks).length;
+const visiblePosts = allTracksOn
+ ? posts
+ : posts.filter((p) => activeTracks.includes(p.data.category));
+---
+
+
+
+ 最近心跳:{agoZh(heartbeat.when)} · {heartbeat.msg}
+
- - 最近心跳:{agoZh(heartbeat.when)} · {heartbeat.msg} -
- )} -