@karaoke-cms/theme-default 0.6.2 → 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,65 @@
1
+ # @karaoke-cms/theme-default
2
+
3
+ Two-column knowledge base theme for karaoke-cms. Includes blog, docs, and tags sections with a clean system-UI design.
4
+
5
+ ## Where it belongs
6
+
7
+ `packages/theme-default/` 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-default',
17
+ };
18
+ ```
19
+
20
+ `@karaoke-cms/astro` resolves the theme string to this package and loads it as a nested Astro integration at build time.
21
+
22
+ ## What it does
23
+
24
+ Injects all site routes and sets the `@theme` Vite alias so that `Base.astro` can resolve `@theme/styles.css`:
25
+
26
+ | Route | Page |
27
+ |-------|------|
28
+ | `/` | Home — recent blog posts + recent docs |
29
+ | `/blog` | Blog index — all published posts, sorted by date |
30
+ | `/blog/[slug]` | Blog post — full content with related posts |
31
+ | `/docs` | Docs index — all published docs, sorted alphabetically |
32
+ | `/docs/[slug]` | Doc page — full content |
33
+ | `/tags` | Tags index — all tags with post counts |
34
+ | `/tags/[tag]` | Tag page — posts filtered by tag |
35
+ | `/404` | Not found page |
36
+
37
+ ### Design system
38
+
39
+ `src/styles.css` defines all tokens as CSS variables on `:root`:
40
+
41
+ - **Typography**: `--font-body`, `--font-mono`, `--font-size-base`, `--font-size-sm/lg/xl`
42
+ - **Color**: `--color-bg`, `--color-text`, `--color-muted`, `--color-border`, `--color-link`, `--color-link-hover`, `--color-link-visited`
43
+ - **Spacing**: `--spacing-xs/sm/md/lg/xl`
44
+ - **Sizing**: `--width-content` (680px), `--width-site` (800px), `--radius-sm`
45
+
46
+ Dark mode is automatic via `@media (prefers-color-scheme: dark)` — no JavaScript toggle.
47
+
48
+ ## How to use
49
+
50
+ Install alongside `@karaoke-cms/astro`:
51
+
52
+ ```bash
53
+ npm install @karaoke-cms/astro @karaoke-cms/theme-default
54
+ ```
55
+
56
+ Set `theme: '@karaoke-cms/theme-default'` (or omit — it's the default) in `karaoke.config.ts`.
57
+
58
+ Your vault needs `blog/` and/or `docs/` directories with Markdown files. Only files with `publish: true` in frontmatter appear on the site.
59
+
60
+ ## How it changes the behavior of the system
61
+
62
+ - Provides the only routes that make up the site's public pages. Without a theme, nothing is served at `/`.
63
+ - The `@theme` alias means `Base.astro` and all pages resolve styles and components from this package at build time. Switching themes is a one-line change in `karaoke.config.ts`.
64
+ - Does not include `/docs` routes — that differentiates it from `theme-blog`. Suitable for mixed blog + documentation sites.
65
+ - The layout is driven by `Base.astro`'s region system — sidebar content (recent posts, search) is controlled by the `layout.regions` config in `karaoke.config.ts`, not hardcoded in the theme.
package/package.json CHANGED
@@ -1,13 +1,29 @@
1
1
  {
2
2
  "name": "@karaoke-cms/theme-default",
3
3
  "type": "module",
4
- "version": "0.6.2",
5
- "description": "Default theme for karaoke-cms",
4
+ "version": "0.9.0",
5
+ "description": "Default theme for karaoke-cms — two-column knowledge base with blog and docs",
6
+ "main": "./src/index.ts",
7
+ "exports": {
8
+ ".": "./src/index.ts"
9
+ },
10
+ "files": [
11
+ "src/"
12
+ ],
6
13
  "keywords": [
7
14
  "astro",
8
15
  "cms",
9
- "theme"
16
+ "theme",
17
+ "karaoke-cms"
10
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
+ },
11
27
  "scripts": {
12
28
  "test": "echo \"Stub — no tests\""
13
29
  }
package/src/index.ts ADDED
@@ -0,0 +1,31 @@
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 themeDefault(_config: KaraokeConfig): AstroIntegration {
8
+ return {
9
+ name: '@karaoke-cms/theme-default',
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: '/docs', entrypoint: `${__dirname}pages/docs/index.astro` });
16
+ injectRoute({ pattern: '/docs/[slug]', entrypoint: `${__dirname}pages/docs/[slug].astro` });
17
+ injectRoute({ pattern: '/tags', entrypoint: `${__dirname}pages/tags/index.astro` });
18
+ injectRoute({ pattern: '/tags/[tag]', entrypoint: `${__dirname}pages/tags/[tag].astro` });
19
+ injectRoute({ pattern: '/404', entrypoint: `${__dirname}pages/404.astro` });
20
+
21
+ updateConfig({
22
+ vite: {
23
+ resolve: {
24
+ alias: { '@theme': `${__dirname}` },
25
+ },
26
+ },
27
+ });
28
+ },
29
+ },
30
+ };
31
+ }
@@ -0,0 +1,14 @@
1
+ ---
2
+ import Base from '@karaoke-cms/astro/layouts/Base.astro';
3
+ import { siteTitle } from 'virtual:karaoke-cms/config';
4
+ ---
5
+
6
+ <Base title={`Page not found — ${siteTitle}`}>
7
+ <div class="post-header">
8
+ <h1>Page not found</h1>
9
+ <p class="post-meta">The page you're looking for doesn't exist or hasn't been published.</p>
10
+ </div>
11
+ <div class="prose">
12
+ <p><a href="/">Go home →</a></p>
13
+ </div>
14
+ </Base>
@@ -0,0 +1,66 @@
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
+ // Resolve related entries by ID across both collections
19
+ const relatedIds = entry.data.related ?? [];
20
+ const related = relatedIds.length > 0
21
+ ? (await Promise.all([
22
+ getCollection('blog', ({ data }) => data.publish === true),
23
+ getCollection('docs', ({ data }) => data.publish === true),
24
+ ]))
25
+ .flat()
26
+ .filter(e => relatedIds.includes(e.id))
27
+ : [];
28
+ ---
29
+
30
+ <Base title={`${entry.data.title} — ${siteTitle}`} description={entry.data.description} type="article">
31
+ <article>
32
+ <div class="post-header">
33
+ <h1>{entry.data.title}</h1>
34
+ <div class="post-meta">
35
+ {entry.data.date && <span>{entry.data.date.toISOString().slice(0, 10)}</span>}
36
+ {entry.data.author && entry.data.date && <span> · </span>}
37
+ {entry.data.author && (
38
+ <span>{Array.isArray(entry.data.author) ? entry.data.author.join(' · ') : entry.data.author}</span>
39
+ )}
40
+ {entry.data.reading_time && <span> · {entry.data.reading_time} min read</span>}
41
+ </div>
42
+ </div>
43
+ <div class="prose">
44
+ <Content />
45
+ </div>
46
+ <div class="post-footer">
47
+ {entry.data.tags && entry.data.tags.length > 0 && (
48
+ <div class="post-tags">
49
+ {entry.data.tags.map(tag => <a href={`/tags/${tag}`} class="tag">#{tag}</a>)}
50
+ </div>
51
+ )}
52
+ {related.length > 0 && (
53
+ <div class="related-posts">
54
+ <p class="related-label">Related</p>
55
+ <ul>
56
+ {related.map(r => (
57
+ <li><a href={`/${r.collection}/${r.id}`}>{r.data.title}</a></li>
58
+ ))}
59
+ </ul>
60
+ </div>
61
+ )}
62
+ <a href="/blog">← Blog</a>
63
+ </div>
64
+ </article>
65
+ <ModuleLoader comments={entry.data.comments} />
66
+ </Base>
@@ -0,0 +1,31 @@
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
+ .sort((a, b) => (b.data.date?.valueOf() ?? 0) - (a.data.date?.valueOf() ?? 0));
8
+ ---
9
+
10
+ <Base title={`Blog — ${siteTitle}`}>
11
+ <div class="listing-header">
12
+ <h1>Blog</h1>
13
+ </div>
14
+ {posts.length > 0 ? (
15
+ <ul class="post-list">
16
+ {posts.map(post => (
17
+ <li>
18
+ {post.data.date && (
19
+ <span class="post-date">{post.data.date.toISOString().slice(0, 10)}</span>
20
+ )}
21
+ <a href={`/blog/${post.id}`}>{post.data.title}</a>
22
+ </li>
23
+ ))}
24
+ </ul>
25
+ ) : (
26
+ <div class="empty-state">
27
+ <p>No posts published yet.</p>
28
+ <p>Create a Markdown file in your vault's <code>blog/</code> folder and set <code>publish: true</code> in the frontmatter to make it appear here.</p>
29
+ </div>
30
+ )}
31
+ </Base>
@@ -0,0 +1,66 @@
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 docs = await getCollection('docs', ({ data }) => data.publish === true);
9
+ return docs.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
+ // Resolve related entries by ID across both collections
19
+ const relatedIds = entry.data.related ?? [];
20
+ const related = relatedIds.length > 0
21
+ ? (await Promise.all([
22
+ getCollection('blog', ({ data }) => data.publish === true),
23
+ getCollection('docs', ({ data }) => data.publish === true),
24
+ ]))
25
+ .flat()
26
+ .filter(e => relatedIds.includes(e.id))
27
+ : [];
28
+ ---
29
+
30
+ <Base title={`${entry.data.title} — ${siteTitle}`} description={entry.data.description} type="article">
31
+ <article>
32
+ <div class="post-header">
33
+ <h1>{entry.data.title}</h1>
34
+ <div class="post-meta">
35
+ {entry.data.date && <span>{entry.data.date.toISOString().slice(0, 10)}</span>}
36
+ {entry.data.author && entry.data.date && <span> · </span>}
37
+ {entry.data.author && (
38
+ <span>{Array.isArray(entry.data.author) ? entry.data.author.join(' · ') : entry.data.author}</span>
39
+ )}
40
+ {entry.data.reading_time && <span> · {entry.data.reading_time} min read</span>}
41
+ </div>
42
+ </div>
43
+ <div class="prose">
44
+ <Content />
45
+ </div>
46
+ <div class="post-footer">
47
+ {entry.data.tags && entry.data.tags.length > 0 && (
48
+ <div class="post-tags">
49
+ {entry.data.tags.map(tag => <a href={`/tags/${tag}`} class="tag">#{tag}</a>)}
50
+ </div>
51
+ )}
52
+ {related.length > 0 && (
53
+ <div class="related-posts">
54
+ <p class="related-label">Related</p>
55
+ <ul>
56
+ {related.map(r => (
57
+ <li><a href={`/${r.collection}/${r.id}`}>{r.data.title}</a></li>
58
+ ))}
59
+ </ul>
60
+ </div>
61
+ )}
62
+ <a href="/docs">← Docs</a>
63
+ </div>
64
+ </article>
65
+ <ModuleLoader comments={entry.data.comments} />
66
+ </Base>
@@ -0,0 +1,31 @@
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 docs = (await getCollection('docs', ({ data }) => data.publish === true))
7
+ .sort((a, b) => a.data.title.localeCompare(b.data.title));
8
+ ---
9
+
10
+ <Base title={`Docs — ${siteTitle}`}>
11
+ <div class="listing-header">
12
+ <h1>Docs</h1>
13
+ </div>
14
+ {docs.length > 0 ? (
15
+ <ul class="post-list">
16
+ {docs.map(doc => (
17
+ <li>
18
+ {doc.data.date && (
19
+ <span class="post-date">{doc.data.date.toISOString().slice(0, 10)}</span>
20
+ )}
21
+ <a href={`/docs/${doc.id}`}>{doc.data.title}</a>
22
+ </li>
23
+ ))}
24
+ </ul>
25
+ ) : (
26
+ <div class="empty-state">
27
+ <p>No docs published yet.</p>
28
+ <p>Create a Markdown file in your vault's <code>docs/</code> folder and set <code>publish: true</code> in the frontmatter to make it appear here.</p>
29
+ </div>
30
+ )}
31
+ </Base>
@@ -0,0 +1,65 @@
1
+ ---
2
+ import { getCollection } from 'astro:content';
3
+ import Base from '@karaoke-cms/astro/layouts/Base.astro';
4
+ import { siteTitle, siteDescription } from 'virtual:karaoke-cms/config';
5
+
6
+ const posts = (await getCollection('blog', ({ data }) => data.publish === true))
7
+ .sort((a, b) => (b.data.date?.valueOf() ?? 0) - (a.data.date?.valueOf() ?? 0))
8
+ .slice(0, 5);
9
+
10
+ const docs = (await getCollection('docs', ({ data }) => data.publish === true))
11
+ .sort((a, b) => a.data.title.localeCompare(b.data.title))
12
+ .slice(0, 5);
13
+ ---
14
+
15
+ <Base title={siteTitle} description={siteDescription}>
16
+ <div class="home-grid">
17
+ <section class="home-section">
18
+ <h2>Blog</h2>
19
+ {posts.length > 0 ? (
20
+ <>
21
+ <ul class="post-list">
22
+ {posts.map(post => (
23
+ <li>
24
+ <a href={`/blog/${post.id}`}>{post.data.title}</a>
25
+ {post.data.date && (
26
+ <span class="post-date">{post.data.date.toISOString().slice(0, 10)}</span>
27
+ )}
28
+ </li>
29
+ ))}
30
+ </ul>
31
+ <a href="/blog" class="view-all">View all →</a>
32
+ </>
33
+ ) : (
34
+ <div class="empty-state">
35
+ <p>No posts yet.</p>
36
+ <p>Add a Markdown file to <code>blog/</code> with <code>publish: true</code> in the frontmatter.</p>
37
+ </div>
38
+ )}
39
+ </section>
40
+
41
+ <section class="home-section">
42
+ <h2>Docs</h2>
43
+ {docs.length > 0 ? (
44
+ <>
45
+ <ul class="post-list">
46
+ {docs.map(doc => (
47
+ <li>
48
+ <a href={`/docs/${doc.id}`}>{doc.data.title}</a>
49
+ {doc.data.date && (
50
+ <span class="post-date">{doc.data.date.toISOString().slice(0, 10)}</span>
51
+ )}
52
+ </li>
53
+ ))}
54
+ </ul>
55
+ <a href="/docs" class="view-all">View all →</a>
56
+ </>
57
+ ) : (
58
+ <div class="empty-state">
59
+ <p>No docs yet.</p>
60
+ <p>Add a Markdown file to <code>docs/</code> with <code>publish: true</code> in the frontmatter.</p>
61
+ </div>
62
+ )}
63
+ </section>
64
+ </div>
65
+ </Base>
@@ -0,0 +1,53 @@
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 [blog, docs] = await Promise.all([
8
+ getCollection('blog', ({ data }) => data.publish === true),
9
+ getCollection('docs', ({ data }) => data.publish === true),
10
+ ]);
11
+
12
+ // Collect all unique tags
13
+ const tags = new Set<string>();
14
+ for (const entry of [...blog, ...docs]) {
15
+ for (const tag of entry.data.tags ?? []) tags.add(tag);
16
+ }
17
+
18
+ return [...tags].map(tag => ({
19
+ params: { tag },
20
+ props: {
21
+ tag,
22
+ entries: [...blog, ...docs]
23
+ .filter(e => e.data.tags?.includes(tag))
24
+ .sort((a, b) => (b.data.date?.valueOf() ?? 0) - (a.data.date?.valueOf() ?? 0)),
25
+ },
26
+ }));
27
+ }
28
+
29
+ const { tag, entries } = Astro.props;
30
+
31
+ // Determine the URL prefix for each entry by its collection
32
+ function href(entry: { collection: string; id: string }) {
33
+ return `/${entry.collection}/${entry.id}`;
34
+ }
35
+ ---
36
+
37
+ <Base title={`#${tag} — ${siteTitle}`}>
38
+ <div class="listing-header">
39
+ <h1>#{tag}</h1>
40
+ <p><a href="/tags">← All tags</a></p>
41
+ </div>
42
+ <ul class="post-list">
43
+ {entries.map(entry => (
44
+ <li>
45
+ {entry.data.date && (
46
+ <span class="post-date">{entry.data.date.toISOString().slice(0, 10)}</span>
47
+ )}
48
+ <a href={href(entry)}>{entry.data.title}</a>
49
+ <span class="post-collection">{entry.collection}</span>
50
+ </li>
51
+ ))}
52
+ </ul>
53
+ </Base>
@@ -0,0 +1,41 @@
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 [blog, docs] = await Promise.all([
7
+ getCollection('blog', ({ data }) => data.publish === true),
8
+ getCollection('docs', ({ data }) => data.publish === true),
9
+ ]);
10
+
11
+ // Count occurrences of each tag across both collections
12
+ const counts = new Map<string, number>();
13
+ for (const entry of [...blog, ...docs]) {
14
+ for (const tag of entry.data.tags ?? []) {
15
+ counts.set(tag, (counts.get(tag) ?? 0) + 1);
16
+ }
17
+ }
18
+
19
+ const tags = [...counts.entries()].sort((a, b) => a[0].localeCompare(b[0]));
20
+ ---
21
+
22
+ <Base title={`Tags — ${siteTitle}`}>
23
+ <div class="listing-header">
24
+ <h1>Tags</h1>
25
+ </div>
26
+ {tags.length > 0 ? (
27
+ <ul class="tag-list">
28
+ {tags.map(([tag, count]) => (
29
+ <li>
30
+ <a href={`/tags/${tag}`}>{tag}</a>
31
+ <span class="tag-count">{count}</span>
32
+ </li>
33
+ ))}
34
+ </ul>
35
+ ) : (
36
+ <div class="empty-state">
37
+ <p>No tags yet.</p>
38
+ <p>Tags are added to posts via the <code>tags</code> frontmatter field or by AI enrichment.</p>
39
+ </div>
40
+ )}
41
+ </Base>
package/src/styles.css ADDED
@@ -0,0 +1,533 @@
1
+ /* @karaoke-cms/theme-default — system UI, clean two-column layout */
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.25rem;
10
+ --font-size-xl: 1.875rem;
11
+
12
+ --line-height-body: 1.7;
13
+ --line-height-heading: 1.2;
14
+
15
+ --color-bg: #fafaf9;
16
+ --color-text: #1a1a1a;
17
+ --color-muted: #737373;
18
+ --color-border: #e5e5e5;
19
+ --color-link: #0066cc;
20
+ --color-link-hover: #004499;
21
+ --color-link-visited: #551a8b;
22
+
23
+ --width-content: 680px;
24
+ --width-site: 800px;
25
+
26
+ --spacing-xs: 0.25rem;
27
+ --spacing-sm: 0.5rem;
28
+ --spacing-md: 1rem;
29
+ --spacing-lg: 2rem;
30
+ --spacing-xl: 4rem;
31
+
32
+ --radius-sm: 4px;
33
+ }
34
+
35
+ @media (prefers-color-scheme: dark) {
36
+ :root {
37
+ --color-bg: #0f0f0f;
38
+ --color-text: #e5e5e5;
39
+ --color-muted: #9ca3af;
40
+ --color-border: #262626;
41
+ --color-link: #60a5fa;
42
+ --color-link-hover: #93c5fd;
43
+ --color-link-visited: #a78bfa;
44
+ }
45
+ }
46
+
47
+ *, *::before, *::after {
48
+ box-sizing: border-box;
49
+ }
50
+
51
+ html {
52
+ font-size: var(--font-size-base);
53
+ }
54
+
55
+ body {
56
+ margin: 0;
57
+ background: var(--color-bg);
58
+ color: var(--color-text);
59
+ font-family: var(--font-body);
60
+ line-height: var(--line-height-body);
61
+ }
62
+
63
+ /* --- Header --- */
64
+
65
+ header {
66
+ border-bottom: 2px solid var(--color-border);
67
+ margin-bottom: var(--spacing-xl);
68
+ }
69
+
70
+ .header-inner {
71
+ max-width: var(--width-site);
72
+ margin: 0 auto;
73
+ padding: 0 var(--spacing-md);
74
+ display: flex;
75
+ align-items: center;
76
+ height: 3.25rem;
77
+ gap: var(--spacing-md);
78
+ }
79
+
80
+ .site-name {
81
+ font-weight: 700;
82
+ font-size: 1rem;
83
+ text-decoration: none;
84
+ color: var(--color-text);
85
+ flex-shrink: 0;
86
+ letter-spacing: -0.01em;
87
+ }
88
+
89
+ .site-name:hover {
90
+ color: var(--color-link);
91
+ }
92
+
93
+ header nav {
94
+ margin-left: auto;
95
+ }
96
+
97
+ header nav ul {
98
+ list-style: none;
99
+ margin: 0;
100
+ padding: 0;
101
+ display: flex;
102
+ gap: var(--spacing-md);
103
+ }
104
+
105
+ header nav ul a {
106
+ text-decoration: none;
107
+ color: var(--color-muted);
108
+ font-size: var(--font-size-sm);
109
+ padding: var(--spacing-xs) 0;
110
+ min-height: 44px;
111
+ display: flex;
112
+ align-items: center;
113
+ transition: color 0.1s;
114
+ }
115
+
116
+ header nav ul a:hover,
117
+ header nav ul a[aria-current="page"] {
118
+ color: var(--color-text);
119
+ }
120
+
121
+ /* --- Page body (left + main + right regions) --- */
122
+
123
+ .page-body {
124
+ display: flex;
125
+ align-items: flex-start;
126
+ max-width: var(--width-site);
127
+ margin: 0 auto;
128
+ padding: 0 var(--spacing-md);
129
+ min-height: calc(100vh - 14rem);
130
+ gap: var(--spacing-lg);
131
+ }
132
+
133
+ .page-body.has-left,
134
+ .page-body.has-right {
135
+ max-width: calc(var(--width-site) + var(--width-sidebar, 220px) + var(--spacing-lg));
136
+ }
137
+
138
+ .page-body.has-left.has-right {
139
+ max-width: calc(var(--width-site) + 2 * (var(--width-sidebar, 220px) + var(--spacing-lg)));
140
+ }
141
+
142
+ main {
143
+ flex: 1;
144
+ min-width: 0;
145
+ }
146
+
147
+ .region-left,
148
+ .region-right {
149
+ width: var(--width-sidebar, 220px);
150
+ flex-shrink: 0;
151
+ padding-top: var(--spacing-xs);
152
+ }
153
+
154
+ /* --- Sidebar --- */
155
+
156
+ .sidebar-section {
157
+ margin-bottom: var(--spacing-lg);
158
+ }
159
+
160
+ .sidebar-heading {
161
+ font-size: var(--font-size-sm);
162
+ text-transform: uppercase;
163
+ letter-spacing: 0.06em;
164
+ color: var(--color-muted);
165
+ font-weight: 600;
166
+ margin: 0 0 var(--spacing-sm);
167
+ }
168
+
169
+ .sidebar-list {
170
+ list-style: none;
171
+ margin: 0;
172
+ padding: 0;
173
+ display: flex;
174
+ flex-direction: column;
175
+ gap: var(--spacing-xs);
176
+ }
177
+
178
+ .sidebar-list li {
179
+ display: flex;
180
+ flex-direction: column;
181
+ gap: 2px;
182
+ }
183
+
184
+ .sidebar-list a {
185
+ font-size: var(--font-size-sm);
186
+ text-decoration: none;
187
+ color: var(--color-text);
188
+ }
189
+
190
+ .sidebar-list a:hover {
191
+ color: var(--color-link);
192
+ }
193
+
194
+ /* --- Footer --- */
195
+
196
+ footer {
197
+ border-top: 1px solid var(--color-border);
198
+ margin-top: var(--spacing-xl);
199
+ }
200
+
201
+ .footer-inner {
202
+ max-width: var(--width-site);
203
+ margin: 0 auto;
204
+ padding: var(--spacing-lg) var(--spacing-md);
205
+ display: flex;
206
+ align-items: center;
207
+ justify-content: space-between;
208
+ gap: var(--spacing-md);
209
+ color: var(--color-muted);
210
+ font-size: var(--font-size-sm);
211
+ }
212
+
213
+ .footer-attr {
214
+ margin-left: auto;
215
+ }
216
+
217
+ footer a {
218
+ color: var(--color-muted);
219
+ text-decoration: none;
220
+ }
221
+
222
+ footer a:hover {
223
+ color: var(--color-link);
224
+ }
225
+
226
+ /* --- Typography --- */
227
+
228
+ h1, h2, h3, h4 {
229
+ line-height: var(--line-height-heading);
230
+ font-weight: 700;
231
+ margin-top: 0;
232
+ }
233
+
234
+ h1 { font-size: var(--font-size-xl); }
235
+ h2 { font-size: var(--font-size-lg); }
236
+
237
+ a {
238
+ color: var(--color-link);
239
+ }
240
+
241
+ a:hover {
242
+ color: var(--color-link-hover);
243
+ }
244
+
245
+ a:visited {
246
+ color: var(--color-link-visited);
247
+ }
248
+
249
+ code {
250
+ font-family: var(--font-mono);
251
+ font-size: 0.875em;
252
+ background: var(--color-border);
253
+ padding: 0.15em 0.35em;
254
+ border-radius: 3px;
255
+ }
256
+
257
+ pre {
258
+ background: var(--color-border);
259
+ padding: var(--spacing-md);
260
+ overflow-x: auto;
261
+ border-radius: 6px;
262
+ }
263
+
264
+ pre code {
265
+ background: none;
266
+ padding: 0;
267
+ }
268
+
269
+ /* --- Post/doc prose --- */
270
+
271
+ .prose {
272
+ max-width: var(--width-content);
273
+ }
274
+
275
+ .prose img {
276
+ max-width: 100%;
277
+ height: auto;
278
+ border-radius: 4px;
279
+ }
280
+
281
+ .prose p {
282
+ margin-top: 0;
283
+ margin-bottom: var(--spacing-md);
284
+ }
285
+
286
+ /* --- Listing pages --- */
287
+
288
+ .listing-header {
289
+ margin-bottom: var(--spacing-lg);
290
+ }
291
+
292
+ .listing-header h1 {
293
+ font-size: var(--font-size-xl);
294
+ margin-bottom: 0;
295
+ }
296
+
297
+ .post-list {
298
+ list-style: none;
299
+ margin: 0;
300
+ padding: 0;
301
+ }
302
+
303
+ .post-list li {
304
+ padding: var(--spacing-sm) 0;
305
+ border-bottom: 1px solid var(--color-border);
306
+ display: flex;
307
+ gap: var(--spacing-md);
308
+ align-items: baseline;
309
+ }
310
+
311
+ .post-list li:first-child {
312
+ border-top: 1px solid var(--color-border);
313
+ }
314
+
315
+ .post-date {
316
+ color: var(--color-muted);
317
+ font-size: var(--font-size-sm);
318
+ white-space: nowrap;
319
+ flex-shrink: 0;
320
+ font-variant-numeric: tabular-nums;
321
+ }
322
+
323
+ .empty-state {
324
+ color: var(--color-muted);
325
+ padding: var(--spacing-lg) 0;
326
+ }
327
+
328
+ .empty-state p {
329
+ margin: 0 0 var(--spacing-sm);
330
+ }
331
+
332
+ /* --- Homepage two-column --- */
333
+
334
+ .home-grid {
335
+ display: grid;
336
+ grid-template-columns: 1fr 1fr;
337
+ gap: var(--spacing-xl);
338
+ }
339
+
340
+ .home-section h2 {
341
+ font-size: var(--font-size-sm);
342
+ text-transform: uppercase;
343
+ letter-spacing: 0.06em;
344
+ font-weight: 600;
345
+ color: var(--color-muted);
346
+ margin-bottom: var(--spacing-md);
347
+ }
348
+
349
+ .home-section .post-list li {
350
+ flex-direction: column;
351
+ gap: var(--spacing-xs);
352
+ }
353
+
354
+ .view-all {
355
+ display: inline-block;
356
+ margin-top: var(--spacing-md);
357
+ font-size: var(--font-size-sm);
358
+ color: var(--color-muted);
359
+ text-decoration: none;
360
+ }
361
+
362
+ .view-all:hover {
363
+ color: var(--color-link);
364
+ }
365
+
366
+ @media (max-width: 640px) {
367
+ .home-grid {
368
+ grid-template-columns: 1fr;
369
+ gap: var(--spacing-lg);
370
+ }
371
+
372
+ .page-body {
373
+ flex-direction: column;
374
+ }
375
+
376
+ .region-left,
377
+ .region-right {
378
+ width: 100%;
379
+ }
380
+
381
+ .footer-inner {
382
+ flex-direction: column;
383
+ align-items: flex-start;
384
+ gap: var(--spacing-sm);
385
+ }
386
+
387
+ .footer-attr {
388
+ margin-left: 0;
389
+ }
390
+ }
391
+
392
+ /* --- Single post/doc --- */
393
+
394
+ .post-header {
395
+ max-width: var(--width-content);
396
+ margin-bottom: var(--spacing-lg);
397
+ padding-bottom: var(--spacing-md);
398
+ border-bottom: 1px solid var(--color-border);
399
+ }
400
+
401
+ .post-header h1 {
402
+ font-size: var(--font-size-xl);
403
+ margin-bottom: var(--spacing-sm);
404
+ }
405
+
406
+ .post-meta {
407
+ color: var(--color-muted);
408
+ font-size: var(--font-size-sm);
409
+ }
410
+
411
+ .post-footer {
412
+ margin-top: var(--spacing-xl);
413
+ padding-top: var(--spacing-md);
414
+ border-top: 1px solid var(--color-border);
415
+ max-width: var(--width-content);
416
+ }
417
+
418
+ .post-tags {
419
+ display: flex;
420
+ flex-wrap: wrap;
421
+ gap: var(--spacing-xs);
422
+ margin-bottom: var(--spacing-md);
423
+ }
424
+
425
+ .tag {
426
+ font-size: var(--font-size-sm);
427
+ color: var(--color-muted);
428
+ text-decoration: none;
429
+ border: 1px solid var(--color-border);
430
+ border-radius: 3px;
431
+ padding: 2px 8px;
432
+ transition: color 0.1s, border-color 0.1s;
433
+ }
434
+
435
+ .tag:hover {
436
+ color: var(--color-text);
437
+ border-color: var(--color-text);
438
+ }
439
+
440
+ .tag-list {
441
+ list-style: none;
442
+ padding: 0;
443
+ display: flex;
444
+ flex-direction: column;
445
+ gap: var(--spacing-xs);
446
+ }
447
+
448
+ .tag-list li {
449
+ display: flex;
450
+ align-items: center;
451
+ gap: var(--spacing-sm);
452
+ }
453
+
454
+ .tag-count {
455
+ font-size: var(--font-size-sm);
456
+ color: var(--color-muted);
457
+ }
458
+
459
+ .post-collection {
460
+ font-size: var(--font-size-sm);
461
+ color: var(--color-muted);
462
+ }
463
+
464
+ /* --- Related posts --- */
465
+
466
+ .related-posts {
467
+ margin-top: var(--spacing-lg);
468
+ }
469
+
470
+ .related-label {
471
+ font-size: var(--font-size-sm);
472
+ text-transform: uppercase;
473
+ letter-spacing: 0.06em;
474
+ font-weight: 600;
475
+ color: var(--color-muted);
476
+ margin: 0 0 var(--spacing-sm);
477
+ }
478
+
479
+ .related-posts ul {
480
+ list-style: none;
481
+ padding: 0;
482
+ margin: 0;
483
+ display: flex;
484
+ flex-direction: column;
485
+ gap: var(--spacing-xs);
486
+ }
487
+
488
+ /* ── karaoke-menu ─────────────────────────────────────────────────────────── */
489
+
490
+ nav.karaoke-menu ul {
491
+ list-style: none;
492
+ padding: 0;
493
+ margin: 0;
494
+ }
495
+
496
+ /* Horizontal — flex row, CSS-only dropdown submenus */
497
+ nav.karaoke-menu[data-orientation="horizontal"] > ul {
498
+ display: flex;
499
+ gap: var(--spacing-md, 1rem);
500
+ }
501
+
502
+ nav.karaoke-menu[data-orientation="horizontal"] li {
503
+ position: relative;
504
+ }
505
+
506
+ nav.karaoke-menu[data-orientation="horizontal"] li ul {
507
+ display: none;
508
+ position: absolute;
509
+ top: 100%;
510
+ left: 0;
511
+ min-width: 10rem;
512
+ flex-direction: column;
513
+ background: var(--color-bg);
514
+ border: 1px solid var(--color-border);
515
+ border-radius: var(--radius-sm, 4px);
516
+ z-index: 100;
517
+ }
518
+
519
+ nav.karaoke-menu[data-orientation="horizontal"] li:hover ul,
520
+ nav.karaoke-menu[data-orientation="horizontal"] li:focus-within ul {
521
+ display: flex;
522
+ }
523
+
524
+ /* Vertical — flex column, submenus always visible and indented */
525
+ nav.karaoke-menu[data-orientation="vertical"] > ul {
526
+ display: flex;
527
+ flex-direction: column;
528
+ gap: var(--spacing-xs, 0.25rem);
529
+ }
530
+
531
+ nav.karaoke-menu[data-orientation="vertical"] li ul {
532
+ padding-left: var(--spacing-md, 1rem);
533
+ }