feat(opensource): 项目墙恢复居中,README 动效以自托管方式回归(5i T6/T8)
用户两项要求:开源页 Projects「依旧采取居中即可,不用左对齐」;README 迁移后
「很多图片元素还有一些 svg 基本上都摒弃了,可以在 readme 的基础上进行再创新,
保留那些动效的图,以及创新更多一些显示方式」。
T6 项目墙居中:`.repo` 从 align-items:stretch/text-align:left 改回 center/center,
`.repo-desc` 加 max-width:34em 防居中后长描述散架;语言点保持灰阶(与对齐无关)。
T8 动效回归(零运行时外链):
- 新增 geek/TypingLines.astro:自绘打字机(SSR 先出首行防闪白,JS 逐字打出/删除/
轮播,光标 CSS 闪烁,prefers-reduced-motion 与单条文案时保持静态,min-height
防抖,astro:page-load 重挂兼容软导航)——替代 readme-typing-svg 外链,且跟随主题
- 新增 scripts/build-perf/fetch-readme-assets.mjs:抓 stats/top-langs/streak/snake
到 public/github-assets/(10s 超时 + UA、透明底、亮暗两套站点色),产物入仓保证
离线可构建;单资产失败保留旧文件、无旧文件则该卡不渲染
- GithubProfile 新增「数据板」:灰阶 hairline 卡 + mono 小标题 + 2 列网格(移动单列),
与原生统计四格、语言占比、热力图组合成展示区;贡献蛇在暗色加 grayscale+invert
滤镜(SVG 内部是自带绿阶,直接贴暗底刺眼)
- build.sh 只做存在性守卫 + 缺资产 WARN,**不挂抓取**(发布链不被外网抖动拖累)
- 后台开源页新增 typing 字段(list,zh/en 双语,≤6 条,summary 取 zh)+ schema 声明
验证: 构建 51 页 exit 0;config-check 211 字段/66 hint 全过;E2E 46/46;
产物断言第三方外链零命中、本地引用 streak-light/streak-dark/snake;
Playwright 实测打字机文本 900ms 内变化(动效在跑)、streak naturalWidth=495、
snake=880、暗色自动切 streak-dark、项目墙 center/center、1280 与 390 均零横向溢出
备注: github-readme-stats 今日全局 503,stats/top-langs 两卡按契约跳过(原生四格与
语言条已覆盖同一信息);服务恢复后跑一次 fetch-readme-assets.mjs 即自动补上。
Spec: 2026-11-02_blog-5i-fonts-polish (T6/T8)
Co-authored-by: GLM-5.3
This commit is contained in:
1 parent
23303bd39a
commit
0ff4ede91f
11 files changed
+608
-5
No files matched your search
@@ -5,6 +5,8 @@
|
||||
// 0600 文件,缺位或失败即隐藏该数字)④src/lib/projects.ts 的仓库列表(语言占比与星标汇总)
|
||||
// 降级纪律:任何一项取数失败只隐藏该项,绝不炸构建、不出现空图片框
|
||||
import { Image } from 'astro:assets';
|
||||
import { existsSync } from 'node:fs';
|
||||
import TypingLines from './TypingLines.astro';
|
||||
import { homeAvatar, homeSocials, openSourceCopy } from '../../lib/site-content';
|
||||
import { githubUserStats, loadProjects } from '../../lib/projects';
|
||||
import { iconPath } from '../../lib/icons';
|
||||
@@ -49,6 +51,40 @@ try {
|
||||
// 只给第一根上强调色会让并列者显得"落后",故并列者一并着色
|
||||
const maxPct = langs.reduce((m, l) => Math.max(m, l.pct), 0);
|
||||
const stats = await githubUserStats();
|
||||
|
||||
// 打字机动效文案:content/site/opensource.yaml 的 typing[](双语,后台可改)
|
||||
// 注:schema 由 orchestrator 统一扩字段;未声明时这里的类型断言取不到值 → 走内置默认
|
||||
type TypingEntry = { zh?: string; en?: string };
|
||||
const typingRaw = (copy as unknown as { typing?: TypingEntry[] }).typing ?? [];
|
||||
const typingLines = typingRaw
|
||||
.map((t) => (locale === 'zh' ? t.zh : (t.en ?? t.zh)) ?? '')
|
||||
.filter(Boolean);
|
||||
const DEFAULT_TYPING = {
|
||||
zh: ['气象出身,写代码为乐', '自托管一切能自托管的东西', '拥抱开源,热爱开源'],
|
||||
en: ['Trained in meteorology, coding for fun', 'Self-hosting whatever can be self-hosted', 'Embrace open source, love open source'],
|
||||
}[locale];
|
||||
const lines = typingLines.length ? typingLines : DEFAULT_TYPING;
|
||||
|
||||
// 自托管 SVG 数据卡(public/github-assets/,由 scripts/build-perf/fetch-readme-assets.mjs 产出入仓)
|
||||
// 缺文件即跳过该卡(异步抓取失败不影响构建),明暗各一份时用 CSS 切换
|
||||
const assetPath = (name: string) => `/github-assets/${name}`;
|
||||
const hasAsset = (name: string) => existsSync(`public/github-assets/${name}`);
|
||||
const boardCards: { label: string; light: string; dark?: string }[] = [];
|
||||
for (const [key, label] of [
|
||||
['stats', locale === 'zh' ? '统计' : 'Stats'],
|
||||
['top-langs', locale === 'zh' ? '语言分布' : 'Top languages'],
|
||||
['streak', locale === 'zh' ? '连续贡献' : 'Streak'],
|
||||
] as const) {
|
||||
if (!hasAsset(`${key}-light.svg`)) continue;
|
||||
boardCards.push({
|
||||
label,
|
||||
light: assetPath(`${key}-light.svg`),
|
||||
dark: hasAsset(`${key}-dark.svg`) ? assetPath(`${key}-dark.svg`) : undefined,
|
||||
});
|
||||
}
|
||||
const snakeCard = hasAsset('snake.svg')
|
||||
? { label: locale === 'zh' ? '贡献蛇' : 'Contribution snake', src: assetPath('snake.svg') }
|
||||
: null;
|
||||
const L = {
|
||||
zh: { followers: '关注者', repos: '公开仓库', stars: '总星标', langs: '语言占比' },
|
||||
en: { followers: 'Followers', repos: 'Public repos', stars: 'Total stars', langs: 'Languages' },
|
||||
@@ -66,6 +102,7 @@ const L = {
|
||||
<p class="pf-name">
|
||||
wait4x<span class="pf-handle mono">@wait4xx</span>
|
||||
</p>
|
||||
<TypingLines lines={lines} />
|
||||
<p class="pf-bio">
|
||||
{copy.blurb}<a class="pf-link" href={copy.repoUrl}>{copy.repoLabel}</a>{' '}{copy.blurbAfter.trim()}
|
||||
</p>
|
||||
@@ -123,6 +160,28 @@ const L = {
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{(boardCards.length > 0 || snakeCard) && (
|
||||
<div class="pf-board">
|
||||
<p class="pf-board-title mono">{locale === 'zh' ? '数据板' : 'Data board'}</p>
|
||||
<div class="pf-board-grid">
|
||||
{boardCards.map((card) => (
|
||||
<figure class="pf-card">
|
||||
<img class="pf-card-img only-light" src={card.light} alt={card.label} loading="lazy" decoding="async" />
|
||||
{card.dark && (
|
||||
<img class="pf-card-img only-dark" src={card.dark} alt={card.label} loading="lazy" decoding="async" />
|
||||
)}
|
||||
<figcaption class="mono">{card.label}</figcaption>
|
||||
</figure>
|
||||
))}
|
||||
{snakeCard && (
|
||||
<figure class="pf-card pf-card-wide pf-card-snake">
|
||||
<img class="pf-card-img" src={snakeCard.src} alt={snakeCard.label} loading="lazy" decoding="async" />
|
||||
<figcaption class="mono">{snakeCard.label}</figcaption>
|
||||
</figure>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
@@ -253,8 +312,55 @@ const L = {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.pf-board {
|
||||
margin-top: var(--space-3);
|
||||
padding-top: var(--space-3);
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.pf-board-title {
|
||||
font-size: 11.5px;
|
||||
color: var(--ink-4);
|
||||
letter-spacing: 0.04em;
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
.pf-board-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.pf-card {
|
||||
margin: 0;
|
||||
padding: var(--space-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.pf-card-wide { grid-column: 1 / -1; }
|
||||
.pf-card-img { max-width: 100%; height: auto; display: block; }
|
||||
.pf-card figcaption {
|
||||
font-size: 11px;
|
||||
color: var(--ink-4);
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
/* 明暗两版自托管 SVG:默认只显示亮色,暗色主题切到暗色版 */
|
||||
/* 贡献蛇:SVG 内部用自带 CSS 变量(绿阶 + #ebedf0 空格),<img> 无法改写内部配色 →
|
||||
滤镜归一到站点灰阶;暗色下反相,避免浅灰格子贴在深底上刺眼(想去掉绿→灰只改这一行) */
|
||||
.pf-card-snake .pf-card-img { filter: grayscale(1) contrast(1.03); }
|
||||
:global([data-theme='dark']) .pf-card-snake .pf-card-img {
|
||||
filter: grayscale(1) invert(1) contrast(0.92);
|
||||
}
|
||||
|
||||
.only-dark { display: none; }
|
||||
:global([data-theme='dark']) .only-light { display: none; }
|
||||
:global([data-theme='dark']) .only-dark { display: block; }
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.pf-lang { grid-template-columns: 5.5rem 1fr 2.6rem; }
|
||||
.pf-stats { gap: var(--space-3); }
|
||||
.pf-board-grid { grid-template-columns: minmax(0, 1fr); }
|
||||
}
|
||||
</style>
|
||||
@@ -73,10 +73,9 @@ try {
|
||||
.repo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* 显式接管交叉轴:全局 .tap 是 inline-flex + align-items:center,
|
||||
列向 flex 下会把标题/元信息行水平居中(视觉终审 P1,卡片左缘参差) */
|
||||
align-items: stretch;
|
||||
text-align: left;
|
||||
/* 用户拍板(5i T6):Projects 卡片内容居中,不用左对齐 */
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
gap: 2px;
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--line);
|
||||
@@ -86,7 +85,12 @@ try {
|
||||
}
|
||||
.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-desc {
|
||||
font-size: 13px;
|
||||
color: var(--ink-3);
|
||||
/* 居中后长描述限行宽,避免整行摊平散架 */
|
||||
max-width: 34em;
|
||||
}
|
||||
.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 {
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
---
|
||||
// 自绘打字机动效(5i T8):替代外链 readme-typing-svg
|
||||
// 纯 CSS + 极少 JS:SSR 先渲染第一行(无 JS / reduced-motion 时就是静态首行,不闪不跳),
|
||||
// JS 接管后循环「逐字打出 → 停留 → 逐字删除 → 下一行」;光标闪烁走 CSS keyframes。
|
||||
interface Props {
|
||||
/** 已本地化的文案行(保持 ≤6 行) */
|
||||
lines: string[];
|
||||
/** 每字毫秒 */
|
||||
speed?: number;
|
||||
}
|
||||
const { lines, speed = 55 } = Astro.props;
|
||||
const safe = lines.map((l) => l.trim()).filter(Boolean).slice(0, 6);
|
||||
const first = safe[0] ?? '';
|
||||
---
|
||||
|
||||
{safe.length > 0 && (
|
||||
<p
|
||||
class="typing"
|
||||
data-lines={JSON.stringify(safe)}
|
||||
data-speed={speed}
|
||||
aria-label={safe.join(';')}
|
||||
>
|
||||
<span class="typing-text" aria-hidden="true">{first}</span>
|
||||
<span class="typing-caret" aria-hidden="true"></span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<script>
|
||||
// 每次软导航重挂:ClientRouter 下模块脚本只跑一次 → 用 astro:page-load 统一初始化
|
||||
const reducedMotion = () => matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
function initTyping() {
|
||||
document.querySelectorAll<HTMLElement>('.typing').forEach((el) => {
|
||||
if (el.dataset.typingOn === '1') return;
|
||||
el.dataset.typingOn = '1';
|
||||
const lines: string[] = JSON.parse(el.dataset.lines ?? '[]');
|
||||
const text = el.querySelector<HTMLElement>('.typing-text');
|
||||
if (!text || lines.length < 2 || reducedMotion()) return; // 单行/减动效:保留 SSR 首行
|
||||
|
||||
const speed = Number(el.dataset.speed ?? 55);
|
||||
let line = 0;
|
||||
let char = lines[0].length;
|
||||
let erasing = true;
|
||||
let timer: number | undefined;
|
||||
|
||||
const step = () => {
|
||||
const current = lines[line % lines.length];
|
||||
if (erasing) {
|
||||
char -= 1;
|
||||
text.textContent = current.slice(0, Math.max(char, 0));
|
||||
if (char <= 0) {
|
||||
erasing = false;
|
||||
line += 1;
|
||||
timer = window.setTimeout(step, 260);
|
||||
return;
|
||||
}
|
||||
timer = window.setTimeout(step, Math.max(18, speed / 2));
|
||||
} else {
|
||||
char += 1;
|
||||
text.textContent = lines[line % lines.length].slice(0, char);
|
||||
if (char >= lines[line % lines.length].length) {
|
||||
erasing = true;
|
||||
timer = window.setTimeout(step, 1800);
|
||||
return;
|
||||
}
|
||||
timer = window.setTimeout(step, speed);
|
||||
}
|
||||
};
|
||||
// 首行打完再停留一下开始删除
|
||||
timer = window.setTimeout(step, 1800);
|
||||
el.addEventListener('astro:before-swap', () => window.clearTimeout(timer), { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
initTyping();
|
||||
document.addEventListener('astro:page-load', initTyping);
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.typing {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--ink-3);
|
||||
margin: 0 0 var(--space-2);
|
||||
min-height: 1.6em; /* 固定行高,打字期间不推挤下方内容 */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1px;
|
||||
}
|
||||
.typing-text { white-space: pre-wrap; }
|
||||
.typing-caret {
|
||||
display: inline-block;
|
||||
width: 0.5em;
|
||||
height: 1em;
|
||||
background: var(--accent);
|
||||
animation: caret-blink 1.05s steps(1) infinite;
|
||||
}
|
||||
@keyframes caret-blink {
|
||||
0%, 49% { opacity: 1; }
|
||||
50%, 100% { opacity: 0; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.typing-caret { animation: none; opacity: 0.6; }
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user