@hutusi/amytis 1.16.0 → 1.17.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 (51) hide show
  1. package/.claude/rules/immersive-reading.md +21 -0
  2. package/.claude/rules/rst.md +13 -0
  3. package/CHANGELOG.md +16 -0
  4. package/CLAUDE.md +10 -11
  5. package/docs/ARCHITECTURE.md +81 -0
  6. package/docs/DIGITAL_GARDEN.md +1 -1
  7. package/docs/guides/importing-vuepress-books.md +95 -36
  8. package/package.json +1 -1
  9. package/scripts/sync-vuepress-book.ts +277 -66
  10. package/site.config.example.ts +3 -3
  11. package/site.config.ts +3 -3
  12. package/src/app/[slug]/layout.tsx +30 -0
  13. package/src/app/books/[slug]/layout.tsx +24 -0
  14. package/src/app/books/[slug]/page.tsx +18 -2
  15. package/src/app/globals.css +67 -0
  16. package/src/app/page.tsx +6 -0
  17. package/src/app/posts/layout.tsx +20 -0
  18. package/src/app/series/[slug]/page.tsx +33 -9
  19. package/src/components/BookReadingShell.tsx +145 -0
  20. package/src/components/BookSidebar.tsx +0 -0
  21. package/src/components/CuratedSeriesSection.tsx +28 -10
  22. package/src/components/FeaturedStoriesSection.tsx +41 -20
  23. package/src/components/Footer.tsx +1 -1
  24. package/src/components/ImmersiveReader.tsx +130 -0
  25. package/src/components/ImmersiveReaderTopBar.tsx +106 -0
  26. package/src/components/ImmersiveReadingFlagHandler.tsx +40 -0
  27. package/src/components/ImmersiveReadingPrefsPopover.tsx +249 -0
  28. package/src/components/ImmersiveReadingProvider.tsx +168 -0
  29. package/src/components/ImmersiveSeriesSidebar.tsx +143 -0
  30. package/src/components/ImmersiveToggleButton.tsx +45 -0
  31. package/src/components/MarkdownRenderer.tsx +31 -0
  32. package/src/components/Navbar.tsx +3 -1
  33. package/src/components/PostReadingShell.tsx +68 -0
  34. package/src/components/ReadingProgressBar.tsx +1 -1
  35. package/src/components/SelectedBooksSection.tsx +27 -8
  36. package/src/hooks/useActiveHeading.ts +35 -13
  37. package/src/hooks/useSidebarAutoScroll.ts +31 -7
  38. package/src/i18n/translations.ts +42 -0
  39. package/src/layouts/BookLayout.tsx +46 -89
  40. package/src/layouts/PostLayout.tsx +154 -115
  41. package/src/lib/immersive-reading-prefs.ts +104 -0
  42. package/src/lib/markdown.ts +18 -11
  43. package/src/lib/scroll-utils.ts +44 -6
  44. package/src/lib/shuffle.ts +15 -1
  45. package/src/lib/sort.ts +15 -0
  46. package/src/lib/urls.ts +5 -0
  47. package/tests/integration/book-index-cta.test.ts +87 -0
  48. package/tests/integration/series-index-cta.test.ts +88 -0
  49. package/tests/integration/sync-vuepress-book.test.ts +205 -2
  50. package/tests/unit/immersive-reading-prefs.test.ts +144 -0
  51. package/vercel.json +7 -0
@@ -996,3 +996,70 @@ html.dark .rst-rendered aside.admonition-danger {
996
996
  font-weight: 600;
997
997
  color: var(--heading);
998
998
  }
