@karaoke-cms/theme-blog 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,79 @@
1
+ # @karaoke-cms/theme-blog
2
+
3
+ Editorial blog theme for karaoke-cms. Features a featured hero post, card grid, and cover image support. Blog-only — no docs or knowledge-base routes.
4
+
5
+ ## Where it belongs
6
+
7
+ `packages/theme-blog/` in the monorepo. Activated via `karaoke.config.ts`:
8
+
9
+ ```ts
10
+ // karaoke.config.ts
11
+ import { loadEnv } from '@karaoke-cms/astro/env';
12
+ const { KARAOKE_VAULT } = loadEnv(new URL('.', import.meta.url));
13
+
14
+ export default {
15
+ vault: KARAOKE_VAULT,
16
+ theme: '@karaoke-cms/theme-blog',
17
+ };
18
+ ```
19
+
20
+ ## What it does
21
+
22
+ Injects blog-focused routes and sets the `@theme` Vite alias:
23
+
24
+ | Route | Page |
25
+ |-------|------|
26
+ | `/` | Home — featured hero post + recent posts card grid |
27
+ | `/blog` | Blog index — all published posts |
28
+ | `/blog/[slug]` | Blog post — full content with cover image |
29
+ | `/tags` | Tags index |
30
+ | `/tags/[tag]` | Tag page |
31
+ | `/404` | Not found page |
32
+
33
+ Note: no `/docs` or `/docs/[slug]` routes. This theme is for publication-style blogs, not knowledge bases.
34
+
35
+ ### Extended frontmatter
36
+
37
+ The theme supports optional blog-specific frontmatter fields via `blogThemeExtension`. Pass it to `makeCollections()` to unlock them:
38
+
39
+ ```ts
40
+ // src/content.config.ts
41
+ import { makeCollections, blogThemeExtension } from '@karaoke-cms/astro/collections';
42
+
43
+ export const collections = makeCollections(import.meta.url, KARAOKE_VAULT, {
44
+ blog: blogThemeExtension,
45
+ });
46
+ ```
47
+
48
+ Extra fields (all optional):
49
+
50
+ | Field | Type | Purpose |
51
+ |-------|------|---------|
52
+ | `cover_image` | `string` | Path or URL to the post's cover image |
53
+ | `featured` | `boolean` | Pins this post as the hero on the home page |
54
+ | `abstract` | `string` | Override the description shown in cards and the hero |
55
+
56
+ Without `blogThemeExtension`, these fields are silently ignored — the theme falls back to `description`.
57
+
58
+ ### Components
59
+
60
+ - **`FeaturedHero`** — large hero block for the featured post with cover image overlay, title, date, and abstract
61
+ - **`PostCard`** — card with cover image, title, date, reading time, and abstract for the grid
62
+
63
+ ### Design system
64
+
65
+ `src/styles.css` defines tokens on `:root`:
66
+
67
+ - **Typography**: `--font-body`, `--font-mono`, `--font-size-base/sm/lg/xl`
68
+ - **Color**: `--color-bg`, `--color-text`, `--color-muted`, `--color-border`, `--color-link`, `--color-link-hover`, `--color-link-visited`, `--color-card-bg`, `--color-hero-overlay`
69
+ - **Spacing**: `--spacing-xs/sm/md/lg/xl`
70
+ - **Sizing**: `--width-content` (700px), `--width-site` (1100px), `--radius-card` (8px), `--radius-sm` (4px)
71
+
72
+ Dark mode via `@media (prefers-color-scheme: dark)`.
73
+
74
+ ## How it changes the behavior of the system
75
+
76
+ - Replaces all routes from `theme-default`. The two themes cannot coexist — only one is active at a time.
77
+ - Home page behavior changes significantly: instead of a two-column blog + docs grid, it renders a featured hero and card grid.
78
+ - Does not support docs — vault content in `docs/` is collected but has no public routes. Use `theme-default` if you need docs.
79
+ - `featured: true` on a post controls the home page hero. If no post is marked featured, the most recent post is promoted automatically.
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@karaoke-cms/theme-blog",
3
+ "type": "module",
4
+ "version": "0.9.0",
5
+ "description": "Blog theme for karaoke-cms — editorial layout with featured hero, card grid, and cover images",
6
+ "main": "./src/index.ts",
7
+ "exports": {
8
+ ".": "./src/index.ts"
9
+ },
10
+ "files": [
11
+ "src/"
12
+ ],
13
+ "keywords": [
14
+ "astro",
15
+ "cms",
16
+ "theme",
17
+ "karaoke-cms"
18
+ ],
19
+ "peerDependencies": {
20
+ "astro": ">=6.0.0",
21
+ "@karaoke-cms/astro": "^0.9.0"
22
+ },
23
+ "devDependencies": {
24
+ "astro": "^6.0.8",
25
+ "@karaoke-cms/astro": "0.9.0"
26
+ },
27
+ "scripts": {
28
+ "test": "echo \"Stub — no tests\""
29
+ }
30
+ }
@@ -0,0 +1,26 @@
1
+ ---
2
+ interface Props {
3
+ title: string;
4
+ href: string;
5
+ date?: Date;
6
+ abstract?: string;
7
+ coverImage?: string;
8
+ }
9
+
10
+ const { title, href, date, abstract, coverImage } = Astro.props;
11
+ ---
12
+
13
+ <a
14
+ href={href}
15
+ class="featured-hero"
16
+ style={coverImage ? `--hero-bg: url('${coverImage}')` : ''}
17
+ >
18
+ <div class="featured-hero-overlay">
19
+ <div class="featured-hero-content">
20
+ <span class="featured-label">Featured</span>
21
+ <h2 class="featured-title">{title}</h2>
22
+ {abstract && <p class="featured-abstract">{abstract}</p>}
23
+ {date && <span class="featured-date">{date.toISOString().slice(0, 10)}</span>}
24
+ </div>
25
+ </div>
26
+ </a>
@@ -0,0 +1,26 @@
1
+ ---
2
+ interface Props {
3
+ title: string;
4
+ href: string;
5
+ date?: Date;
6
+ readingTime?: number;
7
+ abstract?: string;
8
+ coverImage?: string;
9
+ }
10
+
11
+ const { title, href, date, readingTime, abstract, coverImage } = Astro.props;
12
+ ---
13
+
14
+ <a href={href} class="post-card">
15
+ {coverImage && (
16
+ <div class="card-cover" style={`background-image: url('${coverImage}')`} />
17
+ )}
18
+ <div class="card-body">
19
+ <h3 class="card-title">{title}</h3>
20
+ {abstract && <p class="card-abstract">{abstract}</p>}
21
+ <div class="card-meta">
22
+ {date && <span>{date.toISOString().slice(0, 10)}</span>}
23
+ {readingTime && <span>{readingTime} min read</span>}
24
+ </div>
25
+ </div>
26
+ </a>
package/src/index.ts ADDED
@@ -0,0 +1,29 @@
1
+ import type { AstroIntegration } from 'astro';
2
+ import type { KaraokeConfig } from '@karaoke-cms/astro';
3
+ import { fileURLToPath } from 'url';
4
+
5
+ const __dirname = fileURLToPath(new URL('.', import.meta.url));
6
+
7
+ export default function themeBlog(_config: KaraokeConfig): AstroIntegration {
8
+ return {
9
+ name: '@karaoke-cms/theme-blog',
10
+ hooks: {
11
+ 'astro:config:setup': ({ injectRoute, updateConfig }) => {
12
+ injectRoute({ pattern: '/', entrypoint: `${__dirname}pages/index.astro` });
13
+ injectRoute({ pattern: '/blog', entrypoint: `${__dirname}pages/blog/index.astro` });
14
+ injectRoute({ pattern: '/blog/[slug]', entrypoint: `${__dirname}pages/blog/[slug].astro` });
15
+ injectRoute({ pattern: '/tags', entrypoint: `${__dirname}pages/tags/index.astro` });
16
+ injectRoute({ pattern: '/tags/[tag]', entrypoint: `${__dirname}pages/tags/[tag].astro` });
17
+ injectRoute({ pattern: '/404', entrypoint: `${__dirname}pages/404.astro` });
18
+
19
+ updateConfig({
20
+ vite: {
21
+ resolve: {
22
+ alias: { '@theme': `${__dirname}` },
23
+ },
24
+ },
25
+ });
26
+ },
27
+ },
28
+ };
29
+ }
@@ -0,0 +1,12 @@
1
+ ---
2
+ import Base from '@karaoke-cms/astro/layouts/Base.astro';
3
+ import { siteTitle } from 'virtual:karaoke-cms/config';
4
+ ---
5
+
6
+ <Base title={`404 — ${siteTitle}`}>
7
+ <div style="padding: 4rem 0; text-align: center;">
8
+ <h1 style="font-size: 5rem; margin-bottom: 0.5rem; letter-spacing: -0.04em;">404</h1>
9
+ <p style="color: var(--color-muted); margin-bottom: 2rem;">Page not found.</p>
10
+ <a href="/">← Home</a>
11
+ </div>
12
+ </Base>
@@ -0,0 +1,69 @@
1
+ ---
2
+ import { getCollection, render } from 'astro:content';
3
+ import Base from '@karaoke-cms/astro/layouts/Base.astro';
4
+ import ModuleLoader from '@karaoke-cms/astro/components/ModuleLoader.astro';
5
+ import { siteTitle } from 'virtual:karaoke-cms/config';
6
+
7
+ export async function getStaticPaths() {
8
+ const posts = await getCollection('blog', ({ data }) => data.publish === true);
9
+ return posts.map(entry => ({
10
+ params: { slug: entry.id },
11
+ props: { entry },
12
+ }));
13
+ }
14
+
15
+ const { entry } = Astro.props;
16
+ const { Content } = await render(entry);
17
+
18
+ type Extended = { cover_image?: string; abstract?: string };
19
+ const coverImage = (entry.data as Extended).cover_image;
20
+
21
+ const relatedIds = entry.data.related ?? [];
22
+ const related = relatedIds.length > 0
23
+ ? (await Promise.all([
24
+ getCollection('blog', ({ data }) => data.publish === true),
25
+ getCollection('docs', ({ data }) => data.publish === true),
26
+ ]))
27
+ .flat()
28
+ .filter(e => relatedIds.includes(e.id))
29
+ : [];
30
+ ---
31
+
32
+ <Base title={`${entry.data.title} — ${siteTitle}`} description={entry.data.description} type="article">
33
+ <article>
34
+ {coverImage && <img src={coverImage} alt="" class="post-cover" />}
35
+ <div class="post-header">
36
+ <h1>{entry.data.title}</h1>
37
+ <div class="post-meta">
38
+ {entry.data.date && <span>{entry.data.date.toISOString().slice(0, 10)}</span>}
39
+ {entry.data.author && entry.data.date && <span> · </span>}
40
+ {entry.data.author && (
41
+ <span>{Array.isArray(entry.data.author) ? entry.data.author.join(' · ') : entry.data.author}</span>
42
+ )}
43
+ {entry.data.reading_time && <span> · {entry.data.reading_time} min read</span>}
44
+ </div>
45
+ </div>
46
+ <div class="prose">
47
+ <Content />
48
+ </div>
49
+ <div class="post-footer">
50
+ {entry.data.tags && entry.data.tags.length > 0 && (
51
+ <div class="post-tags">
52
+ {entry.data.tags.map(tag => <a href={`/tags/${tag}`} class="tag">#{tag}</a>)}
53
+ </div>
54
+ )}
55
+ {related.length > 0 && (
56
+ <div class="related-posts">
57
+ <p class="related-label">Related</p>
58
+ <ul>
59
+ {related.map(r => (
60
+ <li><a href={`/${r.collection}/${r.id}`}>{r.data.title}</a></li>
61
+ ))}
62
+ </ul>
63
+ </div>
64
+ )}
65
+ <a href="/blog">← Blog</a>
66
+ </div>
67
+ </article>
68
+ <ModuleLoader comments={entry.data.comments} />
69
+ </Base>
@@ -0,0 +1,36 @@
1
+ ---
2
+ import { getCollection } from 'astro:content';
3
+ import Base from '@karaoke-cms/astro/layouts/Base.astro';
4
+ import PostCard from '../../components/PostCard.astro';
5
+ import { siteTitle } from 'virtual:karaoke-cms/config';
6
+
7
+ const posts = (await getCollection('blog', ({ data }) => data.publish === true))
8
+ .sort((a, b) => (b.data.date?.valueOf() ?? 0) - (a.data.date?.valueOf() ?? 0));
9
+
10
+ type Extended = { cover_image?: string; abstract?: string };
11
+ ---
12
+
13
+ <Base title={`Blog — ${siteTitle}`}>
14
+ <div class="listing-header">
15
+ <h1>Blog</h1>
16
+ </div>
17
+ {posts.length > 0 ? (
18
+ <div class="posts-grid">
19
+ {posts.map(post => (
20
+ <PostCard
21
+ title={post.data.title}
22
+ href={`/blog/${post.id}`}
23
+ date={post.data.date}
24
+ readingTime={post.data.reading_time}
25
+ abstract={(post.data as Extended).abstract ?? post.data.description}
26
+ coverImage={(post.data as Extended).cover_image}
27
+ />
28
+ ))}
29
+ </div>
30
+ ) : (
31
+ <div class="empty-state">
32
+ <p>No posts published yet.</p>
33
+ <p>Create a Markdown file in your vault's <code>blog/</code> folder and set <code>publish: true</code>.</p>
34
+ </div>
35
+ )}
36
+ </Base>
@@ -0,0 +1,56 @@
1
+ ---
2
+ import { getCollection } from 'astro:content';
3
+ import Base from '@karaoke-cms/astro/layouts/Base.astro';
4
+ import FeaturedHero from '../components/FeaturedHero.astro';
5
+ import PostCard from '../components/PostCard.astro';
6
+ import { siteTitle, siteDescription } from 'virtual:karaoke-cms/config';
7
+
8
+ const allPosts = (await getCollection('blog', ({ data }) => data.publish === true))
9
+ .sort((a, b) => (b.data.date?.valueOf() ?? 0) - (a.data.date?.valueOf() ?? 0));
10
+
11
+ // blogThemeExtension fields (cover_image, featured, abstract) are optional;
12
+ // they're present if the user passed blogThemeExtension to makeCollections.
13
+ type Extended = { cover_image?: string; featured?: boolean; abstract?: string };
14
+
15
+ const featuredPost = allPosts.find(p => (p.data as Extended).featured) ?? allPosts[0];
16
+ const gridPosts = featuredPost
17
+ ? allPosts.filter(p => p.id !== featuredPost.id).slice(0, 6)
18
+ : allPosts.slice(0, 6);
19
+ ---
20
+
21
+ <Base title={siteTitle} description={siteDescription}>
22
+ {featuredPost && (
23
+ <FeaturedHero
24
+ title={featuredPost.data.title}
25
+ href={`/blog/${featuredPost.id}`}
26
+ date={featuredPost.data.date}
27
+ abstract={(featuredPost.data as Extended).abstract ?? featuredPost.data.description}
28
+ coverImage={(featuredPost.data as Extended).cover_image}
29
+ />
30
+ )}
31
+ {gridPosts.length > 0 && (
32
+ <section class="posts-grid-section">
33
+ <h2 class="section-heading">Recent Posts</h2>
34
+ <div class="posts-grid">
35
+ {gridPosts.map(post => (
36
+ <PostCard
37
+ title={post.data.title}
38
+ href={`/blog/${post.id}`}
39
+ date={post.data.date}
40
+ readingTime={post.data.reading_time}
41
+ abstract={(post.data as Extended).abstract ?? post.data.description}
42
+ coverImage={(post.data as Extended).cover_image}
43
+ />
44
+ ))}
45
+ </div>
46
+ <a href="/blog" class="view-all">All posts →</a>
47
+ </section>
48
+ )}
49
+ {allPosts.length === 0 && (
50
+ <div class="empty-state">
51
+ <p>No posts published yet.</p>
52
+ <p>Add a Markdown file to your vault's <code>blog/</code> folder with <code>publish: true</code>.</p>
53
+ </div>
54
+ )}
55
+ </Base>
56
+
@@ -0,0 +1,43 @@
1
+ ---
2
+ import { getCollection } from 'astro:content';
3
+ import Base from '@karaoke-cms/astro/layouts/Base.astro';
4
+ import { siteTitle } from 'virtual:karaoke-cms/config';
5
+
6
+ export async function getStaticPaths() {
7
+ const posts = await getCollection('blog', ({ data }) => data.publish === true);
8
+
9
+ const tags = new Set<string>();
10
+ for (const entry of posts) {
11
+ for (const tag of entry.data.tags ?? []) tags.add(tag);
12
+ }
13
+
14
+ return [...tags].map(tag => ({
15
+ params: { tag },
16
+ props: {
17
+ tag,
18
+ entries: posts
19
+ .filter(e => e.data.tags?.includes(tag))
20
+ .sort((a, b) => (b.data.date?.valueOf() ?? 0) - (a.data.date?.valueOf() ?? 0)),
21
+ },
22
+ }));
23
+ }
24
+
25
+ const { tag, entries } = Astro.props;
26
+ ---
27
+
28
+ <Base title={`#${tag} — ${siteTitle}`}>
29
+ <div class="listing-header">
30
+ <h1>#{tag}</h1>
31
+ <p><a href="/tags">← All tags</a></p>
32
+ </div>
33
+ <ul class="post-list">
34
+ {entries.map(entry => (
35
+ <li>
36
+ {entry.data.date && (
37
+ <span class="post-date">{entry.data.date.toISOString().slice(0, 10)}</span>
38
+ )}
39
+ <a href={`/blog/${entry.id}`}>{entry.data.title}</a>
40
+ </li>
41
+ ))}
42
+ </ul>
43
+ </Base>
@@ -0,0 +1,37 @@
1
+ ---
2
+ import { getCollection } from 'astro:content';
3
+ import Base from '@karaoke-cms/astro/layouts/Base.astro';
4
+ import { siteTitle } from 'virtual:karaoke-cms/config';
5
+
6
+ const posts = await getCollection('blog', ({ data }) => data.publish === true);
7
+
8
+ const counts = new Map<string, number>();
9
+ for (const entry of posts) {
10
+ for (const tag of entry.data.tags ?? []) {
11
+ counts.set(tag, (counts.get(tag) ?? 0) + 1);
12
+ }
13
+ }
14
+
15
+ const tags = [...counts.entries()].sort((a, b) => a[0].localeCompare(b[0]));
16
+ ---
17
+
18
+ <Base title={`Tags — ${siteTitle}`}>
19
+ <div class="listing-header">
20
+ <h1>Tags</h1>
21
+ </div>
22
+ {tags.length > 0 ? (
23
+ <ul class="tag-list">
24
+ {tags.map(([tag, count]) => (
25
+ <li>
26
+ <a href={`/tags/${tag}`}>{tag}</a>
27
+ <span class="tag-count">{count}</span>
28
+ </li>
29
+ ))}
30
+ </ul>
31
+ ) : (
32
+ <div class="empty-state">
33
+ <p>No tags yet.</p>
34
+ <p>Tags are added to posts via the <code>tags</code> frontmatter field.</p>
35
+ </div>
36
+ )}
37
+ </Base>
package/src/styles.css ADDED
@@ -0,0 +1,690 @@
1
+ /* @karaoke-cms/theme-blog — editorial layout with hero, card grid, cover images */
2
+
3
+ :root {
4
+ --font-body: system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
5
+ --font-mono: ui-monospace, 'Cascadia Code', 'Fira Code', monospace;
6
+
7
+ --font-size-base: 1.0625rem;
8
+ --font-size-sm: 0.875rem;
9
+ --font-size-lg: 1.375rem;
10
+ --font-size-xl: 2.25rem;
11
+
12
+ --line-height-body: 1.7;
13
+ --line-height-heading: 1.15;
14
+
15
+ --color-bg: #ffffff;
16
+ --color-text: #111111;
17
+ --color-muted: #6b7280;
18
+ --color-border: #e5e7eb;
19
+ --color-link: #1d4ed8;
20
+ --color-link-hover: #1e40af;
21
+ --color-link-visited: #6d28d9;
22
+ --color-card-bg: #f9fafb;
23
+ --color-hero-overlay: rgba(0, 0, 0, 0.52);
24
+
25
+ --width-content: 700px;
26
+ --width-site: 1100px;
27
+
28
+ --spacing-xs: 0.25rem;
29
+ --spacing-sm: 0.5rem;
30
+ --spacing-md: 1rem;
31
+ --spacing-lg: 2rem;
32
+ --spacing-xl: 4rem;
33
+
34
+ --radius-card: 8px;
35
+ --radius-sm: 4px;
36
+ }
37
+
38
+ @media (prefers-color-scheme: dark) {
39
+ :root {
40
+ --color-bg: #0a0a0a;
41
+ --color-text: #f3f4f6;
42
+ --color-muted: #9ca3af;
43
+ --color-border: #1f2937;
44
+ --color-link: #60a5fa;
45
+ --color-link-hover: #93c5fd;
46
+ --color-link-visited: #a78bfa;
47
+ --color-card-bg: #111827;
48
+ --color-hero-overlay: rgba(0, 0, 0, 0.65);
49
+ }
50
+ }
51
+
52
+ *, *::before, *::after {
53
+ box-sizing: border-box;
54
+ }
55
+
56
+ html {
57
+ font-size: var(--font-size-base);
58
+ }
59
+
60
+ body {
61
+ margin: 0;
62
+ background: var(--color-bg);
63
+ color: var(--color-text);
64
+ font-family: var(--font-body);
65
+ line-height: var(--line-height-body);
66
+ }
67
+
68
+ /* --- Header --- */
69
+
70
+ header {
71
+ border-bottom: 1px solid var(--color-border);
72
+ margin-bottom: var(--spacing-xl);
73
+ }
74
+
75
+ .header-inner {
76
+ max-width: var(--width-site);
77
+ margin: 0 auto;
78
+ padding: 0 var(--spacing-lg);
79
+ display: flex;
80
+ align-items: center;
81
+ height: 3.5rem;
82
+ gap: var(--spacing-md);
83
+ }
84
+
85
+ .site-name {
86
+ font-weight: 800;
87
+ font-size: 1.0625rem;
88
+ text-decoration: none;
89
+ color: var(--color-text);
90
+ flex-shrink: 0;
91
+ letter-spacing: -0.02em;
92
+ }
93
+
94
+ .site-name:hover {
95
+ color: var(--color-link);
96
+ }
97
+
98
+ header nav {
99
+ margin-left: auto;
100
+ }
101
+
102
+ header nav ul {
103
+ list-style: none;
104
+ margin: 0;
105
+ padding: 0;
106
+ display: flex;
107
+ gap: var(--spacing-lg);
108
+ }
109
+
110
+ header nav ul a {
111
+ text-decoration: none;
112
+ color: var(--color-muted);
113
+ font-size: var(--font-size-sm);
114
+ font-weight: 500;
115
+ padding: var(--spacing-xs) 0;
116
+ min-height: 44px;
117
+ display: flex;
118
+ align-items: center;
119
+ transition: color 0.1s;
120
+ }
121
+
122
+ header nav ul a:hover,
123
+ header nav ul a[aria-current="page"] {
124
+ color: var(--color-text);
125
+ }
126
+
127
+ /* --- Page body --- */
128
+
129
+ .page-body {
130
+ max-width: var(--width-site);
131
+ margin: 0 auto;
132
+ padding: 0 var(--spacing-lg);
133
+ min-height: calc(100vh - 16rem);
134
+ }
135
+
136
+ .page-body.has-left,
137
+ .page-body.has-right {
138
+ display: flex;
139
+ align-items: flex-start;
140
+ gap: var(--spacing-lg);
141
+ }
142
+
143
+ .page-body.has-left.has-right {
144
+ max-width: calc(var(--width-site) + 2 * (var(--width-sidebar, 220px) + var(--spacing-lg)));
145
+ }
146
+
147
+ main {
148
+ flex: 1;
149
+ min-width: 0;
150
+ }
151
+
152
+ .region-left,
153
+ .region-right {
154
+ width: var(--width-sidebar, 220px);
155
+ flex-shrink: 0;
156
+ padding-top: var(--spacing-xs);
157
+ }
158
+
159
+ /* --- Featured Hero --- */
160
+
161
+ .featured-hero {
162
+ display: block;
163
+ text-decoration: none;
164
+ position: relative;
165
+ min-height: 480px;
166
+ border-radius: var(--radius-card);
167
+ overflow: hidden;
168
+ margin-bottom: var(--spacing-xl);
169
+ background: var(--color-text);
170
+ background-image: var(--hero-bg, none);
171
+ background-size: cover;
172
+ background-position: center;
173
+ }
174
+
175
+ .featured-hero-overlay {
176
+ position: absolute;
177
+ inset: 0;
178
+ background: var(--color-hero-overlay);
179
+ display: flex;
180
+ align-items: flex-end;
181
+ }
182
+
183
+ .featured-hero-content {
184
+ padding: var(--spacing-xl) var(--spacing-xl);
185
+ max-width: 680px;
186
+ }
187
+
188
+ .featured-label {
189
+ display: inline-block;
190
+ font-size: var(--font-size-sm);
191
+ font-weight: 700;
192
+ text-transform: uppercase;
193
+ letter-spacing: 0.08em;
194
+ color: rgba(255, 255, 255, 0.7);
195
+ margin-bottom: var(--spacing-sm);
196
+ }
197
+
198
+ .featured-title {
199
+ font-size: 2rem;
200
+ font-weight: 800;
201
+ color: #ffffff;
202
+ line-height: var(--line-height-heading);
203
+ margin: 0 0 var(--spacing-sm);
204
+ letter-spacing: -0.02em;
205
+ }
206
+
207
+ .featured-hero:hover .featured-title {
208
+ text-decoration: underline;
209
+ text-underline-offset: 3px;
210
+ }
211
+
212
+ .featured-abstract {
213
+ font-size: var(--font-size-base);
214
+ color: rgba(255, 255, 255, 0.85);
215
+ margin: 0 0 var(--spacing-sm);
216
+ line-height: 1.6;
217
+ }
218
+
219
+ .featured-date {
220
+ font-size: var(--font-size-sm);
221
+ color: rgba(255, 255, 255, 0.6);
222
+ font-variant-numeric: tabular-nums;
223
+ }
224
+
225
+ /* --- Post card grid --- */
226
+
227
+ .section-heading {
228
+ font-size: var(--font-size-sm);
229
+ font-weight: 700;
230
+ text-transform: uppercase;
231
+ letter-spacing: 0.07em;
232
+ color: var(--color-muted);
233
+ margin: 0 0 var(--spacing-lg);
234
+ }
235
+
236
+ .posts-grid-section {
237
+ margin-bottom: var(--spacing-xl);
238
+ }
239
+
240
+ .posts-grid {
241
+ display: grid;
242
+ grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
243
+ gap: var(--spacing-lg);
244
+ margin-bottom: var(--spacing-lg);
245
+ }
246
+
247
+ .post-card {
248
+ display: flex;
249
+ flex-direction: column;
250
+ text-decoration: none;
251
+ color: inherit;
252
+ background: var(--color-card-bg);
253
+ border: 1px solid var(--color-border);
254
+ border-radius: var(--radius-card);
255
+ overflow: hidden;
256
+ transition: border-color 0.15s, box-shadow 0.15s;
257
+ }
258
+
259
+ .post-card:hover {
260
+ border-color: var(--color-link);
261
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
262
+ }
263
+
264
+ .card-cover {
265
+ height: 160px;
266
+ background-size: cover;
267
+ background-position: center;
268
+ background-color: var(--color-border);
269
+ }
270
+
271
+ .card-body {
272
+ padding: var(--spacing-md);
273
+ display: flex;
274
+ flex-direction: column;
275
+ flex: 1;
276
+ }
277
+
278
+ .card-title {
279
+ font-size: 1rem;
280
+ font-weight: 700;
281
+ margin: 0 0 var(--spacing-sm);
282
+ line-height: var(--line-height-heading);
283
+ color: var(--color-text);
284
+ }
285
+
286
+ .post-card:hover .card-title {
287
+ color: var(--color-link);
288
+ }
289
+
290
+ .card-abstract {
291
+ font-size: var(--font-size-sm);
292
+ color: var(--color-muted);
293
+ margin: 0 0 var(--spacing-sm);
294
+ line-height: 1.55;
295
+ flex: 1;
296
+ display: -webkit-box;
297
+ -webkit-line-clamp: 3;
298
+ -webkit-box-orient: vertical;
299
+ overflow: hidden;
300
+ }
301
+
302
+ .card-meta {
303
+ display: flex;
304
+ gap: var(--spacing-sm);
305
+ font-size: var(--font-size-sm);
306
+ color: var(--color-muted);
307
+ font-variant-numeric: tabular-nums;
308
+ margin-top: auto;
309
+ }
310
+
311
+ /* --- Footer --- */
312
+
313
+ footer {
314
+ border-top: 1px solid var(--color-border);
315
+ margin-top: var(--spacing-xl);
316
+ }
317
+
318
+ .footer-inner {
319
+ max-width: var(--width-site);
320
+ margin: 0 auto;
321
+ padding: var(--spacing-lg) var(--spacing-lg);
322
+ display: flex;
323
+ align-items: center;
324
+ justify-content: space-between;
325
+ gap: var(--spacing-md);
326
+ color: var(--color-muted);
327
+ font-size: var(--font-size-sm);
328
+ }
329
+
330
+ .footer-attr {
331
+ margin-left: auto;
332
+ }
333
+
334
+ footer a {
335
+ color: var(--color-muted);
336
+ text-decoration: none;
337
+ }
338
+
339
+ footer a:hover {
340
+ color: var(--color-link);
341
+ }
342
+
343
+ /* --- Typography --- */
344
+
345
+ h1, h2, h3, h4 {
346
+ line-height: var(--line-height-heading);
347
+ font-weight: 800;
348
+ letter-spacing: -0.02em;
349
+ margin-top: 0;
350
+ }
351
+
352
+ h1 { font-size: var(--font-size-xl); }
353
+ h2 { font-size: var(--font-size-lg); }
354
+
355
+ a {
356
+ color: var(--color-link);
357
+ }
358
+
359
+ a:hover {
360
+ color: var(--color-link-hover);
361
+ }
362
+
363
+ a:visited {
364
+ color: var(--color-link-visited);
365
+ }
366
+
367
+ code {
368
+ font-family: var(--font-mono);
369
+ font-size: 0.875em;
370
+ background: var(--color-border);
371
+ padding: 0.15em 0.35em;
372
+ border-radius: 3px;
373
+ }
374
+
375
+ pre {
376
+ background: var(--color-border);
377
+ padding: var(--spacing-md);
378
+ overflow-x: auto;
379
+ border-radius: 6px;
380
+ }
381
+
382
+ pre code {
383
+ background: none;
384
+ padding: 0;
385
+ }
386
+
387
+ /* --- Single post --- */
388
+
389
+ .post-header {
390
+ max-width: var(--width-content);
391
+ margin-bottom: var(--spacing-lg);
392
+ }
393
+
394
+ .post-cover {
395
+ width: 100%;
396
+ max-height: 400px;
397
+ object-fit: cover;
398
+ border-radius: var(--radius-card);
399
+ margin-bottom: var(--spacing-lg);
400
+ }
401
+
402
+ .post-header h1 {
403
+ font-size: var(--font-size-xl);
404
+ margin-bottom: var(--spacing-sm);
405
+ }
406
+
407
+ .post-meta {
408
+ color: var(--color-muted);
409
+ font-size: var(--font-size-sm);
410
+ font-variant-numeric: tabular-nums;
411
+ }
412
+
413
+ .prose {
414
+ max-width: var(--width-content);
415
+ }
416
+
417
+ .prose img {
418
+ max-width: 100%;
419
+ height: auto;
420
+ border-radius: 4px;
421
+ }
422
+
423
+ .prose p {
424
+ margin-top: 0;
425
+ margin-bottom: var(--spacing-md);
426
+ }
427
+
428
+ .post-footer {
429
+ margin-top: var(--spacing-xl);
430
+ padding-top: var(--spacing-md);
431
+ border-top: 1px solid var(--color-border);
432
+ max-width: var(--width-content);
433
+ }
434
+
435
+ /* --- Listing pages --- */
436
+
437
+ .listing-header {
438
+ margin-bottom: var(--spacing-lg);
439
+ }
440
+
441
+ .listing-header h1 {
442
+ font-size: var(--font-size-xl);
443
+ margin-bottom: 0;
444
+ }
445
+
446
+ /* Simple post list (fallback when no cover images) */
447
+
448
+ .post-list {
449
+ list-style: none;
450
+ margin: 0;
451
+ padding: 0;
452
+ }
453
+
454
+ .post-list li {
455
+ padding: var(--spacing-sm) 0;
456
+ border-bottom: 1px solid var(--color-border);
457
+ display: flex;
458
+ gap: var(--spacing-md);
459
+ align-items: baseline;
460
+ }
461
+
462
+ .post-list li:first-child {
463
+ border-top: 1px solid var(--color-border);
464
+ }
465
+
466
+ .post-date {
467
+ color: var(--color-muted);
468
+ font-size: var(--font-size-sm);
469
+ white-space: nowrap;
470
+ flex-shrink: 0;
471
+ font-variant-numeric: tabular-nums;
472
+ }
473
+
474
+ .empty-state {
475
+ color: var(--color-muted);
476
+ padding: var(--spacing-lg) 0;
477
+ }
478
+
479
+ .empty-state p {
480
+ margin: 0 0 var(--spacing-sm);
481
+ }
482
+
483
+ /* --- Tags --- */
484
+
485
+ .post-tags {
486
+ display: flex;
487
+ flex-wrap: wrap;
488
+ gap: var(--spacing-xs);
489
+ margin-bottom: var(--spacing-md);
490
+ }
491
+
492
+ .tag {
493
+ font-size: var(--font-size-sm);
494
+ color: var(--color-muted);
495
+ text-decoration: none;
496
+ border: 1px solid var(--color-border);
497
+ border-radius: 4px;
498
+ padding: 2px 8px;
499
+ transition: color 0.1s, border-color 0.1s;
500
+ }
501
+
502
+ .tag:hover {
503
+ color: var(--color-text);
504
+ border-color: var(--color-text);
505
+ }
506
+
507
+ .tag-list {
508
+ list-style: none;
509
+ padding: 0;
510
+ display: flex;
511
+ flex-direction: column;
512
+ gap: var(--spacing-xs);
513
+ }
514
+
515
+ .tag-list li {
516
+ display: flex;
517
+ align-items: center;
518
+ gap: var(--spacing-sm);
519
+ }
520
+
521
+ .tag-count {
522
+ font-size: var(--font-size-sm);
523
+ color: var(--color-muted);
524
+ }
525
+
526
+ /* --- Related posts --- */
527
+
528
+ .related-posts {
529
+ margin-top: var(--spacing-lg);
530
+ }
531
+
532
+ .related-label {
533
+ font-size: var(--font-size-sm);
534
+ text-transform: uppercase;
535
+ letter-spacing: 0.06em;
536
+ font-weight: 700;
537
+ color: var(--color-muted);
538
+ margin: 0 0 var(--spacing-sm);
539
+ }
540
+
541
+ .related-posts ul {
542
+ list-style: none;
543
+ padding: 0;
544
+ margin: 0;
545
+ display: flex;
546
+ flex-direction: column;
547
+ gap: var(--spacing-xs);
548
+ }
549
+
550
+ .view-all {
551
+ display: inline-block;
552
+ font-size: var(--font-size-sm);
553
+ font-weight: 600;
554
+ color: var(--color-link);
555
+ text-decoration: none;
556
+ }
557
+
558
+ .view-all:hover {
559
+ text-decoration: underline;
560
+ }
561
+
562
+ /* --- Sidebar --- */
563
+
564
+ .sidebar-section {
565
+ margin-bottom: var(--spacing-lg);
566
+ }
567
+
568
+ .sidebar-heading {
569
+ font-size: var(--font-size-sm);
570
+ text-transform: uppercase;
571
+ letter-spacing: 0.06em;
572
+ color: var(--color-muted);
573
+ font-weight: 700;
574
+ margin: 0 0 var(--spacing-sm);
575
+ }
576
+
577
+ .sidebar-list {
578
+ list-style: none;
579
+ margin: 0;
580
+ padding: 0;
581
+ display: flex;
582
+ flex-direction: column;
583
+ gap: var(--spacing-xs);
584
+ }
585
+
586
+ .sidebar-list a {
587
+ font-size: var(--font-size-sm);
588
+ text-decoration: none;
589
+ color: var(--color-text);
590
+ }
591
+
592
+ .sidebar-list a:hover {
593
+ color: var(--color-link);
594
+ }
595
+
596
+ /* --- Responsive --- */
597
+
598
+ @media (max-width: 768px) {
599
+ .header-inner {
600
+ padding: 0 var(--spacing-md);
601
+ }
602
+
603
+ .page-body {
604
+ padding: 0 var(--spacing-md);
605
+ }
606
+
607
+ .footer-inner {
608
+ padding: var(--spacing-lg) var(--spacing-md);
609
+ flex-direction: column;
610
+ align-items: flex-start;
611
+ gap: var(--spacing-sm);
612
+ }
613
+
614
+ .footer-attr {
615
+ margin-left: 0;
616
+ }
617
+
618
+ .featured-hero {
619
+ min-height: 320px;
620
+ }
621
+
622
+ .featured-hero-content {
623
+ padding: var(--spacing-lg);
624
+ }
625
+
626
+ .featured-title {
627
+ font-size: 1.5rem;
628
+ }
629
+
630
+ .posts-grid {
631
+ grid-template-columns: 1fr;
632
+ }
633
+
634
+ .page-body.has-left,
635
+ .page-body.has-right {
636
+ flex-direction: column;
637
+ }
638
+
639
+ .region-left,
640
+ .region-right {
641
+ width: 100%;
642
+ }
643
+ }
644
+
645
+ /* ── karaoke-menu ─────────────────────────────────────────────────────────── */
646
+
647
+ nav.karaoke-menu ul {
648
+ list-style: none;
649
+ padding: 0;
650
+ margin: 0;
651
+ }
652
+
653
+ /* Horizontal — flex row, CSS-only dropdown submenus */
654
+ nav.karaoke-menu[data-orientation="horizontal"] > ul {
655
+ display: flex;
656
+ gap: var(--spacing-md, 1rem);
657
+ }
658
+
659
+ nav.karaoke-menu[data-orientation="horizontal"] li {
660
+ position: relative;
661
+ }
662
+
663
+ nav.karaoke-menu[data-orientation="horizontal"] li ul {
664
+ display: none;
665
+ position: absolute;
666
+ top: 100%;
667
+ left: 0;
668
+ min-width: 10rem;
669
+ flex-direction: column;
670
+ background: var(--color-bg);
671
+ border: 1px solid var(--color-border);
672
+ border-radius: var(--radius-sm, 4px);
673
+ z-index: 100;
674
+ }
675
+
676
+ nav.karaoke-menu[data-orientation="horizontal"] li:hover ul,
677
+ nav.karaoke-menu[data-orientation="horizontal"] li:focus-within ul {
678
+ display: flex;
679
+ }
680
+
681
+ /* Vertical — flex column, submenus always visible and indented */
682
+ nav.karaoke-menu[data-orientation="vertical"] > ul {
683
+ display: flex;
684
+ flex-direction: column;
685
+ gap: var(--spacing-xs, 0.25rem);
686
+ }
687
+
688
+ nav.karaoke-menu[data-orientation="vertical"] li ul {
689
+ padding-left: var(--spacing-md, 1rem);
690
+ }