@jant/core 0.6.14 → 0.6.16

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 (350) hide show
  1. package/bin/commands/import-site.js +88 -13
  2. package/bin/commands/migrate.js +55 -0
  3. package/bin/commands/posts/rebuild-html.js +184 -0
  4. package/bin/commands/site/snapshot/import.js +14 -10
  5. package/bin/lib/d1-query.js +23 -5
  6. package/bin/lib/migration-runner.js +29 -7
  7. package/bin/lib/node-database.js +32 -0
  8. package/bin/lib/site-snapshot.js +347 -46
  9. package/bin/lib/sql-export.js +1 -1
  10. package/bin/lib/thread-collection-migration.js +326 -0
  11. package/dist/app-C75Zc8dD.js +6 -0
  12. package/dist/{app-2i7-WQHg.js → app-C7_4oab9.js} +7774 -6425
  13. package/dist/client/.vite/manifest.json +8 -8
  14. package/dist/client/_assets/chunks/{url-pLre2DM_.js → url-CU9pEbk5.js} +1 -1
  15. package/dist/client/_assets/{client-BvID5ucz.js → client-Bq0vre8Y.js} +59 -38
  16. package/dist/client/_assets/client-D3VZyG4L.css +2 -0
  17. package/dist/client/_assets/client-auth-F2rb9WfF.js +5571 -0
  18. package/dist/{env-OHRKGcMj.js → env-BPYViDJJ.js} +1 -1
  19. package/dist/{export-DWn149b7.js → export-Dl5KCiEs.js} +1348 -128
  20. package/dist/{github-api-BgSiE71w.js → github-api-BNF35Lkx.js} +1 -1
  21. package/dist/{github-app-BbklkFmU.js → github-app-DdiD-bC4.js} +1 -1
  22. package/dist/{github-sync-C0zyF6XS.js → github-sync-Bqg62IvV.js} +3 -3
  23. package/dist/{github-sync-Ckk0sJxK.js → github-sync-HdK2EgvJ.js} +3 -3
  24. package/dist/index.js +5 -5
  25. package/dist/node.js +7 -7
  26. package/dist/{url-BMYO-Zlt.js → url-CbLAtlZe.js} +517 -12
  27. package/package.json +11 -10
  28. package/src/__tests__/bin/posts-rebuild-html.test.ts +137 -0
  29. package/src/__tests__/dev-navigation-defaults.test.ts +22 -0
  30. package/src/__tests__/export-hugo-build.test.ts +63 -4
  31. package/src/__tests__/export-import-roundtrip.test.ts +6 -4
  32. package/src/__tests__/export-service.test.ts +142 -9
  33. package/src/__tests__/helpers/app.ts +3 -0
  34. package/src/__tests__/helpers/export-fixtures.ts +2 -0
  35. package/src/__tests__/import-site-command.test.ts +65 -0
  36. package/src/__tests__/mise-config.test.ts +22 -0
  37. package/src/__tests__/vite-config.test.ts +45 -0
  38. package/src/app.tsx +16 -3
  39. package/src/client/__tests__/collection-created-notice.test.ts +36 -0
  40. package/src/client/__tests__/collection-form-bridge.test.ts +10 -1
  41. package/src/client/__tests__/collection-page-actions.test.ts +71 -2
  42. package/src/client/__tests__/compose-bridge.test.ts +316 -2
  43. package/src/client/__tests__/compose-shortcuts.test.ts +20 -0
  44. package/src/client/__tests__/feed-video-player.test.ts +16 -1
  45. package/src/client/__tests__/footnote-rail.test.ts +472 -0
  46. package/src/client/__tests__/hydrate-partial.test.ts +3 -0
  47. package/src/client/__tests__/toast.test.ts +82 -1
  48. package/src/client/collection-created-notice.ts +66 -0
  49. package/src/client/collection-form-bridge.ts +17 -0
  50. package/src/client/collection-navigation.ts +44 -0
  51. package/src/client/collection-page-actions.ts +57 -1
  52. package/src/client/components/__tests__/compose-format-convert.test.ts +0 -19
  53. package/src/client/components/__tests__/jant-collection-directory.test.ts +155 -6
  54. package/src/client/components/__tests__/jant-command-palette.test.ts +51 -0
  55. package/src/client/components/__tests__/jant-compose-dialog.test.ts +2268 -206
  56. package/src/client/components/__tests__/jant-compose-editor.test.ts +147 -59
  57. package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +114 -64
  58. package/src/client/components/__tests__/jant-config-editor.test.ts +405 -0
  59. package/src/client/components/__tests__/jant-media-lightbox.test.ts +121 -0
  60. package/src/client/components/__tests__/jant-nav-manager.test.ts +538 -2
  61. package/src/client/components/__tests__/jant-post-form.test.ts +2 -2
  62. package/src/client/components/__tests__/jant-post-menu.test.ts +47 -5
  63. package/src/client/components/__tests__/jant-settings-avatar.test.ts +2 -2
  64. package/src/client/components/__tests__/jant-settings-general.test.ts +3 -3
  65. package/src/client/components/collection-manager-types.ts +9 -3
  66. package/src/client/components/compose-format-convert.ts +5 -1
  67. package/src/client/components/compose-types.ts +55 -3
  68. package/src/client/components/jant-collection-directory.ts +165 -30
  69. package/src/client/components/jant-command-palette.ts +14 -7
  70. package/src/client/components/jant-compose-dialog.ts +1542 -694
  71. package/src/client/components/jant-compose-editor.ts +237 -99
  72. package/src/client/components/jant-compose-fullscreen.ts +27 -9
  73. package/src/client/components/jant-config-editor.ts +722 -0
  74. package/src/client/components/jant-media-lightbox.ts +100 -18
  75. package/src/client/components/jant-nav-manager.ts +1283 -54
  76. package/src/client/components/jant-post-form.ts +3 -3
  77. package/src/client/components/jant-post-menu.ts +177 -95
  78. package/src/client/components/nav-manager-types.ts +51 -2
  79. package/src/client/components/post-form-types.ts +1 -1
  80. package/src/client/compose-bridge.ts +147 -123
  81. package/src/client/compose-launch.ts +13 -0
  82. package/src/client/compose-shortcuts.ts +5 -1
  83. package/src/client/feed-video-player.ts +62 -2
  84. package/src/client/footnote-rail.ts +478 -0
  85. package/src/client/hydrate-partial.ts +2 -0
  86. package/src/client/media-lightbox-events.ts +63 -0
  87. package/src/client/post-refresh.ts +135 -0
  88. package/src/client/site-header-fragment.ts +38 -0
  89. package/src/client/tiptap/__tests__/blockquote-editing.test.ts +201 -0
  90. package/src/client/tiptap/__tests__/clear-formatting.test.ts +168 -0
  91. package/src/client/tiptap/__tests__/link-input-rules.test.ts +121 -0
  92. package/src/client/tiptap/__tests__/list-editing.test.ts +447 -0
  93. package/src/client/tiptap/__tests__/markdown-clipboard.test.ts +727 -4
  94. package/src/client/tiptap/__tests__/table-controls.test.ts +263 -0
  95. package/src/client/tiptap/__tests__/table-size-picker.test.ts +179 -0
  96. package/src/client/tiptap/bubble-menu.ts +88 -10
  97. package/src/client/tiptap/continuous-lists.ts +102 -0
  98. package/src/client/tiptap/create-editor.ts +3 -0
  99. package/src/client/tiptap/extensions.ts +14 -5
  100. package/src/client/tiptap/link-input-rules.ts +4 -4
  101. package/src/client/tiptap/markdown-clipboard.ts +608 -11
  102. package/src/client/tiptap/slash-commands.ts +8 -1
  103. package/src/client/tiptap/structural-keymap.ts +211 -0
  104. package/src/client/tiptap/tab-indent.ts +8 -22
  105. package/src/client/tiptap/table-control-labels.ts +30 -0
  106. package/src/client/tiptap/table-controls.ts +537 -0
  107. package/src/client/tiptap/table-size-picker.ts +254 -0
  108. package/src/client/toast.ts +133 -39
  109. package/src/client-auth.ts +1 -0
  110. package/src/client.ts +1 -0
  111. package/src/db/__tests__/backfill-thread-activity.test.ts +234 -0
  112. package/src/db/__tests__/d1-query.test.ts +43 -1
  113. package/src/db/__tests__/migration-runner.test.ts +63 -0
  114. package/src/db/__tests__/migrations.test.ts +469 -0
  115. package/src/db/__tests__/thread-activity.test.ts +113 -0
  116. package/src/db/__tests__/thread-collection-migration.test.ts +364 -0
  117. package/src/db/backfills/0005_split_thread_activity_from_quiet_replies.sql +68 -0
  118. package/src/db/migrations/0027_old_blue_blade.sql +39 -0
  119. package/src/db/migrations/0028_superb_post.sql +2 -0
  120. package/src/db/migrations/0029_boring_magma.sql +1 -0
  121. package/src/db/migrations/0030_typical_vivisector.sql +50 -0
  122. package/src/db/migrations/0031_many_ego.sql +3 -0
  123. package/src/db/migrations/meta/0027_snapshot.json +2511 -0
  124. package/src/db/migrations/meta/0028_snapshot.json +2511 -0
  125. package/src/db/migrations/meta/0029_snapshot.json +2519 -0
  126. package/src/db/migrations/meta/0030_snapshot.json +2541 -0
  127. package/src/db/migrations/meta/0031_snapshot.json +2562 -0
  128. package/src/db/migrations/meta/_journal.json +35 -0
  129. package/src/db/migrations/pg/0025_moaning_epoch.sql +39 -0
  130. package/src/db/migrations/pg/0026_talented_killer_shrike.sql +2 -0
  131. package/src/db/migrations/pg/0027_short_cannonball.sql +1 -0
  132. package/src/db/migrations/pg/0028_perfect_sharon_carter.sql +27 -0
  133. package/src/db/migrations/pg/0029_rare_hairball.sql +3 -0
  134. package/src/db/migrations/pg/meta/0025_snapshot.json +3204 -0
  135. package/src/db/migrations/pg/meta/0026_snapshot.json +3204 -0
  136. package/src/db/migrations/pg/meta/0027_snapshot.json +3211 -0
  137. package/src/db/migrations/pg/meta/0028_snapshot.json +3248 -0
  138. package/src/db/migrations/pg/meta/0029_snapshot.json +3289 -0
  139. package/src/db/migrations/pg/meta/_journal.json +35 -0
  140. package/src/db/pg/schema.ts +46 -20
  141. package/src/db/rehearsal-fixtures/demo-current.json +39 -4
  142. package/src/db/rehearsal-fixtures/demo-current.sql +121 -6
  143. package/src/db/schema.ts +48 -20
  144. package/src/db/thread-activity.ts +101 -0
  145. package/src/i18n/__tests__/detect.test.ts +8 -2
  146. package/src/i18n/detect.ts +14 -5
  147. package/src/i18n/locales/public/en.po +222 -51
  148. package/src/i18n/locales/public/en.ts +1 -1
  149. package/src/i18n/locales/public/zh-Hans.po +216 -45
  150. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  151. package/src/i18n/locales/public/zh-Hant.po +216 -45
  152. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  153. package/src/i18n/locales/settings/en.po +513 -10
  154. package/src/i18n/locales/settings/en.ts +1 -1
  155. package/src/i18n/locales/settings/zh-Hans.po +515 -12
  156. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  157. package/src/i18n/locales/settings/zh-Hant.po +515 -12
  158. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  159. package/src/lib/__tests__/api-settings.test.ts +299 -0
  160. package/src/lib/__tests__/constants.test.ts +5 -0
  161. package/src/lib/__tests__/feed-policy.test.ts +25 -0
  162. package/src/lib/__tests__/feed.test.ts +168 -0
  163. package/src/lib/__tests__/footnotes-upgrade.test.ts +181 -0
  164. package/src/lib/__tests__/markdown-roundtrip-embed.test.ts +61 -0
  165. package/src/lib/__tests__/markdown-to-tiptap.test.ts +32 -4
  166. package/src/lib/__tests__/markdown.test.ts +24 -6
  167. package/src/lib/__tests__/navigation.test.ts +59 -2
  168. package/src/lib/__tests__/post-body-html.test.ts +176 -0
  169. package/src/lib/__tests__/post-display.test.ts +64 -0
  170. package/src/lib/__tests__/resolve-config.test.ts +113 -1
  171. package/src/lib/__tests__/slug.test.ts +18 -0
  172. package/src/lib/__tests__/summary.test.ts +82 -0
  173. package/src/lib/__tests__/timeline.test.ts +109 -70
  174. package/src/lib/__tests__/timezones.test.ts +31 -3
  175. package/src/lib/__tests__/tiptap-render.test.ts +268 -9
  176. package/src/lib/__tests__/tiptap-to-markdown.test.ts +6 -0
  177. package/src/lib/__tests__/translit.test.ts +87 -0
  178. package/src/lib/__tests__/url.test.ts +53 -0
  179. package/src/lib/__tests__/view.test.ts +53 -0
  180. package/src/lib/__tests__/worker-response-cache.test.ts +18 -1
  181. package/src/lib/api-settings.ts +239 -12
  182. package/src/lib/collection-sort.ts +7 -5
  183. package/src/lib/constants.ts +2 -0
  184. package/src/lib/feed-path.ts +25 -0
  185. package/src/lib/feed-policy.ts +64 -0
  186. package/src/lib/feed.ts +113 -11
  187. package/src/lib/footnotes.ts +264 -0
  188. package/src/lib/github-sync-site-config.ts +2 -0
  189. package/src/lib/hugo-markdown.ts +15 -3
  190. package/src/lib/jant-branding.ts +1 -0
  191. package/src/lib/link-preview.ts +25 -0
  192. package/src/lib/markdown-manager.ts +193 -5
  193. package/src/lib/markdown.ts +10 -3
  194. package/src/lib/navigation.ts +16 -3
  195. package/src/lib/post-body-html.ts +131 -0
  196. package/src/lib/post-display.ts +47 -8
  197. package/src/lib/render.tsx +9 -2
  198. package/src/lib/resolve-config.ts +100 -16
  199. package/src/lib/schemas.ts +133 -1
  200. package/src/lib/settings-paths.ts +5 -0
  201. package/src/lib/site-skill.ts +78 -0
  202. package/src/lib/summary.ts +16 -5
  203. package/src/lib/timeline.ts +137 -131
  204. package/src/lib/timezones.ts +54 -14
  205. package/src/lib/tiptap-render.ts +359 -83
  206. package/src/lib/translit.ts +288 -0
  207. package/src/lib/url.ts +123 -12
  208. package/src/lib/view.ts +58 -20
  209. package/src/lib/worker-response-cache.ts +11 -18
  210. package/src/middleware/__tests__/public-content-access.test.ts +91 -0
  211. package/src/middleware/config.ts +2 -2
  212. package/src/middleware/public-content-access.ts +87 -0
  213. package/src/node/__tests__/cli-export.test.ts +14 -0
  214. package/src/node/__tests__/cli-migrate.test.ts +6 -0
  215. package/src/node/__tests__/cli-site-snapshot.test.ts +205 -2
  216. package/src/node/__tests__/cli-snapshot-meta.test.ts +100 -0
  217. package/src/node/index.ts +2 -1
  218. package/src/preset.css +16 -2
  219. package/src/routes/__tests__/compose.test.ts +51 -0
  220. package/src/routes/__tests__/site-skill.test.ts +112 -0
  221. package/src/routes/api/__tests__/collections.test.ts +91 -19
  222. package/src/routes/api/__tests__/mcp.test.ts +65 -1
  223. package/src/routes/api/__tests__/nav-items.test.ts +91 -0
  224. package/src/routes/api/__tests__/palette.test.ts +44 -0
  225. package/src/routes/api/__tests__/public-access.test.ts +81 -0
  226. package/src/routes/api/__tests__/settings.test.ts +217 -0
  227. package/src/routes/api/collections.ts +22 -26
  228. package/src/routes/api/export.ts +2 -0
  229. package/src/routes/api/internal/__tests__/post-body-html.test.ts +163 -0
  230. package/src/routes/api/internal/__tests__/sites.test.ts +35 -2
  231. package/src/routes/api/internal/post-body-html.ts +43 -0
  232. package/src/routes/api/internal/sites.ts +24 -0
  233. package/src/routes/api/nav-items.ts +24 -1
  234. package/src/routes/api/posts.ts +14 -10
  235. package/src/routes/api/public/__tests__/archive.test.ts +35 -0
  236. package/src/routes/api/public/__tests__/posts.test.ts +246 -0
  237. package/src/routes/api/public/archive.ts +4 -0
  238. package/src/routes/api/public/posts.ts +48 -24
  239. package/src/routes/api/search.ts +3 -0
  240. package/src/routes/api/settings.ts +33 -0
  241. package/src/routes/auth/__tests__/setup.test.ts +7 -4
  242. package/src/routes/compose.tsx +8 -3
  243. package/src/routes/dash/settings.tsx +45 -2
  244. package/src/routes/feed/__tests__/feed.test.ts +210 -6
  245. package/src/routes/feed/__tests__/sitemap.test.ts +68 -1
  246. package/src/routes/feed/feed.ts +23 -14
  247. package/src/routes/pages/__tests__/archive-params.test.ts +384 -3
  248. package/src/routes/pages/__tests__/collection-routing.test.ts +63 -5
  249. package/src/routes/pages/__tests__/collections.test.ts +9 -9
  250. package/src/routes/pages/__tests__/featured.test.ts +6 -2
  251. package/src/routes/pages/__tests__/preview.test.ts +176 -0
  252. package/src/routes/pages/__tests__/search.test.ts +69 -0
  253. package/src/routes/pages/archive.tsx +90 -22
  254. package/src/routes/pages/collection.tsx +32 -19
  255. package/src/routes/pages/collections.tsx +6 -1
  256. package/src/routes/pages/page.tsx +86 -11
  257. package/src/routes/site-skill.ts +18 -0
  258. package/src/services/__tests__/auth.test.ts +7 -5
  259. package/src/services/__tests__/collection.test.ts +190 -44
  260. package/src/services/__tests__/navigation.test.ts +229 -21
  261. package/src/services/__tests__/post-timeline.test.ts +249 -39
  262. package/src/services/__tests__/post.test.ts +844 -5
  263. package/src/services/__tests__/search.test.ts +48 -1
  264. package/src/services/__tests__/settings.test.ts +8 -0
  265. package/src/services/__tests__/site-admin.test.ts +121 -1
  266. package/src/services/auth.ts +2 -2
  267. package/src/services/bootstrap.ts +3 -2
  268. package/src/services/collection.ts +224 -167
  269. package/src/services/export-theme/assets/client-site.css +1 -1
  270. package/src/services/export-theme/assets/client-site.js +46 -25
  271. package/src/services/export-theme/layouts/_default/list.html +18 -4
  272. package/src/services/export-theme/layouts/_default/rss.xml +8 -12
  273. package/src/services/export-theme/layouts/featured/list.html +5 -4
  274. package/src/services/export-theme/layouts/partials/featured-thread.html +56 -0
  275. package/src/services/export-theme/layouts/partials/footer.html +1 -1
  276. package/src/services/export-theme/layouts/partials/media-gallery.html +1 -0
  277. package/src/services/export-theme/styles/main.css +30 -2
  278. package/src/services/export.ts +94 -85
  279. package/src/services/mcp.ts +37 -27
  280. package/src/services/media.ts +1 -1
  281. package/src/services/navigation.ts +185 -46
  282. package/src/services/path.ts +4 -1
  283. package/src/services/post.ts +1142 -342
  284. package/src/services/search.ts +17 -2
  285. package/src/services/settings.ts +2 -10
  286. package/src/services/site-admin.ts +63 -43
  287. package/src/style-cjk-jp.css +1 -1
  288. package/src/style-cjk-kr.css +1 -1
  289. package/src/style-cjk-tc.css +1 -1
  290. package/src/style-cjk.css +1 -1
  291. package/src/styles/components.css +540 -14
  292. package/src/styles/site-media.css +111 -19
  293. package/src/styles/tokens.css +76 -10
  294. package/src/styles/ui.css +2227 -1347
  295. package/src/types/bindings.ts +3 -0
  296. package/src/types/config.ts +243 -12
  297. package/src/types/constants.ts +22 -1
  298. package/src/types/entities.ts +14 -8
  299. package/src/types/operations.ts +18 -10
  300. package/src/types/props.ts +20 -2
  301. package/src/types/raw-assets.d.ts +5 -0
  302. package/src/types/views.ts +22 -4
  303. package/src/ui/__tests__/font-themes.test.ts +42 -30
  304. package/src/ui/compose/ComposeDialog.tsx +109 -19
  305. package/src/ui/dash/appearance/NavigationContent.tsx +332 -4
  306. package/src/ui/dash/appearance/__tests__/NavigationContent.test.tsx +23 -0
  307. package/src/ui/dash/settings/ConfigEditorContent.tsx +578 -0
  308. package/src/ui/dash/settings/GeneralContent.tsx +15 -5
  309. package/src/ui/dash/settings/SettingsRootContent.tsx +21 -1
  310. package/src/ui/dash/settings/__tests__/ConfigEditorContent.test.tsx +98 -0
  311. package/src/ui/dash/settings/__tests__/SettingsRootContent.test.tsx +15 -0
  312. package/src/ui/feed/CuratedThreadPreview.tsx +16 -4
  313. package/src/ui/feed/LinkCard.tsx +2 -12
  314. package/src/ui/feed/LinkPreview.tsx +2 -7
  315. package/src/ui/feed/NoteCard.tsx +10 -20
  316. package/src/ui/feed/PostStatusBadges.tsx +42 -3
  317. package/src/ui/feed/QuoteCard.tsx +2 -12
  318. package/src/ui/feed/ThreadPreview.tsx +26 -34
  319. package/src/ui/feed/TimelineFeed.tsx +2 -2
  320. package/src/ui/feed/__tests__/thread-preview.test.ts +197 -56
  321. package/src/ui/feed/__tests__/timeline-cards.test.ts +178 -3
  322. package/src/ui/feed/thread-preview-state.ts +5 -7
  323. package/src/ui/font-themes.ts +74 -26
  324. package/src/ui/layouts/BaseLayout.tsx +22 -8
  325. package/src/ui/layouts/__tests__/BaseLayout.test.tsx +64 -1
  326. package/src/ui/pages/ArchivePage.tsx +131 -24
  327. package/src/ui/pages/CollectionPage.tsx +101 -22
  328. package/src/ui/pages/CollectionsPage.tsx +3 -1
  329. package/src/ui/pages/PostPage.tsx +39 -12
  330. package/src/ui/pages/SearchPage.tsx +6 -14
  331. package/src/ui/pages/__tests__/ArchivePage.test.tsx +70 -0
  332. package/src/ui/pages/__tests__/PostPage.test.tsx +88 -0
  333. package/src/ui/shared/CollectionDirectory.tsx +13 -9
  334. package/src/ui/shared/CollectionsManager.tsx +17 -7
  335. package/src/ui/shared/DraftPreviewBar.tsx +49 -0
  336. package/src/ui/shared/HomePageBranding.tsx +2 -2
  337. package/src/ui/shared/MediaGallery.tsx +1 -0
  338. package/src/ui/shared/PostFooter.tsx +38 -13
  339. package/src/ui/shared/__tests__/home-page-branding.test.tsx +1 -1
  340. package/src/ui/shared/__tests__/media-gallery.test.ts +1 -0
  341. package/src/ui/shared/__tests__/navigation-labels.test.ts +2 -2
  342. package/src/ui/shared/__tests__/post-footer.test.ts +15 -0
  343. package/src/ui/shared/collection-management-labels.ts +33 -2
  344. package/src/ui/shared/custom-icons.ts +5 -0
  345. package/src/ui/shared/navigation-labels.ts +2 -4
  346. package/src/ui/shared/post-article-attributes.ts +52 -0
  347. package/dist/app-DZDlmh7C.js +0 -6
  348. package/dist/client/_assets/client-Cevfpm8H.css +0 -2
  349. package/dist/client/_assets/client-auth-v3yvhbgZ.js +0 -4736
  350. package/src/client/tiptap/wrapping-input-rules.ts +0 -102
