浅色改为暖纸底 + 纸纹底 + 移动端分类胶囊条

浅色配色(global.css,暗色未动)
- 纯白底在屏幕上偏刺眼、大面积纯色没有视觉落点,整套浅色 token
  压到 L*≈96 并给暖黄彩度(oklch hue 84),墨色换成暖褐而非纯黑
- background #f8f3e9 / surface-subtle #f4eee3 / card #fcf8f0(比页面底亮,
  靠明度差拉层次)/ foreground #221d18 / border #dfd9ce
- 对比度仍留足:正文 15:1、content-secondary 7.5:1、muted-foreground 5.3:1、
  主色链接 4.9:1(AA 要求 4.5:1)
- secondary / sidebar / popover 等原来偏中性的 token 一并转暖,避免冷暖打架

纸纹底
- --paper-noise 用内联 SVG feTurbulence 分形噪声,无需图片文件;
  feColorMatrix saturate=0 去饱和得到带随机 alpha 的灰颗粒,
  因此不需要 mix-blend-mode 就能亮暗通用
- body::before 铺成 position:fixed 整屏颗粒,z-index:1(压在正文上、
  页眉 z-50 / 菜单 z-60 之下),pointer-events:none
- 颗粒浓度:亮色 0.2 / 暗色 0.22。实测亮度标准差 0.05→0.7、0.2→1.9、
  0.3→2.9(0=完全平整),选 2 左右这个「看得出是纸但不觉得脏」的档位
- 顺带让 backdrop-filter 有细节可模糊,此前纯色底上磨砂等于不存在

移动端分类胶囊条(article-list.tsx + sidebar.tsx)
- 移动端侧栏被排在文章列表下面(实测首篇 y=999、侧栏 y=3212),
  分类筛选基本摸不到;现在列表顶部多一条吸附胶囊条
- 复用同一份 categories / activeCategory / handleCategoryChange,不写第二套;
  末尾胶囊在「分类 / 标签」间切换;两端渐隐提示可横向滑;滚动条藏掉
- 把当前项自动滚到中间,只操作这条横向容器,不动页面纵向位置
- sidebar.tsx 的分类/标签块加 hidden lg:block,避免同页出现两份

其他
- 新增 --header-h: 69px(页眉实测高度),移动端菜单面板和吸附胶囊条
  都改用它定位,不再各处硬编码 69/76
- README 补「浅色配色」「纸纹底」「移动端分类胶囊条」三节

验证(headless Chrome 131)
- 390px:胶囊条可见、侧栏两块 display:none;点「思考」→ ?category=思考、
  列表 10→4 条、当前项高亮;切到标签模式列出 35 个标签;无横向溢出
