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

浅色配色(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
+4 -4
View File
@@ -77,8 +77,8 @@ export function Sidebar({
</div>
</div>
{/* Categories */}
<div className="bg-card border border-border/50 rounded-xl p-5">
{/* Categories —— 移动端由 article-list 顶部的吸附胶囊条代替,这里只在 lg 以上显示 */}
<div className="hidden lg:block bg-card border border-border/50 rounded-xl p-5">
<div className="flex items-center gap-2 mb-4">
<div className="w-1 h-4 bg-primary rounded-full" />
<h4 className="font-medium text-foreground">分类</h4>
@@ -120,8 +120,8 @@ export function Sidebar({
)}
</div>
{/* Tags */}
<div className="bg-card border border-border/50 rounded-xl p-5">
{/* Tags —— 同上,移动端在胶囊条里切到「标签」即可 */}
<div className="hidden lg:block bg-card border border-border/50 rounded-xl p-5">
<div className="flex items-center gap-2 mb-4">
<div className="w-1 h-4 bg-primary rounded-full" />
<h4 className="font-medium text-foreground">标签</h4>
+1 -1
View File
@@ -86,7 +86,7 @@ const isCurrent = (href: string) =>
<div class="mobile-menu-bg absolute inset-0" data-dismiss aria-hidden="true"></div>
<div
class="mobile-menu-panel absolute left-3 right-3 top-[76px] p-2 rounded-2xl border max-h-[calc(100dvh-96px)] overflow-y-auto overscroll-contain"
class="mobile-menu-panel absolute left-3 right-3 top-[calc(var(--header-h)+7px)] p-2 rounded-2xl border max-h-[calc(100dvh-96px)] overflow-y-auto overscroll-contain"
role="dialog"
aria-modal="true"
aria-label="站点导航"
+69 -21
View File
@@ -14,27 +14,37 @@
* 参考:0=红 25=朱红 200=青 250=蓝绿 345=粉
*/
--content-delay: 150ms;
--background: oklch(0.994 0.002 85); /* 极淡暖白纸感 */
--foreground: oklch(0.15 0 0);
--surface-subtle: oklch(0.982 0.003 85); /* 区块浅底,替代 scattered muted/30 */
--paper-warm: oklch(0.98 0.008 85); /* 专题笔记背景:暖米色,致敬纸感 */
--card: oklch(0.998 0.002 85);
--card-foreground: oklch(0.15 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.15 0 0);
/* 固定页眉的实际高度(site-header 的 py-4 + 32px 头像 + 1px 边框)。
移动端菜单、吸附的分类胶囊条都靠它定位,改页眉高度时同步改这里。 */
--header-h: 69px;
/*
* 浅色模式 = 暖纸底,不用亮白。
* 纯白底(L*≈99)在屏幕上偏刺眼,而且大面积纯色没有视觉落点。
* 这里整体压到 L*≈96 并给一点暖黄彩度(hue 84 ≈ 米黄),
* 墨色也换成暖褐而非纯黑 —— 对比度仍在 15:1,远高于 WCAG AA 的 4.5:1。
* 换色时记得同步 .dark 之外这几个派生 token。
*/
--background: oklch(0.964 0.014 84); /* 暖纸底 #f8f3e9 */
--foreground: oklch(0.235 0.013 68); /* 暖墨 #221d18 */
--surface-subtle: oklch(0.952 0.016 84); /* 区块浅底 #f4eee3 */
--paper-warm: oklch(0.958 0.022 80); /* 专题笔记背景 #f9f0e1 */
--card: oklch(0.98 0.011 84); /* 卡片比页面底更亮一点,才有层次 #fcf8f0 */
--card-foreground: oklch(0.235 0.013 68);
--popover: oklch(0.984 0.01 84);
--popover-foreground: oklch(0.235 0.013 68);
--primary: oklch(0.55 0.22 var(--primary-hue, 25));
--primary-foreground: oklch(0.995 0 0);
--secondary: oklch(0.97 0.005 var(--primary-hue, 25)); /* 次要按钮/可交互面,略暖 */
--secondary-foreground: oklch(0.15 0 0);
--muted: oklch(0.96 0.003 85); /* 静态背景:placeholder、禁用区 */
--muted-foreground: oklch(0.45 0 0); /* 元信息:日期、标签、placeholder */
--content-secondary: oklch(0.36 0 0); /* 仍要读的内容:摘要、引用、预览正文 */
--secondary: oklch(0.938 0.016 84); /* 次要按钮/可交互面 #f0eadf */
--secondary-foreground: oklch(0.235 0.013 68);
--muted: oklch(0.93 0.014 84); /* 静态背景:placeholder、禁用区 #ece7de */
--muted-foreground: oklch(0.505 0.012 70); /* 元信息:日期、标签、placeholder,5.3:1 */
--content-secondary: oklch(0.425 0.012 70); /* 仍要读的内容:摘要、引用、预览正文,7.5:1 */
--accent: oklch(0.55 0.22 var(--primary-hue, 25));
--accent-foreground: oklch(0.995 0 0);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.577 0.245 27.325);
--border: oklch(0.92 0.003 85);
--input: oklch(0.92 0.003 85);
--border: oklch(0.888 0.016 84);
--input: oklch(0.888 0.016 84);
--ring: oklch(0.55 0.22 var(--primary-hue, 25));
--status-positive-bg: oklch(0.93 0.04 155);
--status-positive-fg: oklch(0.42 0.12 155);
@@ -48,6 +58,14 @@
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--radius: 0.5rem;
/*
* 纸纹底:内联 SVG 的 feTurbulence 分形噪声(无需图片文件)。
* baseFrequency 越大颗粒越细,stitchTiles 保证平铺无接缝。
* feColorMatrix saturate=0 把彩色噪点去饱和,得到带随机 alpha 的灰颗粒,
* 所以不需要 mix-blend-mode 也亮暗通用。
*/
--paper-noise: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
--paper-noise-opacity: 0.2;
/*
* 磨砂玻璃 token —— 只给「浮在内容上方、面积不大」的元素用
* (移动端菜单、页眉、搜索面板…)。不要给文章卡片大面积用,
@@ -58,13 +76,13 @@
--glass-border: color-mix(in oklch, var(--border) 75%, transparent);
--glass-blur: 14px;
--glass-saturate: 1.5;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar: oklch(0.968 0.012 84);
--sidebar-foreground: oklch(0.235 0.013 68);
--sidebar-primary: oklch(0.55 0.22 var(--primary-hue, 25));
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-primary-foreground: oklch(0.995 0 0);
--sidebar-accent: oklch(0.93 0.014 84);
--sidebar-accent-foreground: oklch(0.235 0.013 68);
--sidebar-border: oklch(0.888 0.016 84);
--sidebar-ring: oklch(0.55 0.22 var(--primary-hue, 25));
--admonitions-color-tip: oklch(0.62 0.16 180);
--admonitions-color-note: oklch(0.62 0.16 250);
@@ -135,6 +153,8 @@
--math-color: oklch(0.90 0.008 250);
--math-display-color: oklch(0.94 0.008 250);
--math-display-shadow: 0 1px 0 oklch(0.08 0 0 / 0.18);
/* 暗色下颗粒要更明显一点才看得出来 */
--paper-noise-opacity: 0.22;
}
@theme inline {
@@ -230,6 +250,34 @@ html {
scroll-behavior: auto;
}
/*
* 纸纹底:整屏一层极淡的颗粒。
* - position: fixed 让它不随内容滚动,像纸在屏幕后面而不是印在内容上
* - z-index: 1 压在正文之上、页眉(z-50)/菜单(z-60) 之下;不透明度只有几个百分点,
* 等效于全局加了一层胶片颗粒
* - pointer-events: none 必须留,否则整页点不动
* - 不支持 backdrop-filter 的老浏览器不受影响,它只是背景图
*/
body::before {
content: "";
position: fixed;
inset: 0;
z-index: 1;
pointer-events: none;
background-image: var(--paper-noise);
background-repeat: repeat;
opacity: var(--paper-noise-opacity);
}
/* 横向滚动的胶囊条要藏掉滚动条,但保留键盘/触摸滚动能力 */
.no-scrollbar {
scrollbar-width: none;
-ms-overflow-style: none;
}
.no-scrollbar::-webkit-scrollbar {
display: none;
}
html:focus-within {
scroll-behavior: smooth;
}