999
+
1000
+ /* Immersive reading mode.
1001
+ * Toggled by ImmersiveReadingProvider via the html element's data-immersive
1002
+ * attribute. The fullscreen ImmersiveReader overlay also covers the site
1003
+ * chrome, but these rules are kept as defense-in-depth (and reset the
1004
+ * navbar offset so the scroll padding doesn't peek through the overlay).
1005
+ * Do not strip the data-site-nav / data-site-footer / data-reading-progress
1006
+ * hooks.
1007
+ *
1008
+ * IMPORTANT: every attribute selector below uses an explicit ="true" value,
1009
+ * and no rule starts with `html[...]`. Tailwind v4 / Lightning CSS silently
1010
+ * dead-code-eliminates rules that start with `html[...]` and bare attribute
1011
+ * selectors like `[data-site-nav]` — they end up missing from the compiled
1012
+ * CSS bundle even though they're written here in source. The surviving
1013
+ * patterns are bare-attribute-with-value (e.g. `[data-palette="blue"]`,
1014
+ * `[data-line-numbers="true"]`) and class-prefixed compounds (e.g.
1015
+ * `.dark[data-palette="blue"]`). React serialises a bare JSX
1016
+ * `data-site-nav` prop to the HTML `data-site-nav="true"`, so writing
1017
+ * `="true"` here costs nothing on the component side. */
1018
+ [data-immersive="true"] [data-site-nav="true"],
1019
+ [data-immersive="true"] [data-site-footer="true"],
1020
+ [data-immersive="true"] [data-reading-progress="true"] {
1021
+ display: none;
1022
+ }
1023
+ [data-immersive="true"] #main-content {
1024
+ padding-top: 0;
1025
+ }
1026
+
1027
+ /* Font-size in the reader overlay is driven by a CSS var set inline on the
1028
+ * overlay container. A var (not stacked text-* classes) sidesteps fighting
1029
+ * the prose-lg specificity. */
1030
+ [data-reader-overlay="true"] .prose {
1031
+ font-size: var(--reading-font-size, 1.125rem);
1032
+ }
1033
+
1034
+ /* Reading-theme overrides — scoped to the overlay so they compose with the
1035
+ * site's light/dark theme without leaking outside. 'auto' inherits the site
1036
+ * theme (no override needed); 'light' / 'dark' / 'sepia' set their own
1037
+ * variables. The overlay also adds Tailwind's `.dark` class on its container
1038
+ * when readingTheme === 'dark' so `dark:prose-invert` activates regardless of
1039
+ * the site theme. Shiki code blocks keep their own theme on purpose. */
1040
+ [data-reader-overlay="true"][data-reading-theme="light"] {
1041
+ --background: #fafaf9;
1042
+ --foreground: #44403c;
1043
+ --heading: #1c1917;
1044
+ --muted: #78716c;
1045
+ }
1046
+ [data-reader-overlay="true"][data-reading-theme="dark"] {
1047
+ --background: #292524;
1048
+ --foreground: #f5f5f4;
1049
+ --heading: #fafaf9;
1050
+ --muted: #a8a29e;
1051
+ }
1052
+ [data-reader-overlay="true"][data-reading-theme="sepia"] {
1053
+ --background: #f4ecd8;
1054
+ --foreground: #5b4636;
1055
+ --heading: #3b2f24;
1056
+ --muted: #8a7a66;
1057
+ }
1058
+ [data-reader-overlay="true"][data-reading-theme="sepia"] .prose h1,
1059
+ [data-reader-overlay="true"][data-reading-theme="sepia"] .prose h2,
1060
+ [data-reader-overlay="true"][data-reading-theme="sepia"] .prose h3,
1061
+ [data-reader-overlay="true"][data-reading-theme="sepia"] .prose h4,
1062
+ [data-reader-overlay="true"][data-reading-theme="sepia"] .prose h5,
1063
+ [data-reader-overlay="true"][data-reading-theme="sepia"] .prose h6 {
1064
+ color: var(--heading);
1065
+ }
package/src/app/page.tsx CHANGED
@@ -34,6 +34,7 @@ type HomepageSection = {
34
34
  enabled?: boolean;
35
35
  weight: number;
36
36
  maxItems?: number;
37
+ order?: 'shuffle' | 'date-desc' | 'date-asc';
37
38
  };
38
39
 
39
40
  export default function Home() {
@@ -76,6 +77,7 @@ export default function Home() {
76
77
  url: `/series/${slug}`,
77
78
  postCount: seriesPosts.length,
78
79
  topPosts: seriesPosts.slice(0, 3).map(p => ({ slug: p.slug, title: p.title })),
80
+ date: seriesData?.date ?? seriesPosts[0]?.date ?? '',
79
81
  };
80
82
  })
81
83
  : [];
@@ -89,6 +91,7 @@ export default function Home() {
89
91
  authors: b.authors,
90
92
  chapterCount: b.chapters.length,
91
93
  firstChapter: b.chapters[0]?.id,
94
+ date: b.date,
92
95
  }))
93
96
  : [];
94
97
 
@@ -133,6 +136,7 @@ export default function Home() {
133
136
  key="featured-series"
134
137
  allSeries={seriesItems}
135
138
  maxItems={section.maxItems ?? 6}
139
+ order={section.order ?? 'shuffle'}
136
140
  />
137
141
  );
