视觉终审(glm-5.3-flash:max)判 BLOCK,逐条修完并复测。
P1 阻断:
- 瞬间页日期折成两行(`2026-` / `04-29`):日期列复用 var(--space-5)=40px,
而 10 字符 mono 日期需 ≈75px 且缺 nowrap。抽 --tl-rail(5.4rem) 统一驱动
轨道宽度与两处偏移,加 nowrap;实测日期单行(高度 20px)且圆点与卡片对齐
P2 交付可见破形:
- 关于页「关于我」行首 emoji 在无彩色 emoji 字体的环境渲染成黑方块:
正文行首 emoji 全部去掉(改纯文字段落),并给 --font-serif/--font-sans
链尾补 Apple/Segoe/Noto emoji 回退——用户内容里的 emoji(瞬间、书评)
同样不再落豆腐块
移动端导航两字折行(开/源、文/章):
- ≤450px 的 overflow-x 内滚从未生效:flex 子项默认可压缩,中文词被压到就地
折行。给 nav ul > li 加 flex:none + .nav-link nowrap;实测 360/390/430
三档全部单行(Range.getClientRects 计数逐项验证)
灰阶纪律:
- 项目墙原来按 GitHub linguist 给每个仓染语言色点,开源页铺 15 个仓 = 15 种
彩色,破「外壳灰阶、彩色只作正文强调」红线 → 统一改灰阶点,语言靠文字承载
- 语言占比并列(Python/TypeScript 各 36%)只有第一名着色,读起来像谁领先 →
判定改为「百分比等于最大值者一并着色」
版式与文案细节:
- prose 标题到正文比 tracks 多一档(16+16=32 vs 16)→ .sec-title + .prose-body
margin-top: 0
- 开源页 bio 破折号贴链接(`blog—— Astro`)→ 破折号前补空格
- 页脚 built from<hash> 缺空格(Astro 吃掉换行处空白)→ 显式 {' '}
- 新增一条真实瞬间(含青色楷体富文本)替代只有占位串的样本,让评论区与标签行
的间距可在真实数据下验收
验证: 构建 51 页 exit 0;content-lint 51 页 0/0/0;E2E **46/46 PASS**;
390px 四页 scrollWidth == innerWidth(无横向滚动);
导航 360/390/430 全部单行;日期单行;config-check 全过
Spec: 2026-11-02_blog-5h-content-social (视觉修复)
Co-authored-by: GLM-5.3
316 lines
10 KiB
Plaintext
316 lines
10 KiB
Plaintext
---
|
||
import { Image } from 'astro:assets';
|
||
import { getCollection } from 'astro:content';
|
||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||
import MomentComments from '../MomentComments.astro';
|
||
import ViewMeta from '../geek/ViewMeta.astro';
|
||
import { t } from '../../i18n/ui';
|
||
import { renderRich } from '../../lib/site-content';
|
||
import type { Locale } from '../../lib/locale';
|
||
|
||
// /en/ 侧由 T4 薄页传 locale='en';zh 路由无人传参,默认 zh 零回归
|
||
interface Props {
|
||
locale?: Locale;
|
||
}
|
||
const { locale = 'zh' } = Astro.props;
|
||
|
||
const moments = (await getCollection('moments')).sort(
|
||
(a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime(),
|
||
);
|
||
|
||
// 富文本预渲染(Phase 5h T2):text 走与页面文案同一条管线(markdown → 内容净化 →
|
||
// class 白名单 → 链接卡片),因此瞬间支持 **加粗**、<span class="c-blue font-kai">、列表与换行。
|
||
// 预渲染成 map 再进模板:renderRich 是 async,模板里不能 await。
|
||
// 纯文本老数据零回归:无 markdown 的文本会得到单个 <p>,与旧的 {text} 直出视觉等价。
|
||
const richText = new Map<string, string>();
|
||
for (const m of moments) {
|
||
if (!m.data.text) continue;
|
||
richText.set(m.id, await renderRich(m.data.text));
|
||
}
|
||
const iso = (d: Date) => d.toISOString();
|
||
---
|
||
|
||
<BaseLayout title={`${t('moments_title', locale)} · wait4x`}>
|
||
<h1 class="page-title">{t('moments_title', locale)}</h1>
|
||
<p class="page-note">{t('moments_note', locale)}</p>
|
||
<!-- 整页计数(Phase 5h T4):这是本页唯一的计数点(page 模式);
|
||
每条瞬间卡片底部的徽标是 readonly,读者开评论不会虚增浏览数 -->
|
||
<ViewMeta class="mom-page-meta" pageKey={locale === 'en' ? '/en/moments/' : '/moments/'} {locale} variant="chip" />
|
||
<ol class="tl">
|
||
{moments.map((m) => (
|
||
<li class="tl-item">
|
||
<time class="tl-date" datetime={iso(m.data.pubDate)}>
|
||
{iso(m.data.pubDate).slice(0, 10)}
|
||
</time>
|
||
<div class="mom-card">
|
||
{m.data.image && (
|
||
<figure
|
||
class:list={[
|
||
'mom-fig',
|
||
(m.data.image.width ?? 0) < (m.data.image.height ?? 0) && 'vt',
|
||
]}
|
||
>
|
||
<Image
|
||
src={m.data.image}
|
||
alt={m.data.text ?? m.data.tags.join(' ')}
|
||
widths={[480, 840]}
|
||
sizes="(min-width: 760px) 420px, calc(100vw - 140px)"
|
||
/>
|
||
</figure>
|
||
)}
|
||
{m.data.text && <div class="mom-text" set:html={richText.get(m.id) ?? ''} />}
|
||
{m.data.tags.length > 0 && (
|
||
<p class="mom-tags">
|
||
{m.data.tags.map((tag) => <code class="tag">{`#${tag}`}</code>)}
|
||
</p>
|
||
)}
|
||
<MomentComments slug={m.id} locale={locale} />
|
||
</div>
|
||
</li>
|
||
))}
|
||
</ol>
|
||
|
||
<!-- 安装引导(disclosure 小条):仅浏览器明确可装且未安装时现形,克制不打扰 -->
|
||
<div class="install-cta" id="install-cta" hidden>
|
||
<span>{locale === 'en' ? 'Add to Home Screen — moments work offline' : '装到主屏,离线也能刷瞬间'}</span>
|
||
<button type="button" id="install-btn" class="install-btn">
|
||
{locale === 'en' ? 'Install' : '安装'}
|
||
</button>
|
||
</div>
|
||
</BaseLayout>
|
||
|
||
<script>
|
||
// 时间线渐显:html[data-mom-anim] 由 JS 前置标记——无 JS / reduced-motion 时
|
||
// 标记缺席,CSS 初始隐藏规则整体不生效(渐进降级,元素永远可见)
|
||
let io: IntersectionObserver | null = null;
|
||
const armAnim = () => {
|
||
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;
|
||
if (!('IntersectionObserver' in window)) return;
|
||
document.documentElement.dataset.momAnim = 'on';
|
||
io?.disconnect();
|
||
io = new IntersectionObserver(
|
||
(entries) => {
|
||
for (const e of entries) {
|
||
if (e.isIntersecting) {
|
||
e.target.classList.add('in-view');
|
||
io?.unobserve(e.target);
|
||
}
|
||
}
|
||
},
|
||
{ rootMargin: '0px 0px -8% 0px' },
|
||
);
|
||
document.querySelectorAll('.tl-item:not(.in-view)').forEach((el) => io?.observe(el));
|
||
};
|
||
|
||
document.addEventListener('astro:page-load', () => {
|
||
armAnim();
|
||
refreshInstallCta();
|
||
});
|
||
armAnim();
|
||
|
||
// —— PWA 安装引导:beforeinstallprompt 早在页面加载初期触发,
|
||
// 故模块级立即绑定存事件,page-load 只负责刷新 UI 显隐 ——
|
||
let deferredPrompt: Event & { prompt?: () => Promise<void>; userChoice?: Promise<{ outcome: string }> } | null = null;
|
||
document.addEventListener('beforeinstallprompt', (e) => {
|
||
e.preventDefault();
|
||
deferredPrompt = e as typeof deferredPrompt;
|
||
refreshInstallCta();
|
||
});
|
||
document.addEventListener('appinstalled', () => {
|
||
deferredPrompt = null;
|
||
refreshInstallCta();
|
||
});
|
||
const isInstalled = () =>
|
||
matchMedia('(display-mode: standalone)').matches ||
|
||
(navigator as Navigator & { standalone?: boolean }).standalone === true;
|
||
function refreshInstallCta() {
|
||
const cta = document.getElementById('install-cta');
|
||
if (!cta) return;
|
||
cta.hidden = !(deferredPrompt && !isInstalled());
|
||
}
|
||
document.addEventListener('click', (e) => {
|
||
if ((e.target as HTMLElement)?.id !== 'install-btn') return;
|
||
if (!deferredPrompt) return;
|
||
void (async () => {
|
||
try {
|
||
await deferredPrompt?.prompt?.();
|
||
const choice = await deferredPrompt?.userChoice;
|
||
if (choice?.outcome === 'accepted') {
|
||
deferredPrompt = null;
|
||
refreshInstallCta();
|
||
}
|
||
} catch { /* 用户关闭安装框:保留事件,小条仍在 */ }
|
||
})();
|
||
});
|
||
refreshInstallCta();
|
||
</script>
|
||
|
||
<style>
|
||
.page-title {
|
||
font-size: var(--text-h1);
|
||
line-height: var(--leading-heading);
|
||
color: var(--ink-1);
|
||
margin-top: var(--space-5);
|
||
text-wrap: balance;
|
||
}
|
||
.page-note {
|
||
font-size: 13px;
|
||
color: var(--ink-4);
|
||
margin-block: var(--space-1) var(--space-4);
|
||
}
|
||
.tl {
|
||
list-style: none;
|
||
margin: var(--space-4) 0 0;
|
||
padding: 0;
|
||
}
|
||
/* 时间线骨架:左 mono 日期 + 竖线 + --accent 节点 + 右侧内容卡 */
|
||
.tl-item {
|
||
/* 日期轨宽度集中一处:10 字符 mono 日期(2026-04-29)在 12.5px 下约 75px,
|
||
原来复用 var(--space-5)=40px 导致在「-」处折成两行(视觉终审 P1) */
|
||
--tl-rail: 5.4rem;
|
||
position: relative;
|
||
margin: 0 0 var(--space-4) calc(var(--tl-rail) + var(--space-4));
|
||
padding-left: var(--space-4);
|
||
border-left: 1px solid var(--line);
|
||
}
|
||
.tl-item:last-child { margin-bottom: 0; }
|
||
.tl-item::before {
|
||
content: '';
|
||
position: absolute;
|
||
left: -5px;
|
||
top: 7px;
|
||
width: 9px;
|
||
height: 9px;
|
||
border-radius: 50%;
|
||
background: var(--accent);
|
||
box-shadow: 0 0 0 3px var(--bg);
|
||
}
|
||
.tl-date {
|
||
position: absolute;
|
||
left: calc(-1 * (var(--tl-rail) + var(--space-4)));
|
||
top: 4px;
|
||
width: var(--tl-rail);
|
||
white-space: nowrap;
|
||
text-align: right;
|
||
font-family: var(--font-mono);
|
||
font-size: 12.5px;
|
||
color: var(--ink-4);
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
.mom-card {
|
||
border: 1px solid var(--line);
|
||
border-radius: 12px;
|
||
padding: var(--space-3) var(--space-4);
|
||
background: var(--bg-soft);
|
||
}
|
||
.mom-card > p { font-size: 15px; color: var(--ink-2); }
|
||
/* 富文本正文(T2):首尾 margin 归零,避免卡片被 <p> 的默认外边距撑开;
|
||
段间只留半档间距,列表缩进对齐正文基线。基调保持 15px / ink-2。 */
|
||
.mom-text {
|
||
font-size: 15px;
|
||
color: var(--ink-2);
|
||
line-height: 1.7;
|
||
}
|
||
.mom-text :global(> :first-child) { margin-top: 0; }
|
||
.mom-text :global(> :last-child) { margin-bottom: 0; }
|
||
.mom-text :global(p) { margin: 0 0 var(--space-1); }
|
||
.mom-text :global(ul),
|
||
.mom-text :global(ol) {
|
||
margin: 0 0 var(--space-1);
|
||
padding-left: 1.35em;
|
||
}
|
||
.mom-text :global(li) { margin: 0; }
|
||
.mom-text :global(li + li) { margin-top: 2px; }
|
||
.mom-text :global(blockquote) {
|
||
margin: var(--space-1) 0;
|
||
padding-left: var(--space-2);
|
||
border-left: 2px solid var(--line);
|
||
color: var(--ink-3);
|
||
}
|
||
.mom-text :global(code) {
|
||
font-family: var(--font-mono);
|
||
font-size: 0.92em;
|
||
background: var(--bg);
|
||
border-radius: 4px;
|
||
padding: 1px 5px;
|
||
}
|
||
.mom-text :global(a) {
|
||
color: var(--accent);
|
||
text-decoration: underline;
|
||
text-underline-offset: 0.18em;
|
||
}
|
||
.mom-fig {
|
||
border-radius: 8px;
|
||
overflow: hidden;
|
||
margin: 0 0 var(--space-2);
|
||
max-width: 420px;
|
||
}
|
||
.mom-fig.vt { max-width: 260px; }
|
||
.mom-fig :global(img) { width: 100%; height: auto; }
|
||
.mom-tags {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: var(--space-1);
|
||
margin-top: var(--space-2);
|
||
}
|
||
.tag {
|
||
font-family: var(--font-mono);
|
||
font-size: 12px;
|
||
color: var(--ink-3);
|
||
background: var(--bg);
|
||
border-radius: 5px;
|
||
padding: 1px 7px;
|
||
white-space: nowrap;
|
||
}
|
||
/* 窄屏:日期卡到卡片上方,竖线左移收窄 */
|
||
@media (max-width: 760px) {
|
||
.tl-item { margin-left: var(--space-3); }
|
||
.tl-date {
|
||
position: static;
|
||
display: block;
|
||
width: auto;
|
||
text-align: left;
|
||
margin-bottom: var(--space-1);
|
||
}
|
||
}
|
||
@media (max-width: 560px) {
|
||
.mom-card { padding: var(--space-2) var(--space-3); }
|
||
.mom-fig { max-width: 100%; }
|
||
}
|
||
.install-cta {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
gap: var(--space-2);
|
||
margin-top: var(--space-5);
|
||
padding: var(--space-3);
|
||
border: 1px dashed var(--line);
|
||
border-radius: 8px;
|
||
font-size: 13px;
|
||
color: var(--ink-3);
|
||
}
|
||
.install-btn {
|
||
font-family: var(--font-mono);
|
||
font-size: 12px;
|
||
color: var(--ink-2);
|
||
background: var(--bg-soft);
|
||
border: 1px solid var(--line);
|
||
border-radius: 6px;
|
||
padding: 4px 12px;
|
||
cursor: pointer;
|
||
transition: border-color var(--dur) var(--ease);
|
||
}
|
||
.install-btn:hover { border-color: var(--accent); }
|
||
.mom-page-meta { margin: 0 0 var(--space-4); }
|
||
/* 初始隐藏仅在 JS 标记存在时生效:宁轻勿闹——透明度+8px 上移,进入视口一次性点亮 */
|
||
[data-mom-anim] .tl-item {
|
||
opacity: 0;
|
||
translate: 0 8px;
|
||
transition: opacity 0.45s var(--ease), translate 0.45s var(--ease);
|
||
}
|
||
[data-mom-anim] .tl-item.in-view {
|
||
opacity: 1;
|
||
translate: 0 0;
|
||
}
|
||
</style>
|