@jant/core 0.3.36 → 0.3.37

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 (271) hide show
  1. package/bin/commands/export.js +1 -1
  2. package/bin/commands/import-site.js +529 -0
  3. package/bin/commands/reset-password.js +3 -2
  4. package/dist/client/assets/heic-to-DIRPI3VF.js +1 -0
  5. package/dist/client/assets/url-FWFqPJPb.js +1 -0
  6. package/dist/client/client.css +1 -1
  7. package/dist/client/client.js +4012 -3276
  8. package/dist/index.js +10285 -5809
  9. package/package.json +11 -3
  10. package/src/__tests__/helpers/app.ts +9 -9
  11. package/src/__tests__/helpers/db.ts +91 -93
  12. package/src/app.tsx +157 -27
  13. package/src/auth.ts +20 -2
  14. package/src/client/archive-nav.js +187 -0
  15. package/src/client/audio-player.ts +478 -0
  16. package/src/client/audio-processor.ts +84 -0
  17. package/src/client/avatar-upload.ts +3 -2
  18. package/src/client/components/__tests__/jant-compose-dialog.test.ts +645 -49
  19. package/src/client/components/__tests__/jant-compose-editor.test.ts +208 -16
  20. package/src/client/components/__tests__/jant-post-form.test.ts +19 -9
  21. package/src/client/components/__tests__/jant-settings-avatar.test.ts +2 -2
  22. package/src/client/components/__tests__/jant-settings-general.test.ts +3 -3
  23. package/src/client/components/collection-sidebar-types.ts +7 -9
  24. package/src/client/components/compose-types.ts +101 -4
  25. package/src/client/components/jant-collection-form.ts +43 -7
  26. package/src/client/components/jant-collection-sidebar.ts +88 -84
  27. package/src/client/components/jant-compose-dialog.ts +1655 -219
  28. package/src/client/components/jant-compose-editor.ts +732 -168
  29. package/src/client/components/jant-compose-fullscreen.ts +23 -78
  30. package/src/client/components/jant-media-lightbox.ts +2 -0
  31. package/src/client/components/jant-nav-manager.ts +24 -284
  32. package/src/client/components/jant-post-form.ts +89 -9
  33. package/src/client/components/jant-post-menu.ts +1019 -0
  34. package/src/client/components/jant-settings-avatar.ts +3 -3
  35. package/src/client/components/jant-settings-general.ts +38 -4
  36. package/src/client/components/jant-text-preview.ts +232 -0
  37. package/src/client/components/nav-manager-types.ts +4 -19
  38. package/src/client/components/post-form-template.ts +107 -12
  39. package/src/client/components/post-form-types.ts +11 -4
  40. package/src/client/compose-bridge.ts +410 -109
  41. package/src/client/image-processor.ts +26 -8
  42. package/src/client/media-metadata.ts +247 -0
  43. package/src/client/multipart-upload.ts +160 -0
  44. package/src/client/post-form-bridge.ts +52 -1
  45. package/src/client/settings-bridge.ts +0 -12
  46. package/src/client/thread-context.ts +140 -0
  47. package/src/client/tiptap/create-editor.ts +46 -0
  48. package/src/client/tiptap/extensions.ts +5 -0
  49. package/src/client/tiptap/image-node.ts +2 -8
  50. package/src/client/tiptap/paste-image.ts +2 -13
  51. package/src/client/tiptap/slash-commands.ts +173 -63
  52. package/src/client/toast.ts +101 -3
  53. package/src/client/types/sortablejs.d.ts +15 -0
  54. package/src/client/upload-with-metadata.ts +54 -0
  55. package/src/client/video-processor.ts +207 -0
  56. package/src/client.ts +5 -2
  57. package/src/db/__tests__/migrations.test.ts +118 -0
  58. package/src/db/index.ts +52 -0
  59. package/src/db/migrations/0000_baseline.sql +269 -0
  60. package/src/db/migrations/0001_fts_setup.sql +31 -0
  61. package/src/db/migrations/meta/0000_snapshot.json +703 -119
  62. package/src/db/migrations/meta/0001_snapshot.json +1337 -0
  63. package/src/db/migrations/meta/_journal.json +4 -39
  64. package/src/db/schema.ts +409 -145
  65. package/src/i18n/__tests__/detect.test.ts +115 -0
  66. package/src/i18n/context.tsx +2 -2
  67. package/src/i18n/detect.ts +85 -1
  68. package/src/i18n/i18n.ts +1 -1
  69. package/src/i18n/index.ts +2 -1
  70. package/src/i18n/locales/en.po +487 -1217
  71. package/src/i18n/locales/en.ts +1 -1
  72. package/src/i18n/locales/zh-Hans.po +613 -996
  73. package/src/i18n/locales/zh-Hans.ts +1 -1
  74. package/src/i18n/locales/zh-Hant.po +624 -1007
  75. package/src/i18n/locales/zh-Hant.ts +1 -1
  76. package/src/i18n/middleware.ts +6 -0
  77. package/src/index.ts +5 -7
  78. package/src/lib/__tests__/blurhash-placeholder.test.ts +75 -0
  79. package/src/lib/__tests__/constants.test.ts +0 -1
  80. package/src/lib/__tests__/markdown-to-tiptap.test.ts +358 -0
  81. package/src/lib/__tests__/nanoid.test.ts +26 -0
  82. package/src/lib/__tests__/schemas.test.ts +181 -63
  83. package/src/lib/__tests__/slug.test.ts +126 -0
  84. package/src/lib/__tests__/sse.test.ts +6 -6
  85. package/src/lib/__tests__/summary.test.ts +264 -0
  86. package/src/lib/__tests__/theme.test.ts +1 -1
  87. package/src/lib/__tests__/timeline.test.ts +33 -30
  88. package/src/lib/__tests__/tiptap-to-markdown.test.ts +346 -0
  89. package/src/lib/__tests__/view.test.ts +141 -66
  90. package/src/lib/blurhash-placeholder.ts +102 -0
  91. package/src/lib/constants.ts +3 -1
  92. package/src/lib/emoji-catalog.ts +885 -68
  93. package/src/lib/errors.ts +11 -8
  94. package/src/lib/feed.ts +78 -32
  95. package/src/lib/html.ts +2 -1
  96. package/src/lib/icon-catalog.ts +5033 -1
  97. package/src/lib/icons.ts +3 -2
  98. package/src/lib/index.ts +0 -1
  99. package/src/lib/markdown-to-tiptap.ts +286 -0
  100. package/src/lib/media-helpers.ts +12 -3
  101. package/src/lib/nanoid.ts +29 -0
  102. package/src/lib/navigation.ts +1 -1
  103. package/src/lib/render.tsx +20 -2
  104. package/src/lib/resolve-config.ts +6 -2
  105. package/src/lib/schemas.ts +224 -55
  106. package/src/lib/search-snippet.ts +34 -0
  107. package/src/lib/slug.ts +96 -0
  108. package/src/lib/sse.ts +6 -6
  109. package/src/lib/storage.ts +115 -7
  110. package/src/lib/summary.ts +66 -0
  111. package/src/lib/theme.ts +11 -8
  112. package/src/lib/timeline.ts +74 -34
  113. package/src/lib/tiptap-render.ts +5 -10
  114. package/src/lib/tiptap-to-markdown.ts +305 -0
  115. package/src/lib/upload.ts +190 -29
  116. package/src/lib/url.ts +31 -0
  117. package/src/lib/view.ts +204 -37
  118. package/src/middleware/__tests__/auth.test.ts +191 -11
  119. package/src/middleware/__tests__/onboarding.test.ts +12 -10
  120. package/src/middleware/auth.ts +63 -9
  121. package/src/middleware/onboarding.ts +1 -1
  122. package/src/middleware/secure-headers.ts +40 -0
  123. package/src/preset.css +45 -2
  124. package/src/routes/__tests__/compose.test.ts +17 -24
  125. package/src/routes/api/__tests__/collections.test.ts +109 -61
  126. package/src/routes/api/__tests__/nav-items.test.ts +46 -29
  127. package/src/routes/api/__tests__/posts.test.ts +132 -68
  128. package/src/routes/api/__tests__/search.test.ts +15 -2
  129. package/src/routes/api/__tests__/upload-multipart.test.ts +534 -0
  130. package/src/routes/api/collections.ts +51 -42
  131. package/src/routes/api/custom-urls.ts +80 -0
  132. package/src/routes/api/export.ts +31 -0
  133. package/src/routes/api/nav-items.ts +23 -19
  134. package/src/routes/api/posts.ts +43 -39
  135. package/src/routes/api/search.ts +3 -4
  136. package/src/routes/api/upload-multipart.ts +245 -0
  137. package/src/routes/api/upload.ts +85 -19
  138. package/src/routes/auth/__tests__/setup.test.ts +20 -60
  139. package/src/routes/auth/setup.tsx +26 -33
  140. package/src/routes/auth/signin.tsx +3 -7
  141. package/src/routes/compose.tsx +10 -55
  142. package/src/routes/dash/__tests__/settings-avatar.test.ts +1 -1
  143. package/src/routes/dash/custom-urls.tsx +414 -0
  144. package/src/routes/dash/settings.tsx +304 -232
  145. package/src/routes/feed/__tests__/rss.test.ts +27 -28
  146. package/src/routes/feed/rss.ts +6 -4
  147. package/src/routes/feed/sitemap.ts +2 -12
  148. package/src/routes/pages/__tests__/collections.test.ts +5 -6
  149. package/src/routes/pages/__tests__/featured.test.ts +41 -22
  150. package/src/routes/pages/archive.tsx +175 -39
  151. package/src/routes/pages/collection.tsx +22 -10
  152. package/src/routes/pages/collections.tsx +3 -3
  153. package/src/routes/pages/featured.tsx +28 -4
  154. package/src/routes/pages/home.tsx +16 -15
  155. package/src/routes/pages/latest.tsx +1 -11
  156. package/src/routes/pages/new.tsx +39 -0
  157. package/src/routes/pages/page.tsx +95 -49
  158. package/src/routes/pages/search.tsx +1 -1
  159. package/src/services/__tests__/api-token.test.ts +135 -0
  160. package/src/services/__tests__/collection.test.ts +275 -227
  161. package/src/services/__tests__/custom-url.test.ts +213 -0
  162. package/src/services/__tests__/media.test.ts +162 -22
  163. package/src/services/__tests__/navigation.test.ts +109 -68
  164. package/src/services/__tests__/post-timeline.test.ts +205 -32
  165. package/src/services/__tests__/post.test.ts +713 -234
  166. package/src/services/__tests__/search.test.ts +67 -10
  167. package/src/services/api-token.ts +166 -0
  168. package/src/services/auth.ts +17 -2
  169. package/src/services/collection.ts +397 -131
  170. package/src/services/custom-url.ts +188 -0
  171. package/src/services/export.ts +802 -0
  172. package/src/services/index.ts +26 -19
  173. package/src/services/media.ts +100 -22
  174. package/src/services/navigation.ts +158 -47
  175. package/src/services/path.ts +339 -0
  176. package/src/services/post.ts +687 -154
  177. package/src/services/search.ts +160 -75
  178. package/src/styles/components.css +58 -7
  179. package/src/styles/tokens.css +84 -6
  180. package/src/styles/ui.css +2964 -457
  181. package/src/types/bindings.ts +4 -1
  182. package/src/types/config.ts +12 -4
  183. package/src/types/constants.ts +15 -3
  184. package/src/types/entities.ts +74 -35
  185. package/src/types/operations.ts +11 -24
  186. package/src/types/props.ts +51 -16
  187. package/src/types/views.ts +45 -22
  188. package/src/ui/color-themes.ts +133 -23
  189. package/src/ui/compose/ComposeDialog.tsx +239 -17
  190. package/src/ui/compose/ComposePrompt.tsx +1 -1
  191. package/src/ui/dash/CrudPageHeader.tsx +1 -1
  192. package/src/ui/dash/ListItemRow.tsx +1 -1
  193. package/src/ui/dash/StatusBadge.tsx +3 -1
  194. package/src/ui/dash/appearance/AdvancedContent.tsx +22 -1
  195. package/src/ui/dash/appearance/ColorThemeContent.tsx +22 -2
  196. package/src/ui/dash/appearance/FontThemeContent.tsx +1 -1
  197. package/src/ui/dash/appearance/NavigationContent.tsx +5 -45
  198. package/src/ui/dash/index.ts +0 -3
  199. package/src/ui/dash/settings/AccountContent.tsx +3 -57
  200. package/src/ui/dash/settings/AccountMenuContent.tsx +147 -0
  201. package/src/ui/dash/settings/ApiTokensContent.tsx +232 -0
  202. package/src/ui/dash/settings/AvatarContent.tsx +8 -0
  203. package/src/ui/dash/settings/SessionsContent.tsx +159 -0
  204. package/src/ui/dash/settings/SettingsRootContent.tsx +45 -15
  205. package/src/ui/feed/LinkCard.tsx +89 -40
  206. package/src/ui/feed/NoteCard.tsx +39 -25
  207. package/src/ui/feed/PostStatusBadges.tsx +67 -0
  208. package/src/ui/feed/QuoteCard.tsx +38 -23
  209. package/src/ui/feed/ThreadPreview.tsx +90 -26
  210. package/src/ui/feed/TimelineFeed.tsx +3 -2
  211. package/src/ui/feed/TimelineItem.tsx +15 -6
  212. package/src/ui/feed/__tests__/thread-preview.test.ts +107 -0
  213. package/src/ui/feed/thread-preview-state.ts +61 -0
  214. package/src/ui/font-themes.ts +2 -2
  215. package/src/ui/layouts/BaseLayout.tsx +2 -2
  216. package/src/ui/layouts/SiteLayout.tsx +105 -92
  217. package/src/ui/pages/ArchivePage.tsx +923 -98
  218. package/src/ui/pages/ComposePage.tsx +54 -0
  219. package/src/ui/pages/PostPage.tsx +30 -45
  220. package/src/ui/pages/SearchPage.tsx +181 -37
  221. package/src/ui/shared/AdminBreadcrumb.tsx +29 -0
  222. package/src/ui/shared/CollectionsSidebar.tsx +47 -37
  223. package/src/ui/shared/MediaGallery.tsx +445 -149
  224. package/src/ui/shared/PostFooter.tsx +204 -0
  225. package/src/ui/shared/StarRating.tsx +27 -0
  226. package/src/ui/shared/__tests__/format-chars.test.ts +35 -0
  227. package/src/ui/shared/index.ts +0 -1
  228. package/dist/client/assets/url-8Dj-5CLW.js +0 -1
  229. package/src/client/media-upload.ts +0 -161
  230. package/src/client/page-slug-bridge.ts +0 -42
  231. package/src/db/migrations/0000_square_wallflower.sql +0 -118
  232. package/src/db/migrations/0001_add_search_fts.sql +0 -34
  233. package/src/db/migrations/0002_add_media_attachments.sql +0 -3
  234. package/src/db/migrations/0003_add_navigation_links.sql +0 -8
  235. package/src/db/migrations/0004_add_storage_provider.sql +0 -3
  236. package/src/db/migrations/0005_v2_schema_migration.sql +0 -268
  237. package/src/db/migrations/0006_rename_slug_to_path.sql +0 -5
  238. package/src/db/migrations/0007_post_collections_m2m.sql +0 -94
  239. package/src/db/migrations/0008_add_collection_dividers.sql +0 -8
  240. package/src/db/migrations/0009_drop_collection_show_divider.sql +0 -2
  241. package/src/db/migrations/0010_add_performance_indexes.sql +0 -16
  242. package/src/db/migrations/0011_add_path_registry.sql +0 -23
  243. package/src/db/migrations/0012_add_tiptap_columns.sql +0 -2
  244. package/src/db/migrations/0013_replace_featured_with_visibility.sql +0 -8
  245. package/src/db/migrations/meta/0003_snapshot.json +0 -821
  246. package/src/lib/__tests__/sqid.test.ts +0 -65
  247. package/src/lib/sqid.ts +0 -79
  248. package/src/routes/api/__tests__/pages.test.ts +0 -218
  249. package/src/routes/api/pages.ts +0 -73
  250. package/src/routes/dash/__tests__/pages.test.ts +0 -226
  251. package/src/routes/dash/index.tsx +0 -109
  252. package/src/routes/dash/media.tsx +0 -135
  253. package/src/routes/dash/pages.tsx +0 -245
  254. package/src/routes/dash/posts.tsx +0 -338
  255. package/src/routes/dash/redirects.tsx +0 -263
  256. package/src/routes/pages/post.tsx +0 -59
  257. package/src/services/__tests__/page.test.ts +0 -298
  258. package/src/services/__tests__/path-registry.test.ts +0 -165
  259. package/src/services/__tests__/redirect.test.ts +0 -159
  260. package/src/services/page.ts +0 -216
  261. package/src/services/path-registry.ts +0 -160
  262. package/src/services/redirect.ts +0 -97
  263. package/src/ui/dash/PageForm.tsx +0 -187
  264. package/src/ui/dash/PostList.tsx +0 -95
  265. package/src/ui/dash/media/MediaListContent.tsx +0 -206
  266. package/src/ui/dash/media/ViewMediaContent.tsx +0 -208
  267. package/src/ui/dash/pages/PagesContent.tsx +0 -75
  268. package/src/ui/dash/posts/PostForm.tsx +0 -260
  269. package/src/ui/layouts/DashLayout.tsx +0 -247
  270. package/src/ui/pages/SinglePage.tsx +0 -23
  271. package/src/ui/shared/ThreadView.tsx +0 -136