@@ -7,6 +7,8 @@ import type { Editor } from "@tiptap/core";
7
7
  import { MAX_THREAD_POSTS } from "../../../types.js";
8
8
 
9
9
  import type {
10
+ ComposeFullscreenCloseDetail,
11
+ ComposeFullscreenOpenDetail,
10
12
  ComposeLabels,
11
13
  ComposeCollection,
12
14
  ComposeSubmitDetail,
@@ -67,6 +69,45 @@ async function flushUpdates(el?: JantComposeDialog) {
67
69
  }
68
70
  }
69
71
 
72
+ async function openPublishPanel(el: JantComposeDialog) {
73
+ requireElement(
74
+ el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
75
+ "expected publish settings toggle",
76
+ ).click();
77
+ await flushUpdates(el);
78
+ }
79
+
80
+ /** Options-panel rows are found by their title — the drafts entries moved off
81
+ * the title bar and into that panel. */
82
+ function sheetRow(
83
+ el: JantComposeDialog,
84
+ title: string,
85
+ ): HTMLButtonElement | null {
86
+ return (
87
+ Array.from(
88
+ el.querySelectorAll<HTMLButtonElement>(
89
+ ".compose-publish-panel .compose-sheet-row",
90
+ ),
91
+ ).find(
92
+ (row) =>
93
+ row.querySelector(".compose-sheet-title")?.textContent?.trim() ===
94
+ title,
95
+ ) ?? null
96
+ );
97
+ }
98
+
99
+ function sheetRowTitles(el: JantComposeDialog): (string | undefined)[] {
100
+ return Array.from(
101
+ el.querySelectorAll<HTMLElement>(
102
+ ".compose-publish-panel .compose-sheet-title",
103
+ ),
104
+ ).map((n) => n.textContent?.trim());
105
+ }
106
+
107
+ function draftsRow(el: JantComposeDialog): HTMLButtonElement | null {
108
+ return sheetRow(el, "Drafts");
109
+ }
110
+
70
111
  function deferred<T>() {
71
112
  let resolve!: (value: T) => void;
72
113
  const promise = new Promise<T>((res) => {
@@ -181,6 +222,8 @@ const labels: ComposeLabels = {
181
222
  discardChangesConfirm: "Discard changes?",
182
223
  drafts: "Drafts",
183
224
  draftsEmpty: "No drafts yet. Save a draft to find it here.",
225
+ previewDraft: "Preview",
226
+ draftActions: "Draft actions",
184
227
  deleteDraft: "Delete Draft",
185
228
  draftDeleted: "Draft deleted.",
186
229
  publishFailedDraft: "Couldn't publish. Saved as draft.",
@@ -207,13 +250,13 @@ const labels: ComposeLabels = {
207
250
  publishDateInvalid: "Enter a valid date.",
208
251
  publishDateFutureError:
209
252
  "Choose today or an earlier date, or leave it blank to publish now.",
210
- publishDateSummaryNow: "Publish now",
253
+ publishDateSummaryNow: "Now",
211
254
  publishDateSummaryAction: "Edit publish date",
212
255
  publishSlugLabel: "Custom link",
213
256
  publishSlugPlaceholder: "your-post-link",
214
257
  publishSlugHint: "Leave blank to generate one automatically.",
215
258
  publishSlugAuto: "Generate automatically",
216
- publishSlugSummaryAuto: "Auto link",
259
+ publishSlugSummaryAuto: "Auto",
217
260
  publishSlugSummaryAction: "Edit custom link",
218
261
  publishSlugReset: "Reset link",
219
262
  publishSlugSuggested: "Suggested link",
@@ -229,10 +272,24 @@ const labels: ComposeLabels = {
229
272
  threadLimitReached: "Threads can include up to 20 posts.",
230
273
  showMore: "Show more",
231
274
  showLess: "Show less",
232
- newThread: "New Thread",
233
- replyTitle: "Reply",
234
- editTitle: "Edit",
275
+ closeCompose: "Close compose",
276
+ editing: "Editing",
277
+ composeDialogLabel: "Compose",
235
278
  slashHint: "Type / for commands",
279
+ tableControls: {
280
+ toolbarLabel: "Table controls",
281
+ addRowAbove: "Add row above",
282
+ addRowBelow: "Add row below",
283
+ addColumnBefore: "Add column before",
284
+ addColumnAfter: "Add column after",
285
+ options: "Table options",
286
+ deleteRow: "Delete row",
287
+ deleteColumn: "Delete column",
288
+ toggleHeaderRow: "Toggle header row",
289
+ deleteTable: "Delete table",
290
+ sizePickerLabel: "Choose table size",
291
+ insertTableSize: "Insert %rows% by %cols% table",
292
+ },
236
293
  collectionFormLabels: {
237
294
  titleLabel: "Title",
238
295
  titlePlaceholder: "My Collection",
@@ -261,6 +318,35 @@ const collections: ComposeCollection[] = [
261
318
  { id: "col-2", title: "Movies", slug: "movies" },
262
319
  ];
263
320
 
321
+ /**
322
+ * Date and permalink hang off the post itself, not the publish panel. Both
323
+ * fields are open as soon as the panel is, so there is nothing further to
324
+ * expand. Idempotent.
325
+ */
326
+ async function openPostMeta(el: JantComposeDialog, index = 0) {
327
+ const pill = el.querySelectorAll<HTMLButtonElement>(
328
+ "[data-compose-post-meta-pill]",
329
+ )[index];
330
+ if (pill && pill.getAttribute("aria-expanded") !== "true") pill.click();
331
+ // The slug suggestion is fetched when the panel opens; let it settle.
332
+ await flushUpdates(el);
333
+ await flushUpdates(el);
334
+ }
335
+
336
+ /** The date or permalink as the post's own pill states it. */
337
+ function pillValue(
338
+ el: JantComposeDialog,
339
+ kind: "date" | "slug",
340
+ index = 0,
341
+ ): string | undefined {
342
+ const pill = el.querySelectorAll("[data-compose-post-meta-pill]")[index];
343
+ const selector =
344
+ kind === "date"
345
+ ? ".compose-post-meta-value:not(.compose-post-meta-value-slug)"
346
+ : ".compose-post-meta-value-slug";
347
+ return pill?.querySelector(selector)?.textContent?.trim();
348
+ }
349
+
264
350
  async function createElement(
265
351
  cols: ComposeCollection[] = collections,
266
352
  ): Promise<JantComposeDialog> {
@@ -280,6 +366,7 @@ describe("JantComposeDialog", () => {
280
366
  vi.restoreAllMocks();
281
367
  document.body.innerHTML = "";
282
368
  globalThis.localStorage.clear();
369
+ delete document.documentElement.dataset.sitePathPrefix;
283
370
  Object.defineProperty(globalThis, "matchMedia", {
284
371
  configurable: true,
285
372
  writable: true,
@@ -312,8 +399,11 @@ describe("JantComposeDialog", () => {
312
399
  it("renders with collections and labels", async () => {
313
400
  const el = await createElement();
314
401
 
315
- // Header present
316
- expect(el.querySelector(".compose-dialog-header")).not.toBeNull();
402
+ // No title bar: the composer opens straight onto the post header row. A new
403
+ // post gets no marker either — an empty composer already says that much.
404
+ expect(el.querySelector(".compose-dialog-header")).toBeNull();
405
+ expect(el.querySelector(".compose-thread-post-header")).not.toBeNull();
406
+ expect(el.querySelector(".compose-post-badge")).toBeNull();
317
407
 
318
408
  // Format buttons present
319
409
  const segmentedItems = el.querySelectorAll(".compose-segmented-item");
@@ -331,7 +421,7 @@ describe("JantComposeDialog", () => {
331
421
  expect(postBtn.disabled).toBe(true);
332
422
  expect(
333
423
  requireElement(
334
- el.querySelector<HTMLButtonElement>(".compose-publish-toggle"),
424
+ el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
335
425
  "expected publish settings toggle",
336
426
  ).disabled,
337
427
  ).toBe(false);
@@ -363,7 +453,6 @@ describe("JantComposeDialog", () => {
363
453
  ],
364
454
  },
365
455
  title: "",
366
- showTitle: false,
367
456
  selection: { from: 3, to: 3 },
368
457
  replyExpanded: false,
369
458
  },
@@ -471,7 +560,7 @@ describe("JantComposeDialog", () => {
471
560
  const el = await createElement();
472
561
 
473
562
  requireElement(
474
- el.querySelector<HTMLButtonElement>(".compose-publish-toggle"),
563
+ el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
475
564
  "expected publish settings toggle",
476
565
  ).click();
477
566
  await el.updateComplete;
@@ -479,16 +568,39 @@ describe("JantComposeDialog", () => {
479
568
  expect(el.querySelector(".compose-publish-panel")).not.toBeNull();
480
569
  });
481
570
 
482
- it("updates the draft button label to match its current action", async () => {
571
+ it("offers saving separately from browsing once there is something to save", async () => {
483
572
  const el = await createElement();
573
+ await openPublishPanel(el);
484
574
 
485
- const draftButton = requireElement(
486
- el.querySelector<HTMLButtonElement>(".compose-dialog-draft-btn"),
487
- "expected draft button",
575
+ // Nothing written yet, so there is only a place to go.
576
+ expect(sheetRowTitles(el)).toContain("Drafts");
577
+ expect(sheetRowTitles(el)).not.toContain("Save as draft");
578
+
579
+ const editor = requireElement(
580
+ el.querySelector<JantComposeEditor>("jant-compose-editor"),
581
+ "expected compose editor",
488
582
  );
489
- expect(draftButton.title).toBe("Drafts");
490
- expect(draftButton.getAttribute("aria-label")).toBe("Drafts");
583
+ editor._bodyJson = {
584
+ type: "doc",
585
+ content: [
586
+ { type: "paragraph", content: [{ type: "text", text: "Hello world" }] },
587
+ ],
588
+ };
589
+ await editor.updateComplete;
590
+ await flushUpdates(el);
491
591
 
592
+ // Now both: an action on this post, and the list. Neither label lies about
593
+ // what its row does.
594
+ const titles = sheetRowTitles(el);
595
+ expect(titles).toContain("Save as draft");
596
+ expect(titles).toContain("Drafts");
597
+ expect(titles.indexOf("Save as draft")).toBeLessThan(
598
+ titles.indexOf("Drafts"),
599
+ );
600
+ });
601
+
602
+ it("saves straight to a draft from its own row, with no prompt in the way", async () => {
603
+ const el = await createElement();
492
604
  const editor = requireElement(
493
605
  el.querySelector<JantComposeEditor>("jant-compose-editor"),
494
606
  "expected compose editor",
@@ -500,34 +612,299 @@ describe("JantComposeDialog", () => {
500
612
  ],
501
613
  };
502
614
  await editor.updateComplete;
503
- await el.updateComplete;
615
+ await flushUpdates(el);
504
616
 
505
- expect(draftButton.title).toBe("Save as draft");
506
- expect(draftButton.getAttribute("aria-label")).toBe("Save as draft");
617
+ let detail: ComposeSubmitDetail | null = null;
618
+ el.addEventListener("jant:compose-submit-deferred", (event) => {
619
+ detail = (event as CustomEvent<ComposeSubmitDetail>).detail;
620
+ });
621
+
622
+ await openPublishPanel(el);
623
+ requireElement(
624
+ sheetRow(el, "Save as draft"),
625
+ "expected save-as-draft row",
626
+ ).click();
627
+ await flushUpdates(el);
628
+
629
+ expect(el._confirmPanelOpen).toBe(false);
630
+ expect(el._draftsPanelOpen).toBe(false);
631
+ expect(detail).not.toBeNull();
632
+ expect(detail!.status).toBe("draft");
507
633
  });
508
634
 
509
- it("shows the selected visibility hint in the publish settings panel", async () => {
635
+ it("opens options from its own button, not a chevron on Publish", async () => {
510
636
  const el = await createElement();
511
637
 
638
+ // The split button is gone: Publish is one button, options is another.
639
+ expect(el.querySelector(".compose-publish-toggle")).toBeNull();
640
+
641
+ const trigger = requireElement(
642
+ el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
643
+ "expected options trigger",
644
+ );
645
+ expect(trigger.getAttribute("aria-expanded")).toBe("false");
646
+ expect(el.querySelector(".compose-sheet")).toBeNull();
647
+
648
+ trigger.click();
649
+ await flushUpdates(el);
650
+
651
+ expect(trigger.getAttribute("aria-expanded")).toBe("true");
652
+ expect(el.querySelector(".compose-sheet")).not.toBeNull();
653
+ });
654
+
655
+ it("keeps date and permalink on the post, not in the publish panel", async () => {
656
+ const el = await createElement();
657
+
658
+ // They describe one post, so they hang off the post — not the publish
659
+ // panel, which speaks for the whole submission.
512
660
  requireElement(
513
- el.querySelector<HTMLButtonElement>(".compose-publish-toggle"),
661
+ el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
662
+ "expected options trigger",
663
+ ).click();
664
+ await flushUpdates(el);
665
+ expect(el.querySelector(".compose-publish-date-input")).toBeNull();
666
+ expect(el.querySelector(".compose-publish-slug-input")).toBeNull();
667
+
668
+ // The pill states both values while the panel is shut.
669
+ expect(pillValue(el, "date")).toBe("Now");
670
+ expect(pillValue(el, "slug")).toBe("Auto");
671
+
672
+ // Two settings is not a list to drill through: opening the pill opens both
673
+ // fields at once, with focus on the panel so Tab lands in them.
674
+ await openPostMeta(el);
675
+ expect(el.querySelector(".compose-publish-date-input")).not.toBeNull();
676
+ expect(el.querySelector(".compose-publish-slug-input")).not.toBeNull();
677
+ expect(el.querySelector("[data-compose-post-meta-panel]")).toBe(
678
+ document.activeElement,
679
+ );
680
+ });
681
+
682
+ it("pins the date panel to its pill instead of inside the scroller", async () => {
683
+ const el = await createElement();
684
+
685
+ // The pill lives in `.compose-scroll`, which clips and scrolls. An
686
+ // absolutely positioned panel is taller than that box on an empty post, so
687
+ // it lost its footer, and focusing it scrolled the format switcher off the
688
+ // top to bring it into view. Fixed positioning leaves the scroller behind,
689
+ // which makes placing the panel our job.
690
+ const focusCalls: Array<{
691
+ target: unknown;
692
+ options?: globalThis.FocusOptions;
693
+ }> = [];
694
+ const nativeFocus = globalThis.HTMLElement.prototype.focus;
695
+ globalThis.HTMLElement.prototype.focus = function (
696
+ this: globalThis.HTMLElement,
697
+ options?: globalThis.FocusOptions,
698
+ ) {
699
+ focusCalls.push({ target: this, options });
700
+ return nativeFocus.call(this, options);
701
+ };
702
+
703
+ try {
704
+ await openPostMeta(el);
705
+ } finally {
706
+ globalThis.HTMLElement.prototype.focus = nativeFocus;
707
+ }
708
+
709
+ const panel = requireElement(
710
+ el.querySelector<globalThis.HTMLElement>(
711
+ "[data-compose-post-meta-panel]",
712
+ ),
713
+ "expected post meta panel",
714
+ );
715
+
716
+ expect(focusCalls.find((call) => call.target === panel)?.options).toEqual({
717
+ preventScroll: true,
718
+ });
719
+
720
+ // Placed under the pill, and capped at the room the viewport actually has
721
+ // so a short window scrolls the panel rather than cropping it.
722
+ expect(panel.style.top).toBe("6px");
723
+ expect(panel.style.left).not.toBe("");
724
+ expect(
725
+ panel.style.getPropertyValue("--compose-post-meta-panel-max-height"),
726
+ ).toBe(`${globalThis.innerHeight - 12 - 6}px`);
727
+
728
+ // Inline coordinates only mean anything if the panel is out of the
729
+ // scroller — `position: absolute` here is the bug, not a style choice.
730
+ const css = readFileSync(resolve("src/styles/ui.css"), "utf8");
731
+ expect(css).toMatch(
732
+ /\.compose-post-meta-panel\s*\{\s*position:\s*fixed;[\s\S]*max-height:\s*var\(--compose-post-meta-panel-max-height,\s*none\);\s*overflow-y:\s*auto;/,
733
+ );
734
+ });
735
+
736
+ it("keeps the date panel on its pill when the composer scrolls", async () => {
737
+ const el = await createElement();
738
+ await openPostMeta(el);
739
+
740
+ const panel = requireElement(
741
+ el.querySelector<globalThis.HTMLElement>(
742
+ "[data-compose-post-meta-panel]",
743
+ ),
744
+ "expected post meta panel",
745
+ );
746
+ const pill = requireElement(
747
+ el.querySelector<globalThis.HTMLElement>("[data-compose-post-meta-pill]"),
748
+ "expected post meta pill",
749
+ );
750
+ pill.getBoundingClientRect = () =>
751
+ ({
752
+ top: 100,
753
+ bottom: 130,
754
+ left: 300,
755
+ right: 400,
756
+ width: 100,
757
+ height: 30,
758
+ }) as globalThis.DOMRect;
759
+
760
+ // A `scroll` event on the composer's own box never reaches `window` — it
761
+ // doesn't bubble — so a pinned panel would hang in place while the pill it
762
+ // belongs to slid away underneath it.
763
+ const scroller = requireElement(
764
+ el.querySelector(".compose-scroll"),
765
+ "expected compose scroller",
766
+ );
767
+ scroller.dispatchEvent(new globalThis.Event("scroll"));
768
+
769
+ expect(panel.style.top).toBe("136px");
770
+ expect(panel.style.left).toBe("400px");
771
+ });
772
+
773
+ it("opens the calendar from its own button, not the UA glyph", async () => {
774
+ const el = await createElement();
775
+ await openPostMeta(el);
776
+
777
+ const input = requireElement(
778
+ el.querySelector<HTMLInputElement>(".compose-publish-date-input"),
779
+ "expected publish date input",
780
+ );
781
+ // Chrome only opens the picker from its ~13px indicator, which is hidden
782
+ // here — the button has to drive the same picker itself.
783
+ const showPicker = vi.fn();
784
+ (input as HTMLInputElement & { showPicker: () => void }).showPicker =
785
+ showPicker;
786
+
787
+ requireElement(
788
+ el.querySelector<HTMLButtonElement>("[data-compose-date-picker]"),
789
+ "expected date picker button",
790
+ ).click();
791
+ await flushUpdates(el);
792
+
793
+ expect(showPicker).toHaveBeenCalledTimes(1);
794
+ expect(input).toBe(document.activeElement);
795
+ });
796
+
797
+ it("keeps the date field usable when showPicker is unsupported", async () => {
798
+ const el = await createElement();
799
+ await openPostMeta(el);
800
+
801
+ const input = requireElement(
802
+ el.querySelector<HTMLInputElement>(".compose-publish-date-input"),
803
+ "expected publish date input",
804
+ );
805
+ // Engines without `showPicker` must still get a focused, typable field
806
+ // rather than an uncaught TypeError from the button.
807
+ expect(
808
+ (input as HTMLInputElement & { showPicker?: () => void }).showPicker,
809
+ ).toBeUndefined();
810
+
811
+ expect(() =>
812
+ requireElement(
813
+ el.querySelector<HTMLButtonElement>("[data-compose-date-picker]"),
814
+ "expected date picker button",
815
+ ).click(),
816
+ ).not.toThrow();
817
+ await flushUpdates(el);
818
+
819
+ expect(input).toBe(document.activeElement);
820
+ });
821
+
822
+ it("closes the date panel from its Done button", async () => {
823
+ const el = await createElement();
824
+ await openPostMeta(el);
825
+
826
+ // Escape and an outside click both close it, but neither announces itself,
827
+ // and touch has no Escape key at all.
828
+ requireElement(
829
+ el.querySelector<HTMLButtonElement>("[data-compose-post-meta-done]"),
830
+ "expected Done button",
831
+ ).click();
832
+ await flushUpdates(el);
833
+
834
+ expect(el.querySelector("[data-compose-post-meta-panel]")).toBeNull();
835
+ expect(el.querySelector(".compose-publish-date-input")).toBeNull();
836
+ });
837
+
838
+ it("hands focus back to the post when the date panel closes", async () => {
839
+ const el = await createElement();
840
+ await openPostMeta(el);
841
+ expect(el.querySelector("[data-compose-post-meta-panel]")).toBe(
842
+ document.activeElement,
843
+ );
844
+
845
+ // The panel holds focus while it is open, so it would take focus out of the
846
+ // document with it on the way out. The pill is only visible on hover, focus
847
+ // or a non-default value — and on touch there is no hover — so losing focus
848
+ // to `<body>` hides the only way back in.
849
+ const editor = requireElement(
850
+ el.querySelector<JantComposeEditor>("jant-compose-editor"),
851
+ "expected editor",
852
+ );
853
+ const focusInput = vi.spyOn(editor, "focusInput");
854
+
855
+ requireElement(
856
+ el.querySelector<HTMLButtonElement>("[data-compose-post-meta-pill]"),
857
+ "expected post meta pill",
858
+ ).click();
859
+ await flushUpdates(el);
860
+
861
+ expect(el.querySelector("[data-compose-post-meta-panel]")).toBeNull();
862
+ expect(focusInput).toHaveBeenCalled();
863
+ });
864
+
865
+ it("explains every visibility option and checks the selected one", async () => {
866
+ const el = await createElement();
867
+
868
+ requireElement(
869
+ el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
514
870
  "expected publish settings toggle",
515
871
  ).click();
516
872
  await el.updateComplete;
517
873
 
518
- // Default is public — only public hint shown
874
+ // Each row carries its own hint, so the options can be compared before
875
+ // one is picked — a single hint under a chip group could not do that.
519
876
  expect(el.textContent).toContain("Appears in Latest.");
520
- expect(el.textContent).not.toContain("Only visible when signed in.");
877
+ expect(el.textContent).toContain("Only visible when signed in.");
521
878
 
522
- // Switch to private — hint updates
523
- const chips = el.querySelectorAll<HTMLButtonElement>(
524
- ".compose-publish-chip[role='radio']",
879
+ const rows = el.querySelectorAll<HTMLButtonElement>(
880
+ ".compose-sheet-row[role='radio']",
525
881
  );
526
- chips[2]?.click();
527
- await el.updateComplete;
882
+ expect(rows).toHaveLength(3);
883
+ expect(rows[0].getAttribute("aria-checked")).toBe("true");
884
+ expect(rows[0].querySelector(".compose-sheet-check")).not.toBeNull();
885
+ expect(rows[2].querySelector(".compose-sheet-check")).toBeNull();
528
886
 
529
- expect(el.textContent).toContain("Only visible when signed in.");
530
- expect(el.textContent).not.toContain("Appears in Latest.");
887
+ rows[2].click();
888
+ await flushUpdates(el);
889
+
890
+ // Picking is the whole job of this panel, so it dismisses itself — no
891
+ // Done button to confirm a radio selection.
892
+ expect(el._visibility).toBe("private");
893
+ expect(el.querySelector(".compose-sheet")).toBeNull();
894
+ expect(el.querySelector(".compose-publish-done")).toBeNull();
895
+
896
+ // Reopening shows the new choice checked.
897
+ requireElement(
898
+ el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
899
+ "expected publish settings toggle",
900
+ ).click();
901
+ await flushUpdates(el);
902
+ const updated = el.querySelectorAll<HTMLButtonElement>(
903
+ ".compose-sheet-row[role='radio']",
904
+ );
905
+ expect(updated[2].getAttribute("aria-checked")).toBe("true");
906
+ expect(updated[2].querySelector(".compose-sheet-check")).not.toBeNull();
907
+ expect(updated[0].querySelector(".compose-sheet-check")).toBeNull();
531
908
  });
532
909
 
533
910
  it("format switching updates active state", async () => {
@@ -611,7 +988,7 @@ describe("JantComposeDialog", () => {
611
988
  );
612
989
  }
613
990
 
614
- it("shows the format switcher instead of a title while editing", async () => {
991
+ it("shows the format switcher above the post while editing", async () => {
615
992
  vi.spyOn(globalThis, "requestAnimationFrame").mockImplementation((cb) => {
616
993
  cb(0);
617
994
  return 1;
@@ -622,12 +999,21 @@ describe("JantComposeDialog", () => {
622
999
  await el.openEdit("pst_123");
623
1000
  await flushUpdates(el);
624
1001
 
625
- expect(el.querySelector(".compose-segmented")).not.toBeNull();
626
- // The "Edit post" title is gone — the switcher takes the center slot.
627
- expect(el.querySelector(".compose-dialog-title")).toBeNull();
1002
+ // The switcher has exactly one home: above its own post. Editing says so
1003
+ // through the submit label and a marker in the header row, not a title bar.
1004
+ expect(el.querySelector(".compose-thread-post-header")).toBeTruthy();
1005
+ expect(el.querySelector(".compose-dialog-header")).toBeNull();
1006
+ expect(
1007
+ el
1008
+ .querySelector(".compose-thread-post-header .compose-post-badge")
1009
+ ?.textContent?.trim(),
1010
+ ).toBe(labels.editing);
1011
+ expect(el.querySelector(".compose-publish-main")?.textContent?.trim()).toBe(
1012
+ labels.update,
1013
+ );
628
1014
  });
629
1015
 
630
- it("shows a Reply title with the format selector above the post when replying", async () => {
1016
+ it("shows the format selector above the post when replying", async () => {
631
1017
  const el = await createElement();
632
1018
  await el.openReply("019ce8ce-d6d8-7fda-a5df-c2da2bef5ade", {
633
1019
  contentHtml: "<p>Parent</p>",
@@ -635,18 +1021,50 @@ describe("JantComposeDialog", () => {
635
1021
  });
636
1022
  await flushUpdates(el);
637
1023
 
638
- expect(el.querySelector(".compose-dialog-title")?.textContent?.trim()).toBe(
639
- "Reply",
640
- );
641
- // The header center no longer hosts the format selector...
642
- expect(
643
- el.querySelector(".compose-dialog-header-center .compose-segmented"),
644
- ).toBeNull();
645
- // ...it sits inline above the reply editor instead.
1024
+ expect(el.querySelector(".compose-dialog-header")).toBeNull();
1025
+ // The selector sits inline above the reply editor.
646
1026
  expect(el.querySelector(".compose-thread-post-header")).not.toBeNull();
647
1027
  expect(
648
1028
  el.querySelector(".compose-thread-layout.compose-reply-compose-layout"),
649
1029
  ).not.toBeNull();
1030
+ expect(el.querySelector(".compose-collection-trigger")).toBeNull();
1031
+ expect(
1032
+ el.querySelector(".compose-action-row-without-collection"),
1033
+ ).not.toBeNull();
1034
+ expect(el.querySelector(".compose-publish-main")).not.toBeNull();
1035
+ });
1036
+
1037
+ it("puts a reply's add-to-thread row on the rail with the other posts", async () => {
1038
+ const el = await createElement();
1039
+ await el.openReply("019ce8ce-d6d8-7fda-a5df-c2da2bef5ade", {
1040
+ contentHtml: "<p>Parent</p>",
1041
+ dateText: "Mar 14",
1042
+ });
1043
+ await flushUpdates(el);
1044
+
1045
+ // Inside the layout, not a sibling of it: out there it misses the rail's
1046
+ // indent and lands a rail's width left of everything above it.
1047
+ const layout = el.querySelector(
1048
+ ".compose-thread-layout.compose-reply-compose-layout",
1049
+ );
1050
+ const addRow = layout?.querySelector(":scope > .compose-thread-add-row");
1051
+ expect(addRow).not.toBeNull();
1052
+ expect(el.querySelector(".compose-add-thread-trigger")).toBeNull();
1053
+ // The dashed dot marks where the next post lands, as in thread compose.
1054
+ expect(addRow?.querySelector(".compose-thread-add-dot")).not.toBeNull();
1055
+ // ...and it stays the last row, so the rail still stops at the reply.
1056
+ expect(layout?.lastElementChild).toBe(addRow);
1057
+ });
1058
+
1059
+ it("keeps a single post's add-to-thread row off the rail", async () => {
1060
+ const el = await createElement();
1061
+ await flushUpdates(el);
1062
+
1063
+ // No rail to join, so no dot: the row's position says "next slot" alone.
1064
+ const trigger = el.querySelector(".compose-add-thread-trigger");
1065
+ expect(trigger).not.toBeNull();
1066
+ expect(el.querySelector(".compose-thread-add-dot")).toBeNull();
1067
+ expect(trigger?.querySelector(".compose-thread-add-btn")).not.toBeNull();
650
1068
  });
651
1069
 
652
1070
  it("shows an Edit title with the format selector above the post when editing a reply", async () => {
@@ -668,6 +1086,7 @@ describe("JantComposeDialog", () => {
668
1086
  title: "Hello",
669
1087
  body: null,
670
1088
  replyToId: parentId,
1089
+ collectionIds: ["col-thread"],
671
1090
  };
672
1091
  return Promise.resolve(
673
1092
  new Response(JSON.stringify(json), {
@@ -680,13 +1099,307 @@ describe("JantComposeDialog", () => {
680
1099
  await el.openEdit("pst_123");
681
1100
  await flushUpdates(el);
682
1101
 
683
- expect(el.querySelector(".compose-dialog-title")?.textContent?.trim()).toBe(
684
- "Edit",
1102
+ expect(el.querySelector(".compose-publish-main")?.textContent?.trim()).toBe(
1103
+ labels.update,
685
1104
  );
686
- expect(
687
- el.querySelector(".compose-dialog-header-center .compose-segmented"),
688
- ).toBeNull();
1105
+ expect(el.querySelector(".compose-dialog-header")).toBeNull();
689
1106
  expect(el.querySelector(".compose-thread-post-header")).not.toBeNull();
1107
+ expect(el._collectionIds).toEqual([]);
1108
+ expect(el.querySelector(".compose-collection-trigger")).toBeNull();
1109
+ });
1110
+
1111
+ it("keeps the format selector above the post in single-post mode", async () => {
1112
+ const el = await createElement();
1113
+ await flushUpdates(el);
1114
+
1115
+ // One selector, one place — above its own post.
1116
+ const items = el.querySelectorAll<HTMLButtonElement>(
1117
+ ".compose-thread-post-header .compose-segmented-item",
1118
+ );
1119
+ expect(items.length).toBe(3);
1120
+
1121
+ items[1].click(); // link
1122
+ await flushUpdates(el);
1123
+ expect(el._format).toBe("link");
1124
+
1125
+ // A single post has no "which one", so no position marker.
1126
+ expect(el.querySelector(".compose-post-position")).toBeNull();
1127
+ });
1128
+
1129
+ it("gives every thread post its own date and permalink control", async () => {
1130
+ const el = await createElement();
1131
+ el._threadItems = [
1132
+ { id: "thread-1", format: "note" },
1133
+ { id: "thread-2", format: "note" },
1134
+ ];
1135
+ await flushUpdates(el);
1136
+
1137
+ // Every post is separately addressable (`path_registry` has a row each),
1138
+ // so every post gets both controls — not just the root.
1139
+ const pills = el.querySelectorAll("[data-compose-post-meta-pill]");
1140
+ expect(pills).toHaveLength(2);
1141
+ for (const pill of pills) {
1142
+ expect(
1143
+ pill.querySelector(".compose-post-meta-value-slug"),
1144
+ ).not.toBeNull();
1145
+ }
1146
+ });
1147
+
1148
+ it("sends a reply's own permalink and blocks an invalid one", async () => {
1149
+ const el = await createElement();
1150
+ el._threadItems = [
1151
+ { id: "thread-1", format: "note" },
1152
+ { id: "thread-2", format: "note", slug: "part-two" },
1153
+ ];
1154
+ await flushUpdates(el);
1155
+
1156
+ const build = (
1157
+ el as unknown as {
1158
+ _buildEditorPostDetail: (
1159
+ editor: JantComposeEditor,
1160
+ format: string,
1161
+ index: number,
1162
+ status: string,
1163
+ ) => ComposeSubmitDetail;
1164
+ }
1165
+ )._buildEditorPostDetail;
1166
+ const editors = el.querySelectorAll<JantComposeEditor>(
1167
+ "jant-compose-editor",
1168
+ );
1169
+
1170
+ expect(build.call(el, editors[1], "note", 1, "published").slug).toBe(
1171
+ "part-two",
1172
+ );
1173
+ expect(build.call(el, editors[0], "note", 0, "published").slug).toBe(
1174
+ undefined,
1175
+ );
1176
+
1177
+ // A reply's bad slug blocks publish just like the root's would.
1178
+ el._threadItems = [
1179
+ { id: "thread-1", format: "note" },
1180
+ { id: "thread-2", format: "note", slug: "not a slug!" },
1181
+ ];
1182
+ await flushUpdates(el);
1183
+ expect(
1184
+ (el as unknown as { _canPublish: () => boolean })._canPublish(),
1185
+ ).toBe(false);
1186
+ });
1187
+
1188
+ it("sends a reply's own date and leaves the rest for the server to inherit", async () => {
1189
+ const el = await createElement();
1190
+ el._threadItems = [
1191
+ { id: "thread-1", format: "note" },
1192
+ { id: "thread-2", format: "note", publishedAtInput: "2024-03-09" },
1193
+ { id: "thread-3", format: "note" },
1194
+ ];
1195
+ await flushUpdates(el);
1196
+
1197
+ const detail = (
1198
+ el as unknown as {
1199
+ _buildEditorPostDetail: (
1200
+ editor: JantComposeEditor,
1201
+ format: string,
1202
+ index: number,
1203
+ status: string,
1204
+ ) => ComposeSubmitDetail;
1205
+ }
1206
+ )._buildEditorPostDetail;
1207
+ const editors = el.querySelectorAll<JantComposeEditor>(
1208
+ "jant-compose-editor",
1209
+ );
1210
+
1211
+ const second = detail.call(el, editors[1], "note", 1, "published");
1212
+ const third = detail.call(el, editors[2], "note", 2, "published");
1213
+
1214
+ expect(second.publishedAt).toBeGreaterThan(0);
1215
+ // Undefined, not "now" — the server fills it from the root.
1216
+ expect(third.publishedAt).toBeUndefined();
1217
+ });
1218
+
1219
+ it("blocks publishing when any thread post has a future date", async () => {
1220
+ const el = await createElement();
1221
+ const future = new Date(Date.now() + 86400000 * 3)
1222
+ .toISOString()
1223
+ .slice(0, 10);
1224
+ el._threadItems = [
1225
+ { id: "thread-1", format: "note" },
1226
+ { id: "thread-2", format: "note", publishedAtInput: future },
1227
+ ];
1228
+ await flushUpdates(el);
1229
+
1230
+ expect(
1231
+ (el as unknown as { _canPublish: () => boolean })._canPublish(),
1232
+ ).toBe(false);
1233
+ });
1234
+
1235
+ /**
1236
+ * Publish reads each row's own answer, so a row that has been removed stops
1237
+ * being asked. These used to fail: the button was computed during render from
1238
+ * the editors still in the DOM — including the one that render was about to
1239
+ * remove.
1240
+ */
1241
+ describe("publish reflects the rows that are left", () => {
1242
+ const publishButton = (el: JantComposeDialog) =>
1243
+ requireElement(
1244
+ el.querySelector<HTMLButtonElement>(".compose-publish-main"),
1245
+ "expected publish button",
1246
+ );
1247
+
1248
+ async function typeInto(
1249
+ el: JantComposeDialog,
1250
+ index: number,
1251
+ text: string,
1252
+ ) {
1253
+ const editor = el.querySelectorAll<JantComposeEditor>(
1254
+ "jant-compose-editor",
1255
+ )[index];
1256
+ requireEditor(
1257
+ requireElement(editor, `expected editor ${index}`),
1258
+ ).commands.insertContent(text);
1259
+ await flushUpdates(el);
1260
+ }
1261
+
1262
+ async function addPost(el: JantComposeDialog) {
1263
+ requireElement(
1264
+ el.querySelector<HTMLButtonElement>(".compose-thread-add-btn"),
1265
+ "expected add-to-thread button",
1266
+ ).click();
1267
+ await flushUpdates(el);
1268
+ await flushUpdates(el);
1269
+ }
1270
+
1271
+ async function removePost(el: JantComposeDialog, index: number) {
1272
+ requireElement(
1273
+ el.querySelectorAll<HTMLButtonElement>(".compose-thread-post-remove")[
1274
+ index
1275
+ ],
1276
+ `expected remove button ${index}`,
1277
+ ).click();
1278
+ await flushUpdates(el);
1279
+ await flushUpdates(el);
1280
+ }
1281
+
1282
+ it("re-enables publish when the empty last post is removed", async () => {
1283
+ const el = await createElement();
1284
+ await typeInto(el, 0, "one");
1285
+ await addPost(el);
1286
+ await typeInto(el, 1, "two");
1287
+ await addPost(el);
1288
+
1289
+ expect(publishButton(el).disabled).toBe(true);
1290
+
1291
+ await removePost(el, 2);
1292
+
1293
+ expect(publishButton(el).disabled).toBe(false);
1294
+ });
1295
+
1296
+ it("re-enables publish when the emptied post is the first of two", async () => {
1297
+ const el = await createElement();
1298
+ await typeInto(el, 0, "one");
1299
+ await addPost(el);
1300
+ await typeInto(el, 1, "two");
1301
+
1302
+ requireEditor(
1303
+ el.querySelectorAll<JantComposeEditor>("jant-compose-editor")[0],
1304
+ ).commands.clearContent();
1305
+ await flushUpdates(el);
1306
+ expect(publishButton(el).disabled).toBe(true);
1307
+
1308
+ await removePost(el, 0);
1309
+
1310
+ expect(publishButton(el).disabled).toBe(false);
1311
+ });
1312
+
1313
+ it("keeps each post's own text when the middle post is removed", async () => {
1314
+ const el = await createElement();
1315
+ await typeInto(el, 0, "AAA");
1316
+ await addPost(el);
1317
+ await typeInto(el, 1, "BBB");
1318
+ await addPost(el);
1319
+ await typeInto(el, 2, "CCC");
1320
+
1321
+ await removePost(el, 1);
1322
+
1323
+ const text = Array.from(
1324
+ el.querySelectorAll<JantComposeEditor>("jant-compose-editor"),
1325
+ ).map((editor) => requireEditor(editor).getText());
1326
+ expect(text).toEqual(["AAA", "CCC"]);
1327
+ });
1328
+ });
1329
+
1330
+ it("numbers each post in a thread", async () => {
1331
+ const el = await createElement();
1332
+ el._threadItems = [
1333
+ { id: "thread-1", format: "note" },
1334
+ { id: "thread-2", format: "note" },
1335
+ ];
1336
+ await flushUpdates(el);
1337
+
1338
+ const positions = () =>
1339
+ Array.from(el.querySelectorAll(".compose-post-position")).map((n) =>
1340
+ n.textContent?.trim(),
1341
+ );
1342
+ expect(positions()).toEqual(["1/2", "2/2"]);
1343
+
1344
+ (el as unknown as { _addThreadItem: () => void })._addThreadItem();
1345
+ await flushUpdates(el);
1346
+
1347
+ // The total re-renders on every post, not just the new one.
1348
+ expect(positions()).toEqual(["1/3", "2/3", "3/3"]);
1349
+ });
1350
+
1351
+ const positionsIn = (el: JantComposeDialog) =>
1352
+ Array.from(el.querySelectorAll(".compose-post-position")).map((n) =>
1353
+ n.textContent?.trim(),
1354
+ );
1355
+
1356
+ it("numbers a reply from the end of the thread it continues", async () => {
1357
+ // The parent is the second post of a thread, so the reply is the third —
1358
+ // not the first, and not the second.
1359
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(
1360
+ new Response(JSON.stringify({ id: "pst_parent", threadPosition: 2 }), {
1361
+ headers: { "Content-Type": "application/json" },
1362
+ }),
1363
+ );
1364
+
1365
+ const el = await createElement();
1366
+ await el.openReply("pst_parent", {
1367
+ contentHtml: "<p>Parent</p>",
1368
+ dateText: "Mar 14",
1369
+ });
1370
+ await flushUpdates(el);
1371
+ await flushUpdates(el);
1372
+
1373
+ expect(positionsIn(el)).toEqual(["3/3"]);
1374
+
1375
+ (el as unknown as { _addThreadItem: () => void })._addThreadItem();
1376
+ await flushUpdates(el);
1377
+
1378
+ expect(positionsIn(el)).toEqual(["3/4", "4/4"]);
1379
+ });
1380
+
1381
+ it("shows no number for a reply until the parent's position is known", async () => {
1382
+ // Counting from 1 and then correcting to 3 is worse than arriving late, so
1383
+ // the marker stays away while the lookup is in flight — and stays away for
1384
+ // good if it fails.
1385
+ vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("offline"));
1386
+
1387
+ const el = await createElement();
1388
+ await el.openReply("pst_parent", {
1389
+ contentHtml: "<p>Parent</p>",
1390
+ dateText: "Mar 14",
1391
+ });
1392
+ await flushUpdates(el);
1393
+ await flushUpdates(el);
1394
+
1395
+ expect(positionsIn(el)).toEqual([]);
1396
+ });
1397
+
1398
+ it("leaves a lone new post unnumbered — there is no chain to place it in", async () => {
1399
+ const el = await createElement();
1400
+ await flushUpdates(el);
1401
+
1402
+ expect(el.querySelector(".compose-post-position")).toBeNull();
690
1403
  });
691
1404
 
692
1405
  it("switches format from the inline selector when replying", async () => {
@@ -1013,13 +1726,13 @@ describe("JantComposeDialog", () => {
1013
1726
  await editor.updateComplete;
1014
1727
 
1015
1728
  requireElement(
1016
- el.querySelector<HTMLButtonElement>(".compose-publish-toggle"),
1729
+ el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
1017
1730
  "expected publish settings toggle",
1018
1731
  ).click();
1019
1732
  await el.updateComplete;
1020
1733
 
1021
1734
  const options = el.querySelectorAll<HTMLButtonElement>(
1022
- ".compose-publish-chip[role='radio']",
1735
+ ".compose-sheet-row[role='radio']",
1023
1736
  );
1024
1737
  expect(options).toHaveLength(3);
1025
1738
  options[1]?.click();
@@ -1073,11 +1786,12 @@ describe("JantComposeDialog", () => {
1073
1786
  await editor.updateComplete;
1074
1787
 
1075
1788
  requireElement(
1076
- el.querySelector<HTMLButtonElement>(".compose-publish-toggle"),
1789
+ el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
1077
1790
  "expected publish settings toggle",
1078
1791
  ).click();
1079
1792
  await el.updateComplete;
1080
1793
 
1794
+ await openPostMeta(el);
1081
1795
  const publishedAtInput = requireElement(
1082
1796
  el.querySelector<HTMLInputElement>(".compose-publish-date-input"),
1083
1797
  "expected publish date input",
@@ -1118,16 +1832,17 @@ describe("JantComposeDialog", () => {
1118
1832
  }
1119
1833
  });
1120
1834
 
1121
- it("shows a publish date summary near the submit controls after the date changes", async () => {
1835
+ it("keeps the chosen publish date readable on the post's pill", async () => {
1122
1836
  const el = await createElement();
1123
1837
 
1124
1838
  const publishToggle = requireElement(
1125
- el.querySelector<HTMLButtonElement>(".compose-publish-toggle"),
1839
+ el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
1126
1840
  "expected publish settings toggle",
1127
1841
  );
1128
1842
  publishToggle.click();
1129
1843
  await el.updateComplete;
1130
1844
 
1845
+ await openPostMeta(el);
1131
1846
  const publishedAtInput = requireElement(
1132
1847
  el.querySelector<HTMLInputElement>(".compose-publish-date-input"),
1133
1848
  "expected publish date input",
@@ -1136,31 +1851,25 @@ describe("JantComposeDialog", () => {
1136
1851
  publishedAtInput.dispatchEvent(new Event("input", { bubbles: true }));
1137
1852
  await el.updateComplete;
1138
1853
 
1139
- publishToggle.click();
1140
- await el.updateComplete;
1141
-
1142
- const summary = requireElement(
1143
- el.querySelector<HTMLButtonElement>(
1144
- "[data-compose-publish-date-summary]",
1145
- ),
1146
- "expected publish date summary",
1147
- );
1148
- expect(summary.dataset.publishDate).toBe("2024-01-15");
1149
- expect(summary.textContent).toContain("2024");
1150
-
1151
- summary.click();
1152
- await el.updateComplete;
1854
+ // Close the panel: the chosen date stays readable on the pill.
1855
+ requireElement(
1856
+ el.querySelector<HTMLButtonElement>("[data-compose-post-meta-pill]"),
1857
+ "expected post meta pill",
1858
+ ).click();
1153
1859
  await flushUpdates(el);
1860
+ expect(el.querySelector(".compose-publish-date-input")).toBeNull();
1861
+ expect(pillValue(el, "date")).toContain("2024");
1154
1862
 
1863
+ await openPostMeta(el);
1155
1864
  expect(
1156
1865
  requireElement(
1157
1866
  el.querySelector<HTMLInputElement>(".compose-publish-date-input"),
1158
1867
  "expected publish date input after reopening publish settings",
1159
- ),
1160
- ).toBe(document.activeElement);
1868
+ ).value,
1869
+ ).toBe("2024-01-15");
1161
1870
  });
1162
1871
 
1163
- it("does not show a publish date summary for an unchanged date while editing a post", async () => {
1872
+ it("shows the existing publish date on the post pill while editing a post", async () => {
1164
1873
  const el = await createElement();
1165
1874
  (
1166
1875
  el as unknown as {
@@ -1186,10 +1895,12 @@ describe("JantComposeDialog", () => {
1186
1895
  el.requestUpdate();
1187
1896
  await el.updateComplete;
1188
1897
 
1189
- expect(el.querySelector("[data-compose-publish-date-summary]")).toBeNull();
1898
+ await openPostMeta(el);
1899
+
1900
+ expect(pillValue(el, "date")).toContain("2024");
1190
1901
  });
1191
1902
 
1192
- it("shows a publish now summary after clearing the publish date while editing a post", async () => {
1903
+ it("shows Now on the post date control after clearing the date while editing", async () => {
1193
1904
  const el = await createElement();
1194
1905
  (
1195
1906
  el as unknown as {
@@ -1215,15 +1926,12 @@ describe("JantComposeDialog", () => {
1215
1926
  el.requestUpdate();
1216
1927
  await el.updateComplete;
1217
1928
 
1218
- expect(
1219
- requireElement(
1220
- el.querySelector<HTMLElement>("[data-compose-publish-date-summary]"),
1221
- "expected publish now summary",
1222
- ).textContent,
1223
- ).toContain("Publish now");
1929
+ await openPostMeta(el);
1930
+
1931
+ expect(pillValue(el, "date")).toBe("Now");
1224
1932
  });
1225
1933
 
1226
- it("shows a custom link summary near the submit controls after the link changes", async () => {
1934
+ it("keeps the custom link readable on the post's pill", async () => {
1227
1935
  mockSlugApi((url) => {
1228
1936
  if (url.searchParams.get("mode") === "suggest") {
1229
1937
  return { body: { slug: "configured-post" } };
@@ -1236,7 +1944,7 @@ describe("JantComposeDialog", () => {
1236
1944
 
1237
1945
  const el = await createElement();
1238
1946
  const publishToggle = requireElement(
1239
- el.querySelector<HTMLButtonElement>(".compose-publish-toggle"),
1947
+ el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
1240
1948
  "expected publish settings toggle",
1241
1949
  );
1242
1950
 
@@ -1244,6 +1952,7 @@ describe("JantComposeDialog", () => {
1244
1952
  await el.updateComplete;
1245
1953
  await flushUpdates(el);
1246
1954
 
1955
+ await openPostMeta(el);
1247
1956
  const slugInput = requireElement(
1248
1957
  el.querySelector<HTMLInputElement>(".compose-publish-slug-input"),
1249
1958
  "expected custom link input",
@@ -1253,31 +1962,25 @@ describe("JantComposeDialog", () => {
1253
1962
  await new Promise((resolve) => setTimeout(resolve, 300));
1254
1963
  await el.updateComplete;
1255
1964
 
1256
- publishToggle.click();
1257
- await el.updateComplete;
1258
-
1259
- const summary = requireElement(
1260
- el.querySelector<HTMLButtonElement>(
1261
- "[data-compose-publish-slug-summary]",
1262
- ),
1263
- "expected custom link summary",
1264
- );
1265
- expect(summary.dataset.publishSlug).toBe("reading-notes");
1266
- expect(summary.textContent).toContain("/reading-notes");
1267
-
1268
- summary.click();
1269
- await el.updateComplete;
1965
+ // Close the panel: the custom link stays readable on the pill.
1966
+ requireElement(
1967
+ el.querySelector<HTMLButtonElement>("[data-compose-post-meta-pill]"),
1968
+ "expected post meta pill",
1969
+ ).click();
1270
1970
  await flushUpdates(el);
1971
+ expect(el.querySelector(".compose-publish-slug-input")).toBeNull();
1972
+ expect(pillValue(el, "slug")).toBe("/reading-notes");
1271
1973
 
1974
+ await openPostMeta(el);
1272
1975
  expect(
1273
1976
  requireElement(
1274
1977
  el.querySelector<HTMLInputElement>(".compose-publish-slug-input"),
1275
1978
  "expected custom link input after reopening publish settings",
1276
- ),
1277
- ).toBe(document.activeElement);
1979
+ ).value,
1980
+ ).toBe("reading-notes");
1278
1981
  });
1279
1982
 
1280
- it("does not show a custom link summary for an unchanged link while editing a post", async () => {
1983
+ it("shows the existing custom link on the post pill while editing a post", async () => {
1281
1984
  const el = await createElement();
1282
1985
  (
1283
1986
  el as unknown as {
@@ -1303,10 +2006,12 @@ describe("JantComposeDialog", () => {
1303
2006
  el.requestUpdate();
1304
2007
  await el.updateComplete;
1305
2008
 
1306
- expect(el.querySelector("[data-compose-publish-slug-summary]")).toBeNull();
2009
+ await openPostMeta(el);
2010
+
2011
+ expect(pillValue(el, "slug")).toBe("/reading-notes");
1307
2012
  });
1308
2013
 
1309
- it("shows an auto link summary after clearing the custom link while editing a post", async () => {
2014
+ it("shows Auto on the post permalink pill after clearing the link while editing", async () => {
1310
2015
  const el = await createElement();
1311
2016
  (
1312
2017
  el as unknown as {
@@ -1332,12 +2037,9 @@ describe("JantComposeDialog", () => {
1332
2037
  el.requestUpdate();
1333
2038
  await el.updateComplete;
1334
2039
 
1335
- expect(
1336
- requireElement(
1337
- el.querySelector<HTMLElement>("[data-compose-publish-slug-summary]"),
1338
- "expected auto link summary",
1339
- ).textContent,
1340
- ).toContain("Auto link");
2040
+ await openPostMeta(el);
2041
+
2042
+ expect(pillValue(el, "slug")).toBe("Auto");
1341
2043
  });
1342
2044
 
1343
2045
  it("blocks publishing with a future publish date", async () => {
@@ -1362,11 +2064,12 @@ describe("JantComposeDialog", () => {
1362
2064
  await editor.updateComplete;
1363
2065
 
1364
2066
  requireElement(
1365
- el.querySelector<HTMLButtonElement>(".compose-publish-toggle"),
2067
+ el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
1366
2068
  "expected publish settings toggle",
1367
2069
  ).click();
1368
2070
  await el.updateComplete;
1369
2071
 
2072
+ await openPostMeta(el);
1370
2073
  const publishedAtInput = requireElement(
1371
2074
  el.querySelector<HTMLInputElement>(".compose-publish-date-input"),
1372
2075
  "expected publish date input",
@@ -1393,13 +2096,13 @@ describe("JantComposeDialog", () => {
1393
2096
  const el = await createElement();
1394
2097
 
1395
2098
  requireElement(
1396
- el.querySelector<HTMLButtonElement>(".compose-publish-toggle"),
2099
+ el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
1397
2100
  "expected publish settings toggle",
1398
2101
  ).click();
1399
2102
  await el.updateComplete;
1400
2103
 
1401
2104
  const options = el.querySelectorAll<HTMLButtonElement>(
1402
- ".compose-publish-chip[role='radio']",
2105
+ ".compose-sheet-row[role='radio']",
1403
2106
  );
1404
2107
  expect(options).toHaveLength(3);
1405
2108
  options[2]?.click();
@@ -1413,28 +2116,157 @@ describe("JantComposeDialog", () => {
1413
2116
  ).toBe("Post privately");
1414
2117
  });
1415
2118
 
1416
- it("updates the publish button label for hidden visibility", async () => {
2119
+ it("updates the publish button label for hidden visibility", async () => {
2120
+ const el = await createElement();
2121
+
2122
+ requireElement(
2123
+ el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
2124
+ "expected publish settings toggle",
2125
+ ).click();
2126
+ await el.updateComplete;
2127
+
2128
+ const options = el.querySelectorAll<HTMLButtonElement>(
2129
+ ".compose-sheet-row[role='radio']",
2130
+ );
2131
+ expect(options).toHaveLength(3);
2132
+ options[1]?.click();
2133
+ await el.updateComplete;
2134
+
2135
+ expect(
2136
+ requireElement(
2137
+ el.querySelector<HTMLButtonElement>(".compose-publish-main"),
2138
+ "expected publish button",
2139
+ ).textContent?.trim(),
2140
+ ).toBe("Post hidden");
2141
+ });
2142
+
2143
+ it("flips visibility from the Hide from Latest shortcut", async () => {
2144
+ const el = await createElement();
2145
+
2146
+ const toggle = requireElement(
2147
+ el.querySelector<HTMLInputElement>(
2148
+ ".compose-quick-actions-row .compose-publish-quick-toggle-input",
2149
+ ),
2150
+ "expected hide-from-latest shortcut",
2151
+ );
2152
+ expect(toggle.checked).toBe(false);
2153
+
2154
+ toggle.checked = true;
2155
+ toggle.dispatchEvent(new globalThis.Event("change"));
2156
+ await el.updateComplete;
2157
+ expect(el._visibility).toBe("latest_hidden");
2158
+
2159
+ toggle.checked = false;
2160
+ toggle.dispatchEvent(new globalThis.Event("change"));
2161
+ await el.updateComplete;
2162
+ expect(el._visibility).toBe("public");
2163
+ });
2164
+
2165
+ it("drops the Hide from Latest shortcut once the post is private", async () => {
1417
2166
  const el = await createElement();
1418
2167
 
1419
2168
  requireElement(
1420
- el.querySelector<HTMLButtonElement>(".compose-publish-toggle"),
2169
+ el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
1421
2170
  "expected publish settings toggle",
1422
2171
  ).click();
1423
2172
  await el.updateComplete;
1424
2173
 
1425
- const options = el.querySelectorAll<HTMLButtonElement>(
1426
- ".compose-publish-chip[role='radio']",
2174
+ // A checkbox cannot speak for a third state, so it steps aside.
2175
+ el.querySelectorAll<HTMLButtonElement>(
2176
+ ".compose-sheet-row[role='radio']",
2177
+ )[2]?.click();
2178
+ await el.updateComplete;
2179
+
2180
+ expect(el._visibility).toBe("private");
2181
+ expect(el.querySelector(".compose-quick-actions-row")).toBeNull();
2182
+ });
2183
+
2184
+ it("offers the quiet reply shortcut on a reply, in place of visibility", async () => {
2185
+ const el = await createElement();
2186
+
2187
+ await el.openReply("019ce8ce-d6d8-7fda-a5df-c2da2bef5ade", {
2188
+ contentHtml: "<p>Parent</p>",
2189
+ dateText: "Mar 14",
2190
+ });
2191
+ await flushUpdates(el);
2192
+
2193
+ const toggles = el.querySelectorAll<HTMLInputElement>(
2194
+ ".compose-quick-actions-row .compose-publish-quick-toggle-input",
1427
2195
  );
1428
- expect(options).toHaveLength(3);
1429
- options[1]?.click();
2196
+ // A reply inherits the root's visibility, so only quiet reply is offered.
2197
+ expect(toggles).toHaveLength(1);
2198
+
2199
+ const toggle = requireElement(
2200
+ toggles.item(0),
2201
+ "expected quiet reply shortcut",
2202
+ );
2203
+ toggle.checked = true;
2204
+ toggle.dispatchEvent(new globalThis.Event("change"));
1430
2205
  await el.updateComplete;
1431
2206
 
2207
+ expect(el._quietReply).toBe(true);
1432
2208
  expect(
1433
2209
  requireElement(
1434
2210
  el.querySelector<HTMLButtonElement>(".compose-publish-main"),
1435
2211
  "expected publish button",
1436
2212
  ).textContent?.trim(),
1437
- ).toBe("Post hidden");
2213
+ ).toBe("Reply quietly");
2214
+ });
2215
+
2216
+ it("drops the quiet reply switch when editing an existing reply", async () => {
2217
+ const parentId = "019ce8ce-d6d8-7fda-a5df-c2da2bef5ade";
2218
+ vi.spyOn(globalThis, "fetch").mockImplementation((input) => {
2219
+ const url = String(input);
2220
+ const json = url.includes(parentId)
2221
+ ? { id: parentId, bodyHtml: "<p>Parent</p>", format: "note" }
2222
+ : {
2223
+ id: "pst_123",
2224
+ format: "note",
2225
+ title: null,
2226
+ body: null,
2227
+ replyToId: parentId,
2228
+ };
2229
+ return Promise.resolve(
2230
+ new Response(JSON.stringify(json), {
2231
+ headers: { "Content-Type": "application/json" },
2232
+ }),
2233
+ );
2234
+ });
2235
+
2236
+ const el = await createElement();
2237
+ await el.openEdit("pst_123");
2238
+ await flushUpdates(el);
2239
+
2240
+ // Saving an edit goes through the update path, which has no create-time
2241
+ // thread bump to skip — so the switch is gone rather than dead.
2242
+ expect(el.querySelector(".compose-quick-actions-row")).toBeNull();
2243
+
2244
+ el._showPublishPanel = true;
2245
+ await flushUpdates(el);
2246
+ expect(
2247
+ Array.from(el.querySelectorAll(".compose-sheet-title")).some(
2248
+ (row) => row.textContent?.trim() === labels.quietReplyLabel,
2249
+ ),
2250
+ ).toBe(false);
2251
+ });
2252
+
2253
+ it("keeps the quiet reply switch on a thread draft, which saves by recreating", async () => {
2254
+ const el = await createElement();
2255
+ await el.openReply("019ce8ce-d6d8-7fda-a5df-c2da2bef5ade", {
2256
+ contentHtml: "<p>Parent</p>",
2257
+ dateText: "Mar 14",
2258
+ });
2259
+ (el as unknown as { _addThreadItem: () => void })._addThreadItem();
2260
+ // Re-editing a saved thread draft deletes and recreates every post, so the
2261
+ // create-time flag still lands.
2262
+ el._draftSourceId = "pst_draft";
2263
+ await flushUpdates(el);
2264
+
2265
+ expect(
2266
+ el.querySelectorAll(
2267
+ ".compose-quick-actions-row .compose-publish-quick-toggle-input",
2268
+ ),
2269
+ ).toHaveLength(1);
1438
2270
  });
1439
2271
 
1440
2272
  it("opens a new post with the requested collection and keeps the last visibility until refresh", async () => {
@@ -1447,13 +2279,13 @@ describe("JantComposeDialog", () => {
1447
2279
  expect(el._visibility).toBe("public");
1448
2280
 
1449
2281
  requireElement(
1450
- el.querySelector<HTMLButtonElement>(".compose-publish-toggle"),
2282
+ el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
1451
2283
  "expected publish settings toggle",
1452
2284
  ).click();
1453
2285
  await el.updateComplete;
1454
2286
 
1455
2287
  const options = el.querySelectorAll<HTMLButtonElement>(
1456
- ".compose-publish-chip[role='radio']",
2288
+ ".compose-sheet-row[role='radio']",
1457
2289
  );
1458
2290
  options[1]?.click();
1459
2291
  await el.updateComplete;
@@ -1495,7 +2327,6 @@ describe("JantComposeDialog", () => {
1495
2327
  slug: "",
1496
2328
  visibility: "public",
1497
2329
  rating: 0,
1498
- showTitle: false,
1499
2330
  showRating: false,
1500
2331
  collectionIds: ["col-1"],
1501
2332
  attachedTexts: [],
@@ -1510,6 +2341,250 @@ describe("JantComposeDialog", () => {
1510
2341
  expect(el._collectionIds).toEqual(["col-2", "col-1"]);
1511
2342
  });
1512
2343
 
2344
+ function seedDraftWithMedia() {
2345
+ globalThis.localStorage.setItem(
2346
+ "jant:compose-draft",
2347
+ JSON.stringify({
2348
+ format: "note",
2349
+ title: "",
2350
+ bodyJson: {
2351
+ type: "doc",
2352
+ content: [
2353
+ {
2354
+ type: "paragraph",
2355
+ content: [{ type: "text", text: "Draft with images" }],
2356
+ },
2357
+ ],
2358
+ },
2359
+ url: "",
2360
+ quoteText: "",
2361
+ quoteAuthor: "",
2362
+ slug: "",
2363
+ visibility: "public",
2364
+ rating: 0,
2365
+ showTitle: false,
2366
+ showRating: false,
2367
+ collectionIds: [],
2368
+ replyToId: null,
2369
+ attachedTexts: [],
2370
+ attachmentOrder: ["client-a", "client-b"],
2371
+ mediaAttachments: [
2372
+ {
2373
+ clientId: "client-a",
2374
+ mediaId: "med_aaa",
2375
+ url: "/media/aaa.jpg",
2376
+ mimeType: "image/jpeg",
2377
+ name: "aaa.jpg",
2378
+ alt: "first",
2379
+ },
2380
+ ],
2381
+ savedAt: Date.now(),
2382
+ }),
2383
+ );
2384
+ }
2385
+
2386
+ it("restores uploaded media from the draft snapshot plus bridge-supplied uploads", async () => {
2387
+ const el = await createElement();
2388
+ seedDraftWithMedia();
2389
+
2390
+ // client-b's upload finished after the dialog closed — only the bridge
2391
+ // knows about it, so it arrives via restoreMedia instead of the snapshot.
2392
+ await el.openNew({
2393
+ restoreDraft: true,
2394
+ restoreToast: false,
2395
+ restoreMedia: [
2396
+ {
2397
+ clientId: "client-b",
2398
+ mediaId: "med_bbb",
2399
+ url: "/media/bbb.png",
2400
+ mimeType: "image/png",
2401
+ name: "bbb.png",
2402
+ },
2403
+ ],
2404
+ });
2405
+ await flushUpdates(el);
2406
+
2407
+ const editor = requireElement(
2408
+ el.querySelector<JantComposeEditor>("jant-compose-editor"),
2409
+ "expected compose editor",
2410
+ );
2411
+ const attachments = editor._attachments;
2412
+ expect(attachments.map((a) => a.clientId)).toEqual([
2413
+ "client-a",
2414
+ "client-b",
2415
+ ]);
2416
+ expect(attachments.map((a) => a.mediaId)).toEqual(["med_aaa", "med_bbb"]);
2417
+ expect(attachments.every((a) => a.status === "done")).toBe(true);
2418
+ expect(attachments[0].remoteUrl).toBe("/media/aaa.jpg");
2419
+ expect(attachments[0].alt).toBe("first");
2420
+ expect(editor._attachmentOrder).toEqual(["client-a", "client-b"]);
2421
+ });
2422
+
2423
+ it("suppresses the draft-restored toast when restoreToast is false", async () => {
2424
+ const container = document.createElement("div");
2425
+ container.id = "toast-container";
2426
+ document.body.appendChild(container);
2427
+ const el = await createElement();
2428
+ seedDraftWithMedia();
2429
+
2430
+ await el.openNew({ restoreDraft: true, restoreToast: false });
2431
+ await flushUpdates(el);
2432
+ expect(container.querySelector(".toast")).toBeNull();
2433
+
2434
+ el.reset();
2435
+ await el.openNew({ restoreDraft: true });
2436
+ await flushUpdates(el);
2437
+ expect(container.querySelector(".toast span")?.textContent).toBe(
2438
+ "Draft restored.",
2439
+ );
2440
+ });
2441
+
2442
+ const NOTE_TITLE_KEY = "jant:compose-note-title";
2443
+
2444
+ function titleToggle(el: JantComposeDialog, index = 0): HTMLButtonElement {
2445
+ return requireElement(
2446
+ el.querySelectorAll<HTMLButtonElement>(
2447
+ '.compose-tool-btn[title="Title"]',
2448
+ )[index] ?? null,
2449
+ "expected title toggle",
2450
+ );
2451
+ }
2452
+
2453
+ it("remembers a note's title toggle for the next new post", async () => {
2454
+ const el = await createElement();
2455
+ await el.openNew({ restoreDraft: false });
2456
+ await flushUpdates(el);
2457
+
2458
+ expect(el.querySelector(".compose-note-title")).not.toBeNull();
2459
+
2460
+ titleToggle(el).click();
2461
+ await flushUpdates(el);
2462
+
2463
+ expect(globalThis.localStorage.getItem(NOTE_TITLE_KEY)).toBe("0");
2464
+ expect(el.querySelector(".compose-note-title")).toBeNull();
2465
+
2466
+ await el.openNew({ restoreDraft: false });
2467
+ await flushUpdates(el);
2468
+
2469
+ expect(el.querySelector(".compose-note-title")).toBeNull();
2470
+
2471
+ titleToggle(el).click();
2472
+ await flushUpdates(el);
2473
+
2474
+ expect(globalThis.localStorage.getItem(NOTE_TITLE_KEY)).toBe("1");
2475
+
2476
+ await el.openNew({ restoreDraft: false });
2477
+ await flushUpdates(el);
2478
+
2479
+ expect(el.querySelector(".compose-note-title")).not.toBeNull();
2480
+ });
2481
+
2482
+ it("opens a fresh composer without the title field when it was last turned off", async () => {
2483
+ globalThis.localStorage.setItem(NOTE_TITLE_KEY, "0");
2484
+
2485
+ const el = await createElement();
2486
+
2487
+ expect(el.querySelector(".compose-note-title")).toBeNull();
2488
+ });
2489
+
2490
+ it("does not remember the title toggle from an edit", async () => {
2491
+ mockEditPost({ format: "note", title: "Hello", body: null });
2492
+
2493
+ const el = await createElement();
2494
+ await el.openEdit("pst_123");
2495
+ await flushUpdates(el);
2496
+
2497
+ titleToggle(el).click();
2498
+ await flushUpdates(el);
2499
+
2500
+ // Dropping this post's title says nothing about the next post's.
2501
+ expect(globalThis.localStorage.getItem(NOTE_TITLE_KEY)).toBeNull();
2502
+ });
2503
+
2504
+ it("does not remember the title toggle from a reply", async () => {
2505
+ const el = await createElement();
2506
+ await el.openReply("019ce8ce-d6d8-7fda-a5df-c2da2bef5ade", {
2507
+ contentHtml: "<p>Parent</p>",
2508
+ dateText: "Mar 14",
2509
+ });
2510
+ await flushUpdates(el);
2511
+
2512
+ titleToggle(el).click();
2513
+ await flushUpdates(el);
2514
+
2515
+ expect(el.querySelector(".compose-note-title")).not.toBeNull();
2516
+ expect(globalThis.localStorage.getItem(NOTE_TITLE_KEY)).toBeNull();
2517
+ });
2518
+
2519
+ it("does not remember the title toggle from a thread post", async () => {
2520
+ const el = await createElement();
2521
+ (el as unknown as { _addThreadItem: () => void })._addThreadItem();
2522
+ await flushUpdates(el);
2523
+
2524
+ titleToggle(el).click();
2525
+ await flushUpdates(el);
2526
+
2527
+ expect(globalThis.localStorage.getItem(NOTE_TITLE_KEY)).toBeNull();
2528
+ });
2529
+
2530
+ it("does not remember a title the editor reveals on its own", async () => {
2531
+ const el = await createElement();
2532
+ await el.openNew({ restoreDraft: false });
2533
+ await flushUpdates(el);
2534
+
2535
+ const editor = requireElement(
2536
+ el.querySelector<JantComposeEditor>("jant-compose-editor"),
2537
+ "expected compose editor",
2538
+ );
2539
+ // The fullscreen and H1-promotion paths land here. Neither is the author
2540
+ // stating a habit, so neither writes one down.
2541
+ editor.setEditorState(null, "Promoted", true);
2542
+ await flushUpdates(el);
2543
+
2544
+ expect(el.querySelector(".compose-note-title")).not.toBeNull();
2545
+ expect(globalThis.localStorage.getItem(NOTE_TITLE_KEY)).toBeNull();
2546
+ });
2547
+
2548
+ it("lets a restored draft's title state win over the remembered default", async () => {
2549
+ globalThis.localStorage.setItem(NOTE_TITLE_KEY, "0");
2550
+ globalThis.localStorage.setItem(
2551
+ "jant:compose-draft",
2552
+ JSON.stringify({
2553
+ format: "note",
2554
+ title: "Draft title",
2555
+ showTitle: true,
2556
+ bodyJson: {
2557
+ type: "doc",
2558
+ content: [
2559
+ {
2560
+ type: "paragraph",
2561
+ content: [{ type: "text", text: "Draft body" }],
2562
+ },
2563
+ ],
2564
+ },
2565
+ url: "",
2566
+ quoteText: "",
2567
+ quoteAuthor: "",
2568
+ slug: "",
2569
+ visibility: "public",
2570
+ rating: 0,
2571
+ showRating: false,
2572
+ collectionIds: [],
2573
+ attachedTexts: [],
2574
+ attachmentOrder: [],
2575
+ savedAt: Date.now(),
2576
+ }),
2577
+ );
2578
+
2579
+ const el = await createElement();
2580
+ await el.openNew();
2581
+ await flushUpdates(el);
2582
+
2583
+ expect(
2584
+ el.querySelector<HTMLInputElement>(".compose-note-title")?.value,
2585
+ ).toBe("Draft title");
2586
+ });
2587
+
1513
2588
  it("includes a custom slug from the publish settings panel in the submit payload", async () => {
1514
2589
  mockSlugApi((url) => {
1515
2590
  if (url.searchParams.get("mode") === "suggest") {
@@ -1538,13 +2613,14 @@ describe("JantComposeDialog", () => {
1538
2613
  await editor.updateComplete;
1539
2614
 
1540
2615
  const publishToggle = requireElement(
1541
- el.querySelector<HTMLButtonElement>(".compose-publish-toggle"),
2616
+ el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
1542
2617
  "expected publish settings toggle",
1543
2618
  );
1544
2619
  publishToggle.click();
1545
2620
  await el.updateComplete;
1546
2621
  await flushUpdates(el);
1547
2622
 
2623
+ await openPostMeta(el);
1548
2624
  const slugInput = requireElement(
1549
2625
  el.querySelector<HTMLInputElement>(".compose-publish-slug-input"),
1550
2626
  "expected custom link input",
@@ -1584,7 +2660,7 @@ describe("JantComposeDialog", () => {
1584
2660
  const el = await createElement();
1585
2661
 
1586
2662
  const publishToggle = requireElement(
1587
- el.querySelector<HTMLButtonElement>(".compose-publish-toggle"),
2663
+ el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
1588
2664
  "expected publish settings toggle",
1589
2665
  );
1590
2666
 
@@ -1592,6 +2668,7 @@ describe("JantComposeDialog", () => {
1592
2668
  await el.updateComplete;
1593
2669
  await flushUpdates(el);
1594
2670
 
2671
+ await openPostMeta(el);
1595
2672
  const slugInput = requireElement(
1596
2673
  el.querySelector<HTMLInputElement>(".compose-publish-slug-input"),
1597
2674
  "expected custom link input",
@@ -1606,6 +2683,7 @@ describe("JantComposeDialog", () => {
1606
2683
  publishToggle.click();
1607
2684
  await el.updateComplete;
1608
2685
 
2686
+ await openPostMeta(el);
1609
2687
  expect(el.querySelector(".compose-publish-slug-input")).not.toBeNull();
1610
2688
  });
1611
2689
 
@@ -1664,7 +2742,7 @@ describe("JantComposeDialog", () => {
1664
2742
  } as never);
1665
2743
 
1666
2744
  requireElement(
1667
- el.querySelector<HTMLButtonElement>(".compose-publish-toggle"),
2745
+ el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
1668
2746
  "expected publish settings toggle",
1669
2747
  ).click();
1670
2748
  await el.updateComplete;
@@ -1735,7 +2813,7 @@ describe("JantComposeDialog", () => {
1735
2813
  } as never);
1736
2814
 
1737
2815
  requireElement(
1738
- el.querySelector<HTMLButtonElement>(".compose-publish-toggle"),
2816
+ el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
1739
2817
  "expected publish settings toggle",
1740
2818
  ).click();
1741
2819
  await el.updateComplete;
@@ -1757,7 +2835,7 @@ describe("JantComposeDialog", () => {
1757
2835
  const el = await createElement();
1758
2836
 
1759
2837
  requireElement(
1760
- el.querySelector<HTMLButtonElement>(".compose-publish-toggle"),
2838
+ el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
1761
2839
  "expected publish settings toggle",
1762
2840
  ).click();
1763
2841
  await el.updateComplete;
@@ -1785,13 +2863,14 @@ describe("JantComposeDialog", () => {
1785
2863
  await editor.updateComplete;
1786
2864
 
1787
2865
  const publishToggle = requireElement(
1788
- el.querySelector<HTMLButtonElement>(".compose-publish-toggle"),
2866
+ el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
1789
2867
  "expected publish settings toggle",
1790
2868
  );
1791
2869
  publishToggle.click();
1792
2870
  await el.updateComplete;
1793
2871
  await flushUpdates(el);
1794
2872
 
2873
+ await openPostMeta(el);
1795
2874
  const slugInput = requireElement(
1796
2875
  el.querySelector<HTMLInputElement>(".compose-publish-slug-input"),
1797
2876
  "expected custom link input",
@@ -1836,7 +2915,6 @@ describe("JantComposeDialog", () => {
1836
2915
  el.querySelector<JantComposeEditor>("jant-compose-editor"),
1837
2916
  "expected compose editor",
1838
2917
  );
1839
- editor._showTitle = true;
1840
2918
  editor._title = "Hello World";
1841
2919
  editor._bodyJson = {
1842
2920
  type: "doc",
@@ -1847,13 +2925,14 @@ describe("JantComposeDialog", () => {
1847
2925
  await editor.updateComplete;
1848
2926
 
1849
2927
  const publishToggle = requireElement(
1850
- el.querySelector<HTMLButtonElement>(".compose-publish-toggle"),
2928
+ el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
1851
2929
  "expected publish settings toggle",
1852
2930
  );
1853
2931
  publishToggle.click();
1854
2932
  await el.updateComplete;
1855
2933
  await flushUpdates(el);
1856
2934
 
2935
+ await openPostMeta(el);
1857
2936
  expect(
1858
2937
  el.querySelector(".compose-slug-suggestion-value")?.textContent?.trim(),
1859
2938
  ).toBe("/hello-world");
@@ -1874,11 +2953,11 @@ describe("JantComposeDialog", () => {
1874
2953
  ).toBeUndefined();
1875
2954
  });
1876
2955
 
1877
- it("shows visibility and custom link settings in the publish panel", async () => {
2956
+ it("keeps the publish panel to whole-submission settings", async () => {
1878
2957
  const el = await createElement();
1879
2958
 
1880
2959
  const publishToggle = requireElement(
1881
- el.querySelector<HTMLButtonElement>(".compose-publish-toggle"),
2960
+ el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
1882
2961
  "expected publish settings toggle",
1883
2962
  );
1884
2963
  publishToggle.click();
@@ -1890,12 +2969,16 @@ describe("JantComposeDialog", () => {
1890
2969
  );
1891
2970
  expect(panel.textContent).not.toContain("Publish settings");
1892
2971
  expect(panel.textContent).toContain("Visibility");
1893
- expect(panel.textContent).toContain("Published on");
1894
- expect(panel.textContent).toContain("Custom link");
1895
2972
  expect(panel.textContent).not.toContain("Save as draft");
1896
2973
  expect(panel.textContent).not.toContain("Discard");
1897
- expect(panel.querySelector(".compose-publish-date-input")).not.toBeNull();
1898
- expect(panel.querySelector(".compose-publish-slug-input")).not.toBeNull();
2974
+ // Post-scoped settings are not in here — they live on the post.
2975
+ expect(panel.textContent).not.toContain("Published on");
2976
+ expect(panel.textContent).not.toContain("Custom link");
2977
+
2978
+ await openPostMeta(el);
2979
+ expect(el.querySelector(".compose-publish-date-input")).not.toBeNull();
2980
+ await openPostMeta(el);
2981
+ expect(el.querySelector(".compose-publish-slug-input")).not.toBeNull();
1899
2982
  });
1900
2983
 
1901
2984
  it("clears the custom slug with the reset action", async () => {
@@ -1911,13 +2994,14 @@ describe("JantComposeDialog", () => {
1911
2994
 
1912
2995
  const el = await createElement();
1913
2996
  const publishToggle = requireElement(
1914
- el.querySelector<HTMLButtonElement>(".compose-publish-toggle"),
2997
+ el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
1915
2998
  "expected publish settings toggle",
1916
2999
  );
1917
3000
  publishToggle.click();
1918
3001
  await el.updateComplete;
1919
3002
  await flushUpdates(el);
1920
3003
 
3004
+ await openPostMeta(el);
1921
3005
  const slugInput = requireElement(
1922
3006
  el.querySelector<HTMLInputElement>(".compose-publish-slug-input"),
1923
3007
  "expected custom link input",
@@ -1937,6 +3021,7 @@ describe("JantComposeDialog", () => {
1937
3021
  ).click();
1938
3022
  await flushUpdates(el);
1939
3023
 
3024
+ await openPostMeta(el);
1940
3025
  expect(
1941
3026
  requireElement(
1942
3027
  el.querySelector<HTMLInputElement>(".compose-publish-slug-input"),
@@ -1959,7 +3044,7 @@ describe("JantComposeDialog", () => {
1959
3044
  await el.updateComplete;
1960
3045
 
1961
3046
  const publishToggle = requireElement(
1962
- el.querySelector<HTMLButtonElement>(".compose-publish-toggle"),
3047
+ el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
1963
3048
  "expected publish settings toggle",
1964
3049
  );
1965
3050
  publishToggle.click();
@@ -1991,18 +3076,18 @@ describe("JantComposeDialog", () => {
1991
3076
  el.querySelector<JantComposeEditor>("jant-compose-editor"),
1992
3077
  "expected compose editor",
1993
3078
  );
1994
- editor._showTitle = true;
1995
3079
  editor._title = "Hello World";
1996
3080
  await editor.updateComplete;
1997
3081
 
1998
3082
  const publishToggle = requireElement(
1999
- el.querySelector<HTMLButtonElement>(".compose-publish-toggle"),
3083
+ el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
2000
3084
  "expected publish settings toggle",
2001
3085
  );
2002
3086
  publishToggle.click();
2003
3087
  await el.updateComplete;
2004
3088
  await flushUpdates(el);
2005
3089
 
3090
+ await openPostMeta(el);
2006
3091
  expect(
2007
3092
  el.querySelector(".compose-slug-suggestion-value")?.textContent?.trim(),
2008
3093
  ).toBe("/hello-world");
@@ -2052,13 +3137,14 @@ describe("JantComposeDialog", () => {
2052
3137
  await editor.updateComplete;
2053
3138
 
2054
3139
  const publishToggle = requireElement(
2055
- el.querySelector<HTMLButtonElement>(".compose-publish-toggle"),
3140
+ el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
2056
3141
  "expected publish settings toggle",
2057
3142
  );
2058
3143
  publishToggle.click();
2059
3144
  await el.updateComplete;
2060
3145
  await flushUpdates(el);
2061
3146
 
3147
+ await openPostMeta(el);
2062
3148
  const slugInput = requireElement(
2063
3149
  el.querySelector<HTMLInputElement>(".compose-publish-slug-input"),
2064
3150
  "expected custom link input",
@@ -2109,11 +3195,12 @@ describe("JantComposeDialog", () => {
2109
3195
  await editor.updateComplete;
2110
3196
 
2111
3197
  requireElement(
2112
- el.querySelector<HTMLButtonElement>(".compose-publish-toggle"),
3198
+ el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
2113
3199
  "expected publish settings toggle",
2114
3200
  ).click();
2115
3201
  await el.updateComplete;
2116
3202
 
3203
+ await openPostMeta(el);
2117
3204
  const slugInput = requireElement(
2118
3205
  el.querySelector<HTMLInputElement>(".compose-publish-slug-input"),
2119
3206
  "expected custom link input",
@@ -2161,11 +3248,12 @@ describe("JantComposeDialog", () => {
2161
3248
  await editor.updateComplete;
2162
3249
 
2163
3250
  requireElement(
2164
- el.querySelector<HTMLButtonElement>(".compose-publish-toggle"),
3251
+ el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
2165
3252
  "expected publish settings toggle",
2166
3253
  ).click();
2167
3254
  await el.updateComplete;
2168
3255
 
3256
+ await openPostMeta(el);
2169
3257
  const slugInput = requireElement(
2170
3258
  el.querySelector<HTMLInputElement>(".compose-publish-slug-input"),
2171
3259
  "expected custom link input",
@@ -2279,9 +3367,8 @@ describe("JantComposeDialog", () => {
2279
3367
  slug: "",
2280
3368
  visibility: "public",
2281
3369
  rating: 0,
2282
- showTitle: false,
2283
3370
  showRating: false,
2284
- collectionIds: [],
3371
+ collectionIds: ["col-stale"],
2285
3372
  replyToId,
2286
3373
  attachedTexts: [],
2287
3374
  attachmentOrder: [],
@@ -2300,6 +3387,8 @@ describe("JantComposeDialog", () => {
2300
3387
  "expected compose editor",
2301
3388
  );
2302
3389
  expect(el._replyToId).toBe(replyToId);
3390
+ expect(el._collectionIds).toEqual([]);
3391
+ expect(el.querySelector(".compose-collection-trigger")).toBeNull();
2303
3392
  expect(editor.getData().body).toContain("Recovered reply draft");
2304
3393
  });
2305
3394
 
@@ -2333,19 +3422,69 @@ describe("JantComposeDialog", () => {
2333
3422
  "expected reply button",
2334
3423
  ).click();
2335
3424
 
2336
- const savedDraftRaw = globalThis.localStorage.getItem("jant:compose-draft");
2337
- if (!savedDraftRaw) {
2338
- throw new Error("expected local compose draft");
2339
- }
2340
- const savedDraft = JSON.parse(savedDraftRaw) as {
2341
- replyToId: string;
2342
- bodyJson: { content: Array<{ content: Array<{ text: string }> }> };
2343
- };
2344
-
2345
- expect(savedDraft.replyToId).toBe(replyToId);
2346
- expect(savedDraft.bodyJson.content[0]?.content[0]?.text).toBe(
2347
- "Persist me before submit",
2348
- );
3425
+ const savedDraftRaw = globalThis.localStorage.getItem("jant:compose-draft");
3426
+ if (!savedDraftRaw) {
3427
+ throw new Error("expected local compose draft");
3428
+ }
3429
+ const savedDraft = JSON.parse(savedDraftRaw) as {
3430
+ replyToId: string;
3431
+ bodyJson: { content: Array<{ content: Array<{ text: string }> }> };
3432
+ };
3433
+
3434
+ expect(savedDraft.replyToId).toBe(replyToId);
3435
+ expect(savedDraft.bodyJson.content[0]?.content[0]?.text).toBe(
3436
+ "Persist me before submit",
3437
+ );
3438
+ });
3439
+
3440
+ it("includes quiet reply intent when submitting multiple reply posts", async () => {
3441
+ const el = await createElement();
3442
+ await el.openReply("019ce8ce-d6d8-7fda-a5df-c2da2bef5ade", {
3443
+ contentHtml: "<p>Parent</p>",
3444
+ dateText: "Mar 14",
3445
+ });
3446
+
3447
+ (
3448
+ el as unknown as {
3449
+ _addThreadItem: () => void;
3450
+ }
3451
+ )._addThreadItem();
3452
+ await flushUpdates(el);
3453
+
3454
+ el._quietReply = true;
3455
+ const editors = Array.from(
3456
+ el.querySelectorAll<JantComposeEditor>("jant-compose-editor"),
3457
+ );
3458
+ expect(editors).toHaveLength(2);
3459
+
3460
+ editors.forEach((editor, index) => {
3461
+ editor._bodyJson = {
3462
+ type: "doc",
3463
+ content: [
3464
+ {
3465
+ type: "paragraph",
3466
+ content: [{ type: "text", text: `Quiet reply ${index + 1}` }],
3467
+ },
3468
+ ],
3469
+ };
3470
+ });
3471
+ await Promise.all(editors.map((editor) => editor.updateComplete));
3472
+ await el.updateComplete;
3473
+
3474
+ let receivedDetail: ComposeSubmitDetail | null = null;
3475
+ el.addEventListener("jant:compose-submit-deferred", (event) => {
3476
+ receivedDetail = (event as CustomEvent<ComposeSubmitDetail>).detail;
3477
+ });
3478
+
3479
+ requireElement(
3480
+ el.querySelector<HTMLButtonElement>(".compose-publish-main"),
3481
+ "expected reply button",
3482
+ ).click();
3483
+
3484
+ expect(receivedDetail).not.toBeNull();
3485
+ const detail = receivedDetail as unknown as ComposeSubmitDetail;
3486
+ expect(detail.quietReply).toBe(true);
3487
+ expect(detail.threadPosts?.[0]?.quietReply).toBe(true);
2349
3488
  });
2350
3489
 
2351
3490
  it("does not add more thread items than the shared limit", async () => {
@@ -2364,6 +3503,34 @@ describe("JantComposeDialog", () => {
2364
3503
  expect(el._threadItems).toHaveLength(MAX_THREAD_POSTS);
2365
3504
  });
2366
3505
 
3506
+ it("scrolls the thread composer to the bottom after adding an item", async () => {
3507
+ const el = await createElement();
3508
+ el._threadItems = [
3509
+ { id: "thread-1", format: "note" },
3510
+ { id: "thread-2", format: "note" },
3511
+ ];
3512
+ await el.updateComplete;
3513
+
3514
+ const scroller = requireElement(
3515
+ el.querySelector<HTMLElement>(".compose-scroll"),
3516
+ "expected compose scroll region",
3517
+ );
3518
+ Object.defineProperty(scroller, "scrollHeight", {
3519
+ configurable: true,
3520
+ value: 640,
3521
+ });
3522
+ scroller.scrollTop = 120;
3523
+
3524
+ (
3525
+ el as unknown as {
3526
+ _addThreadItem: () => void;
3527
+ }
3528
+ )._addThreadItem();
3529
+ await flushUpdates(el);
3530
+
3531
+ expect(scroller.scrollTop).toBe(640);
3532
+ });
3533
+
2367
3534
  it("omits visibility from locked edit submissions", async () => {
2368
3535
  const el = await createElement();
2369
3536
  const editor = requireElement(
@@ -2405,6 +3572,20 @@ describe("JantComposeDialog", () => {
2405
3572
  );
2406
3573
  });
2407
3574
 
3575
+ it("gives the collection trigger one glyph and no chevron", async () => {
3576
+ const el = await createElement();
3577
+ const trigger = requireElement(
3578
+ el.querySelector<HTMLButtonElement>(".compose-collection-trigger"),
3579
+ "expected collection trigger",
3580
+ );
3581
+
3582
+ expect(trigger.querySelectorAll("svg").length).toBe(1);
3583
+ expect(
3584
+ trigger.querySelector(".compose-collection-trigger-svg"),
3585
+ ).not.toBeNull();
3586
+ expect(trigger.querySelector(".compose-collection-chevron")).toBeNull();
3587
+ });
3588
+
2408
3589
  it("collection selector toggles IDs", async () => {
2409
3590
  const el = await createElement();
2410
3591
 
@@ -2935,13 +4116,10 @@ describe("JantComposeDialog", () => {
2935
4116
  };
2936
4117
  await editor.updateComplete;
2937
4118
 
2938
- // Click the draft header button — should show confirm panel
2939
- const draftBtn = requireElement(
2940
- el.querySelector<HTMLButtonElement>(".compose-dialog-header-btn"),
2941
- "expected draft button",
2942
- );
2943
- draftBtn.click();
2944
- await el.updateComplete;
4119
+ // Click the drafts row in the options panel — should show confirm panel
4120
+ await openPublishPanel(el);
4121
+ requireElement(draftsRow(el), "expected drafts row").click();
4122
+ await flushUpdates(el);
2945
4123
 
2946
4124
  expect(el._confirmPanelOpen).toBe(true);
2947
4125
  expect(el.querySelector(".compose-confirm-panel")).not.toBeNull();
@@ -2958,13 +4136,10 @@ describe("JantComposeDialog", () => {
2958
4136
  }),
2959
4137
  );
2960
4138
 
2961
- // Click the draft header button — should open drafts panel
2962
- const draftBtn = requireElement(
2963
- el.querySelector<HTMLButtonElement>(".compose-dialog-header-btn"),
2964
- "expected draft button",
2965
- );
2966
- draftBtn.click();
2967
- await el.updateComplete;
4139
+ // Click the drafts row in the options panel — should open drafts panel
4140
+ await openPublishPanel(el);
4141
+ requireElement(draftsRow(el), "expected drafts row").click();
4142
+ await flushUpdates(el);
2968
4143
 
2969
4144
  expect(el._draftsPanelOpen).toBe(true);
2970
4145
 
@@ -2978,6 +4153,132 @@ describe("JantComposeDialog", () => {
2978
4153
  fetchSpy.mockRestore();
2979
4154
  });
2980
4155
 
4156
+ it("opens a prefixed draft preview from the overflow menu without loading the editor", async () => {
4157
+ document.documentElement.dataset.sitePathPrefix = "/blog";
4158
+ const el = await createElement();
4159
+ el._draftsPanelOpen = true;
4160
+ el._draftsLoading = false;
4161
+ el._drafts = [
4162
+ {
4163
+ id: "pst_draft",
4164
+ slug: "draft-slug",
4165
+ format: "note",
4166
+ title: "Preview me",
4167
+ bodyText: "Draft body",
4168
+ bodyHtml: "<p>Draft body</p>",
4169
+ url: null,
4170
+ quoteText: null,
4171
+ replyToId: null,
4172
+ updatedAt: 0,
4173
+ mediaAttachments: [],
4174
+ },
4175
+ ];
4176
+ await el.updateComplete;
4177
+
4178
+ const trigger = requireElement(
4179
+ el.querySelector<HTMLButtonElement>(".compose-draft-more"),
4180
+ "expected draft actions trigger",
4181
+ );
4182
+ expect(trigger.getAttribute("aria-label")).toBe("Draft actions");
4183
+ expect(trigger.getAttribute("aria-haspopup")).toBe("menu");
4184
+ expect(trigger.getAttribute("aria-expanded")).toBe("false");
4185
+
4186
+ trigger.click();
4187
+ await el.updateComplete;
4188
+
4189
+ expect(trigger.getAttribute("aria-expanded")).toBe("true");
4190
+ const menu = requireElement(
4191
+ el.querySelector<HTMLElement>('[role="menu"]'),
4192
+ "expected draft actions menu",
4193
+ );
4194
+ const items = menu.querySelectorAll<HTMLElement>('[role="menuitem"]');
4195
+ expect(items).toHaveLength(2);
4196
+ expect(items[0]?.textContent?.trim()).toBe("Preview");
4197
+ expect(items[1]?.textContent?.trim()).toBe("Delete Draft");
4198
+
4199
+ const previewLink = requireElement(
4200
+ menu.querySelector<HTMLAnchorElement>('a[role="menuitem"]'),
4201
+ "expected preview link",
4202
+ );
4203
+ expect(previewLink.getAttribute("href")).toBe("/blog/preview/draft-slug");
4204
+ expect(previewLink.target).toBe("_blank");
4205
+ expect(previewLink.rel).toBe("noopener noreferrer");
4206
+
4207
+ const fetchSpy = vi.spyOn(globalThis, "fetch");
4208
+ previewLink.addEventListener("click", (event) => event.preventDefault());
4209
+ previewLink.click();
4210
+ await el.updateComplete;
4211
+
4212
+ expect(fetchSpy).not.toHaveBeenCalled();
4213
+ expect(el._draftsPanelOpen).toBe(true);
4214
+ expect(el._draftMenuOpenId).toBeNull();
4215
+ });
4216
+
4217
+ it("uses title or quote text for draft previews and identifies each format", async () => {
4218
+ const el = await createElement();
4219
+ el._draftsPanelOpen = true;
4220
+ el._draftsLoading = false;
4221
+ el._drafts = [
4222
+ {
4223
+ id: "pst_note",
4224
+ slug: "note-draft",
4225
+ format: "note",
4226
+ title: "A titled note",
4227
+ bodyText: "This body should not be the preview",
4228
+ bodyHtml: "<p>This body should not be the preview</p>",
4229
+ url: null,
4230
+ quoteText: null,
4231
+ replyToId: null,
4232
+ updatedAt: 0,
4233
+ mediaAttachments: [],
4234
+ },
4235
+ {
4236
+ id: "pst_quote",
4237
+ slug: "quote-draft",
4238
+ format: "quote",
4239
+ title: "Quote title",
4240
+ bodyText: "Supporting note",
4241
+ bodyHtml: "<p>Supporting note</p>",
4242
+ url: null,
4243
+ quoteText: "The quoted words come first.",
4244
+ replyToId: null,
4245
+ updatedAt: 0,
4246
+ mediaAttachments: [],
4247
+ },
4248
+ {
4249
+ id: "pst_link",
4250
+ slug: "link-draft",
4251
+ format: "link",
4252
+ title: null,
4253
+ bodyText: "A link note",
4254
+ bodyHtml: "<p>A link note</p>",
4255
+ url: "https://example.com",
4256
+ quoteText: null,
4257
+ replyToId: null,
4258
+ updatedAt: 0,
4259
+ mediaAttachments: [],
4260
+ },
4261
+ ];
4262
+ await el.updateComplete;
4263
+
4264
+ const rows = el.querySelectorAll<HTMLElement>(".compose-draft-item");
4265
+ expect(rows).toHaveLength(3);
4266
+ expect(rows[0]?.querySelector(".compose-draft-preview")?.textContent).toBe(
4267
+ "A titled note",
4268
+ );
4269
+ expect(rows[1]?.querySelector(".compose-draft-preview")?.textContent).toBe(
4270
+ "The quoted words come first.",
4271
+ );
4272
+ expect(rows[2]?.querySelector(".compose-draft-preview")?.textContent).toBe(
4273
+ "A link note",
4274
+ );
4275
+ expect(
4276
+ Array.from(el.querySelectorAll(".compose-draft-format")).map(
4277
+ (format) => format.textContent,
4278
+ ),
4279
+ ).toEqual(["Note", "Quote", "Link"]);
4280
+ });
4281
+
2981
4282
  it("does not dispatch submit when loading", async () => {
2982
4283
  const el = await createElement();
2983
4284
  el._loading = true;
@@ -3036,6 +4337,7 @@ describe("JantComposeDialog", () => {
3036
4337
  alt: "",
3037
4338
  error: null,
3038
4339
  posterUrl: null,
4340
+ remoteUrl: null,
3039
4341
  summary: null,
3040
4342
  chars: null,
3041
4343
  },
@@ -3054,52 +4356,451 @@ describe("JantComposeDialog", () => {
3054
4356
  expect(mediaBtn?.querySelector(".compose-tool-label")?.textContent).toBe(
3055
4357
  "Add",
3056
4358
  );
3057
-
3058
- URL.revokeObjectURL(previewUrl);
4359
+
4360
+ URL.revokeObjectURL(previewUrl);
4361
+ });
4362
+
4363
+ it("puts the single-post editor straight inside the one scroll region", async () => {
4364
+ const el = await createElement();
4365
+
4366
+ const scroller = requireElement(
4367
+ el.querySelector<HTMLElement>(".compose-scroll"),
4368
+ "expected compose scroll region",
4369
+ );
4370
+ const editor = requireElement(
4371
+ el.querySelector<JantComposeEditor>("jant-compose-editor"),
4372
+ "expected compose editor",
4373
+ );
4374
+
4375
+ // The rules that lay the editor out are keyed on
4376
+ // `.compose-scroll > jant-compose-editor`. Any wrapper breaks them —
4377
+ // including a `display: contents` one, which drops the box but not the DOM
4378
+ // parentage the selector matches on.
4379
+ expect(editor.parentElement).toBe(scroller);
4380
+ // The action row is the composer's pinned floor; it must stay outside.
4381
+ expect(scroller.querySelector(".compose-action-row")).toBeNull();
4382
+ expect(el.querySelector(".compose-action-row")).not.toBeNull();
4383
+ });
4384
+
4385
+ it("scrolls in exactly one place across every compose mode", async () => {
4386
+ const css = readFileSync(resolve("src/styles/ui.css"), "utf8");
4387
+
4388
+ // `.compose-scroll` owns the scrolling for single-post, reply and thread
4389
+ // alike. Anything nested inside it that scrolls on its own is the bug this
4390
+ // replaced: three stacked scrollers, and previews shrunk to fit them.
4391
+ expect(css).toMatch(
4392
+ /\.compose-scroll\s*\{[\s\S]*flex:\s*1;[\s\S]*min-height:\s*0;[\s\S]*overflow-y:\s*auto;/,
4393
+ );
4394
+ expect(css).not.toMatch(/\.compose-body\s*\{[^}]*overflow-y:\s*auto;/);
4395
+ expect(css).not.toMatch(
4396
+ /\.compose-thread-compose-layout\s*\{[^}]*overflow-y:\s*auto;/,
4397
+ );
4398
+ expect(css).not.toMatch(
4399
+ /\.compose-editor-row\s+\.compose-attachments-dock\s*\{[^}]*overflow-y:\s*auto;/,
4400
+ );
4401
+
4402
+ // Nothing between the scroll region and the text may clip or claim a fixed
4403
+ // share of the height — every block is sized by its own content.
4404
+ expect(css).not.toMatch(
4405
+ /\.compose-editor-row\s*\{[^}]*overflow:\s*hidden;/,
4406
+ );
4407
+ expect(css).not.toMatch(
4408
+ /\.compose-thread-layout\s*\{[^}]*overflow:\s*hidden;/,
4409
+ );
4410
+ });
4411
+
4412
+ it("lets attachment previews take the room the pinned toolbar used to need", () => {
4413
+ const css = readFileSync(resolve("src/styles/ui.css"), "utf8");
4414
+
4415
+ expect(css).toMatch(
4416
+ /\.compose-attachment-img\s*\{[\s\S]*max-height:\s*min\(420px,\s*46dvh\);/,
4417
+ );
4418
+ expect(css).toMatch(
4419
+ /\.compose-attachment:only-child\s+\.compose-attachment-img\s*\{[\s\S]*max-height:\s*min\(460px,\s*52dvh\);/,
4420
+ );
4421
+ expect(css).toMatch(
4422
+ /\.compose-attachment:not\(:only-child\)\s+\.compose-attachment-img\s*\{[\s\S]*height:\s*min\(340px,\s*40dvh\);/,
4423
+ );
4424
+ // Reply compose no longer shrinks its previews to 96×72 to spare the
4425
+ // toolbar — one preview size everywhere.
4426
+ expect(css).not.toMatch(/--compose-reply-attachment-width/);
4427
+ });
4428
+
4429
+ it("routes a format change straight off the single-post editor", async () => {
4430
+ const el = await createElement();
4431
+ const editor = requireElement(
4432
+ el.querySelector<JantComposeEditor>("jant-compose-editor"),
4433
+ "expected compose editor",
4434
+ );
4435
+
4436
+ editor.dispatchEvent(
4437
+ new CustomEvent("jant:format-change", {
4438
+ detail: { format: "quote" },
4439
+ bubbles: true,
4440
+ }),
4441
+ );
4442
+ await flushUpdates(el);
4443
+
4444
+ expect(el._format).toBe("quote");
4445
+ });
4446
+
4447
+ it("shows the replied-to post at the same scale as the reply", () => {
4448
+ const css = readFileSync(resolve("src/styles/ui.css"), "utf8");
4449
+
4450
+ // Same ceiling as the composer's own previews — the parent is not a
4451
+ // thumbnail strip.
4452
+ expect(css).toMatch(
4453
+ /\.compose-reply-context-body img,[\s\S]*?video\s*\{[\s\S]*?max-height:\s*min\(420px,\s*46dvh\);/,
4454
+ );
4455
+ // One rule governs every picture in the context. A second one keyed on
4456
+ // `.compose-reply-context-media-img` would sit at lower specificity than
4457
+ // the `…-body img` rule above it and never apply — the media strip is
4458
+ // inside the body.
4459
+ expect(css).not.toMatch(/\.compose-reply-context-media-img\s*\{/);
4460
+
4461
+ // Expanding hands the post its full height; a capped, separately scrolling
4462
+ // box is what left no room for full-size pictures.
4463
+ expect(css).toMatch(
4464
+ /\.compose-reply-context\.expanded\s*\{\s*max-height:\s*none;\s*overflow:\s*visible;/,
4465
+ );
4466
+ // Fullscreen's collapsed cap must not outrank that.
4467
+ expect(css).toMatch(
4468
+ /\.compose-fullscreen-thread-layout\s+\.compose-reply-context:not\(\.expanded\)\s*\{\s*max-height:\s*140px;/,
4469
+ );
4470
+ });
4471
+
4472
+ it("keeps reply compose tools inside the editor's text column", () => {
4473
+ const css = readFileSync(resolve("src/styles/ui.css"), "utf8");
4474
+
4475
+ // `flex: 1` on the editor is horizontal — it claims the width the rail dot
4476
+ // leaves. Height comes from content.
4477
+ expect(css).toMatch(
4478
+ /\.compose-editor-row\s*>\s*jant-compose-editor\s*\{[\s\S]*flex:\s*1;[\s\S]*min-width:\s*0;/,
4479
+ );
4480
+ expect(css).toMatch(
4481
+ /\.compose-reply-compose-layout\s+\.compose-thread-post-header\s*\+\s*\.compose-body\s*\{[\s\S]*padding-top:\s*12px;/,
4482
+ );
4483
+ expect(css).toMatch(
4484
+ /@media \(max-width:\s*760px\),\s*\(hover:\s*none\) and \(pointer:\s*coarse\)\s*\{[\s\S]*\.compose-thread-layout\s*\{[\s\S]*padding-top:\s*0\.75rem;/,
4485
+ );
4486
+ expect(css).toMatch(
4487
+ /\.compose-dialog,[\s\S]*\.compose-page-shell\s*>\s*jant-compose-dialog\s*\{[\s\S]*--compose-quote-input-size:\s*var\(--type-content-subtitle\);[\s\S]*--compose-quote-input-leading:\s*1\.32;/,
4488
+ );
4489
+ // On the page the card's own padding is the text column, so every row
4490
+ // inside it reads a zero gutter and only restates its right.
4491
+ expect(css).toMatch(
4492
+ /\.compose-page-shell\s+\.compose-dialog-inner-page\s*\{[\s\S]*--compose-gutter:\s*0px;/,
4493
+ );
4494
+ expect(css).toMatch(
4495
+ /\.compose-page-shell\s+\.compose-dialog-inner-page\s+\.compose-thread-post-header\s*\{\s*padding-inline-end:\s*0;/,
4496
+ );
4497
+ expect(css).toMatch(
4498
+ /\.compose-quote-text\s*\{[\s\S]*font-size:\s*var\(--compose-quote-input-size\);[\s\S]*line-height:\s*var\(--compose-quote-input-leading\);/,
4499
+ );
4500
+ expect(css).toMatch(
4501
+ /\.compose-reply-compose-layout\s*\{[\s\S]*--compose-title-input-size:\s*calc\(var\(--type-content-body\) \* 1\.2\);[\s\S]*--compose-quote-input-size:\s*calc\(var\(--type-content-body\) \* 1\.06\);[\s\S]*--compose-quote-input-leading:\s*1\.42;[\s\S]*--compose-inline-input-size:\s*var\(--type-base\);/,
4502
+ );
4503
+ expect(css).toMatch(
4504
+ /\.compose-reply-compose-layout\s+\.compose-quote-wrap\s*\{[\s\S]*margin-top:\s*0\.4rem;[\s\S]*padding:\s*24px 18px 18px;[\s\S]*border-radius:\s*0\.95rem;/,
4505
+ );
4506
+ expect(css).toMatch(
4507
+ /\.compose-reply-compose-layout\s+\.compose-link-url-wrap\s*\{[\s\S]*margin-top:\s*0\.4rem;/,
4508
+ );
4509
+ // The reply layout tunes only the lead-in. Height is the shared four-line
4510
+ // floor, which reads this layout's own leading and type size, so a
4511
+ // `min-height` here would just restate what the base rule already knows.
4512
+ expect(css).toMatch(
4513
+ /\.compose-reply-compose-layout\s+\.compose-quote-text\s*\{\s*padding-top:\s*0\.35rem;\s*\}/,
4514
+ );
4515
+ expect(css).not.toMatch(
4516
+ /\.compose-reply-compose-layout\s+\.compose-quote-text\s*\{[^}]*min-height:/,
4517
+ );
4518
+ expect(css).toMatch(
4519
+ /\.compose-reply-compose-layout\s+\.compose-tiptap-body\s+\.tiptap\s*\{[\s\S]*font-size:\s*var\(--type-content-body\);/,
4520
+ );
4521
+ });
4522
+
4523
+ it("opens every compose text surface at a floor stated in lines", () => {
4524
+ const css = readFileSync(resolve("src/styles/ui.css"), "utf8");
4525
+
4526
+ // Two lines for the editor, four for the quote — as multiples of the
4527
+ // leading, not pixels, so the floor follows the type instead of going
4528
+ // stale next to it.
4529
+ expect(css).toMatch(
4530
+ /\.compose-tiptap-body \.tiptap\s*\{[\s\S]*min-height:\s*calc\(var\(--type-body-leading\) \* 2em\);/,
4531
+ );
4532
+ expect(css).toMatch(
4533
+ /\.compose-quote-text\s*\{[\s\S]*min-height:\s*calc\(var\(--compose-quote-input-leading\) \* 4em\);/,
4534
+ );
4535
+
4536
+ // One floor for every editor: a note's body and the "thoughts" under a
4537
+ // link or a quote all open the same way.
4538
+ expect(css).not.toMatch(/\.compose-tiptap-thoughts/);
4539
+ });
4540
+
4541
+ it("runs the thread rail from the first dot to the last post's, and no further", () => {
4542
+ const css = readFileSync(resolve("src/styles/ui.css"), "utf8");
4543
+
4544
+ // Drawn per row rather than as one strip down the layout, which is what
4545
+ // lets both ends be trimmed back to a dot.
4546
+ expect(css).not.toMatch(/\.compose-thread-layout::before\s*\{/);
4547
+ expect(css).toMatch(
4548
+ /\.compose-thread-layout > \*::before\s*\{[\s\S]*top:\s*0;[\s\S]*bottom:\s*0;/,
4549
+ );
4550
+ expect(css).toMatch(
4551
+ /\.compose-thread-layout > :first-child::before\s*\{\s*top:\s*var\(--compose-thread-dot-center\);/,
4552
+ );
4553
+ expect(css).toMatch(
4554
+ /\.compose-editor-row:not\(:has\(~ \.compose-editor-row\)\)::before\s*\{\s*bottom:\s*auto;\s*height:\s*var\(--compose-thread-dot-center\);/,
4555
+ );
4556
+ // The "add" row is a placeholder, not a post — the rail stops before it.
4557
+ expect(css).toMatch(
4558
+ /\.compose-thread-layout > \.compose-thread-add-row::before\s*\{\s*display:\s*none;/,
4559
+ );
4560
+
4561
+ // The dot reads its offset off the same centre the rail is trimmed to, so
4562
+ // resizing the marker cannot leave the line pointing past it.
4563
+ expect(css).toMatch(
4564
+ /\.compose-thread-dot\s*\{[\s\S]*margin-top:\s*calc\(\s*var\(--compose-thread-dot-center\) -\s*var\(--compose-thread-row-padding-top\) -\s*var\(--site-thread-marker-size\) \/ 2\s*\);/,
4565
+ );
4566
+ });
4567
+
4568
+ it("starts every compose row's ink on one text column", () => {
4569
+ const css = readFileSync(resolve("src/styles/ui.css"), "utf8");
4570
+
4571
+ // One knob for the column itself, read by every row rather than restated
4572
+ // as a literal per row.
4573
+ expect(css).toMatch(
4574
+ /\.compose-dialog,\s*\.compose-page-shell\s*>\s*jant-compose-dialog\s*\{[\s\S]*--compose-gutter:\s*20px;/,
4575
+ );
4576
+ expect(css).toMatch(
4577
+ /@media \(min-width:\s*700px\)\s*\{[\s\S]*--compose-gutter:\s*24px;/,
4578
+ );
4579
+ // Zero wherever the indent already comes from outside the editor.
4580
+ expect(css).toMatch(
4581
+ /\.compose-editor-row\s*\{\s*\/\*[\s\S]*?\*\/\s*--compose-gutter:\s*0px;/,
4582
+ );
4583
+
4584
+ for (const rule of [
4585
+ /\.compose-body\s*\{[^}]*padding:\s*16px 20px 12px var\(--compose-gutter\);/,
4586
+ /\.compose-tools-row\s*\{[\s\S]*padding:\s*6px 10px 6px var\(--compose-gutter\);/,
4587
+ /\.compose-attachments-dock\s*\{\s*padding:\s*8px 20px 4px var\(--compose-gutter\);/,
4588
+ /\.compose-thread-post-header\s*\{[\s\S]*padding:\s*10px 16px 2px var\(--compose-gutter\);/,
4589
+ /\.compose-add-thread-trigger\s*\{\s*padding:\s*1px 10px 4px var\(--compose-gutter\);/,
4590
+ /\.compose-action-row\s*\{\s*padding:\s*10px 12px 14px var\(--compose-gutter\);/,
4591
+ ]) {
4592
+ expect(css).toMatch(rule);
4593
+ }
4594
+
4595
+ // Each control hangs its box back out of the column by its own optical
4596
+ // inset, so the glyph or the label lands on the edge and not the box.
4597
+ expect(css).toMatch(
4598
+ /\.compose-tools-row\s*\{[\s\S]*--compose-tool-hang:\s*12px;/,
4599
+ );
4600
+ expect(css).toMatch(
4601
+ /\.compose-tools-row > :first-child\s*\{\s*margin-inline-start:\s*calc\(-1 \* var\(--compose-tool-hang\)\);/,
4602
+ );
4603
+ expect(css).toMatch(
4604
+ /\.compose-thread-add-btn\s*\{[\s\S]*padding:\s*5px var\(--compose-add-hang\);\s*margin-inline-start:\s*calc\(-1 \* var\(--compose-add-hang\)\);/,
4605
+ );
4606
+ expect(css).toMatch(
4607
+ /\.compose-collection-select\s*\{[\s\S]*margin-inline-start:\s*calc\(\s*-1 \* \(var\(--compose-collection-pad-icon\) \+ 1px\)\s*\);/,
4608
+ );
4609
+
4610
+ // The ad-hoc thread-only indents these replaced are gone — they were what
4611
+ // let each mode drift to its own edge in the first place.
4612
+ expect(css).not.toMatch(/margin-inline-start:\s*-10px;/);
4613
+ expect(css).not.toMatch(/\.compose-thread-layout \.compose-tools-row\s*\{/);
4614
+ // One "Add to thread" button, not two rule sets that have to stay in step.
4615
+ expect(css).not.toMatch(/\.compose-add-thread-btn/);
4616
+ });
4617
+
4618
+ it("declares one marker vocabulary for the compose and reading rails", () => {
4619
+ const css = readFileSync(resolve("src/styles/ui.css"), "utf8");
4620
+
4621
+ // Both rails draw the same dot; only the surface behind it differs.
4622
+ // `[^}]*` rather than `[\s\S]*` throughout: a greedy any-char run walks past
4623
+ // the rule's closing brace and happily matches a later rule's declaration.
4624
+ expect(css).toMatch(
4625
+ /\.thread-group,\s*\.compose-thread-layout\s*\{\s*--site-thread-marker-size:\s*7px;[^}]*--site-thread-marker-gap:\s*9px;/,
4626
+ );
4627
+ expect(css).toMatch(
4628
+ /\.compose-thread-layout\s*\{\s*--site-thread-marker-surface:\s*var\(--compose-paper-bg\);\s*\}/,
4629
+ );
4630
+ // 7:1.5 marker-to-rail on both, from one token — compose used to hardcode
4631
+ // its own 1.5px while reading ran a 1px hairline under the same 7px dot.
4632
+ expect(css).toMatch(/--site-thread-rail-line-width:\s*1\.5px;/);
4633
+ expect(css).not.toMatch(
4634
+ /\.thread-group-preview,\s*\.thread-group-detail\s*\{[^}]*--site-thread-rail-line-width:/,
4635
+ );
4636
+ expect(css).toMatch(
4637
+ /\.compose-thread-layout > \*::before\s*\{[^}]*width:\s*var\(--site-thread-rail-line-width\);\s*margin-left:\s*calc\(var\(--site-thread-rail-line-width\) \/ -2\);/,
4638
+ );
4639
+ // The reading rail's fade-in gradient stays — it softens where collapsed
4640
+ // ancestor context begins, which compose solves by trimming per row.
4641
+ expect(css).toMatch(
4642
+ /\.thread-group-preview::before,\s*\.thread-group-detail::before\s*\{\s*background:\s*linear-gradient\(/,
4643
+ );
4644
+ // Both rails now stop at the last dot. Reading masks its group-wide line
4645
+ // from that dot down; `> ` keeps the mask off the last ancestor inside
4646
+ // `.thread-context-shell`, which the rail must run straight through.
4647
+ expect(css).toMatch(
4648
+ /\.thread-group > \.thread-item:last-child::after\s*\{[^}]*top:\s*50%;\s*bottom:\s*0;\s*width:\s*var\(--site-thread-rail-line-width\);\s*background-color:\s*var\(--site-thread-marker-surface\);/,
4649
+ );
4650
+ expect(css).not.toMatch(/\.thread-group \.thread-item:last-child::after/);
4651
+ // The ring is reading's alone: the token is declared on `.thread-group`, and
4652
+ // compose's dot rule never reads it. The compose rail is quiet by design —
4653
+ // it already has a card edge, a format switcher and a "1/3" per row.
4654
+ expect(css).toMatch(
4655
+ /\.thread-group\s*\{[^}]*--site-thread-marker-ring-inset:\s*2px;\s*--site-thread-marker-ring-width:\s*2px;/,
4656
+ );
4657
+ expect(css).not.toMatch(
4658
+ /\.compose-thread-layout\s*\{[^}]*--site-thread-marker-ring/,
4659
+ );
4660
+ expect(css).not.toMatch(
4661
+ /\.compose-thread-dot::before\s*\{[^}]*--site-thread-(dot-ring|marker-ring)/,
4662
+ );
4663
+ // The per-dot border and the size-based hero markers stay gone: a border
4664
+ // eats into the fill instead of sitting outside it, and a bigger dot
4665
+ // repeats what the full card beside it already says.
4666
+ for (const dead of [
4667
+ "--site-thread-marker-border-width",
4668
+ "--site-thread-marker-hero-size",
4669
+ "--site-thread-marker-hero-border-width",
4670
+ ]) {
4671
+ expect(css).not.toContain(dead);
4672
+ }
4673
+ expect(css).not.toMatch(/\.compose-thread-dot(::before)?\s*\{[^}]*border:/);
3059
4674
  });
3060
4675
 
3061
- it("keeps reply compose tools inside the constrained editor surface", () => {
4676
+ it("punches the dot's gap with the surface colour on both rails", () => {
3062
4677
  const css = readFileSync(resolve("src/styles/ui.css"), "utf8");
3063
4678
 
4679
+ // Compose stops at the punch-out; reading layers its ring inside the same
4680
+ // radius, so the outermost layer — the one that breaks the rail line — is
4681
+ // still the gap at its full size on both rails.
4682
+ const punchOut =
4683
+ /0 0 0 var\(--site-thread-marker-gap\)\s*var\(--site-thread-marker-surface\);/;
3064
4684
  expect(css).toMatch(
3065
- /\.compose-editor-row\s*>\s*jant-compose-editor\s*\{[\s\S]*align-self:\s*stretch;[\s\S]*overflow:\s*hidden;/,
4685
+ new RegExp(
4686
+ `\\.compose-thread-dot::before\\s*\\{[^}]*box-shadow:\\s*${punchOut.source}`,
4687
+ ),
3066
4688
  );
3067
- expect(css).toMatch(/\.compose-tools-row\s*\{[\s\S]*flex-shrink:\s*0;/);
3068
4689
  expect(css).toMatch(
3069
- /\.compose-editor-row\s+\.compose-attachments-dock\s*\{[\s\S]*max-height:\s*min\(240px,\s*34dvh\);[\s\S]*overflow-y:\s*auto;/,
4690
+ new RegExp(`\\.thread-item::before\\s*\\{[^}]*${punchOut.source}`),
3070
4691
  );
4692
+ // Reading's ring: a band of surface, then the accent tint, both drawn as
4693
+ // spread inside the punch. A border would eat the fill instead.
3071
4694
  expect(css).toMatch(
3072
- /\.compose-reply-compose-layout\s+\.compose-editor-row\s+\.compose-attachments-dock\s*\{[\s\S]*--compose-reply-attachment-width:\s*96px;[\s\S]*--compose-reply-attachment-height:\s*72px;[\s\S]*max-height:\s*min\(116px,\s*18dvh\);/,
4695
+ /\.thread-item::before\s*\{[^}]*box-shadow:\s*0 0 0 var\(--site-thread-marker-ring-inset\)\s*var\(--site-thread-marker-surface\),\s*0 0 0\s*calc\(\s*var\(--site-thread-marker-ring-inset\) \+\s*var\(--site-thread-marker-ring-width\)\s*\)\s*var\(--site-thread-dot-ring\),/,
3073
4696
  );
4697
+ expect(css).not.toMatch(/\.thread-item::before\s*\{[^}]*border:\s/);
4698
+ });
4699
+
4700
+ it("marks the post a view is about with an accent fill, not a bigger dot", () => {
4701
+ const css = readFileSync(resolve("src/styles/ui.css"), "utf8");
4702
+
4703
+ // Feed hero, curated highlight and the opened detail post share one rule,
4704
+ // matching compose's `.is-current`. Size is not a second signal.
3074
4705
  expect(css).toMatch(
3075
- /\.compose-reply-compose-layout[\s\S]*\.compose-attachment:only-child[\s\S]*\.compose-attachment-img,[\s\S]*\.compose-reply-compose-layout[\s\S]*\.compose-attachment:not\(:only-child\)[\s\S]*\.compose-attachment-img\s*\{[\s\S]*height:\s*var\(--compose-reply-attachment-height\);[\s\S]*object-fit:\s*cover;/,
4706
+ /\.thread-item-hero::before,\s*\.thread-item-curated::before,\s*\.thread-item-current::before\s*\{\s*background-color:\s*var\(--site-thread-marker-current\);\s*\}/,
3076
4707
  );
4708
+ // Both rails take the current-post colour from one token, and that token is
4709
+ // a tint — the raw accent made a 7px dot the loudest thing on the page.
3077
4710
  expect(css).toMatch(
3078
- /\.compose-reply-compose-layout\s+\.compose-thread-post-header\s*\+\s*\.compose-body\s*\{[\s\S]*padding-top:\s*12px;/,
4711
+ /\.compose-editor-row\.is-current \.compose-thread-dot::before\s*\{\s*background-color:\s*var\(--site-thread-marker-current\);/,
4712
+ );
4713
+ expect(css).toMatch(
4714
+ /--site-thread-marker-current:\s*color-mix\(\s*in srgb,\s*var\(--site-accent\) 90%,\s*var\(--site-threadline\)\s*\);/,
4715
+ );
4716
+ expect(css).not.toMatch(
4717
+ /\.thread-item-current::before\s*\{[^}]*var\(--site-accent\)[^-]/,
3079
4718
  );
4719
+ // Every dot's geometry now derives from the shared token.
3080
4720
  expect(css).toMatch(
3081
- /@media \(max-width:\s*760px\),\s*\(hover:\s*none\) and \(pointer:\s*coarse\)\s*\{[\s\S]*\.compose-thread-layout\s*\{[\s\S]*padding-top:\s*0\.75rem;[\s\S]*\.compose-thread-layout::before\s*\{[\s\S]*top:\s*0\.75rem;/,
4721
+ /\.thread-item::before\s*\{[^}]*width:\s*var\(--site-thread-marker-size\);/,
3082
4722
  );
4723
+ expect(css).not.toMatch(/\.thread-item::before\s*\{[^}]*width:\s*\d+px;/);
4724
+ });
4725
+
4726
+ it("centres every rail child on the line by giving them a shared track", () => {
4727
+ const css = readFileSync(resolve("src/styles/ui.css"), "utf8");
4728
+
4729
+ // The line's x is derived from the track, never from a dot's own size — a
4730
+ // hardcoded half-marker is what put the line down the dot's edge before.
3083
4731
  expect(css).toMatch(
3084
- /\.compose-dialog,[\s\S]*\.compose-page-shell\s*>\s*jant-compose-dialog\s*\{[\s\S]*--compose-quote-input-size:\s*var\(--type-content-subtitle\);[\s\S]*--compose-quote-input-leading:\s*1\.32;/,
4732
+ /--compose-thread-rail-left:\s*calc\(\s*var\(--compose-thread-padding-left\) \+ var\(--compose-thread-rail-width\) \/ 2\s*\);/,
3085
4733
  );
4734
+ expect(css).not.toMatch(/--compose-thread-rail-left:[^;]*\+ \d/);
4735
+
4736
+ // Post dot: fills the track, centres the circle in it.
3086
4737
  expect(css).toMatch(
3087
- /\.compose-quote-text\s*\{[\s\S]*font-size:\s*var\(--compose-quote-input-size\);[\s\S]*line-height:\s*var\(--compose-quote-input-leading\);/,
4738
+ /\.compose-thread-dot\s*\{[\s\S]*justify-content:\s*center;[\s\S]*width:\s*var\(--compose-thread-rail-width\);/,
3088
4739
  );
4740
+ // "Add to thread" placeholder: fills the track outright, so no offset.
3089
4741
  expect(css).toMatch(
3090
- /\.compose-reply-compose-layout\s*\{[\s\S]*--compose-title-input-size:\s*var\(--type-content-subtitle\);[\s\S]*--compose-quote-input-size:\s*calc\(var\(--type-content-body\) \* 1\.06\);[\s\S]*--compose-quote-input-leading:\s*1\.42;[\s\S]*--compose-inline-input-size:\s*var\(--type-base\);/,
4742
+ /\.compose-thread-add-dot\s*\{[\s\S]*width:\s*var\(--compose-thread-rail-width\);[\s\S]*height:\s*var\(--compose-thread-rail-width\);/,
3091
4743
  );
4744
+ expect(css).not.toMatch(/\.compose-thread-add-dot\s*\{[^}]*margin-left:/);
4745
+ // Reply meta hangs off the same track as the rows above it.
3092
4746
  expect(css).toMatch(
3093
- /\.compose-reply-compose-layout\s+\.compose-quote-wrap\s*\{[\s\S]*margin-top:\s*0\.4rem;[\s\S]*padding:\s*24px 18px 18px;[\s\S]*border-radius:\s*0\.95rem;/,
4747
+ /\.compose-reply-meta\s*\{[\s\S]*var\(--compose-thread-gap\) \+\s*var\(--compose-thread-rail-width\)/,
3094
4748
  );
4749
+ });
4750
+
4751
+ it("accents the dot of the post holding the cursor, not every editor row", () => {
4752
+ const css = readFileSync(resolve("src/styles/ui.css"), "utf8");
4753
+
4754
+ // Which selector carries the highlight — the colour itself is pinned by
4755
+ // "marks the post a view is about…", so this stays token-agnostic.
3095
4756
  expect(css).toMatch(
3096
- /\.compose-reply-compose-layout\s+\.compose-link-url-wrap\s*\{[\s\S]*margin-top:\s*0\.4rem;/,
4757
+ /\.compose-editor-row\.is-current \.compose-thread-dot::before\s*\{\s*background-color:/,
4758
+ );
4759
+ // The old rule lit every post in a thread at once.
4760
+ expect(css).not.toMatch(
4761
+ /\.compose-editor-row \.compose-thread-dot\s*\{\s*background-color:/,
3097
4762
  );
4763
+ });
4764
+
4765
+ it("moves the current-post marker as focus moves between thread posts", async () => {
4766
+ const el = await createElement();
4767
+ el._threadItems = [
4768
+ { id: "thread-1", format: "note" },
4769
+ { id: "thread-2", format: "note" },
4770
+ { id: "thread-3", format: "note" },
4771
+ ];
4772
+ await flushUpdates(el);
4773
+
4774
+ const currentIndexes = () =>
4775
+ Array.from(el.querySelectorAll<HTMLElement>(".compose-thread-post"))
4776
+ .map((row, index) =>
4777
+ row.classList.contains("is-current") ? index : -1,
4778
+ )
4779
+ .filter((index) => index >= 0);
4780
+
4781
+ // Exactly one at a time — the old rule accented all three.
4782
+ expect(currentIndexes()).toEqual([0]);
4783
+
4784
+ const second = requireElement(
4785
+ el.querySelector<HTMLElement>(
4786
+ '.compose-thread-post[data-thread-index="1"]',
4787
+ ),
4788
+ "expected the second thread post",
4789
+ );
4790
+ second.dispatchEvent(new Event("focusin", { bubbles: true }));
4791
+ await flushUpdates(el);
4792
+
4793
+ expect(currentIndexes()).toEqual([1]);
4794
+ });
4795
+
4796
+ it("keeps passive footnote references quiet until the editor selects them", () => {
4797
+ const css = readFileSync(resolve("src/styles/ui.css"), "utf8");
4798
+
3098
4799
  expect(css).toMatch(
3099
- /\.compose-reply-compose-layout\s+\.compose-quote-text\s*\{[\s\S]*min-height:\s*6\.5rem;[\s\S]*padding-top:\s*0\.35rem;/,
4800
+ /\.compose-tiptap-body \.tiptap \.tiptap-footnote-reference\s*\{[^}]*color:\s*var\(--site-footnote-marker\);/,
3100
4801
  );
3101
4802
  expect(css).toMatch(
3102
- /\.compose-reply-compose-layout\s+\.compose-tiptap-body\s+\.tiptap\s*\{[\s\S]*font-size:\s*var\(--type-content-body\);/,
4803
+ /\.tiptap-footnote-reference\.ProseMirror-selectednode\s*\{[^}]*color:\s*var\(--site-accent\);[^}]*outline:\s*1px solid var\(--site-accent\);/,
3103
4804
  );
3104
4805
  });
3105
4806
 
@@ -3125,6 +4826,7 @@ describe("JantComposeDialog", () => {
3125
4826
  alt: "",
3126
4827
  error: null,
3127
4828
  posterUrl: null,
4829
+ remoteUrl: null,
3128
4830
  summary: null,
3129
4831
  chars: null,
3130
4832
  },
@@ -3167,6 +4869,7 @@ describe("JantComposeDialog", () => {
3167
4869
  alt: "",
3168
4870
  error: null,
3169
4871
  posterUrl: null,
4872
+ remoteUrl: null,
3170
4873
  summary: null,
3171
4874
  chars: null,
3172
4875
  },
@@ -3222,6 +4925,7 @@ describe("JantComposeDialog", () => {
3222
4925
  alt: "A test image",
3223
4926
  error: null,
3224
4927
  posterUrl: null,
4928
+ remoteUrl: null,
3225
4929
  summary: null,
3226
4930
  chars: null,
3227
4931
  },
@@ -3292,6 +4996,7 @@ describe("JantComposeDialog", () => {
3292
4996
  alt: "Alt for pending",
3293
4997
  error: null,
3294
4998
  posterUrl: null,
4999
+ remoteUrl: null,
3295
5000
  summary: null,
3296
5001
  chars: null,
3297
5002
  },
@@ -3367,7 +5072,97 @@ describe("JantComposeDialog", () => {
3367
5072
  );
3368
5073
  });
3369
5074
 
3370
- // ── Close confirmation ─────────────────────────────────────────────
5075
+ // ── Leaving compose ────────────────────────────────────────────────
5076
+
5077
+ it("names the dialog for assistive tech now that no title is drawn", async () => {
5078
+ // The visible title carried the dialog's accessible name; it moves to an
5079
+ // attribute rather than disappearing with the row.
5080
+ const dialog = document.createElement("dialog");
5081
+ document.body.appendChild(dialog);
5082
+ const el = document.createElement(
5083
+ "jant-compose-dialog",
5084
+ ) as JantComposeDialog;
5085
+ el.collections = collections;
5086
+ el.labels = labels;
5087
+ dialog.appendChild(el);
5088
+ await flushUpdates(el);
5089
+
5090
+ expect(dialog.getAttribute("aria-label")).toBe(labels.composeDialogLabel);
5091
+
5092
+ // Tear the dialog down here: happy-dom throws when `body.innerHTML = ""`
5093
+ // in the shared beforeEach removes one it did not itself create.
5094
+ el.remove();
5095
+ dialog.remove();
5096
+ });
5097
+
5098
+ it("closes from the × in the post header row", async () => {
5099
+ const el = await createElement();
5100
+ await flushUpdates(el);
5101
+ const requestCloseSpy = vi.spyOn(el, "requestClose");
5102
+
5103
+ const closeBtn = requireElement(
5104
+ el.querySelector<HTMLButtonElement>(
5105
+ ".compose-thread-post-header .compose-close-btn",
5106
+ ),
5107
+ "expected close button in the post header",
5108
+ );
5109
+ expect(closeBtn.getAttribute("aria-label")).toBe(labels.cancel);
5110
+
5111
+ closeBtn.click();
5112
+ await flushUpdates(el);
5113
+
5114
+ expect(requestCloseSpy).toHaveBeenCalledTimes(1);
5115
+ });
5116
+
5117
+ it("hands the header slot back to per-post remove in a thread, and moves the exit into the options panel", async () => {
5118
+ const el = await createElement();
5119
+ el._threadItems = [
5120
+ { id: "thread-1", format: "note" },
5121
+ { id: "thread-2", format: "note" },
5122
+ ];
5123
+ await flushUpdates(el);
5124
+
5125
+ // Each post owns its × for removing itself, so no close button competes.
5126
+ expect(el.querySelector(".compose-close-btn")).toBeNull();
5127
+ expect(
5128
+ el.querySelectorAll(".compose-thread-post-remove").length,
5129
+ ).toBeGreaterThan(0);
5130
+
5131
+ await openPublishPanel(el);
5132
+ const closeRow = requireElement(
5133
+ Array.from(
5134
+ el.querySelectorAll<HTMLButtonElement>(
5135
+ ".compose-publish-panel .compose-sheet-row",
5136
+ ),
5137
+ ).find(
5138
+ (row) =>
5139
+ row.querySelector(".compose-sheet-title")?.textContent?.trim() ===
5140
+ labels.closeCompose,
5141
+ ) ?? null,
5142
+ "expected a close row in the options panel",
5143
+ );
5144
+
5145
+ const requestCloseSpy = vi.spyOn(el, "requestClose");
5146
+ closeRow.click();
5147
+ await flushUpdates(el);
5148
+
5149
+ expect(el._showPublishPanel).toBe(false);
5150
+ expect(requestCloseSpy).toHaveBeenCalledTimes(1);
5151
+ });
5152
+
5153
+ it("keeps the exit out of the options panel when the × already offers one", async () => {
5154
+ const el = await createElement();
5155
+ await openPublishPanel(el);
5156
+
5157
+ const titles = Array.from(
5158
+ el.querySelectorAll<HTMLElement>(
5159
+ ".compose-publish-panel .compose-sheet-title",
5160
+ ),
5161
+ ).map((n) => n.textContent?.trim());
5162
+
5163
+ expect(titles).not.toContain(labels.closeCompose);
5164
+ expect(titles).toContain("Drafts");
5165
+ });
3371
5166
 
3372
5167
  it("requestClose on empty form closes immediately without confirmation", async () => {
3373
5168
  const el = await createElement();
@@ -3879,6 +5674,57 @@ describe("JantComposeDialog", () => {
3879
5674
  expect(tiptap.getText()).toBe("");
3880
5675
  });
3881
5676
 
5677
+ it("Escape cancels the table size picker without closing compose", async () => {
5678
+ const el = await createElement();
5679
+ const editor = requireElement(
5680
+ el.querySelector<JantComposeEditor>("jant-compose-editor"),
5681
+ "expected compose editor",
5682
+ );
5683
+ const tiptap = requireEditor(editor);
5684
+
5685
+ tiptap.commands.focus("end");
5686
+ tiptap.commands.insertContent("/tab");
5687
+ await flushUpdates(el);
5688
+
5689
+ const tableItem = Array.from(
5690
+ document.querySelectorAll<HTMLElement>(".tiptap-slash-item"),
5691
+ ).find((item) => item.textContent?.includes("Table"));
5692
+ requireElement(
5693
+ tableItem ?? null,
5694
+ "expected Table slash item",
5695
+ ).dispatchEvent(
5696
+ new globalThis.MouseEvent("mousedown", {
5697
+ bubbles: true,
5698
+ cancelable: true,
5699
+ }),
5700
+ );
5701
+ await vi.waitFor(() => {
5702
+ expect(
5703
+ document.querySelector(".tiptap-table-size-picker"),
5704
+ ).not.toBeNull();
5705
+ });
5706
+
5707
+ const requestCloseSpy = vi.spyOn(el, "requestClose");
5708
+ const currentSize = requireElement(
5709
+ document.querySelector<HTMLButtonElement>(
5710
+ ".tiptap-table-size-cell.is-current",
5711
+ ),
5712
+ "expected current table size",
5713
+ );
5714
+ const escapeEvent = new globalThis.KeyboardEvent("keydown", {
5715
+ key: "Escape",
5716
+ bubbles: true,
5717
+ cancelable: true,
5718
+ });
5719
+ currentSize.dispatchEvent(escapeEvent);
5720
+ await flushUpdates(el);
5721
+
5722
+ expect(escapeEvent.defaultPrevented).toBe(true);
5723
+ expect(requestCloseSpy).not.toHaveBeenCalled();
5724
+ expect(document.querySelector(".tiptap-table-size-picker")).toBeNull();
5725
+ expect(tiptap.getText()).toBe("");
5726
+ });
5727
+
3882
5728
  it("shows a clear empty state when slash commands have no matches", async () => {
3883
5729
  const el = await createElement();
3884
5730
  const editor = requireElement(
@@ -4101,10 +5947,12 @@ describe("JantComposeDialog", () => {
4101
5947
  };
4102
5948
  await editor.updateComplete;
4103
5949
 
4104
- const submitSpy = vi.spyOn(
4105
- el as unknown as { _submit: (status: "published" | "draft") => void },
4106
- "_submit",
4107
- );
5950
+ const submitSpy = vi
5951
+ .spyOn(
5952
+ el as unknown as { _submit: (status: "published" | "draft") => void },
5953
+ "_submit",
5954
+ )
5955
+ .mockImplementation(() => {});
4108
5956
 
4109
5957
  el.dispatchEvent(
4110
5958
  new globalThis.KeyboardEvent("keydown", {
@@ -4117,6 +5965,139 @@ describe("JantComposeDialog", () => {
4117
5965
  expect(submitSpy).toHaveBeenCalledWith("published");
4118
5966
  });
4119
5967
 
5968
+ it("publishes the latest fullscreen content after handing it back", async () => {
5969
+ const el = await createElement();
5970
+ const editor = requireElement(
5971
+ el.querySelector<JantComposeEditor>("jant-compose-editor"),
5972
+ "expected compose editor",
5973
+ );
5974
+ const submitSpy = vi
5975
+ .spyOn(
5976
+ el as unknown as { _submit: (status: "published" | "draft") => void },
5977
+ "_submit",
5978
+ )
5979
+ .mockImplementation(() => {});
5980
+ const setEditorStateSpy = vi.spyOn(editor, "setEditorState");
5981
+
5982
+ (
5983
+ el as unknown as {
5984
+ _handleFullscreenClose: (
5985
+ event: CustomEvent<ComposeFullscreenCloseDetail>,
5986
+ ) => void;
5987
+ }
5988
+ )._handleFullscreenClose(
5989
+ new CustomEvent<ComposeFullscreenCloseDetail>("jant:fullscreen-close", {
5990
+ detail: {
5991
+ json: {
5992
+ type: "doc",
5993
+ content: [
5994
+ {
5995
+ type: "paragraph",
5996
+ content: [{ type: "text", text: "Written while fullscreen" }],
5997
+ },
5998
+ ],
5999
+ },
6000
+ title: "Fullscreen title",
6001
+ showTitle: true,
6002
+ replyExpanded: false,
6003
+ intent: "publish",
6004
+ editorIndex: 0,
6005
+ },
6006
+ }),
6007
+ );
6008
+
6009
+ expect(setEditorStateSpy).toHaveBeenCalled();
6010
+ expect(editor.getData()).toMatchObject({
6011
+ title: "Fullscreen title",
6012
+ body: expect.stringContaining("Written while fullscreen"),
6013
+ });
6014
+ expect(submitSpy).toHaveBeenCalledWith("published");
6015
+ });
6016
+
6017
+ it("does not submit an empty fullscreen post", async () => {
6018
+ const el = await createElement();
6019
+ const submitSpy = vi.spyOn(
6020
+ el as unknown as { _submit: (status: "published" | "draft") => void },
6021
+ "_submit",
6022
+ );
6023
+
6024
+ (
6025
+ el as unknown as {
6026
+ _handleFullscreenClose: (
6027
+ event: CustomEvent<ComposeFullscreenCloseDetail>,
6028
+ ) => void;
6029
+ }
6030
+ )._handleFullscreenClose(
6031
+ new CustomEvent<ComposeFullscreenCloseDetail>("jant:fullscreen-close", {
6032
+ detail: {
6033
+ json: { type: "doc", content: [{ type: "paragraph" }] },
6034
+ title: "",
6035
+ showTitle: false,
6036
+ replyExpanded: false,
6037
+ intent: "publish",
6038
+ editorIndex: 0,
6039
+ },
6040
+ }),
6041
+ );
6042
+
6043
+ expect(submitSpy).not.toHaveBeenCalled();
6044
+ });
6045
+
6046
+ it("tracks the source editor for fullscreen thread editing", async () => {
6047
+ const el = await createElement();
6048
+ el._threadItems = [
6049
+ { id: "thread-1", format: "note" },
6050
+ { id: "thread-2", format: "note" },
6051
+ ];
6052
+ await el.updateComplete;
6053
+
6054
+ const editors = Array.from(
6055
+ el.querySelectorAll<JantComposeEditor>("jant-compose-editor"),
6056
+ );
6057
+ const openDetail: ComposeFullscreenOpenDetail = {
6058
+ json: null,
6059
+ title: "",
6060
+ showTitle: false,
6061
+ };
6062
+ editors[1]?.dispatchEvent(
6063
+ new CustomEvent<ComposeFullscreenOpenDetail>("jant:fullscreen-open", {
6064
+ bubbles: true,
6065
+ detail: openDetail,
6066
+ }),
6067
+ );
6068
+
6069
+ expect(openDetail).toMatchObject({ editorIndex: 1 });
6070
+
6071
+ (
6072
+ el as unknown as {
6073
+ _handleFullscreenClose: (
6074
+ event: CustomEvent<ComposeFullscreenCloseDetail>,
6075
+ ) => void;
6076
+ }
6077
+ )._handleFullscreenClose(
6078
+ new CustomEvent<ComposeFullscreenCloseDetail>("jant:fullscreen-close", {
6079
+ detail: {
6080
+ json: {
6081
+ type: "doc",
6082
+ content: [
6083
+ {
6084
+ type: "paragraph",
6085
+ content: [{ type: "text", text: "Second thread post" }],
6086
+ },
6087
+ ],
6088
+ },
6089
+ title: "",
6090
+ showTitle: false,
6091
+ replyExpanded: false,
6092
+ editorIndex: 1,
6093
+ },
6094
+ }),
6095
+ );
6096
+
6097
+ expect(editors[0]?.getData().body).toBe("");
6098
+ expect(editors[1]?.getData().body).toContain("Second thread post");
6099
+ });
6100
+
4120
6101
  it("Cmd/Ctrl+Enter focuses the link URL when the URL is invalid", async () => {
4121
6102
  const el = await createElement();
4122
6103
  el._format = "link";
@@ -4643,9 +6624,12 @@ describe("JantComposeDialog", () => {
4643
6624
  el._format = "note";
4644
6625
  await el.updateComplete;
4645
6626
 
4646
- // Format switcher should be visible (not "Edit post" title)
6627
+ // Format switcher should be visible, and the submit label says this is a
6628
+ // new post rather than an edit.
4647
6629
  expect(el.querySelector(".compose-segmented")).not.toBeNull();
4648
- expect(el.querySelector(".compose-dialog-title")).toBeNull();
6630
+ expect(el.querySelector(".compose-publish-main")?.textContent?.trim()).toBe(
6631
+ "Post",
6632
+ );
4649
6633
 
4650
6634
  // Button should say "Post", not "Done"
4651
6635
  const postBtn = requireElement(
@@ -4730,11 +6714,94 @@ describe("JantComposeDialog", () => {
4730
6714
  expect((receivedDetail as unknown as ComposeSubmitDetail).editPostId).toBe(
4731
6715
  "draft789",
4732
6716
  );
6717
+ expect(
6718
+ (receivedDetail as unknown as ComposeSubmitDetail).draftSourceId,
6719
+ ).toBe("draft789");
4733
6720
  expect((receivedDetail as unknown as ComposeSubmitDetail).status).toBe(
4734
6721
  "published",
4735
6722
  );
4736
6723
  });
4737
6724
 
6725
+ it("opens a thread draft without deleting it and preserves its draft identity", async () => {
6726
+ const rootId = "pst_draft_root";
6727
+ const replyId = "pst_draft_reply";
6728
+ const requests: Array<{ url: string; method: string }> = [];
6729
+ vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => {
6730
+ const url = String(input);
6731
+ requests.push({ url, method: init?.method ?? "GET" });
6732
+
6733
+ if (url === `/api/posts/${rootId}`) {
6734
+ return new Response(
6735
+ JSON.stringify({
6736
+ id: rootId,
6737
+ threadId: rootId,
6738
+ format: "note",
6739
+ status: "draft",
6740
+ slug: "draft-thread",
6741
+ title: "Draft thread",
6742
+ body: null,
6743
+ attachments: [],
6744
+ collectionIds: [],
6745
+ }),
6746
+ { headers: { "Content-Type": "application/json" } },
6747
+ );
6748
+ }
6749
+
6750
+ if (url === "/api/posts?status=draft&limit=50") {
6751
+ return new Response(
6752
+ JSON.stringify({
6753
+ posts: [
6754
+ {
6755
+ id: replyId,
6756
+ threadId: rootId,
6757
+ replyToId: rootId,
6758
+ format: "quote",
6759
+ status: "draft",
6760
+ quoteText: "Draft reply",
6761
+ attachments: [],
6762
+ },
6763
+ ],
6764
+ }),
6765
+ { headers: { "Content-Type": "application/json" } },
6766
+ );
6767
+ }
6768
+
6769
+ throw new Error(`Unexpected fetch: ${url}`);
6770
+ });
6771
+
6772
+ const el = await createElement();
6773
+ await el.openDraft(rootId);
6774
+ await flushUpdates(el);
6775
+
6776
+ expect(el._draftSourceId).toBe(rootId);
6777
+ expect(el.querySelectorAll("jant-compose-editor")).toHaveLength(2);
6778
+ expect(requests).toEqual([
6779
+ { url: `/api/posts/${rootId}`, method: "GET" },
6780
+ { url: "/api/posts?status=draft&limit=50", method: "GET" },
6781
+ ]);
6782
+
6783
+ let receivedDetail: ComposeSubmitDetail | null = null;
6784
+ el.addEventListener("jant:compose-submit-deferred", (event) => {
6785
+ receivedDetail = (event as CustomEvent<ComposeSubmitDetail>).detail;
6786
+ });
6787
+ requireElement(
6788
+ el.querySelector<HTMLButtonElement>(".compose-publish-main"),
6789
+ "expected publish button",
6790
+ ).click();
6791
+
6792
+ expect(receivedDetail).not.toBeNull();
6793
+ expect(
6794
+ (receivedDetail as unknown as ComposeSubmitDetail).draftSourceId,
6795
+ ).toBe(rootId);
6796
+ expect((receivedDetail as unknown as ComposeSubmitDetail).editPostId).toBe(
6797
+ rootId,
6798
+ );
6799
+ expect(
6800
+ (receivedDetail as unknown as ComposeSubmitDetail).threadPosts,
6801
+ ).toHaveLength(2);
6802
+ expect(requests.every((request) => request.method === "GET")).toBe(true);
6803
+ });
6804
+
4738
6805
  it("draft button confirm save dispatches draft then opens drafts panel", async () => {
4739
6806
  const el = await createElement();
4740
6807
  const editor = requireElement(
@@ -4764,13 +6831,10 @@ describe("JantComposeDialog", () => {
4764
6831
  receivedDetail = (event as CustomEvent<ComposeSubmitDetail>).detail;
4765
6832
  });
4766
6833
 
4767
- // Click draft button → confirm panel
4768
- const draftBtn = requireElement(
4769
- el.querySelector<HTMLButtonElement>(".compose-dialog-header-btn"),
4770
- "expected draft button",
4771
- );
4772
- draftBtn.click();
4773
- await el.updateComplete;
6834
+ // Click the drafts row in the options panel → confirm panel
6835
+ await openPublishPanel(el);
6836
+ requireElement(draftsRow(el), "expected drafts row").click();
6837
+ await flushUpdates(el);
4774
6838
  expect(el._confirmPanelOpen).toBe(true);
4775
6839
 
4776
6840
  // Click "Save"
@@ -4835,13 +6899,10 @@ describe("JantComposeDialog", () => {
4835
6899
  submitFired = true;
4836
6900
  });
4837
6901
 
4838
- // Click draft button → confirm panel
4839
- const draftBtn = requireElement(
4840
- el.querySelector<HTMLButtonElement>(".compose-dialog-header-btn"),
4841
- "expected draft button",
4842
- );
4843
- draftBtn.click();
4844
- await el.updateComplete;
6902
+ // Click the drafts row in the options panel → confirm panel
6903
+ await openPublishPanel(el);
6904
+ requireElement(draftsRow(el), "expected drafts row").click();
6905
+ await flushUpdates(el);
4845
6906
 
4846
6907
  // Click "Don't save"
4847
6908
  requireElement(
@@ -4883,6 +6944,7 @@ describe("JantComposeDialog", () => {
4883
6944
  alt: "",
4884
6945
  error: null,
4885
6946
  posterUrl: null,
6947
+ remoteUrl: null,
4886
6948
  summary: null,
4887
6949
  chars: null,
4888
6950
  },