138
142
  case 'featured-books':
@@ -142,6 +146,7 @@ export default function Home() {
142
146
  key="featured-books"
143
147
  books={bookItems}
144
148
  maxItems={section.maxItems ?? 4}
149
+ order={section.order ?? 'shuffle'}
145
150
  />
146
151
  );
147
152
  case 'featured-posts':
@@ -151,6 +156,7 @@ export default function Home() {
151
156
  key="featured-posts"
152
157
  allFeatured={featuredItems}
153
158
  maxItems={section.maxItems ?? 4}
159
+ order={section.order ?? 'shuffle'}
154
160
  />
155
161
  );
156
162
  case 'latest-posts':
@@ -0,0 +1,20 @@
1
+ import { Suspense, type ReactNode } from 'react';
2
+ import { ImmersiveReadingProvider } from '@/components/ImmersiveReadingProvider';
3
+ import ImmersiveReadingFlagHandler from '@/components/ImmersiveReadingFlagHandler';
4
+
5
+ // Mirror of `src/app/[slug]/layout.tsx` for the default-path post URL
6
+ // (`/posts/<slug>`). Series posts can live under either URL pattern
7
+ // depending on the `series.autoPaths` setting in site.config.ts — having the
8
+ // provider mounted at both layout boundaries means immersive state persists
9
+ // across in-series navigation regardless of which pattern the user's site
10
+ // uses. Same Suspense isolation around the flag handler.
11
+ export default function PostsLayout({ children }: { children: ReactNode }) {
12
+ return (
13
+ <ImmersiveReadingProvider>
14
+ <Suspense fallback={null}>
15
+ <ImmersiveReadingFlagHandler />
16
+ </Suspense>
17
+ {children}
18
+ </ImmersiveReadingProvider>
19
+ );
20
+ }
@@ -156,16 +156,40 @@ export default async function SeriesPage({ params }: { params: Promise<{ slug: s
156
156
  const firstPost = (seriesData?.sort === 'date-asc' || seriesData?.sort === 'manual' || isCollection)
157
157
  ? allPosts[0]
158
158
  : allPosts[allPosts.length - 1];
159
+ const primaryHref = isCollection ? getPostUrlInCollection(firstPost, slug) : getPostUrl(firstPost);
160
+ // primaryHref already carries `?collection=<slug>` for collection
161
+ // contexts (see getPostUrlInCollection), so naïvely appending
162
+ // `?immersive=1` would produce an invalid double-`?` URL and the
163
+ // flag handler would never fire. Use the right separator.
164
+ const immersiveHref = `${primaryHref}${primaryHref.includes('?') ? '&' : '?'}immersive=1`;
159
165
  return (
160
- <Link
161
- href={isCollection ? getPostUrlInCollection(firstPost, slug) : getPostUrl(firstPost)}
162
- className="mt-6 inline-flex items-center gap-2 px-5 py-2.5 rounded-full bg-accent text-white text-sm font-medium hover:bg-accent/90 transition-colors no-underline"
163
- >
164
- {t('start_reading')}
165
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
166
- <polyline points="9 18 15 12 9 6" />
167
- </svg>
168
- </Link>
166
+ <div className="mt-6 flex flex-wrap items-center justify-center gap-3">
167
+ <Link
168
+ href={primaryHref}
169
+ className="inline-flex items-center gap-2 px-5 py-2.5 rounded-full bg-accent text-white text-sm font-medium hover:bg-accent/90 transition-colors no-underline"
170
+ >
171
+ {t('start_reading')}
172
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
173
+ <polyline points="9 18 15 12 9 6" />
174
+ </svg>
175
+ </Link>
176
+ {/* Secondary CTA — opens the first post of the series in immersive mode.
177
+ The `?immersive=1` query param is read by ImmersiveReadingProvider
178
+ on mount, which calls enter() then strips the flag from the URL
179
+ so back-navigation doesn't re-trigger it. */}
180
+ <Link
181
+ href={immersiveHref}
182
+ className="inline-flex items-center gap-2 px-5 py-2.5 border border-muted/30 text-foreground/80 hover:text-accent hover:border-accent/50 rounded-full text-sm font-medium no-underline transition-colors"
183
+ >
184
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
185
+ <path d="M3 7V5a2 2 0 0 1 2-2h2" />
186
+ <path d="M17 3h2a2 2 0 0 1 2 2v2" />
187
+ <path d="M21 17v2a2 2 0 0 1-2 2h-2" />
188
+ <path d="M7 21H5a2 2 0 0 1-2-2v-2" />
189
+ </svg>
190
+ {t('immersive_reading')}
191
+ </Link>
192
+ </div>
169
193
  );
170
194
  })()}