@@ -12,30 +12,51 @@
12
12
 
13
13
  import type { FC } from "hono/jsx";
14
14
  import type { TimelineCardProps } from "../../types.js";
15
+ import { StarRating } from "../shared/StarRating.js";
16
+ import { PostFooter } from "../shared/PostFooter.js";
17
+ import { PostStatusBadges } from "./PostStatusBadges.js";
18
+ import { sanitizeUrl } from "../../lib/url.js";
19
+
20
+ export const QuoteCard: FC<TimelineCardProps> = ({
21
+ post,
22
+ mode = "feed",
23
+ display,
24
+ }) => {
25
+ const isCompact = mode === "compact";
26
+ const isDetail = mode === "detail";
27
+ const articleClass = `h-entry post-menu-target${isCompact ? " feed-compact" : isDetail ? " py-6" : " feed-quote-post"}`;
28
+ const safeUrl = post.url ? sanitizeUrl(post.url) : "";
29
+ const commentaryHtml = post.bodyHtml ?? null;
15
30
 
16
- export const QuoteCard: FC<TimelineCardProps> = ({ post, compact }) => {
17
31
  return (
18
32
  <article
19
- class={`h-entry${compact ? " feed-compact" : ""}`}
33
+ class={articleClass}
34
+ {...(isDetail ? { "data-page": "post" } : {})}
20
35
  data-post
21
36
  data-format="quote"
37
+ data-post-id={post.id}
38
+ data-post-permalink={post.permalink}
39
+ {...(post.pinned ? { "data-post-pinned": "" } : {})}
40
+ {...(post.featured ? { "data-post-featured": "" } : {})}
41
+ data-post-visibility={post.visibility}
42
+ {...(!isDetail && post.threadRootId ? { "data-post-reply": "" } : {})}
22
43
  >
44
+ {!isCompact && !display?.hideStatusBadges && <PostStatusBadges />}
23
45
  {post.quoteText && (
24
- <blockquote class="feed-quote">
46
+ <blockquote class={`feed-quote${isCompact ? "" : " feed-quote-card"}`}>
25
47
  <div
26
- class={`e-content ${compact ? "text-sm" : "text-base"} leading-relaxed`}
48
+ class={`e-content feed-quote-content${isCompact ? " text-sm" : ""}`}
27
49
  >
28
50
  {post.quoteText}
29
51
  </div>
30
52
  </blockquote>
31
53
  )}
32
- {!compact && (post.title || post.url) && (
33
- <div class="mt-2 text-sm text-muted-foreground">
34
- &mdash;{" "}
35
- {post.url ? (
54
+ {!isCompact && (post.title || safeUrl) && (
55
+ <div class="feed-quote-attribution">
56
+ {safeUrl ? (
36
57
  <a
37
- href={post.url}
38
- class="hover:underline"
58
+ href={safeUrl}
59
+ class="feed-quote-source"
39
60
  target="_blank"
40
61
  rel="noopener noreferrer"
41
62
  >
@@ -46,23 +67,17 @@ export const QuoteCard: FC<TimelineCardProps> = ({ post, compact }) => {
46
67
  )}
47
68
  </div>
48
69
  )}
49
- {!compact && post.bodyHtml && (
70
+ {!isCompact && commentaryHtml && (
50
71
  <div
51
- class="mt-3 prose text-muted-foreground"
72
+ class="feed-quote-commentary prose text-muted-foreground"
52
73
  data-post-body
53
- dangerouslySetInnerHTML={{ __html: post.bodyHtml }}
74
+ dangerouslySetInnerHTML={{ __html: commentaryHtml }}
54
75
  />
55
76
  )}
56
- <footer class="mt-2" data-post-meta>
57
- <a
58
- href={post.permalink}
59
- class="u-url text-xs text-muted-foreground hover:underline"
60
- >
61
- <time class="dt-published" datetime={post.publishedAt}>
62
- {post.publishedAtFormatted}
63
- </time>
64
- </a>
65
- </footer>
77
+ {!isCompact && !display?.hideRating && (
78
+ <StarRating rating={post.rating} />
79
+ )}
80
+ <PostFooter post={post} detail={isDetail} display={display?.footer} />
66
81
  </article>
67
82
  );
68
83
  };
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * Thread Preview
3
3
  *
4
- * Root post + vertical line connector + compact replies underneath.
4
+ * Shows latest reply as the hero post with faded ancestor context above.
5
+ * Thread line connects all posts via `.thread-group` / `.thread-item`.
5
6
  */
6
7
 
7
8
  import type { FC } from "hono/jsx";
@@ -9,40 +10,103 @@ import { useLingui } from "@lingui/react/macro";
9
10
  import type { ThreadPreviewProps } from "../../types.js";
10
11
  import { TimelineItem } from "./TimelineItem.js";
11
12
  import { TimelineItemFromPost } from "./TimelineItem.js";
13
+ import {
14
+ getThreadPreviewState,
15
+ isThreadContextLikelyOverflow,
16
+ } from "./thread-preview-state.js";
17
+
18
+ const THREAD_CONTEXT_DISPLAY = {
19
+ hideStatusBadges: true,
20
+ hideRating: true,
21
+ footer: {
22
+ hideActions: true,
23
+ },
24
+ } as const;
12
25
 
13
26
  export const ThreadPreview: FC<ThreadPreviewProps> = ({
14
27
  rootPost,
15
- previewReplies,
28
+ latestReply,
29
+ parentReply,
16
30
  totalReplyCount,
17
31
  }) => {
18
32
  const { t } = useLingui();
19
- const remainingCount = totalReplyCount - previewReplies.length;
33
+ const showMoreLabel = t({
34
+ message: "Show more",
35
+ comment: "@context: Button to expand faded thread context",
36
+ });
37
+ const showLessLabel = t({
38
+ message: "Show less",
39
+ comment: "@context: Button to collapse expanded thread context",
40
+ });
41
+ const { hiddenCount } = getThreadPreviewState({
42
+ hasParentReply: parentReply !== undefined,
43
+ totalReplyCount,
44
+ });
45
+ const startsCollapsedWithAffordances = isThreadContextLikelyOverflow({
46
+ rootPost,
47
+ parentReply,
48
+ hiddenCount,
49
+ });
20
50
 
21
51
  return (
22
- <div>
23
- <TimelineItem item={{ post: rootPost }} />
24
- {previewReplies.length > 0 && (
25
- <div class="feed-replies">
26
- {previewReplies.map((reply) => (
27
- <div key={reply.id} class="feed-reply">
28
- <TimelineItemFromPost post={reply} compact />
29
- </div>
30
- ))}
31
- {remainingCount > 0 && (
32
- <div class="feed-reply">
33
- <a
34
- href={rootPost.permalink}
35
- class="text-sm text-muted-foreground hover:text-foreground hover:underline"
36
- >
37
- {t({
38
- message: `Show ${remainingCount} more ${remainingCount === 1 ? "reply" : "replies"}`,
39
- comment: "@context: Link to show remaining thread replies",
40
- })}
41
- </a>
42
- </div>
43
- )}
52
+ <div class="thread-group thread-group-preview">
53
+ {/* Faded ancestor context */}
54
+ <div
55
+ class={`thread-context-shell thread-context-collapsed${startsCollapsedWithAffordances ? " thread-context-faded" : ""}`}
56
+ data-thread-context
57
+ >
58
+ {/* Root post */}
59
+ <div class="thread-item thread-item-context">
60
+ <TimelineItemFromPost
61
+ post={rootPost}
62
+ mode="feed"
63
+ display={THREAD_CONTEXT_DISPLAY}
64
+ />
44
65
  </div>
45
- )}
66
+
67
+ {/* Hidden posts gap */}
68
+ {hiddenCount > 0 && (
69
+ <div class="thread-item thread-item-gap">
70
+ <a href={latestReply.permalink} class="thread-gap-link">
71
+ {t({
72
+ message: `${hiddenCount} more ${hiddenCount === 1 ? "post" : "posts"}`,
73
+ comment:
74
+ "@context: Link showing count of hidden thread posts between root and latest",
75
+ })}
76
+ </a>
77
+ </div>
78
+ )}
79
+
80
+ {/* Parent of latest reply */}
81
+ {parentReply && (
82
+ <div class="thread-item thread-item-context">
83
+ <TimelineItemFromPost
84
+ post={parentReply}
85
+ mode="feed"
86
+ display={THREAD_CONTEXT_DISPLAY}
87
+ />
88
+ </div>
89
+ )}
90
+
91
+ <div class="thread-context-fade" />
92
+ </div>
93
+
94
+ {/* Toggle button */}
95
+ <button
96
+ type="button"
97
+ class={`thread-context-toggle text-xs text-muted-foreground hover:text-foreground${startsCollapsedWithAffordances ? "" : " hidden"}`}
98
+ data-thread-context-toggle
99
+ data-label-more={showMoreLabel}
100
+ data-label-less={showLessLabel}
101
+ aria-expanded="false"
102
+ >
103
+ {showMoreLabel}
104
+ </button>
105
+
106
+ {/* Latest reply (full card, hero) */}
107
+ <div class="thread-item thread-item-hero">
108
+ <TimelineItem item={{ post: latestReply }} />
109
+ </div>
46
110
  </div>
47
111
  );
48
112
  };
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Timeline Feed
3
3
  *
4
- * Flat list of posts separated by simple dividers.
4
+ * Flat list of posts separated by lightweight dividers.
5
5
  */
6
6
 
7
7
  import type { FC } from "hono/jsx";
@@ -25,7 +25,8 @@ export const TimelineFeed: FC<TimelineFeedProps> = ({
25
25
  {item.threadPreview ? (
26
26
  <ThreadPreview
27
27
  rootPost={item.post}
28
- previewReplies={item.threadPreview.replies}
28
+ latestReply={item.threadPreview.latestReply}
29
+ parentReply={item.threadPreview.parentReply}
29
30
  totalReplyCount={item.threadPreview.totalReplyCount}
30
31
  />
31
32
  ) : (
@@ -8,8 +8,10 @@ import type { FC } from "hono/jsx";
8
8
  import type {
9
9
  TimelineItemView,
10
10
  TimelineCardProps,
11
+ TimelineCardDisplayOptions,
11
12
  PostView,
12
13
  Format,
14
+ CardMode,
13
15
  } from "../../types.js";
14
16
  import { NoteCard } from "./NoteCard.js";
15
17
  import { LinkCard } from "./LinkCard.js";
@@ -23,23 +25,30 @@ const CARD_MAP: Record<Format, FC<TimelineCardProps>> = {
23
25
 
24
26
  interface TimelineItemProps {
25
27
  item: TimelineItemView;
26
- compact?: boolean;
28
+ mode?: CardMode;
29
+ display?: TimelineCardDisplayOptions;
27
30
  }
28
31
 
29
32
  interface TimelineItemFromPostProps {
30
33
  post: PostView;
31
- compact?: boolean;
34
+ mode?: CardMode;
35
+ display?: TimelineCardDisplayOptions;
32
36
  }
33
37
 
34
- export const TimelineItem: FC<TimelineItemProps> = ({ item, compact }) => {
38
+ export const TimelineItem: FC<TimelineItemProps> = ({
39
+ item,
40
+ mode,
41
+ display,
42
+ }) => {
35
43
  const Card = CARD_MAP[item.post.format];
36
- return <Card post={item.post} compact={compact} />;
44
+ return <Card post={item.post} mode={mode} display={display} />;
37
45
  };
38
46
 
39
47
  export const TimelineItemFromPost: FC<TimelineItemFromPostProps> = ({
40
48
  post,
41
- compact,
49
+ mode,
50
+ display,
42
51
  }) => {
43
52
  const Card = CARD_MAP[post.format];
44
- return <Card post={post} compact={compact} />;
53
+ return <Card post={post} mode={mode} display={display} />;
45
54
  };
@@ -0,0 +1,107 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { PostView } from "../../../types.js";
3
+ import {
4
+ getThreadPreviewState,
5
+ isThreadContextLikelyOverflow,
6
+ } from "../thread-preview-state.js";
7
+
8
+ function createPostView(overrides: Partial<PostView> = {}): PostView {
9
+ return {
10
+ id: "post-1",
11
+ permalink: "/post-1",
12
+ slug: "post-1",
13
+ format: "note",
14
+ status: "published",
15
+ visibility: "public",
16
+ pinned: false,
17
+ featured: false,
18
+ publishedAt: "2026-03-14T00:00:00.000Z",
19
+ publishedAtFormatted: "Mar 14, 2026",
20
+ publishedAtTime: "00:00",
21
+ publishedAtRelative: "now",
22
+ updatedAt: "2026-03-14T00:00:00.000Z",
23
+ media: [],
24
+ collections: [],
25
+ isLastInThread: false,
26
+ ...overrides,
27
+ };
28
+ }
29
+
30
+ describe("getThreadPreviewState", () => {
31
+ it("has no hidden ancestors for a 2-post thread", () => {
32
+ expect(
33
+ getThreadPreviewState({
34
+ hasParentReply: false,
35
+ totalReplyCount: 1,
36
+ }),
37
+ ).toEqual({
38
+ hiddenCount: 0,
39
+ });
40
+ });
41
+
42
+ it("has no hidden ancestors for a 3-post thread with parent context", () => {
43
+ expect(
44
+ getThreadPreviewState({
45
+ hasParentReply: true,
46
+ totalReplyCount: 2,
47
+ }),
48
+ ).toEqual({
49
+ hiddenCount: 0,
50
+ });
51
+ });
52
+
53
+ it("counts hidden ancestors for longer threads", () => {
54
+ expect(
55
+ getThreadPreviewState({
56
+ hasParentReply: true,
57
+ totalReplyCount: 5,
58
+ }),
59
+ ).toEqual({
60
+ hiddenCount: 3,
61
+ });
62
+ });
63
+
64
+ it("treats hidden ancestors as likely overflow", () => {
65
+ expect(
66
+ isThreadContextLikelyOverflow({
67
+ rootPost: createPostView(),
68
+ hiddenCount: 1,
69
+ }),
70
+ ).toBe(true);
71
+ });
72
+
73
+ it("treats media-heavy context as likely overflow", () => {
74
+ expect(
75
+ isThreadContextLikelyOverflow({
76
+ rootPost: createPostView({
77
+ media: [
78
+ {
79
+ id: "media-1",
80
+ url: "/image.jpg",
81
+ thumbnailUrl: "/image-thumb.jpg",
82
+ mimeType: "image/jpeg",
83
+ },
84
+ ],
85
+ }),
86
+ hiddenCount: 0,
87
+ }),
88
+ ).toBe(true);
89
+ });
90
+
91
+ it("keeps very short context collapsed without affordances", () => {
92
+ expect(
93
+ isThreadContextLikelyOverflow({
94
+ rootPost: createPostView({
95
+ bodyHtml: "<p>Short note.</p>",
96
+ }),
97
+ parentReply: createPostView({
98
+ id: "post-2",
99
+ permalink: "/post-2",
100
+ slug: "post-2",
101
+ bodyHtml: "<p>Tiny reply.</p>",
102
+ }),
103
+ hiddenCount: 0,
104
+ }),
105
+ ).toBe(false);
106
+ });
107
+ });
@@ -0,0 +1,61 @@
1
+ import type { PostView } from "../../types.js";
2
+ import { stripHtml } from "../../lib/excerpt.js";
3
+
4
+ export function getThreadPreviewState({
5
+ hasParentReply,
6
+ totalReplyCount,
7
+ }: {
8
+ hasParentReply: boolean;
9
+ totalReplyCount: number;
10
+ }) {
11
+ const hiddenCount = hasParentReply
12
+ ? totalReplyCount - 2 // exclude latest + parent
13
+ : totalReplyCount - 1; // exclude latest only
14
+
15
+ return {
16
+ hiddenCount,
17
+ };
18
+ }
19
+
20
+ function getRenderedTextLength(post?: PostView): number {
21
+ if (!post) return 0;
22
+
23
+ const htmlText = stripHtml(post.summaryHtml ?? post.bodyHtml ?? "");
24
+ return (
25
+ (post.title?.length ?? 0) +
26
+ (post.quoteText?.length ?? 0) +
27
+ (post.excerpt?.length ?? 0) +
28
+ htmlText.length
29
+ );
30
+ }
31
+
32
+ export function isThreadContextLikelyOverflow({
33
+ rootPost,
34
+ parentReply,
35
+ hiddenCount,
36
+ }: {
37
+ rootPost: PostView;
38
+ parentReply?: PostView;
39
+ hiddenCount: number;
40
+ }): boolean {
41
+ if (hiddenCount > 0) return true;
42
+
43
+ const contextPosts = [rootPost, parentReply].filter(
44
+ (post): post is PostView => post !== undefined,
45
+ );
46
+
47
+ if (
48
+ contextPosts.some(
49
+ (post) => post.media.length > 0 || post.summaryHasMore === true,
50
+ )
51
+ ) {
52
+ return true;
53
+ }
54
+
55
+ const renderedTextLength = contextPosts.reduce(
56
+ (sum, post) => sum + getRenderedTextLength(post),
57
+ 0,
58
+ );
59
+
60
+ return renderedTextLength > 220;
61
+ }
@@ -29,7 +29,7 @@ export interface FontTheme {
29
29
 
30
30
  /** System sans-serif stack */
31
31
  const SANS =
32
- 'system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans CJK SC", sans-serif';
32
+ 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Helvetica, Arial, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans CJK SC", sans-serif';
33
33
 
34
34
  /**
35
35
  * Editorial serif stack
@@ -38,7 +38,7 @@ const SANS =
38
38
  * Charter (macOS); Cambria / Sitka Text (Windows); Georgia (universal)
39
39
  */
40
40
  const EDITORIAL_SERIF =
41
- 'ui-serif, "Iowan Old Style", Charter, "Bitstream Charter", Cambria, "Sitka Text", Georgia, "Songti SC", "Noto Serif CJK SC", "STSong", "SimSun", serif';
41
+ 'ui-serif, "New York Small", "New York", "Iowan Old Style", Charter, "Bitstream Charter", Cambria, "Sitka Text", Georgia, "Songti SC", "Noto Serif CJK SC", "STSong", "SimSun", serif';
42
42
 
43
43
  /**
44
44
  * Classical serif stack
@@ -113,11 +113,11 @@ export const BaseLayout: FC<PropsWithChildren<BaseLayoutProps>> = ({
113
113
  {...(isAuthenticated ? { "data-authenticated": true } : {})}
114
114
  >
115
115
  {content}
116
- <div id="toast-container" class="toast-container">
116
+ <div id="toast-container" class="toast-container" popover="manual">
117
117
  {toast && (
118
118
  <div
119
119
  class={`toast ${toast.type === "error" ? "toast-error" : "toast-success"}`}
120
- data-init="history.replaceState({}, '', location.pathname); setTimeout(() => { el.classList.add('toast-out'); el.addEventListener('animationend', () => el.remove()) }, 3000)"
120
+ data-init="el.closest('[popover]').showPopover(); history.replaceState({}, '', location.pathname); setTimeout(() => { el.classList.add('toast-out'); el.addEventListener('animationend', () => el.remove()) }, 3000)"
121
121
  >
122
122
  {toast.type === "error" ? (
123
123
  <svg