Add fluid typography scale and responsive headings
This commit is contained in:
@@ -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,16 +50,17 @@ export default function BlogPostPage({ params }: Props) {
|
||||
<PostToc />
|
||||
</aside>
|
||||
<div className="flex-1 space-y-6">
|
||||
<SectionDivider>
|
||||
<ScrollReveal>
|
||||
<header className="mb-2 space-y-2">
|
||||
{post.published_at && (
|
||||
<p className="text-xs text-slate-500 dark:text-slate-500">
|
||||
<p className="type-small 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">
|
||||
<h1 className="type-display font-bold leading-tight text-slate-900 dark:text-slate-50">
|
||||
{post.title}
|
||||
</h1>
|
||||
{post.tags && (
|
||||
@@ -68,7 +71,7 @@ export default function BlogPostPage({ params }: Props) {
|
||||
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>
|
||||
@@ -77,7 +80,9 @@ export default function BlogPostPage({ params }: Props) {
|
||||
)}
|
||||
</header>
|
||||
</ScrollReveal>
|
||||
</SectionDivider>
|
||||
|
||||
<SectionDivider>
|
||||
<ScrollReveal>
|
||||
<article className="prose prose-lg prose-slate max-w-none dark:prose-dark">
|
||||
{post.feature_image && (
|
||||
@@ -92,7 +97,11 @@ export default function BlogPostPage({ params }: Props) {
|
||||
<div dangerouslySetInnerHTML={{ __html: post.body.html }} />
|
||||
</article>
|
||||
</ScrollReveal>
|
||||
</SectionDivider>
|
||||
|
||||
<FooterCue />
|
||||
|
||||
<SectionDivider>
|
||||
<ScrollReveal>
|
||||
<PostStorylineNav
|
||||
current={post}
|
||||
@@ -100,15 +109,17 @@ export default function BlogPostPage({ params }: Props) {
|
||||
older={neighbors.older}
|
||||
/>
|
||||
</ScrollReveal>
|
||||
</SectionDivider>
|
||||
|
||||
{relatedPosts.length > 0 && (
|
||||
<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="text-base font-semibold text-slate-900 dark:text-slate-50">
|
||||
<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">
|
||||
<p className="type-small text-slate-500 dark:text-slate-400">
|
||||
為你挑選相似主題
|
||||
</p>
|
||||
</div>
|
||||
@@ -119,6 +130,7 @@ export default function BlogPostPage({ params }: Props) {
|
||||
</div>
|
||||
</section>
|
||||
</ScrollReveal>
|
||||
</SectionDivider>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
43
components/footer-cue.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
53
components/section-divider.tsx
Normal file
53
components/section-divider.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user