@hutusi/amytis 1.7.0 → 1.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.
Files changed (83) hide show
  1. package/.github/workflows/ci.yml +1 -1
  2. package/CHANGELOG.md +63 -0
  3. package/CLAUDE.md +9 -18
  4. package/GEMINI.md +6 -0
  5. package/README.md +44 -0
  6. package/TODO.md +15 -3
  7. package/bun.lock +5 -3
  8. package/content/about.mdx +64 -10
  9. package/content/about.zh.mdx +66 -9
  10. package/content/books/sample-book/index.mdx +3 -3
  11. package/content/flows/2026/02/05.md +0 -1
  12. package/content/flows/2026/02/10.mdx +2 -1
  13. package/content/flows/2026/02/15.md +2 -1
  14. package/content/flows/2026/02/18.mdx +2 -1
  15. package/content/flows/2026/02/20.md +0 -1
  16. package/content/notes/algorithms-and-data-structures.mdx +51 -0
  17. package/content/notes/digital-garden-philosophy.mdx +36 -0
  18. package/content/notes/react-server-components.mdx +49 -0
  19. package/content/notes/tailwind-v4.mdx +45 -0
  20. package/content/notes/zettelkasten-method.mdx +33 -0
  21. package/content/series/digital-garden/01-philosophy.mdx +25 -12
  22. package/docs/ARCHITECTURE.md +9 -1
  23. package/docs/CONTRIBUTING.md +26 -0
  24. package/docs/DIGITAL_GARDEN.md +72 -0
  25. package/imports/README.md +45 -0
  26. package/package.json +12 -5
  27. package/scripts/generate-knowledge-graph.ts +162 -0
  28. package/scripts/import-book.ts +176 -0
  29. package/scripts/new-flow-from-chat.ts +238 -0
  30. package/scripts/new-flow.ts +0 -5
  31. package/scripts/new-note.ts +53 -0
  32. package/scripts/sync-book-chapters.ts +210 -0
  33. package/site.config.ts +30 -7
  34. package/src/app/authors/[author]/page.tsx +3 -1
  35. package/src/app/books/[slug]/[chapter]/page.tsx +2 -1
  36. package/src/app/books/[slug]/page.tsx +6 -5
  37. package/src/app/flows/[year]/[month]/[day]/page.tsx +35 -29
  38. package/src/app/flows/[year]/[month]/page.tsx +18 -13
  39. package/src/app/flows/[year]/page.tsx +25 -15
  40. package/src/app/flows/page/[page]/page.tsx +5 -9
  41. package/src/app/flows/page.tsx +5 -8
  42. package/src/app/globals.css +41 -0
  43. package/src/app/graph/page.tsx +21 -0
  44. package/src/app/layout.tsx +4 -2
  45. package/src/app/notes/[slug]/page.tsx +129 -0
  46. package/src/app/notes/page/[page]/page.tsx +60 -0
  47. package/src/app/notes/page.tsx +33 -0
  48. package/src/app/page/[page]/page.tsx +1 -0
  49. package/src/app/page.tsx +4 -5
  50. package/src/app/posts/[slug]/page.tsx +5 -2
  51. package/src/app/posts/page/[page]/page.tsx +4 -1
  52. package/src/app/search.json/route.ts +17 -3
  53. package/src/app/series/[slug]/page/[page]/page.tsx +1 -0
  54. package/src/app/series/[slug]/page.tsx +3 -3
  55. package/src/app/sitemap.ts +1 -1
  56. package/src/app/tags/[tag]/page.tsx +3 -3
  57. package/src/components/Backlinks.tsx +39 -0
  58. package/src/components/BookMobileNav.tsx +11 -11
  59. package/src/components/BookSidebar.tsx +17 -25
  60. package/src/components/BrowserDetectionBanner.tsx +96 -0
  61. package/src/components/FeaturedStoriesSection.tsx +1 -1
  62. package/src/components/FlowCalendarSidebar.tsx +4 -2
  63. package/src/components/FlowContent.tsx +4 -3
  64. package/src/components/FlowHubTabs.tsx +50 -0
  65. package/src/components/FlowTimelineEntry.tsx +7 -9
  66. package/src/components/KnowledgeGraph.tsx +324 -0
  67. package/src/components/LanguageProvider.tsx +14 -5
  68. package/src/components/MarkdownRenderer.tsx +13 -2
  69. package/src/components/Navbar.tsx +237 -10
  70. package/src/components/NoteContent.tsx +123 -0
  71. package/src/components/NoteSidebar.tsx +132 -0
  72. package/src/components/RecentNotesSection.tsx +6 -11
  73. package/src/components/Search.tsx +7 -3
  74. package/src/components/TagContentTabs.tsx +0 -1
  75. package/src/i18n/translations.ts +43 -17
  76. package/src/layouts/BookLayout.tsx +3 -3
  77. package/src/layouts/PostLayout.tsx +8 -3
  78. package/src/lib/i18n.ts +83 -6
  79. package/src/lib/markdown.ts +306 -19
  80. package/src/lib/remark-wikilinks.ts +59 -0
  81. package/src/lib/search-utils.ts +2 -1
  82. package/tests/unit/static-params.test.ts +238 -0
  83. package/content/series/digital-garden/01-philosophy/index.mdx +0 -23
