feat(geek): 极客层 P0 四件套——键盘/构建透明/页面转场/项目墙

- Keyboard:? 帮助浮层 + t 明暗 + g-chords 导航(300ms 缓冲,输入域旁路);
  路由表经 data-routes 注入(module 脚本 currentScript 为 null 的坑)
- BuildInfo:vite.define 四重回退注入 commit/时间,hash 链到 gitea
- ClientRouter 页面转场(reduced-motion 降级在 global.css 总闸)
- ProjectWall:构建时双源拉取(gitea+github)置顶排序+缓存兜底,零运行时 JS
- Header 补主题可视切换钮:键盘 t 的触屏等价物(同 key 同逻辑)

验证: pnpm astro build exit 0;dist 标记 built from×2/kbd-help×4/
      view-transition×2; astro check 未跑(无类型错误构建期可证伪面有限)

Spec: 2026-10-24_add-blog-skeleton
Co-authored-by: GLM-5.3
This commit is contained in:
wait4xandGLM-5.3 committed 2026-10-24 23:13:23 +08:00
1 parent b50eddf2bb
commit 725bfde21f
8 files changed
+324 -2

No files matched your search

+19
View File
@@ -35,11 +35,30 @@ const isCurrent = (href: string) =>
</svg>
</button>
</li>
<!-- 触屏等价物:主题可视切换(与键盘 t / ThemeScript 同一存储 key) -->
<li>
<button class="tap nav-link" type="button" data-theme-toggle aria-label="切换明暗">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<circle cx="12" cy="12" r="4"></circle>
<path d="M12 2v2m0 16v2M4.9 4.9l1.4 1.4m11.4 11.4 1.4 1.4M2 12h2m16 0h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"></path>
</svg>
</button>
</li>
</ul>
</nav>
</div>
</header>
<script>
// 与 geek/Keyboard.ts 的 t 键同一逻辑与 key,双入口不改行为
document.querySelector('[data-theme-toggle]')?.addEventListener('click', () => {
const root = document.documentElement;
const next = root.dataset.theme === 'dark' ? 'light' : 'dark';
root.dataset.theme = next;
localStorage.setItem('theme', next);
});
</script>
<style>
.site-header {
border-bottom: 1px solid var(--line);
+13
View File
@@ -0,0 +1,13 @@
---
// 构建透明(极客层 P0):页脚 built-from 行,hash 链到自家 gitea commit
const sha = import.meta.env.GIT_COMMIT_SHA as string;
const date = import.meta.env.GIT_COMMIT_DATE as string;
const short = sha === 'dev' ? 'dev' : sha.slice(0, 7);
const commitUrl = `https://git.wait4x.online/wait4x/blog/commit/${sha}`;
---
<p class="build-info">
built from
{short === 'dev' ? <span>dev</span> : <a href={commitUrl} class="link mono-link">{short}</a>}
· {new Date(date).toISOString().slice(0, 16).replace('T', ' ')}Z · Astro
</p>
+98
View File
@@ -0,0 +1,98 @@
---
// 键盘层(极客层 P0):? 帮助 / t 主题 / g-chords 导航
// 惯例来源:GitHub 键位(g+N、? 帮助);每项能力都有可视入口(触屏等价物原则)
const routes: Record<string, string> = {
h: '/',
a: '/archives/',
g: '/gallery/',
m: '/moments/',
b: '/about/',
};
---
<dialog id="kbd-help" class="kbd-help" data-routes={JSON.stringify(routes)}>
<h2>键盘快捷键</h2>
<table>
<tbody>
<tr><td><kbd>t</kbd></td><td>切换明暗</td></tr>
<tr><td><kbd>?</kbd></td><td>本帮助</td></tr>
<tr><td><kbd>g</kbd> <kbd>h</kbd></td><td>首页</td></tr>
<tr><td><kbd>g</kbd> <kbd>a</kbd></td><td>文章</td></tr>
<tr><td><kbd>g</kbd> <kbd>g</kbd></td><td>图库</td></tr>
<tr><td><kbd>g</kbd> <kbd>m</kbd></td><td>瞬间</td></tr>
<tr><td><kbd>Esc</kbd></td><td>关闭浮层</td></tr>
</tbody>
</table>
<p class="hint">提示:试试终端彩蛋(Phase 4 上线 <code>~</code>)</p>
</dialog>
<script>
// chord 缓冲 300ms;输入域聚焦时整体旁路;Esc 关一切浮层
// 路由表由 data-routes 注入(module 脚本里 currentScript 恒为 null,不能走内联取参)
const helpEl = document.getElementById('kbd-help') as HTMLElement & { dataset: { routes?: string } };
const routes: Record<string, string> = JSON.parse(helpEl?.dataset.routes ?? '{}');
let chord = '';
let chordTimer: number | undefined;
const inField = () => {
const el = document.activeElement;
return el instanceof HTMLElement &&
(el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.isContentEditable);
};
const toggleTheme = () => {
const root = document.documentElement;
const next = root.dataset.theme === 'dark' ? 'light' : 'dark';
root.dataset.theme = next;
localStorage.setItem('theme', next);
};
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
document.querySelector('dialog[open]')?.close();
return;
}
if (inField() || e.metaKey || e.ctrlKey || e.altKey) return;
if (e.key === 't') { toggleTheme(); return; }
if (e.key === '?') {
(document.getElementById('kbd-help') as HTMLDialogElement | null)?.showModal();
return;
}
if (e.key === 'g') {
chord = 'g';
clearTimeout(chordTimer);
chordTimer = window.setTimeout(() => (chord = ''), 300);
return;
}
if (chord === 'g' && routes[e.key]) {
location.href = routes[e.key]!;
chord = '';
}
});
</script>
<style>
.kbd-help {
border: 1px solid var(--line);
border-radius: 10px;
background: var(--bg);
color: var(--ink-2);
padding: var(--space-4);
max-width: 340px;
}
.kbd-help::backdrop { background: rgba(0, 0, 0, 0.4); }
.kbd-help h2 { font-size: var(--text-h3); color: var(--ink-1); margin-bottom: var(--space-3); }
table { border-collapse: collapse; }
td { padding: 4px 10px 4px 0; font-size: 14px; }
kbd {
font-family: var(--font-mono);
font-size: 12px;
border: 1px solid var(--line);
border-bottom-width: 2px;
border-radius: 4px;
padding: 1px 6px;
background: var(--bg-soft);
}
.hint { margin-top: var(--space-3); font-size: 12px; color: var(--ink-3); }
</style>
+71
View File
@@ -0,0 +1,71 @@
---
// 开源项目墙(极客层 P0):构建时渲染,零运行时 JS
// 双源数据见 src/lib/projects.ts;语言色点用 linguist 常用色映射
import { loadProjects, type RepoCard } from '../../lib/projects';
const LANG_COLORS: Record<string, string> = {
Astro: '#ff5a03', TypeScript: '#3178c6', JavaScript: '#f1e05a',
Python: '#3572a5', Go: '#00add8', Rust: '#dea584', Shell: '#89e051',
HTML: '#e34c26', CSS: '#563d7c', Dockerfile: '#384d54',
};
let projects: RepoCard[] = [];
try {
projects = await loadProjects();
} catch {
projects = []; // 兜底已在 lib 内处理,此处再守一层:空墙不破构建
}
---
<section class="project-wall" aria-label="开源项目">
<h2 class="wall-title">开源 · Projects</h2>
<ul>
{projects.map((p) => (
<li>
<a href={p.url} class="repo tap" rel="noopener noreferrer">
<span class="repo-name">
{p.source === 'gitea' ? '自托管' : 'GitHub'} / {p.name}
</span>
<span class="repo-desc">{p.description}</span>
<span class="repo-meta">
{p.language && (
<span class="lang">
<span class="dot" style={`background:${LANG_COLORS[p.language] ?? '#8b8f98'}`} />
{p.language}
</span>
)}
<span class="stars">★ {p.stars}</span>
</span>
</a>
</li>
))}
</ul>
</section>
<style>
.project-wall { margin-top: var(--space-6); }
.wall-title {
font-size: 14px;
font-family: var(--font-mono);
color: var(--ink-3);
letter-spacing: 0.08em;
margin-bottom: var(--space-3);
}
ul { list-style: none; padding: 0; display: grid; gap: var(--space-2); }
.repo {
display: flex;
flex-direction: column;
gap: 2px;
padding: var(--space-3);
border: 1px solid var(--line);
border-radius: 8px;
transition: border-color var(--dur) var(--ease);
min-height: 44px;
}
.repo:hover { border-color: var(--accent); }
.repo-name { font-family: var(--font-mono); font-size: 14px; color: var(--ink-1); }
.repo-desc { font-size: 13px; color: var(--ink-3); }
.repo-meta { display: flex; gap: var(--space-3); font-size: 12px; color: var(--ink-3); margin-top: 4px; }
.lang { display: inline-flex; align-items: center; gap: 4px; }
.dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
</style>