Add fluid typography scale and responsive headings

This commit is contained in:
2025-11-19 00:22:09 +08:00
parent b4ee8b122f
commit dc5ca97fee
9 changed files with 256 additions and 79 deletions

View File

@@ -9,6 +9,8 @@ import { PostToc } from '@/components/post-toc';
import { ScrollReveal } from '@/components/scroll-reveal'; import { ScrollReveal } from '@/components/scroll-reveal';
import { PostCard } from '@/components/post-card'; import { PostCard } from '@/components/post-card';
import { PostStorylineNav } from '@/components/post-storyline-nav'; import { PostStorylineNav } from '@/components/post-storyline-nav';
import { SectionDivider } from '@/components/section-divider';
import { FooterCue } from '@/components/footer-cue';
export function generateStaticParams() { export function generateStaticParams() {
return allPosts.map((post) => ({ return allPosts.map((post) => ({
@@ -48,77 +50,87 @@ export default function BlogPostPage({ params }: Props) {
<PostToc /> <PostToc />
</aside> </aside>
<div className="flex-1 space-y-6"> <div className="flex-1 space-y-6">
<ScrollReveal> <SectionDivider>
<header className="mb-2 space-y-2"> <ScrollReveal>
{post.published_at && ( <header className="mb-2 space-y-2">
<p className="text-xs text-slate-500 dark:text-slate-500"> {post.published_at && (
{new Date(post.published_at).toLocaleDateString( <p className="type-small text-slate-500 dark:text-slate-500">
siteConfig.defaultLocale {new Date(post.published_at).toLocaleDateString(
)} siteConfig.defaultLocale
</p> )}
)} </p>
<h1 className="text-2xl font-bold leading-tight text-slate-900 sm:text-3xl dark:text-slate-50"> )}
{post.title} <h1 className="type-display font-bold leading-tight text-slate-900 dark:text-slate-50">
</h1> {post.title}
{post.tags && ( </h1>
<div className="flex flex-wrap gap-2 pt-1"> {post.tags && (
{post.tags.map((t) => ( <div className="flex flex-wrap gap-2 pt-1">
<Link {post.tags.map((t) => (
key={t} <Link
href={`/tags/${encodeURIComponent( key={t}
t.toLowerCase().replace(/\s+/g, '-') href={`/tags/${encodeURIComponent(
)}`} t.toLowerCase().replace(/\s+/g, '-')
className="rounded-full bg-accent-soft px-2 py-0.5 text-xs text-accent-textLight transition hover:bg-accent hover:text-white dark:bg-slate-800 dark:text-slate-100 dark:hover:bg-slate-700" )}`}
> className="tag-chip rounded-full bg-accent-soft px-2 py-0.5 text-xs text-accent-textLight dark:bg-slate-800 dark:text-slate-100"
#{t} >
</Link> #{t}
))} </Link>
</div> ))}
)} </div>
</header> )}
</ScrollReveal> </header>
</ScrollReveal>
</SectionDivider>
<ScrollReveal> <SectionDivider>
<article className="prose prose-lg prose-slate max-w-none dark:prose-dark"> <ScrollReveal>
{post.feature_image && ( <article className="prose prose-lg prose-slate max-w-none dark:prose-dark">
// feature_image is stored as "../assets/xyz", serve from "/assets/xyz" {post.feature_image && (
// eslint-disable-next-line @next/next/no-img-element // feature_image is stored as "../assets/xyz", serve from "/assets/xyz"
<img // eslint-disable-next-line @next/next/no-img-element
src={post.feature_image.replace('../assets', '/assets')} <img
alt={post.title} src={post.feature_image.replace('../assets', '/assets')}
className="my-4 rounded" alt={post.title}
/> className="my-4 rounded"
)} />
<div dangerouslySetInnerHTML={{ __html: post.body.html }} /> )}
</article> <div dangerouslySetInnerHTML={{ __html: post.body.html }} />
</ScrollReveal> </article>
</ScrollReveal>
</SectionDivider>
<ScrollReveal> <FooterCue />
<PostStorylineNav
current={post} <SectionDivider>
newer={neighbors.newer} <ScrollReveal>
older={neighbors.older} <PostStorylineNav
/> current={post}
</ScrollReveal> newer={neighbors.newer}
older={neighbors.older}
/>
</ScrollReveal>
</SectionDivider>
{relatedPosts.length > 0 && ( {relatedPosts.length > 0 && (
<ScrollReveal> <SectionDivider>
<section className="space-y-4 rounded-xl border border-slate-200 bg-white/80 p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900/50"> <ScrollReveal>
<div className="flex items-center justify-between gap-2"> <section className="space-y-4 rounded-xl border border-slate-200 bg-white/80 p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900/50">
<h2 className="text-base font-semibold text-slate-900 dark:text-slate-50"> <div className="flex items-center justify-between gap-2">
<h2 className="type-subtitle font-semibold text-slate-900 dark:text-slate-50">
</h2>
<p className="text-xs text-slate-500 dark:text-slate-400"> </h2>
<p className="type-small text-slate-500 dark:text-slate-400">
</p>
</div> </p>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> </div>
{relatedPosts.map((related) => ( <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<PostCard key={related._id} post={related} showTags={false} /> {relatedPosts.map((related) => (
))} <PostCard key={related._id} post={related} showTags={false} />
</div> ))}
</section> </div>
</ScrollReveal> </section>
</ScrollReveal>
</SectionDivider>
)} )}
</div> </div>
</div> </div>

View File

@@ -11,10 +11,10 @@ export default function BlogIndexPage() {
return ( return (
<section className="space-y-4"> <section className="space-y-4">
<header className="space-y-1"> <header className="space-y-1">
<h1 className="text-lg font-semibold text-slate-900 dark:text-slate-50"> <h1 className="type-title font-semibold text-slate-900 dark:text-slate-50">
</h1> </h1>
<p className="text-xs text-slate-500 dark:text-slate-400"> <p className="type-small text-slate-500 dark:text-slate-400">
</p> </p>
</header> </header>

View File

@@ -9,17 +9,17 @@ export default function HomePage() {
return ( return (
<section className="space-y-6"> <section className="space-y-6">
<header className="space-y-1 text-center"> <header className="space-y-1 text-center">
<h1 className="text-2xl font-bold text-slate-900 sm:text-3xl dark:text-slate-50"> <h1 className="type-title font-bold text-slate-900 dark:text-slate-50">
{siteConfig.name} {siteConfig.name}
</h1> </h1>
<p className="text-sm text-slate-600 dark:text-slate-300"> <p className="type-small text-slate-600 dark:text-slate-300">
{siteConfig.tagline} {siteConfig.tagline}
</p> </p>
</header> </header>
<div> <div>
<div className="mb-3 flex items-baseline justify-between"> <div className="mb-3 flex items-baseline justify-between">
<h2 className="text-sm font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400"> <h2 className="type-small font-semibold uppercase tracking-[0.4em] text-slate-500 dark:text-slate-400">
</h2> </h2>
<Link <Link

View File

@@ -21,11 +21,11 @@ export default function TagIndexPage() {
return ( return (
<section className="space-y-4"> <section className="space-y-4">
<h1 className="flex items-center gap-2 text-lg font-semibold text-slate-900 dark:text-slate-50"> <h1 className="type-title flex items-center gap-2 font-semibold text-slate-900 dark:text-slate-50">
<FontAwesomeIcon icon={faTags} className="h-5 w-5 text-slate-400" /> <FontAwesomeIcon icon={faTags} className="h-5 w-5 text-slate-400" />
</h1> </h1>
<p className="text-xs text-slate-500 dark:text-slate-400"> <p className="type-small text-slate-500 dark:text-slate-400">
{tags.length} {tags.length}
</p> </p>
<div className="flex flex-wrap gap-3 text-xs"> <div className="flex flex-wrap gap-3 text-xs">
@@ -35,7 +35,7 @@ export default function TagIndexPage() {
<Link <Link
key={tag} key={tag}
href={`/tags/${slug}`} href={`/tags/${slug}`}
className={`rounded-full px-3 py-1 shadow-sm transition-transform transition-shadow duration-200 ease-out hover:-translate-y-0.5 hover:shadow-md ${color}`} className={`tag-chip rounded-full px-3 py-1 shadow-sm transition-transform transition-shadow duration-200 ease-out hover:-translate-y-0.5 hover:shadow-md ${color}`}
> >
<span className="mr-1">{tag}</span> <span className="mr-1">{tag}</span>
<span className="opacity-70">({count})</span> <span className="opacity-70">({count})</span>

43
components/footer-cue.tsx Normal file
View File

@@ -0,0 +1,43 @@
'use client';
import { useEffect, useRef, useState } from 'react';
export function FooterCue() {
const ref = useRef<HTMLDivElement | null>(null);
const [active, setActive] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
if (!('IntersectionObserver' in window)) {
setActive(true);
return;
}
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
setActive(true);
}
});
},
{ threshold: 0.2 }
);
observer.observe(el);
return () => observer.disconnect();
}, []);
return (
<div ref={ref} className="flex flex-col items-center gap-2 py-4 text-[11px] uppercase tracking-[0.3em] text-slate-400 dark:text-slate-500">
<span className="text-xs"></span>
<span
className={`h-10 w-px overflow-hidden rounded-full bg-gradient-to-b from-transparent via-accent to-transparent transition-[height,opacity] duration-500 ease-snappy ${
active ? 'opacity-80' : 'h-4 opacity-30'
}`}
/>
</div>
);
}

View File

@@ -64,11 +64,12 @@ export function Hero() {
<div className="pointer-events-none absolute -bottom-20 right-[-3rem] h-44 w-44 rounded-full bg-indigo-300/35 blur-3xl mix-blend-soft-light motion-safe:animate-float-soft dark:bg-indigo-500/25" /> <div className="pointer-events-none absolute -bottom-20 right-[-3rem] h-44 w-44 rounded-full bg-indigo-300/35 blur-3xl mix-blend-soft-light motion-safe:animate-float-soft dark:bg-indigo-500/25" />
<div className="relative flex items-center gap-4 motion-safe:animate-fade-in-up"> <div className="relative flex items-center gap-4 motion-safe:animate-fade-in-up">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-slate-900 text-2xl font-semibold text-slate-50 shadow-md transition-transform duration-300 ease-out group-hover:scale-105 dark:bg-slate-100 dark:text-slate-900"> <div className="flex h-16 w-16 items-center justify-center rounded-full bg-slate-900 text-xl font-semibold text-slate-50 shadow-md transition-transform duration-300 ease-out group-hover:scale-105 dark:bg-slate-100 dark:text-slate-900">
{initial} {initial}
</div> </div>
<div> <div>
<h1 className="text-2xl font-bold tracking-tight sm:text-3xl"> <h1 className="hero-title type-display font-bold tracking-tight">
<span className="hero-title__sweep" aria-hidden="true" />
{name} {name}
</h1> </h1>
<div className="mt-1"> <div className="mt-1">

View File

@@ -103,7 +103,7 @@ export function RightSidebar() {
<Link <Link
key={tag} key={tag}
href={`/tags/${slug}`} href={`/tags/${slug}`}
className={`${sizeClass} rounded-full bg-accent-soft px-2 py-0.5 text-accent-textLight transition hover:bg-accent hover:text-white dark:bg-slate-800 dark:text-slate-100 dark:hover:bg-slate-700`} className={`${sizeClass} tag-chip rounded-full bg-accent-soft px-2 py-0.5 text-accent-textLight transition hover:bg-accent hover:text-white dark:bg-slate-800 dark:text-slate-100 dark:hover:bg-slate-700`}
> >
{tag} {tag}
</Link> </Link>

View File

@@ -0,0 +1,53 @@
'use client';
import { useEffect, useRef, useState, ReactNode } from 'react';
import clsx from 'clsx';
interface SectionDividerProps {
children: ReactNode;
className?: string;
}
export function SectionDivider({ children, className }: SectionDividerProps) {
const ref = useRef<HTMLDivElement | null>(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
if (!('IntersectionObserver' in window)) {
setVisible(true);
return;
}
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
setVisible(true);
}
});
},
{ threshold: 0.15, rootMargin: '0px 0px -20% 0px' }
);
observer.observe(el);
return () => observer.disconnect();
}, []);
return (
<div
ref={ref}
className={clsx('space-y-4', className)}
>
<span
className={clsx(
'block h-[2px] w-full origin-left rounded-full bg-gradient-to-r from-slate-200 via-accent-soft to-slate-200 transition-transform duration-500 ease-snappy dark:from-slate-800 dark:to-slate-800',
visible ? 'scale-x-100 opacity-100' : 'scale-x-50 opacity-30'
)}
/>
{children}
</div>
);
}

View File

@@ -7,10 +7,22 @@
--motion-duration-medium: 260ms; --motion-duration-medium: 260ms;
--motion-ease-snappy: cubic-bezier(0.32, 0.72, 0, 1); --motion-ease-snappy: cubic-bezier(0.32, 0.72, 0, 1);
--card-translate-y: -6px; --card-translate-y: -6px;
--line-height-body: clamp(1.5, 0.15vw + 1.45, 1.65);
font-size: clamp(15px, 0.65vw + 11px, 19px);
}
@media (min-width: 2560px) {
:root {
font-size: 20px;
}
} }
body { body {
@apply bg-white text-gray-900 transition-colors duration-200 ease-snappy dark:bg-gray-950 dark:text-gray-100 text-[15px] sm:text-base; @apply bg-white text-gray-900 transition-colors duration-200 ease-snappy dark:bg-gray-950 dark:text-gray-100;
font-size: 1rem;
line-height: var(--line-height-body);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang TC', 'Noto Sans TC', 'Microsoft JhengHei', 'Noto Sans', sans-serif;
} }
.toc-target-highlight { .toc-target-highlight {
@@ -34,6 +46,37 @@ body {
@apply -translate-y-0.5 shadow-md; @apply -translate-y-0.5 shadow-md;
} }
.prose {
font-size: clamp(1rem, 0.2vw + 0.9rem, 1.2rem);
line-height: var(--line-height-body);
}
.prose h1 {
font-size: clamp(2.2rem, 1.4rem + 2.2vw, 3.4rem);
line-height: 1.25;
}
.prose h2 {
font-size: clamp(1.8rem, 1.1rem + 1.6vw, 2.8rem);
line-height: 1.3;
}
.prose h3 {
font-size: clamp(1.4rem, 0.9rem + 1vw, 2rem);
line-height: 1.35;
}
.prose p,
.prose li {
font-size: clamp(1rem, 0.2vw + 0.9rem, 1.15rem);
line-height: var(--line-height-body);
}
.prose small,
.prose figcaption {
font-size: clamp(0.85rem, 0.2vw + 0.8rem, 0.95rem);
}
.prose h1 > a, .prose h1 > a,
.prose h2 > a, .prose h2 > a,
.prose h3 > a, .prose h3 > a,
@@ -103,6 +146,31 @@ body {
} }
@layer components { @layer components {
.type-display {
font-size: clamp(2.2rem, 1.6rem + 2.4vw, 3.5rem);
line-height: 1.2;
}
.type-title {
font-size: clamp(1.6rem, 1.1rem + 1.4vw, 2.6rem);
line-height: 1.3;
}
.type-subtitle {
font-size: clamp(1.25rem, 0.9rem + 1vw, 1.9rem);
line-height: 1.35;
}
.type-body {
font-size: clamp(1rem, 0.2vw + 0.9rem, 1.15rem);
line-height: var(--line-height-body);
}
.type-small {
font-size: clamp(0.85rem, 0.2vw + 0.8rem, 0.95rem);
line-height: 1.4;
}
.motion-card { .motion-card {
transition: transform var(--motion-duration-medium) var(--motion-ease-snappy), transition: transform var(--motion-duration-medium) var(--motion-ease-snappy),
box-shadow var(--motion-duration-medium) var(--motion-ease-snappy), box-shadow var(--motion-duration-medium) var(--motion-ease-snappy),