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

View File

@@ -11,10 +11,10 @@ export default function BlogIndexPage() {
return (
<section className="space-y-4">
<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>
<p className="text-xs text-slate-500 dark:text-slate-400">
<p className="type-small text-slate-500 dark:text-slate-400">
</p>
</header>

View File

@@ -9,17 +9,17 @@ export default function HomePage() {
return (
<section className="space-y-6">
<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}
</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}
</p>
</header>
<div>
<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>
<Link

View File

@@ -21,11 +21,11 @@ export default function TagIndexPage() {
return (
<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" />
</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}
</p>
<div className="flex flex-wrap gap-3 text-xs">
@@ -35,7 +35,7 @@ export default function TagIndexPage() {
<Link
key={tag}
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="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="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}
</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}
</h1>
<div className="mt-1">

View File

@@ -103,7 +103,7 @@ export function RightSidebar() {
<Link
key={tag}
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}
</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-ease-snappy: cubic-bezier(0.32, 0.72, 0, 1);
--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 {
@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 {
@@ -34,6 +46,37 @@ body {
@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 h2 > a,
.prose h3 > a,
@@ -103,6 +146,31 @@ body {
}
@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 {
transition: transform var(--motion-duration-medium) var(--motion-ease-snappy),
box-shadow var(--motion-duration-medium) var(--motion-ease-snappy),