Skip to content

Commit 1a79e1d

Browse files
authored
feat: design system foundation — tokens, dark mode, components, layout (#679)
Implements the design system foundation from /specs/design-system.md:\n\nTailwind CSS 4 @theme tokens:\n- Brand colors (violet primary, cyan secondary, amber accent)\n- Content type colors (blue=blog, amber=podcast, emerald=course, red=video, pink=short)\n- Dark mode (default) + light mode via html.light class\n- Typography (Inter + JetBrains Mono), shadows, radius, transitions\n- Prose styles for Portable Text article content\n- Reduced motion support\n\nDark mode:\n- ThemeScript inline in <head> before CSS (no FOUC)\n- ThemeToggle with sun/moon icons, localStorage persistence\n- class strategy (not dark: variant)\n\n10 components:\n- Button (4 variants × 3 sizes, polymorphic a/button)\n- Badge (5 content types via CSS custom properties)\n- Avatar (5 sizes, initials fallback)\n- Tag (active/inactive, topic links)\n- Container (4 sizes)\n- ContentCard (standard with Badge, Avatar, duration overlay)\n- ContentCardFeatured (horizontal layout, excerpt, guest)\n- ContentCardCompact (sidebar/related)\n- Header (sticky, backdrop blur, mobile drawer, search trigger, theme toggle)\n- Footer (4-column grid, newsletter, social links)\n\nDesign QA approved by @uidesigner."
1 parent 3b12874 commit 1a79e1d

File tree

15 files changed

+952
-56
lines changed

15 files changed

+952
-56
lines changed
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
---
2+
interface Props {
3+
src?: string;
4+
name: string;
5+
size?: "xs" | "sm" | "md" | "lg" | "xl";
6+
}
7+
8+
const { src, name, size = "md" } = Astro.props;
9+
10+
const sizeMap: Record<string, string> = {
11+
xs: "w-6 h-6 text-[10px]",
12+
sm: "w-8 h-8 text-xs",
13+
md: "w-10 h-10 text-sm",
14+
lg: "w-16 h-16 text-xl",
15+
xl: "w-24 h-24 text-3xl",
16+
};
17+
18+
const initials = name
19+
.split(" ")
20+
.map((n) => n[0])
21+
.join("")
22+
.slice(0, 2)
23+
.toUpperCase();
24+
---
25+
26+
<div class:list={[
27+
"relative rounded-full overflow-hidden bg-primary/20 flex items-center justify-center flex-shrink-0",
28+
sizeMap[size],
29+
]}>
30+
{src ? (
31+
<img src={src} alt={name} class="w-full h-full object-cover" loading="lazy" />
32+
) : (
33+
<span class="font-semibold text-primary">{initials}</span>
34+
)}
35+
</div>
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
---
2+
interface Props {
3+
type: "blog" | "podcast" | "course" | "video" | "short";
4+
size?: "sm" | "md";
5+
}
6+
7+
const { type, size = "md" } = Astro.props;
8+
9+
const colors: Record<string, string> = {
10+
blog: "bg-[--color-type-blog]/15 text-[--color-type-blog] border-[--color-type-blog]/25",
11+
podcast: "bg-[--color-type-podcast]/15 text-[--color-type-podcast] border-[--color-type-podcast]/25",
12+
course: "bg-[--color-type-course]/15 text-[--color-type-course] border-[--color-type-course]/25",
13+
video: "bg-[--color-type-video]/15 text-[--color-type-video] border-[--color-type-video]/25",
14+
short: "bg-[--color-type-short]/15 text-[--color-type-short] border-[--color-type-short]/25",
15+
};
16+
17+
const labels: Record<string, string> = {
18+
blog: "Blog",
19+
podcast: "Podcast",
20+
course: "Course",
21+
video: "Video",
22+
short: "Short",
23+
};
24+
25+
const sizes: Record<string, string> = {
26+
sm: "px-1.5 py-0.5 text-[10px]",
27+
md: "px-2 py-0.5 text-xs",
28+
};
29+
---
30+
31+
<span class:list={[
32+
"inline-flex items-center font-semibold uppercase tracking-wider rounded-sm border",
33+
colors[type],
34+
sizes[size],
35+
]}>
36+
{labels[type]}
37+
</span>
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
---
2+
interface Props {
3+
variant?: "primary" | "secondary" | "ghost" | "destructive";
4+
size?: "sm" | "md" | "lg";
5+
href?: string;
6+
class?: string;
7+
[key: string]: any;
8+
}
9+
10+
const { variant = "primary", size = "md", href, class: className, ...rest } = Astro.props;
11+
12+
const baseClasses = "inline-flex items-center justify-center font-medium transition-colors duration-150 disabled:opacity-50 disabled:pointer-events-none";
13+
14+
const variantClasses = {
15+
primary: "bg-primary text-white hover:bg-primary-hover focus-visible:ring-2 focus-visible:ring-primary/50 focus-visible:ring-offset-2 focus-visible:ring-offset-[--bg]",
16+
secondary: "border border-[--border] text-[--text] hover:bg-[--surface-hover] hover:border-[--border-hover]",
17+
ghost: "text-[--text-secondary] hover:text-[--text] hover:bg-[--surface-hover]",
18+
destructive: "bg-destructive text-white hover:bg-destructive-hover",
19+
};
20+
21+
const sizeClasses = {
22+
sm: "px-3 py-1.5 text-sm rounded-md gap-1.5",
23+
md: "px-4 py-2 text-sm rounded-lg gap-2",
24+
lg: "px-6 py-3 text-base rounded-lg gap-2",
25+
};
26+
27+
const classes = [baseClasses, variantClasses[variant], sizeClasses[size], className].filter(Boolean).join(" ");
28+
const Tag = href ? "a" : "button";
29+
---
30+
31+
{href ? (
32+
<a href={href} class={classes} {...rest}>
33+
<slot />
34+
</a>
35+
) : (
36+
<button class={classes} {...rest}>
37+
<slot />
38+
</button>
39+
)}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
interface Props {
3+
size?: "default" | "narrow" | "wide" | "full";
4+
class?: string;
5+
}
6+
7+
const { size = "default", class: className } = Astro.props;
8+
9+
const sizes: Record<string, string> = {
10+
narrow: "max-w-3xl",
11+
default: "max-w-6xl",
12+
wide: "max-w-7xl",
13+
full: "max-w-none",
14+
};
15+
---
16+
17+
<div class:list={["mx-auto px-4 sm:px-6 lg:px-8", sizes[size], className]}>
18+
<slot />
19+
</div>
Lines changed: 46 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,49 +1,62 @@
11
---
2+
import Badge from "./Badge.astro";
3+
import Avatar from "./Avatar.astro";
24
import { urlForImage } from "@/utils/sanity";
35
46
interface Props {
57
title: string;
6-
slug: string;
7-
coverImage?: any;
8-
excerpt?: string;
9-
date?: string;
8+
url: string;
9+
type: "blog" | "podcast" | "course" | "video" | "short";
10+
thumbnail?: any;
11+
duration?: string;
12+
metadata?: string;
13+
authorName?: string;
14+
authorImage?: any;
1015
}
1116
12-
const { title, slug, coverImage, excerpt, date } = Astro.props;
17+
const { title, url, type, thumbnail, duration, metadata, authorName, authorImage } = Astro.props;
1318
14-
const imageUrl = coverImage
15-
? urlForImage(coverImage).width(640).height(360).format("webp").url()
16-
: null;
17-
18-
const formattedDate = date
19-
? new Date(date).toLocaleDateString("en-US", {
20-
year: "numeric",
21-
month: "short",
22-
day: "numeric",
23-
})
24-
: null;
19+
const thumbnailUrl = thumbnail ? urlForImage(thumbnail).width(640).height(360).url() : undefined;
20+
const authorImageUrl = authorImage ? urlForImage(authorImage).width(48).height(48).url() : undefined;
2521
---
2622

27-
<a href={slug} class="group block rounded-lg border hover:border-blue-500 transition-colors overflow-hidden">
28-
{imageUrl && (
29-
<img
30-
src={imageUrl}
31-
alt={title}
32-
width={640}
33-
height={360}
34-
class="w-full aspect-video object-cover"
35-
loading="lazy"
36-
/>
23+
<a href={url} class="group block rounded-xl border border-[--border] bg-[--surface] overflow-hidden transition-all duration-200 hover:border-[--border-hover] hover:shadow-[--shadow-glow] hover:-translate-y-0.5">
24+
{/* Thumbnail */}
25+
{thumbnailUrl && (
26+
<div class="relative aspect-video overflow-hidden">
27+
<img
28+
src={thumbnailUrl}
29+
alt={title}
30+
class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
31+
loading="lazy"
32+
/>
33+
{duration && (
34+
<span class="absolute bottom-2 right-2 bg-black/80 text-white text-xs font-mono px-1.5 py-0.5 rounded-sm">
35+
{duration}
36+
</span>
37+
)}
38+
</div>
3739
)}
38-
<div class="p-4">
39-
<h3 class="font-semibold text-lg group-hover:text-blue-600 transition-colors line-clamp-2">
40+
41+
{/* Content */}
42+
<div class="p-4 space-y-2">
43+
{/* Badge + Metadata Row */}
44+
<div class="flex items-center gap-2 text-xs">
45+
<Badge type={type} />
46+
{metadata && <span class="text-[--text-tertiary]">{metadata}</span>}
47+
</div>
48+
49+
{/* Title */}
50+
<h3 class="font-semibold text-[--text] line-clamp-2 group-hover:text-primary transition-colors">
4051
{title}
4152
</h3>
42-
{excerpt && (
43-
<p class="text-gray-600 text-sm mt-2 line-clamp-2">{excerpt}</p>
44-
)}
45-
{formattedDate && (
46-
<time datetime={date} class="text-gray-400 text-xs mt-2 block">{formattedDate}</time>
53+
54+
{/* Author */}
55+
{authorName && (
56+
<div class="flex items-center gap-2">
57+
<Avatar src={authorImageUrl} name={authorName} size="xs" />
58+
<span class="text-sm text-[--text-secondary]">{authorName}</span>
59+
</div>
4760
)}
4861
</div>
4962
</a>
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
---
2+
import Badge from "./Badge.astro";
3+
import Avatar from "./Avatar.astro";
4+
import { urlForImage } from "@/utils/sanity";
5+
6+
interface Props {
7+
title: string;
8+
url: string;
9+
type: "blog" | "podcast" | "course" | "video" | "short";
10+
thumbnail?: any;
11+
metadata?: string;
12+
authorName?: string;
13+
authorImage?: any;
14+
}
15+
16+
const { title, url, type, thumbnail, metadata, authorName, authorImage } = Astro.props;
17+
18+
const thumbnailUrl = thumbnail ? urlForImage(thumbnail).width(192).height(128).url() : undefined;
19+
const authorImageUrl = authorImage ? urlForImage(authorImage).width(32).height(32).url() : undefined;
20+
---
21+
22+
<a href={url} class="group flex gap-3 p-3 rounded-lg border border-[--border] bg-[--surface] transition-all duration-200 hover:border-[--border-hover] hover:bg-[--surface-hover]">
23+
{thumbnailUrl && (
24+
<div class="relative w-24 h-16 rounded-md overflow-hidden flex-shrink-0">
25+
<img src={thumbnailUrl} alt={title} class="w-full h-full object-cover" loading="lazy" />
26+
</div>
27+
)}
28+
29+
<div class="flex-1 min-w-0 space-y-1">
30+
<div class="flex items-center gap-2 text-xs">
31+
<Badge type={type} size="sm" />
32+
{metadata && <span class="text-[--text-tertiary]">{metadata}</span>}
33+
</div>
34+
35+
<h4 class="font-medium text-sm text-[--text] line-clamp-1 group-hover:text-primary transition-colors">
36+
{title}
37+
</h4>
38+
39+
{authorName && (
40+
<div class="flex items-center gap-1.5">
41+
<Avatar src={authorImageUrl} name={authorName} size="xs" />
42+
<span class="text-xs text-[--text-tertiary]">{authorName}</span>
43+
</div>
44+
)}
45+
</div>
46+
</a>
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
---
2+
import Badge from "./Badge.astro";
3+
import Avatar from "./Avatar.astro";
4+
import { urlForImage } from "@/utils/sanity";
5+
6+
interface Props {
7+
title: string;
8+
url: string;
9+
type: "blog" | "podcast" | "course" | "video" | "short";
10+
thumbnail?: any;
11+
duration?: string;
12+
metadata?: string;
13+
excerpt?: string;
14+
authorName?: string;
15+
authorImage?: any;
16+
guestName?: string;
17+
guestImage?: any;
18+
}
19+
20+
const {
21+
title, url, type, thumbnail, duration, metadata, excerpt,
22+
authorName, authorImage, guestName, guestImage,
23+
} = Astro.props;
24+
25+
const thumbnailUrl = thumbnail ? urlForImage(thumbnail).width(800).height(450).url() : undefined;
26+
const authorImageUrl = authorImage ? urlForImage(authorImage).width(64).height(64).url() : undefined;
27+
const guestImageUrl = guestImage ? urlForImage(guestImage).width(64).height(64).url() : undefined;
28+
---
29+
30+
<a href={url} class="group block rounded-xl border border-[--border] bg-[--surface] overflow-hidden transition-all duration-200 hover:border-primary/50 hover:shadow-[--shadow-glow] md:flex md:gap-6 p-4 md:p-6">
31+
{/* Thumbnail */}
32+
{thumbnailUrl && (
33+
<div class="relative aspect-video md:w-1/2 rounded-lg overflow-hidden flex-shrink-0">
34+
<img
35+
src={thumbnailUrl}
36+
alt={title}
37+
class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
38+
/>
39+
{duration && (
40+
<span class="absolute bottom-2 right-2 bg-black/80 text-white text-xs font-mono px-2 py-1 rounded-sm">
41+
{duration}
42+
</span>
43+
)}
44+
</div>
45+
)}
46+
47+
{/* Content */}
48+
<div class="mt-4 md:mt-0 flex flex-col justify-center space-y-3">
49+
<div class="flex items-center gap-2 text-sm">
50+
<Badge type={type} />
51+
{metadata && <span class="text-[--text-tertiary]">{metadata}</span>}
52+
</div>
53+
54+
<h2 class="text-2xl md:text-3xl font-bold text-[--text] group-hover:text-primary transition-colors">
55+
{title}
56+
</h2>
57+
58+
{excerpt && (
59+
<p class="text-[--text-secondary] line-clamp-2 md:line-clamp-3">{excerpt}</p>
60+
)}
61+
62+
<div class="flex items-center gap-3">
63+
{authorName && (
64+
<>
65+
<Avatar src={authorImageUrl} name={authorName} size="sm" />
66+
<span class="text-sm text-[--text-secondary]">{authorName}</span>
67+
</>
68+
)}
69+
{guestName && (
70+
<>
71+
<Avatar src={guestImageUrl} name={guestName} size="sm" />
72+
<span class="text-sm text-[--text-secondary]">{guestName}</span>
73+
</>
74+
)}
75+
</div>
76+
</div>
77+
</a>

0 commit comments

Comments
 (0)