- 1280px:胶囊条隐藏、侧栏分类恢复显示(display:block)
- 亮/暗两套截图正常,书架、文章页均无异常
This commit is contained in:
dsh
2026-09-15 09:37:14 +08:00
parent 9d9f87a013
commit 5532c35064
5 changed files with 244 additions and 27 deletions
+124
View File
@@ -301,9 +301,133 @@ export function ArticleList({
setCurrentPage(page)
}
/* ---------- 移动端分类胶囊条 ---------- */
// 移动端侧栏被推到文章列表下面(实测第一篇文章 y=999、侧栏 y=3212),
// 所以手机上看不到分类筛选。这里在页眉下面再挂一条吸附的胶囊条,
// 复用同一份 categories / activeCategory / handleCategoryChange,不写第二套逻辑。
const [filterMode, setFilterMode] = useState<"categories" | "tags">(activeTag ? "tags" : "categories")
const filterBarRef = useRef<HTMLDivElement>(null)
const [filterEdges, setFilterEdges] = useState({ left: false, right: false })
const updateFilterEdges = useCallback(() => {
const el = filterBarRef.current
if (!el) return
const max = el.scrollWidth - el.clientWidth
setFilterEdges({
left: el.scrollLeft > 4,
right: max > 4 && el.scrollLeft < max - 4,
})
}, [])
// 把当前项滚到中间,只操作这一条横向滚动容器,不碰页面纵向位置
useEffect(() => {
const el = filterBarRef.current
if (!el) return
const active = el.querySelector<HTMLElement>('[data-filter-active="true"]')
if (!active) {
updateFilterEdges()
return
}
const target = active.offsetLeft - (el.clientWidth - active.offsetWidth) / 2
el.scrollTo({ left: Math.max(0, target), behavior: "smooth" })
const timer = window.setTimeout(updateFilterEdges, 350)
return () => window.clearTimeout(timer)
}, [activeCategory, activeTag, filterMode, updateFilterEdges])
useEffect(() => {
const el = filterBarRef.current
if (!el) return
updateFilterEdges()
el.addEventListener("scroll", updateFilterEdges, { passive: true })
window.addEventListener("resize", updateFilterEdges)
return () => {
el.removeEventListener("scroll", updateFilterEdges)
window.removeEventListener("resize", updateFilterEdges)
}
}, [updateFilterEdges, filterMode, categories.length, tags.length])
const filterPill =
"shrink-0 inline-flex items-center h-8 px-3 rounded-full border text-[13px] leading-none transition-colors duration-150"
const filterPillOn = "bg-primary border-primary text-primary-foreground"
const filterPillOff = "border-border text-muted-foreground hover:border-primary hover:text-primary"
const filterBar = (
<div className="lg:hidden sticky top-[var(--header-h)] z-30 -mx-6 mb-8 border-b border-border/60 bg-surface-subtle/80 backdrop-blur-md">
<div
ref={filterBarRef}
className="no-scrollbar flex items-center gap-2 overflow-x-auto px-6 py-2.5"
>
{filterMode === "categories" ? (
<>
{categories.map((category) => (
<button
key={category.id}
type="button"
data-filter-active={activeCategory === category.id ? "true" : undefined}
onClick={() => handleCategoryChange(category.id)}
className={cn(filterPill, activeCategory === category.id ? filterPillOn : filterPillOff)}
>
{category.name}
<span className="ml-1 text-[11px] opacity-60">{category.count}</span>
</button>
))}
<span className="shrink-0 w-px h-4 bg-border" aria-hidden="true" />
<button
type="button"
onClick={() => setFilterMode("tags")}
className={cn(filterPill, filterPillOff)}
>
标签 ›
</button>
</>
) : (
<>
<button
type="button"
onClick={() => setFilterMode("categories")}
className={cn(filterPill, filterPillOff)}
>
‹ 分类
</button>
<span className="shrink-0 w-px h-4 bg-border" aria-hidden="true" />
{tags.map((tag) => (
<button
key={tag}
type="button"
data-filter-active={activeTag === tag ? "true" : undefined}
onClick={() => handleTagChange(activeTag === tag ? null : tag)}
className={cn(filterPill, activeTag === tag ? filterPillOn : filterPillOff)}
>
{tag}
</button>
))}
</>
)}
</div>
{/* 两端渐隐,提示还能横向滑 */}
<span
aria-hidden="true"
className={cn(
"pointer-events-none absolute left-0 top-0 bottom-0 w-8 bg-gradient-to-r from-surface-subtle/90 to-transparent transition-opacity duration-200",
filterEdges.left ? "opacity-100" : "opacity-0",
)}
/>
<span
aria-hidden="true"
className={cn(
"pointer-events-none absolute right-0 top-0 bottom-0 w-8 bg-gradient-to-l from-surface-subtle/90 to-transparent transition-opacity duration-200",
filterEdges.right ? "opacity-100" : "opacity-0",
)}
/>
</div>
)
return (
<section ref={sectionRef} className="article-list-root px-6 py-16 bg-surface-subtle">
<div className="max-w-6xl mx-auto">
{filterBar}
{/* Section header */}
<div className="mb-10 onload-animation" style={{ animationDelay: "50ms" }}>
<div