浅色改为暖纸底 + 纸纹底 + 移动端分类胶囊条
浅色配色(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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user