171
195
  {authors.length > 0 && (
@@ -0,0 +1,145 @@
1
+ 'use client';
2
+
3
+ import type { ReactNode } from 'react';
4
+ import BookSidebar from '@/components/BookSidebar';
5
+ import BookMobileNav from '@/components/BookMobileNav';
6
+ import PrevNextNav from '@/components/PrevNextNav';
7
+ import ReadingProgressBar from '@/components/ReadingProgressBar';
8
+ import Comments from '@/components/Comments';
9
+ import { useLanguage } from '@/components/LanguageProvider';
10
+ import { useImmersiveReading } from '@/components/ImmersiveReadingProvider';
11
+ import ImmersiveReader from '@/components/ImmersiveReader';
12
+ import ImmersiveToggleButton from '@/components/ImmersiveToggleButton';
13
+ import type { BookTocItem, BookChapterEntry, Heading } from '@/lib/markdown';
14
+ import { getBookUrl } from '@/lib/urls';
15
+
16
+ interface BookReadingShellProps {
17
+ book: {
18
+ slug: string;
19
+ title: string;
20
+ toc: BookTocItem[];
21
+ chapters: BookChapterEntry[];
22
+ showChapterExcerpt: boolean;
23
+ };
24
+ chapter: {
25
+ slug: string;
26
+ title: string;
27
+ wordCount: number;
28
+ readingMinutes: number;
29
+ excerpt?: string;
30
+ headings: Heading[];
31
+ };
32
+ prev: { href: string; title: string } | null;
33
+ next: { href: string; title: string } | null;
34
+ comments: { slug: string; postUrl: string } | null;
35
+ children: ReactNode;
36
+ }
37
+
38
+ export default function BookReadingShell({
39
+ book,
40
+ chapter,
41
+ prev,
42
+ next,
43
+ comments,
44
+ children,
45
+ }: BookReadingShellProps) {
46
+ const { t } = useLanguage();
47
+ const { enabled } = useImmersiveReading();
48
+
49
+ const chapterHeader = (
50
+ <header className="mb-12 pb-8 border-b border-muted/10">
51
+ <div className="flex items-center gap-3 text-xs font-sans text-muted mb-4">
52
+ <span className="uppercase tracking-widest font-semibold text-accent">
53
+ {t('chapter')}
54
+ </span>
55
+ <span className="w-1 h-1 rounded-full bg-muted/30" />
56
+ <span className="font-mono">
57
+ {chapter.wordCount.toLocaleString()} {t('words')}
58
+ </span>
59
+ <span className="w-1 h-1 rounded-full bg-muted/30" />
60
+ <span className="font-mono text-muted/70">
61
+ {chapter.readingMinutes} {t('reading_time')}
62
+ </span>
63
+ {/* ImmersiveToggleButton hides itself when enabled — no outer wrap
64
+ needed beyond the layout positioning. */}
65
+ <span className="ml-auto">
66
+ <ImmersiveToggleButton />
67
+ </span>
68
+ </div>
69
+
70
+ <h1 className="text-3xl md:text-4xl font-serif font-bold text-heading leading-tight mb-4">
71
+ {chapter.title}
72
+ </h1>
73
+
74
+ {book.showChapterExcerpt && chapter.excerpt && (
75
+ <p className="text-lg text-muted font-serif italic leading-relaxed">
76
+ {chapter.excerpt}
77
+ </p>
78
+ )}
79
+ </header>
80
+ );
81
+
82
+ const prevNext = (
83
+ <div className="mt-16 pt-8 border-t border-muted/10">
84
+ <PrevNextNav prev={prev} next={next} size="lg" />
85
+ </div>
86
+ );
87
+
88
+ if (enabled) {
89
+ return (
90
+ <ImmersiveReader
91
+ rootHref={getBookUrl(book.slug)}
92
+ rootTitle={book.title}
93
+ currentTitle={chapter.title}
94
+ sidebar={
95
+ <BookSidebar
96
+ mode="fill"
97
+ bookSlug={book.slug}
98
+ bookTitle={book.title}
99
+ toc={book.toc}
100
+ chapters={book.chapters}
101
+ currentChapter={chapter.slug}
102
+ headings={chapter.headings}
103
+ />
104
+ }
105
+ >
106
+ {chapterHeader}
107
+ {children}
108
+ {prevNext}
109
+ </ImmersiveReader>
110
+ );
111
+ }
112
+
113
+ return (
114
+ <div className="layout-container">
115
+ <ReadingProgressBar />
116
+ <div className="grid grid-cols-1 lg:grid-cols-[280px_minmax(0,1fr)] gap-8 items-start">
117
+ <BookSidebar
118
+ bookSlug={book.slug}
119
+ bookTitle={book.title}
120
+ toc={book.toc}
121
+ chapters={book.chapters}
122
+ currentChapter={chapter.slug}
123
+ headings={chapter.headings}
124
+ />
125
+
126
+ <article className="min-w-0 w-full max-w-3xl mx-auto overflow-x-hidden">
127
+ <div className="lg:hidden mb-8">
128
+ <BookMobileNav
129
+ bookSlug={book.slug}
130
+ bookTitle={book.title}
131
+ toc={book.toc}
132
+ chapters={book.chapters}
133
+ currentChapter={chapter.slug}
134
+ />
135
+ </div>
136
+
137
+ {chapterHeader}
138
+ {children}
139
+ {comments && <Comments slug={comments.slug} postUrl={comments.postUrl} />}
140
+ {prevNext}
141
+ </article>
142
+ </div>
143
+ </div>
144
+ );
145
+ }
Binary file
@@ -1,11 +1,12 @@
1
1
  'use client';
2
2
 
3
- import { useState, useCallback } from 'react';
3
+ import { useState, useEffect, useCallback } from 'react';
4
4
  import Link from 'next/link';
5
5
  import HorizontalScroll from './HorizontalScroll';
6
6
  import CoverImage from './CoverImage';
7
7
  import { useLanguage } from './LanguageProvider';
8
- import { shuffle, shuffleSeeded } from '@/lib/shuffle';
8
+ import { shuffle } from '@/lib/shuffle';
9
+ import { byDateAsc, byDateDesc } from '@/lib/sort';
9
10
  import { getPostUrl, getSeriesListUrl } from '@/lib/urls';
10
11
 
11
12
  export interface SeriesItem {
@@ -16,21 +17,38 @@ export interface SeriesItem {
16
17
  url: string;
17
18
  postCount: number;
18
19
  topPosts: { slug: string; title: string }[];
20
+ date: string;
19
21
  }
20
22
 
23
+ type SeriesOrder = 'shuffle' | 'date-desc' | 'date-asc';
24
+
21
25
  interface CuratedSeriesSectionProps {
22
26
  allSeries: SeriesItem[];
23
27
  maxItems: number;
28
+ order?: SeriesOrder;
29
+ }
30
+
31
+ function canonicalOrder(series: SeriesItem[], order: SeriesOrder): SeriesItem[] {
32
+ if (order === 'date-desc') return [...series].sort(byDateDesc);
33
+ if (order === 'date-asc') return [...series].sort(byDateAsc);
34
+ return series;
24
35
  }
25
36
 
26
- export default function CuratedSeriesSection({ allSeries, maxItems }: CuratedSeriesSectionProps) {
37
+ export default function CuratedSeriesSection({ allSeries, maxItems, order = 'shuffle' }: CuratedSeriesSectionProps) {
27
38
  const { t } = useLanguage();
28
- // Use a daily seed so SSR and client hydration agree on the initial order,
29
- // preventing a visible reshuffle flash on page load.
30
- const [displayed, setDisplayed] = useState(() => {
31
- const dailySeed = Math.floor(Date.now() / 86400000);
32
- return shuffleSeeded(allSeries, dailySeed).slice(0, maxItems);
33
- });
39
+ // SSR renders the canonical input order so server and client agree on first paint.
40
+ // For 'shuffle', the post-mount useEffect swaps to a fresh random permutation,
41
+ // so every reload re-rolls without any hydration mismatch.
42
+ const [displayed, setDisplayed] = useState(() => canonicalOrder(allSeries, order).slice(0, maxItems));
43
+
44
+ // Shuffle on mount so every reload re-rolls. SSR's canonical render is stable; the
45
+ // post-hydration swap is the intentional client-only behaviour, not a sync issue.
46
+ useEffect(() => {
47
+ if (order === 'shuffle') {
48
+ // eslint-disable-next-line react-hooks/set-state-in-effect
49
+ setDisplayed(shuffle(allSeries).slice(0, maxItems));
50
+ }
51
+ }, [allSeries, maxItems, order]);
34
52
 
35
53
  const handleShuffle = useCallback(() => {
36
54
  setDisplayed(shuffle(allSeries).slice(0, maxItems));
@@ -43,7 +61,7 @@ export default function CuratedSeriesSection({ allSeries, maxItems }: CuratedSer
43
61
  <div className="flex items-center justify-between mb-8">
44
62
  <h2 className="text-2xl sm:text-3xl font-serif font-bold text-heading">{t('curated_series')}</h2>
45
63
  <div className="flex items-center gap-4">
46
- {allSeries.length > maxItems && (
64
+ {order === 'shuffle' && allSeries.length > maxItems && (
47
65
  <button
48
66
  onClick={handleShuffle}
49
67
  className="rounded-sm text-sm text-muted transition-colors hover:text-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 focus-visible:ring-offset-2"
@@ -1,10 +1,11 @@
1
1
  'use client';
2
2
 
3
- import { useState, useCallback } from 'react';
3
+ import { useState, useEffect, useCallback } from 'react';
4
4
  import Link from 'next/link';
5
5
  import CoverImage from './CoverImage';
6
6
  import { useLanguage } from './LanguageProvider';
7
- import { shuffle, shuffleSeeded } from '@/lib/shuffle';
7
+ import { shuffle } from '@/lib/shuffle';
8
+ import { byDateAsc, byDateDesc } from '@/lib/sort';
8
9
  import { getPostUrl } from '@/lib/urls';
9
10
 
10
11
  export interface FeaturedPost {
@@ -20,56 +21,76 @@ export interface FeaturedPost {
20
21
  pinned?: boolean;
21
22
  }
22
23
 
24
+ type PostOrder = 'shuffle' | 'date-desc' | 'date-asc';
25
+
23
26
  interface FeaturedStoriesSectionProps {
24
27
  allFeatured: FeaturedPost[];
25
28
  maxItems: number;
29
+ order?: PostOrder;
30
+ }
31
+
32
+ function canonicalOrder(posts: FeaturedPost[], order: PostOrder): FeaturedPost[] {
33
+ if (order === 'date-desc') return [...posts].sort(byDateDesc);
34
+ if (order === 'date-asc') return [...posts].sort(byDateAsc);
35
+ return posts;
26
36
  }
27
37
 
28
- function buildDisplayed(allFeatured: FeaturedPost[], maxItems: number, shuffledNonPinned: FeaturedPost[]): FeaturedPost[] {
38
+ function buildDisplayed(allFeatured: FeaturedPost[], maxItems: number, orderedNonPinned: FeaturedPost[]): FeaturedPost[] {
29
39
  const pinned = allFeatured.filter(p => p.pinned);
30
- const nonPinned = allFeatured.filter(p => !p.pinned);
31
40
 
32
- const hero = pinned[0] ?? nonPinned[0];
41
+ const hero = pinned[0] ?? orderedNonPinned[0];
33
42
  if (!hero) return [];
34
43
 
35
44
  const maxSecondaries = maxItems - 1;
36
45
  const fixedSecondaries = pinned.slice(1, maxSecondaries + 1); // cap to available secondary slots
37
- const shuffleSlots = Math.max(0, maxSecondaries - fixedSecondaries.length);
46
+ const fillSlots = Math.max(0, maxSecondaries - fixedSecondaries.length);
38
47
 
39
48
  // Non-pinned pool excludes the hero if the hero is non-pinned
40
49
  const heroIsNonPinned = !hero.pinned;
41
- const shufflePool = heroIsNonPinned ? nonPinned.filter(p => p.slug !== hero.slug) : nonPinned;
42
- const shuffledSlice = shuffledNonPinned.filter(p => shufflePool.some(q => q.slug === p.slug)).slice(0, shuffleSlots);
50
+ const fillPool = heroIsNonPinned ? orderedNonPinned.filter(p => p.slug !== hero.slug) : orderedNonPinned;
51
+ const fillSlice = fillPool.slice(0, fillSlots);
43
52
 
44
- return [hero, ...fixedSecondaries, ...shuffledSlice];
53
+ return [hero, ...fixedSecondaries, ...fillSlice];
45
54
  }
46
55
 
47
- export default function FeaturedStoriesSection({ allFeatured, maxItems }: FeaturedStoriesSectionProps) {
56
+ export default function FeaturedStoriesSection({ allFeatured, maxItems, order = 'shuffle' }: FeaturedStoriesSectionProps) {
48
57
  const { t } = useLanguage();
49
58
 
50
59
  const nonPinned = allFeatured.filter(p => !p.pinned);
51
60
 
52
- // Use a daily seed so SSR and client hydration agree on the initial order,
53
- // preventing a visible reshuffle flash on page load.
54
- const [shuffledNonPinned, setShuffledNonPinned] = useState<FeaturedPost[]>(() => {
55
- const dailySeed = Math.floor(Date.now() / 86400000);
56
- return shuffleSeeded(nonPinned, dailySeed);
57
- });
61
+ // SSR renders the canonical input order so server and client agree on first paint.
62
+ // For 'shuffle', the post-mount useEffect swaps to a fresh random permutation,
63
+ // so every reload re-rolls without any hydration mismatch.
64
+ const [orderedNonPinned, setOrderedNonPinned] = useState<FeaturedPost[]>(() => canonicalOrder(nonPinned, order));
65
+
66
+ // Shuffle on mount so every reload re-rolls. SSR's canonical render is stable; the
67
+ // post-hydration swap is the intentional client-only behaviour, not a sync issue.
68
+ useEffect(() => {
69
+ if (order === 'shuffle') {
70
+ // eslint-disable-next-line react-hooks/set-state-in-effect
71
+ setOrderedNonPinned(shuffle(nonPinned));
72
+ }
73
+ // eslint-disable-next-line react-hooks/exhaustive-deps
74
+ }, [allFeatured, order]);
58
75
 
59
76
  const handleShuffle = useCallback(() => {
60
- setShuffledNonPinned(shuffle(nonPinned));
77
+ setOrderedNonPinned(shuffle(nonPinned));
61
78
  // eslint-disable-next-line react-hooks/exhaustive-deps
62
79
  }, [allFeatured]);
63
80
 
64
- const displayed = buildDisplayed(allFeatured, maxItems, shuffledNonPinned);
81
+ const displayed = buildDisplayed(allFeatured, maxItems, orderedNonPinned);
65
82
 
66
83
  if (displayed.length === 0) return null;
67
84
 
68
- // Show shuffle button only when there are more non-pinned posts than available shuffle slots
85
+ // Show shuffle button only when shuffling AND there's at least one non-pinned slot
86
+ // AND there are more non-pinned posts than available slots
69
87
  const pinned = allFeatured.filter(p => p.pinned);
70
88
  const fixedCount = 1 + Math.min(pinned.slice(1).length, maxItems - 1);
71
89
  const shuffleSlots = Math.max(0, maxItems - fixedCount);
72
- const canShuffle = nonPinned.length > shuffleSlots + (pinned.length === 0 ? 1 : 0);
90
+ const canShuffle =
91
+ order === 'shuffle'
92
+ && shuffleSlots > 0
93
+ && nonPinned.length > shuffleSlots + (pinned.length === 0 ? 1 : 0);
73
94
 
74
95
  const [hero, ...secondary] = displayed;
75
96
 
@@ -11,7 +11,7 @@ export default function Footer() {
11
11
  const { t, language } = useLanguage();
12
12
 
13
13
  return (
14
- <footer className="bg-muted/5 border-t border-muted/10 mt-auto select-none">
14
+ <footer data-site-footer className="bg-muted/5 border-t border-muted/10 mt-auto select-none">
15
15
  <div className="max-w-6xl mx-auto px-6 py-10 lg:py-16">
16
16
  <div className="grid grid-cols-2 lg:grid-cols-4 gap-8 lg:gap-12 mb-10 lg:mb-12">
17
17
  {/* Brand */}
@@ -0,0 +1,130 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react';
4
+ import ImmersiveReaderTopBar from '@/components/ImmersiveReaderTopBar';
5
+ import {
6
+ useImmersiveReading,
7
+ type ReadingColumnWidth,
8
+ type ReadingFontSize,
9
+ } from '@/components/ImmersiveReadingProvider';
10
+
11
+ const FONT_SIZE_REM: Record<ReadingFontSize, string> = {
12
+ s: '1rem',
13
+ m: '1.125rem',
14
+ l: '1.25rem',
15
+ xl: '1.5rem',
16
+ };
17
+
18
+ const COLUMN_WIDTH_CLASS: Record<ReadingColumnWidth, string> = {
19
+ narrow: 'max-w-2xl',
20
+ medium: 'max-w-3xl',
21
+ wide: 'max-w-4xl',
22
+ full: 'max-w-none',
23
+ };
24
+
25
+ const COLUMN_PADDING_CLASS: Record<ReadingColumnWidth, string> = {
26
+ narrow: 'px-6 sm:px-8',
27
+ medium: 'px-6 sm:px-8',
28
+ wide: 'px-6 sm:px-8',
29
+ full: 'px-6 sm:px-10',
30
+ };
31
+
32
+ interface ImmersiveReaderProps {
33
+ /** Breadcrumb root link — book detail page for books, series index for series. */
34
+ rootHref: string;
35
+ /** Left side of the top-bar breadcrumb (book title or series title). */
36
+ rootTitle: string;
37
+ /** Right side of the breadcrumb (chapter or post title). */
38
+ currentTitle: string;
39
+ /** Pre-rendered sidebar element. Caller passes `<BookSidebar mode="fill" ...>`
40
+ * or `<SeriesList mode="fill" ...>` so the overlay stays content-type-agnostic. */
41
+ sidebar: ReactNode;
42
+ children: ReactNode;
43
+ }
44
+
45
+ export default function ImmersiveReader({
46
+ rootHref,
47
+ rootTitle,
48
+ currentTitle,
49
+ sidebar,
50
+ children,
51
+ }: ImmersiveReaderProps) {
52
+ const { fontSize, readingTheme, columnWidth, sidebarOpen } = useImmersiveReading();
53
+
54
+ const mainRef = useRef<HTMLElement>(null);
55
+ const [progress, setProgress] = useState(0);
56
+
57
+ useEffect(() => {
58
+ const main = mainRef.current;
59
+ if (!main) return;
60
+ let rafId = 0;
61
+ const compute = () => {
62
+ const scrollable = main.scrollHeight - main.clientHeight;
63
+ const pct =
64
+ scrollable > 0
65
+ ? Math.min(100, Math.max(0, (main.scrollTop / scrollable) * 100))
66
+ : 0;
67
+ setProgress(pct);
68
+ };
69
+ const onScroll = () => {
70
+ cancelAnimationFrame(rafId);
71
+ rafId = requestAnimationFrame(compute);
72
+ };
73
+ compute();
74
+ main.addEventListener('scroll', onScroll, { passive: true });
75
+ return () => {
76
+ cancelAnimationFrame(rafId);
77
+ main.removeEventListener('scroll', onScroll);
78
+ };
79
+ }, []);
80
+
81
+ const overlayStyle: CSSProperties = {
82
+ ['--reading-font-size' as keyof CSSProperties]: FONT_SIZE_REM[fontSize],
83
+ } as CSSProperties;
84
+
85
+ return (
86
+ <div
87
+ data-reader-overlay
88
+ data-reading-theme={readingTheme}
89
+ style={overlayStyle}
90
+ // `dark` is added when readingTheme === 'dark' so Tailwind's `dark:`
91
+ // variants (notably `dark:prose-invert` in MarkdownRenderer) activate
92
+ // inside the overlay even when the site itself is in light mode.
93
+ className={`fixed inset-0 z-40 flex flex-col bg-background text-foreground ${
94
+ readingTheme === 'dark' ? 'dark' : ''
95
+ }`}
96
+ role="dialog"
97
+ aria-modal="true"
98
+ aria-label={rootTitle}
99
+ >
100
+ <ImmersiveReaderTopBar
101
+ rootHref={rootHref}
102
+ rootTitle={rootTitle}
103
+ currentTitle={currentTitle}
104
+ />
105
+
106
+ {progress > 0 && (
107
+ <div className="h-0.5 w-full bg-muted/10 shrink-0">
108
+ <div
109
+ className="h-full bg-accent/70 transition-[width] duration-150 ease-out"
110
+ style={{ width: `${progress}%` }}
111
+ />
112
+ </div>
113
+ )}
114
+
115
+ <div className="flex-1 min-h-0 flex">
116
+ {sidebarOpen && (
117
+ <aside className="w-[280px] shrink-0 border-r border-muted/15 bg-background/60">
118
+ {sidebar}
119
+ </aside>
120
+ )}
121
+
122
+ <main ref={mainRef} className="flex-1 min-w-0 overflow-y-auto">
123
+ <article className={`${COLUMN_WIDTH_CLASS[columnWidth]} mx-auto ${COLUMN_PADDING_CLASS[columnWidth]} py-10`}>
124
+ {children}
125
+ </article>
126
+ </main>
127
+ </div>
128
+ </div>
129
+ );
130
+ }