@@ -17,7 +17,7 @@ export default function BookMobileNav({ bookSlug, bookTitle, toc, chapters, curr
17
17
  const { t } = useLanguage();
18
18
  const [isExpanded, setIsExpanded] = useState(false);
19
19
 
20
- const currentIndex = chapters.findIndex(ch => ch.file === currentChapter);
20
+ const currentIndex = chapters.findIndex(ch => ch.id === currentChapter);
21
21
  const prevChapter = currentIndex > 0 ? chapters[currentIndex - 1] : null;
22
22
  const nextChapter = currentIndex < chapters.length - 1 ? chapters[currentIndex + 1] : null;
23
23
 
@@ -51,7 +51,7 @@ export default function BookMobileNav({ bookSlug, bookTitle, toc, chapters, curr
51
51
  <div className="flex gap-3 mb-3">
52
52
  {prevChapter ? (
53
53
  <Link
54
- href={`/books/${bookSlug}/${prevChapter.file}`}
54
+ href={`/books/${bookSlug}/${prevChapter.id}`}
55
55
  className="flex-1 flex items-center gap-2 py-2.5 px-3 rounded-lg bg-muted/5 hover:bg-muted/10 no-underline transition-colors group"
56
56
  >
57
57
  <svg className="w-4 h-4 flex-shrink-0 text-muted group-hover:text-accent transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
@@ -67,7 +67,7 @@ export default function BookMobileNav({ bookSlug, bookTitle, toc, chapters, curr
67
67
  )}
68
68
  {nextChapter ? (
69
69
  <Link
70
- href={`/books/${bookSlug}/${nextChapter.file}`}
70
+ href={`/books/${bookSlug}/${nextChapter.id}`}
71
71
  className="flex-1 flex items-center justify-end gap-2 py-2.5 px-3 rounded-lg bg-muted/5 hover:bg-muted/10 no-underline transition-colors group text-right"
72
72
  >
73
73
  <div className="min-w-0">
@@ -113,19 +113,19 @@ export default function BookMobileNav({ bookSlug, bookTitle, toc, chapters, curr
113
113
  </div>
114
114
  <ol className="space-y-1">
115
115
  {item.chapters.map(ch => {
116
- const isCurrent = ch.file === currentChapter;
117
- const chIdx = chapters.findIndex(c => c.file === ch.file);
116
+ const isCurrent = ch.id === currentChapter;
117
+ const chIdx = chapters.findIndex(c => c.id === ch.id);
118
118
  const isPast = chIdx < currentIndex;
119
119
 
120
120
  return (
121
- <li key={ch.file}>
121
+ <li key={ch.id}>
122
122
  {isCurrent ? (
123
123
  <div className="flex items-center gap-3 py-1.5 px-2 rounded-lg bg-accent/5">
124
124
  <span className="text-sm font-semibold text-accent truncate">{ch.title}</span>
125
125
  </div>
126
126
  ) : (
127
127
  <Link
128
- href={`/books/${bookSlug}/${ch.file}`}
128
+ href={`/books/${bookSlug}/${ch.id}`}
129
129
  className={`block py-1.5 px-2 rounded-lg text-sm no-underline hover:bg-muted/5 transition-colors ${
130
130
  isPast ? 'text-foreground/70 hover:text-foreground' : 'text-muted hover:text-foreground'
131
131
  }`}
@@ -140,19 +140,19 @@ export default function BookMobileNav({ bookSlug, bookTitle, toc, chapters, curr
140
140
  </div>
141
141
  );
142
142
  } else {
143
- const isCurrent = item.file === currentChapter;
144
- const chIdx = chapters.findIndex(c => c.file === item.file);
143
+ const isCurrent = item.id === currentChapter;
144
+ const chIdx = chapters.findIndex(c => c.id === item.id);
145
145
  const isPast = chIdx < currentIndex;
146
146
 
147
147
  return (
148
- <div key={item.file}>
148
+ <div key={item.id}>
149
149
  {isCurrent ? (
150
150
  <div className="flex items-center gap-3 py-1.5 px-2 rounded-lg bg-accent/5">
151
151
  <span className="text-sm font-semibold text-accent truncate">{item.title}</span>
152
152
  </div>
153
153
  ) : (
154
154
  <Link
155
- href={`/books/${bookSlug}/${item.file}`}
155
+ href={`/books/${bookSlug}/${item.id}`}
156
156
  className={`block py-1.5 px-2 rounded-lg text-sm no-underline hover:bg-muted/5 transition-colors ${
157
157
  isPast ? 'text-foreground/70 hover:text-foreground' : 'text-muted hover:text-foreground'
158
158
  }`}
@@ -16,7 +16,7 @@ interface BookSidebarProps {
16
16
 
17
17
  export default function BookSidebar({ bookSlug, bookTitle, toc, chapters, currentChapter, headings = [] }: BookSidebarProps) {
18
18
  const { t } = useLanguage();
19
- const currentIndex = chapters.findIndex(ch => ch.file === currentChapter);
19
+ const currentIndex = chapters.findIndex(ch => ch.id === currentChapter);
20
20
  const [headingsCollapsed, setHeadingsCollapsed] = useState(false);
21
21
  const currentItemRef = useRef<HTMLLIElement>(null);
22
22
  const sidebarRef = useRef<HTMLElement>(null);
@@ -27,7 +27,7 @@ export default function BookSidebar({ bookSlug, bookTitle, toc, chapters, curren
27
27
  const initial: Record<string, boolean> = {};
28
28
  for (const item of toc) {
29
29
  if ('part' in item) {
30
- const containsCurrent = item.chapters.some(ch => ch.file === currentChapter);
30
+ const containsCurrent = item.chapters.some(ch => ch.id === currentChapter);
31
31
  initial[item.part] = !containsCurrent;
32
32
  }
33
33
  }
@@ -96,7 +96,7 @@ export default function BookSidebar({ bookSlug, bookTitle, toc, chapters, curren
96
96
  // Expand part containing current chapter when it changes
97
97
  useEffect(() => {
98
98
  for (const item of toc) {
99
- if ('part' in item && item.chapters.some(ch => ch.file === currentChapter)) {
99
+ if ('part' in item && item.chapters.some(ch => ch.id === currentChapter)) {
100
100
  setCollapsedParts(prev => ({ ...prev, [item.part]: false }));
101
101
  }
102
102
  }
@@ -156,23 +156,23 @@ export default function BookSidebar({ bookSlug, bookTitle, toc, chapters, curren
156
156
  toc.forEach((item) => {
157
157
  if ('part' in item) {
158
158
  item.chapters.forEach(ch => {
159
- chapterIndices.set(ch.file, currentGlobalIdx++);
159
+ chapterIndices.set(ch.id, currentGlobalIdx++);
160
160
  });
161
161
  } else {
162
- chapterIndices.set(item.file, currentGlobalIdx++);
162
+ chapterIndices.set(item.id, currentGlobalIdx++);
163
163
  }
164
164
  });
165
165
 
166
166
  // Helper to render a chapter link + inline headings if current
167
- const renderChapterItem = (ch: { title: string; file: string }) => {
168
- const isCurrent = ch.file === currentChapter;
169
- const idx = chapterIndices.get(ch.file) ?? 0;
167
+ const renderChapterItem = (ch: { title: string; id: string }) => {
168
+ const isCurrent = ch.id === currentChapter;
169
+ const idx = chapterIndices.get(ch.id) ?? 0;
170
170
  const isPast = idx < currentIndex;
171
171
 
172
172
  return (
173
- <li key={ch.file} ref={isCurrent ? currentItemRef : undefined}>
173
+ <li key={ch.id} ref={isCurrent ? currentItemRef : undefined}>
174
174
  <Link
175
- href={`/books/${bookSlug}/${ch.file}`}
175
+ href={`/books/${bookSlug}/${ch.id}`}
176
176
  className={`block py-2 px-3 rounded-lg text-sm no-underline transition-all duration-200 ${
177
177
  isCurrent
178
178
  ? 'bg-accent/10 text-accent font-semibold border-l-2 border-accent'
@@ -196,27 +196,19 @@ export default function BookSidebar({ bookSlug, bookTitle, toc, chapters, curren
196
196
  >
197
197
  {/* Book Header */}
198
198
  <div className="mb-6 pb-4 border-b border-muted/10">
199
- <Link href={`/books/${bookSlug}`} className="group block no-underline">
200
- <span className="text-[10px] font-sans font-bold uppercase tracking-widest text-accent mb-2 block">
199
+ <div className="flex items-center justify-between mb-2">
200
+ <span className="text-[10px] font-sans font-bold uppercase tracking-widest text-accent">
201
201
  {t('book')}
202
202
  </span>
203
- <h3 className="font-serif font-bold text-heading text-lg leading-snug group-hover:text-accent transition-colors">
204
- {bookTitle}
205
- </h3>
206
- </Link>
207
-
208
- {/* Progress */}
209
- <div className="mt-3 flex items-center gap-3">
210
- <div className="flex-1 h-1 bg-muted/10 rounded-full overflow-hidden">
211
- <div
212
- className="h-full bg-accent/60 rounded-full transition-all duration-500"
213
- style={{ width: `${((currentIndex + 1) / chapters.length) * 100}%` }}
214
- />
215
- </div>
216
203
  <span className="text-xs font-mono text-muted whitespace-nowrap">
217
204
  {currentIndex + 1}/{chapters.length}
218
205
  </span>
219
206
  </div>
207
+ <Link href={`/books/${bookSlug}`} className="group block no-underline">
208
+ <h3 className="font-serif font-bold text-heading text-lg leading-snug group-hover:text-accent transition-colors">
209
+ {bookTitle}
210
+ </h3>
211
+ </Link>
220
212
  </div>
221
213
 
222
214
  {/* TOC */}
@@ -0,0 +1,96 @@
1
+ 'use client';
2
+
3
+ import { useState, useSyncExternalStore } from 'react';
4
+ import { useLanguage } from '@/components/LanguageProvider';
5
+
6
+ const DISMISSED_KEY = 'browser-warning-dismissed';
7
+
8
+ function isOutdatedBrowser(): boolean {
9
+ // Detect Internet Explorer
10
+ if (/MSIE|Trident/.test(navigator.userAgent)) return true;
11
+
12
+ // CSS custom properties (Chrome 49+, Firefox 31+, Safari 9.1+)
13
+ if (!('CSS' in window) || !CSS.supports('color', 'var(--x)')) return true;
14
+
15
+ // CSS oklch() — required by Tailwind CSS v4 color system (Chrome 111+, Firefox 113+, Safari 15.4+)
16
+ // Note: CSS.supports() only handles property-value declarations, not at-rules,
17
+ // so @layer cannot be tested here. oklch already sets a higher minimum than @layer anyway.
18
+ if (!CSS.supports('color', 'oklch(0.5 0.1 0)')) return true;
19
+
20
+ return false;
21
+ }
22
+
23
+ function subscribe() {
24
+ return () => {};
25
+ }
26
+
27
+ function getSnapshot(): boolean {
28
+ try {
29
+ if (localStorage.getItem(DISMISSED_KEY)) return false;
30
+ } catch {
31
+ // localStorage unavailable (private browsing, sandboxed iframe, etc.)
32
+ return false;
33
+ }
34
+ return isOutdatedBrowser();
35
+ }
36
+
37
+ function getServerSnapshot(): boolean {
38
+ return false;
39
+ }
40
+
41
+ export default function BrowserDetectionBanner({ updateUrl }: { updateUrl?: string }) {
42
+ const { t } = useLanguage();
43
+ const isOutdated = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
44
+ const [dismissed, setDismissed] = useState(false);
45
+
46
+ if (!isOutdated || dismissed) return null;
47
+
48
+ const dismiss = () => {
49
+ try {
50
+ localStorage.setItem(DISMISSED_KEY, '1');
51
+ } catch {
52
+ // Ignore — dismissal works for current session via state
53
+ }
54
+ setDismissed(true);
55
+ };
56
+
57
+ return (
58
+ <div
59
+ role="alert"
60
+ className="bg-amber-100 dark:bg-amber-900/40 border-b border-amber-300 dark:border-amber-700 text-amber-900 dark:text-amber-100 px-4 py-2.5 flex items-center justify-center gap-3 text-sm"
61
+ >
62
+ <svg
63
+ className="w-4 h-4 shrink-0 text-amber-600 dark:text-amber-400"
64
+ viewBox="0 0 20 20"
65
+ fill="currentColor"
66
+ aria-hidden="true"
67
+ >
68
+ <path
69
+ fillRule="evenodd"
70
+ d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z"
71
+ clipRule="evenodd"
72
+ />
73
+ </svg>
74
+ <span>{t('browser_outdated')}</span>
75
+ {updateUrl && (
76
+ <a
77
+ href={updateUrl}
78
+ target="_blank"
79
+ rel="noopener noreferrer"
80
+ className="underline underline-offset-2 font-medium hover:opacity-75 transition-opacity shrink-0"
81
+ >
82
+ {t('browser_update')}
83
+ </a>
84
+ )}
85
+ <button
86
+ onClick={dismiss}
87
+ aria-label={t('browser_dismiss')}
88
+ className="ml-auto p-1 rounded hover:opacity-70 transition-opacity focus-ring shrink-0"
89
+ >
90
+ <svg className="w-4 h-4" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
91
+ <path d="M4.293 4.293a1 1 0 011.414 0L8 6.586l2.293-2.293a1 1 0 111.414 1.414L9.414 8l2.293 2.293a1 1 0 01-1.414 1.414L8 9.414l-2.293 2.293a1 1 0 01-1.414-1.414L6.586 8 4.293 5.707a1 1 0 010-1.414z" />
92
+ </svg>
93
+ </button>
94
+ </div>
95
+ );
96
+ }
@@ -40,7 +40,7 @@ export default function FeaturedStoriesSection({ allFeatured, maxItems, scrollTh
40
40
  return (
41
41
  <section className="mb-24">
42
42
  <div className="flex items-center justify-between mb-12">
43
- <h2 className="text-3xl font-serif font-bold text-heading">{t('featured_stories')}</h2>
43
+ <h2 className="text-3xl font-serif font-bold text-heading">{t('featured_articles')}</h2>
44
44
  {allFeatured.length > maxItems && (
45
45
  <button
46
46
  onClick={handleShuffle}
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import { useState, useMemo } from 'react';
3
+ import { useState, useMemo, type ReactNode } from 'react';
4
4
  import Link from 'next/link';
5
5
  import { useLanguage } from '@/components/LanguageProvider';
6
6
 
@@ -10,11 +10,12 @@ interface FlowCalendarSidebarProps {
10
10
  tags?: Record<string, number>;
11
11
  selectedTag?: string | null;
12
12
  onTagSelect?: (tag: string) => void;
13
+ breadcrumb?: ReactNode;
13
14
  }
14
15
 
15
16
  const WEEKDAYS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];
16
17
 
17
- export default function FlowCalendarSidebar({ entryDates, currentDate, tags, selectedTag, onTagSelect }: FlowCalendarSidebarProps) {
18
+ export default function FlowCalendarSidebar({ entryDates, currentDate, tags, selectedTag, onTagSelect, breadcrumb }: FlowCalendarSidebarProps) {
18
19
  const { t } = useLanguage();
19
20
  const initialDate = currentDate ? new Date(currentDate + 'T00:00:00') : new Date();
20
21
  const [viewYear, setViewYear] = useState(initialDate.getFullYear());
@@ -77,6 +78,7 @@ export default function FlowCalendarSidebar({ entryDates, currentDate, tags, sel
77
78
 
78
79
  return (
79
80
  <aside className="hidden lg:block sticky top-20 self-start w-[280px] max-h-[calc(100vh-6rem)]">
81
+ {breadcrumb && <div className="mb-4">{breadcrumb}</div>}
80
82
  <div className="border border-muted/20 rounded-lg p-4">
81
83
  {/* Month navigation */}
82
84
  <div className="flex items-center justify-between mb-3">
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import { useState, useMemo } from 'react';
3
+ import { useState, useMemo, type ReactNode } from 'react';
4
4
  import { useLanguage } from '@/components/LanguageProvider';
5
5
  import FlowCalendarSidebar from '@/components/FlowCalendarSidebar';
6
6
  import FlowTimelineEntry from '@/components/FlowTimelineEntry';
@@ -24,9 +24,10 @@ interface FlowContentProps {
24
24
  totalPages: number;
25
25
  basePath: string;
26
26
  };
27
+ breadcrumb?: ReactNode;
27
28
  }
28
29
 
29
- export default function FlowContent({ flows, entryDates, tags, currentDate, pagination }: FlowContentProps) {
30
+ export default function FlowContent({ flows, entryDates, tags, currentDate, pagination, breadcrumb }: FlowContentProps) {
30
31
  const { t } = useLanguage();
31
32
  const [selectedTag, setSelectedTag] = useState<string | null>(null);
32
33
 
@@ -47,6 +48,7 @@ export default function FlowContent({ flows, entryDates, tags, currentDate, pagi
47
48
  tags={tags}
48
49
  selectedTag={selectedTag}
49
50
  onTagSelect={handleTagSelect}
51
+ breadcrumb={breadcrumb}
50
52
  />
51
53
 
52
54
  <div className="flex-1 min-w-0">
@@ -72,7 +74,6 @@ export default function FlowContent({ flows, entryDates, tags, currentDate, pagi
72
74
  <FlowTimelineEntry
73
75
  key={flow.slug}
74
76
  date={flow.date}
75
- title={flow.title}
76
77
  excerpt={flow.excerpt}
77
78
  tags={flow.tags}
78
79
  slug={flow.slug}
@@ -0,0 +1,50 @@
1
+ 'use client';
2
+
3
+ import { usePathname } from 'next/navigation';
4
+ import Link from 'next/link';
5
+ import { useLanguage } from './LanguageProvider';
6
+
7
+ interface FlowHubTabsProps {
8
+ subtitle?: string;
9
+ }
10
+
11
+ export default function FlowHubTabs({ subtitle }: FlowHubTabsProps) {
12
+ const pathname = usePathname();
13
+ const { t } = useLanguage();
14
+
15
+ // Normalize: strip trailing slash added by next.config trailingSlash:true
16
+ const path = pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
17
+
18
+ const isFlowsActive = path === '/flows' || path.startsWith('/flows/page');
19
+ const isNotesActive = path === '/notes' || path.startsWith('/notes/page');
20
+ const isGraphActive = path.startsWith('/graph');
21
+
22
+ const tabs = [
23
+ { href: '/flows', label: t('tab_daily_flow'), active: isFlowsActive },
24
+ { href: '/notes', label: t('notes'), active: isNotesActive },
25
+ { href: '/graph', label: t('tab_graph'), active: isGraphActive },
26
+ ];
27
+
28
+ return (
29
+ <div className="mb-10">
30
+ <div className="flex items-end gap-8 border-b border-muted/20">
31
+ {tabs.map(tab => (
32
+ <Link
33
+ key={tab.href}
34
+ href={tab.href}
35
+ className={`pb-3 text-3xl font-bold no-underline border-b-2 -mb-px transition-colors ${
36
+ tab.active
37
+ ? 'border-accent text-heading'
38
+ : 'border-transparent text-muted/30 hover:text-muted/60'
39
+ }`}
40
+ >
41
+ {tab.label}
42
+ </Link>
43
+ ))}
44
+ </div>
45
+ {subtitle && (
46
+ <p className="mt-3 text-sm text-muted">{subtitle}</p>
47
+ )}
48
+ </div>
49
+ );
50
+ }
@@ -3,25 +3,23 @@ import Tag from './Tag';
3
3
 
4
4
  interface FlowTimelineEntryProps {
5
5
  date: string;
6
- title: string;
7
6
  excerpt: string;
8
7
  tags: string[];
9
8
  slug: string;
10
9
  }
11
10
 
12
- export default function FlowTimelineEntry({ date, title, excerpt, tags, slug }: FlowTimelineEntryProps) {
11
+ export default function FlowTimelineEntry({ date, excerpt, tags, slug }: FlowTimelineEntryProps) {
13
12
  return (
14
13
  <article className="relative pl-6 pb-8 border-l-2 border-muted/20 last:pb-0">
15
14
  {/* Timeline dot */}
16
15
  <div className="absolute -left-[5px] top-1.5 w-2 h-2 rounded-full bg-accent" />
17
16
 
18
- <time className="text-xs font-mono text-accent">{date}</time>
19
- <h3 className="mt-1 mb-2 font-serif text-xl font-bold text-heading">
20
- <Link href={`/flows/${slug}`} className="no-underline hover:text-accent transition-colors">
21
- {title}
22
- </Link>
23
- </h3>
24
- <p className="text-sm text-muted leading-relaxed line-clamp-3">{excerpt}</p>
17
+ <Link href={`/flows/${slug}`} className="no-underline group">
18
+ <time className="text-sm font-mono text-accent group-hover:text-accent/70 transition-colors">{date}</time>
19
+ </Link>
20
+ {excerpt && (
21
+ <p className="mt-1.5 text-sm text-muted leading-relaxed line-clamp-3">{excerpt}</p>
22
+ )}
25
23
  {tags.length > 0 && (
26
24
  <div className="mt-2 flex flex-wrap gap-2">
27
25
  {tags.map(tag => (