@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
@@ -9,6 +9,7 @@
9
9
 
10
10
  import { LitElement, html, nothing } from "lit";
11
11
  import { classMap } from "lit/directives/class-map.js";
12
+ import { repeat } from "lit/directives/repeat.js";
12
13
  import { unsafeHTML } from "lit/directives/unsafe-html.js";
13
14
  import { unsafeSVG } from "lit/directives/unsafe-svg.js";
14
15
  import type { Editor, JSONContent } from "@tiptap/core";
@@ -20,8 +21,10 @@ import type {
20
21
  ComposeSubmitDetail,
21
22
  ComposeSubmitAttachment,
22
23
  ComposeAttachment,
24
+ ComposeRowStatus,
23
25
  DraftItem,
24
26
  LocalDraft,
27
+ LocalDraftMedia,
25
28
  ComposeFullscreenOpenDetail,
26
29
  ComposeFullscreenReplyContext,
27
30
  ComposeFullscreenCloseDetail,
@@ -61,6 +64,18 @@ interface ReplyToData {
61
64
  interface ThreadItem {
62
65
  id: string;
63
66
  format: ComposeFormat;
67
+ /**
68
+ * Per-post publish date. Empty means "follows the thread" — the server fills
69
+ * it from the root (see `createThreadWithAttachments`). The root's own date
70
+ * stays on `_publishedAtInput` so every existing edit/draft/submit path keeps
71
+ * working unchanged.
72
+ */
73
+ publishedAtInput?: string;
74
+ publishedAtTimeMinutes?: number | null;
75
+ /** Per-post permalink. Empty means the server assigns a random id. */
76
+ slug?: string;
77
+ /** Last availability answer for this post's slug. */
78
+ slugTaken?: boolean;
64
79
  }
65
80
 
66
81
  interface ApiMediaAttachment {
@@ -91,6 +106,7 @@ type ApiAttachment = ApiMediaAttachment | ApiTextAttachment;
91
106
  interface ComposePostResponse {
92
107
  id: string;
93
108
  threadId?: string;
109
+ threadPosition?: number;
94
110
  format: ComposeFormat;
95
111
  slug?: string | null;
96
112
  visibility?: ComposeVisibility | null;
@@ -116,11 +132,17 @@ interface ComposeOpenOptions {
116
132
  collectionId?: string;
117
133
  restoreDraft?: boolean;
118
134
  initialFormat?: ComposeFormat;
135
+ /** false suppresses the "Draft restored." toast (failure reopen: the error toast is enough) */
136
+ restoreToast?: boolean;
137
+ /** Uploads the bridge knows completed — merged over the draft's own media snapshot */
138
+ restoreMedia?: LocalDraftMedia[];
119
139
  }
120
140
 
121
141
  interface ComposeReplyOpenOptions {
122
142
  restoreDraft?: boolean;
123
143
  initialFormat?: ComposeFormat;
144
+ restoreToast?: boolean;
145
+ restoreMedia?: LocalDraftMedia[];
124
146
  }
125
147
 
126
148
  interface ComposeStateSnapshot {
@@ -136,7 +158,6 @@ interface ComposeStateSnapshot {
136
158
  quoteText: string;
137
159
  quoteAuthor: string;
138
160
  rating: number;
139
- showTitle: boolean;
140
161
  showRating: boolean;
141
162
  attachments: Array<{
142
163
  clientId: string;
@@ -160,21 +181,20 @@ interface ComposeStateSnapshot {
160
181
 
161
182
  const EDITOR_FLOATING_UI_SELECTOR = "[data-editor-floating-ui]";
162
183
 
184
+ /**
185
+ * Row key for the single-post composer. Thread posts key on their own id; a
186
+ * lone post has no thread item to borrow one from.
187
+ */
188
+ const SINGLE_ROW_ID = "single";
189
+
163
190
  interface ComposeFilePickerCloseDetail {
164
191
  cancelled?: boolean;
165
192
  }
166
193
 
167
- interface ComposePublishSummaryChip {
168
- kind: "publishedAt" | "slug";
169
- text: string;
170
- actionLabel: string;
171
- value: string;
172
- }
173
-
174
194
  const COMPOSE_PUBLISH_PANEL_FULLSCREEN_QUERY =
175
195
  "(max-width: 700px), (max-height: 760px), (hover: none) and (pointer: coarse)";
176
196
 
177
- const COMPOSE_DIALOG_HEADER_ICONS = {
197
+ const COMPOSE_SHEET_ROW_ICONS = {
178
198
  drafts: `
179
199
  <rect x="3.85" y="3.45" width="7.85" height="8.35" rx="2.35" />
180
200
  <rect
@@ -183,7 +203,7 @@ const COMPOSE_DIALOG_HEADER_ICONS = {
183
203
  width="7.85"
184
204
  height="8.35"
185
205
  rx="2.35"
186
- fill="var(--compose-dialog-icon-paper-fill)"
206
+ fill="var(--compose-icon-paper-fill)"
187
207
  stroke="none"
188
208
  />
189
209
  <rect x="6.15" y="5.75" width="7.85" height="8.35" rx="2.35" />
@@ -191,6 +211,14 @@ const COMPOSE_DIALOG_HEADER_ICONS = {
191
211
  <path d="M8.55 10.8h3.95" stroke-width="1.2" />
192
212
  <path d="M8.55 13.05h2.45" stroke-width="1.2" />
193
213
  `,
214
+ /* A bookmark: keep this where you can come back to it. One silhouette holds
215
+ up at the 16px these rows render at, where the page-and-pencil it replaced
216
+ turned to mush — and it stays clearly apart from the stacked pages above,
217
+ which the page alone did not. The down-arrow/tray before that read as a
218
+ download. */
219
+ saveDraft: `
220
+ <path d="M6.65 3.6h4.7a2 2 0 0 1 2 2v8.7L9 11.75l-4.35 2.55V5.6a2 2 0 0 1 2-2Z" />
221
+ `,
194
222
  } as const;
195
223
 
196
224
  const COMPOSE_PUBLISH_VISIBILITY_ICONS: Record<ComposeVisibility, string> = {
@@ -216,15 +244,43 @@ const COMPOSE_PUBLISH_ACTION_ICONS = {
216
244
  check: `
217
245
  <path d="M4.35 8.2 6.9 10.7 11.65 5.95" />
218
246
  `,
219
- chevron: `
220
- <path d="M5.1 6.45 8 9.3l2.9-2.85" />
247
+ caretRight: `
248
+ <path d="M6.45 5.1 9.3 8l-2.85 2.9" />
249
+ `,
250
+ close: `
251
+ <path d="M4.5 4.5 11.5 11.5" />
252
+ <path d="M11.5 4.5 4.5 11.5" />
253
+ `,
254
+ calendar: `
255
+ <rect x="2.75" y="3.45" width="10.5" height="9.8" rx="2.2" />
256
+ <path d="M5.35 2.55v2.1" />
257
+ <path d="M10.65 2.55v2.1" />
258
+ <path d="M2.75 6.2h10.5" />
259
+ `,
260
+ /* Two sliders — "settings for this thing", without borrowing the gear, which
261
+ elsewhere means site settings. No enclosing box: the trigger is already a
262
+ round button, and a rounded square inside a circle read as two competing
263
+ frames. Drawn on a 24 grid so the tracks have room to separate. */
264
+ options: `
265
+ <path d="M4 8.5h3.4" stroke-width="1.8" />
266
+ <path d="M12.6 8.5H20" stroke-width="1.8" />
267
+ <circle cx="10" cy="8.5" r="2.2" stroke-width="1.8" />
268
+ <path d="M4 15.5h7.4" stroke-width="1.8" />
269
+ <path d="M16.6 15.5H20" stroke-width="1.8" />
270
+ <circle cx="14" cy="15.5" r="2.2" stroke-width="1.8" />
221
271
  `,
222
272
  } as const;
223
273
 
224
274
  const COMPOSE_COLLECTION_PICKER_ICONS = {
275
+ /* A stack seen edge-on, not a folder: a collection is a post filed under
276
+ several topics at once, and the folder glyph promises a single container
277
+ the model does not have. Drawn spanning y 3.5–12.5 so the artwork's centre
278
+ is the viewBox's — it used to sit at 6.9 against a centre of 8, and since
279
+ layout centres the svg *box*, that 1.1-unit drift rode straight through as
280
+ an icon floating above its own label. */
225
281
  collection: `
226
- <rect x="3" y="5.05" width="10" height="8.15" rx="2.2" />
227
- <path d="M5.1 5.05V4.2a1.1 1.1 0 0 1 1.1-1.1h3.6a1.1 1.1 0 0 1 1.1 1.1v.85" />
282
+ <path d="M2.5 6.3 8 3.5l5.5 2.8L8 9.1 2.5 6.3Z" />
283
+ <path d="M2.5 9.7 8 12.5l5.5-2.8" />
228
284
  `,
229
285
  search: `
230
286
  <circle cx="7.1" cy="7.1" r="3.65" />
@@ -239,18 +295,15 @@ const COMPOSE_COLLECTION_PICKER_ICONS = {
239
295
  <path d="M8 5.55v4.9" />
240
296
  <path d="M5.55 8h4.9" />
241
297
  `,
242
- chevron: `
243
- <path d="M5.1 6.45 8 9.3l2.9-2.85" />
244
- `,
245
298
  } as const;
246
299
 
247
- function renderComposeHeaderIcon(
248
- icon: (typeof COMPOSE_DIALOG_HEADER_ICONS)[keyof typeof COMPOSE_DIALOG_HEADER_ICONS],
300
+ function renderComposeSheetRowIcon(
301
+ icon: (typeof COMPOSE_SHEET_ROW_ICONS)[keyof typeof COMPOSE_SHEET_ROW_ICONS],
249
302
  ) {
250
303
  return html`<svg
251
- class="compose-dialog-header-icon"
252
- width="24"
253
- height="24"
304
+ class="compose-sheet-row-icon"
305
+ width="18"
306
+ height="18"
254
307
  viewBox="0 0 18 18"
255
308
  fill="none"
256
309
  stroke="currentColor"
@@ -528,6 +581,38 @@ export class JantComposeDialog extends LitElement {
528
581
  }
529
582
  }
530
583
 
584
+ /**
585
+ * Whether a note's title field starts visible, remembered per browser.
586
+ * Titling notes is a habit rather than a per-post decision, so the last
587
+ * deliberate answer carries over. One key, not one per collection: where the
588
+ * note was started says nothing about whether it wants a name.
589
+ */
590
+ private static _NOTE_TITLE_KEY = "jant:compose-note-title";
591
+
592
+ /** Defaults to on, so a browser with nothing stored behaves as it always has. */
593
+ private static _getNoteTitleDefault(): boolean {
594
+ try {
595
+ return (
596
+ globalThis.localStorage.getItem(JantComposeDialog._NOTE_TITLE_KEY) !==
597
+ "0"
598
+ );
599
+ } catch {
600
+ // localStorage unavailable
601
+ return true;
602
+ }
603
+ }
604
+
605
+ private static _setNoteTitleDefault(show: boolean) {
606
+ try {
607
+ globalThis.localStorage.setItem(
608
+ JantComposeDialog._NOTE_TITLE_KEY,
609
+ show ? "1" : "0",
610
+ );
611
+ } catch {
612
+ // localStorage unavailable
613
+ }
614
+ }
615
+
531
616
  static properties = {
532
617
  collections: { type: Array },
533
618
  labels: { type: Object },
@@ -561,13 +646,16 @@ export class JantComposeDialog extends LitElement {
561
646
  _addCollectionPanelOpen: { state: true },
562
647
  _replyToId: { state: true },
563
648
  _replyToData: { state: true },
649
+ _replyParentPosition: { state: true },
564
650
  _replyExpanded: { state: true },
565
651
  _threadItems: { state: true },
652
+ _rowStatus: { state: true },
566
653
  _focusedThreadIndex: { state: true },
567
654
  _slug: { state: true },
568
655
  _publishedAtInput: { state: true },
569
656
  _visibility: { state: true },
570
657
  _showPublishPanel: { state: true },
658
+ _postMetaIndex: { state: true },
571
659
  _publishPanelFullscreen: { state: true },
572
660
  _suggestedSlug: { state: true },
573
661
  _suggestedSlugLoading: { state: true },
@@ -606,13 +694,20 @@ export class JantComposeDialog extends LitElement {
606
694
  declare _addCollectionPanelOpen: boolean;
607
695
  declare _replyToId: string | null;
608
696
  declare _replyToData: ReplyToData | null;
697
+ /** Where the post being replied to sits in its own chain; see
698
+ * `_positionLabel`. Null until the parent has been read back. */
699
+ declare _replyParentPosition: number | null;
609
700
  declare _replyExpanded: boolean;
610
701
  declare _threadItems: ThreadItem[];
702
+ /** Each row's own answer, keyed by row id. See `_rowIds`. */
703
+ declare _rowStatus: ReadonlyMap<string, ComposeRowStatus>;
611
704
  declare _focusedThreadIndex: number;
612
705
  declare _slug: string;
613
706
  declare _publishedAtInput: string;
614
707
  declare _visibility: ComposeVisibility;
615
708
  declare _showPublishPanel: boolean;
709
+ /** Index of the post whose date/permalink panel is open; null when closed. */
710
+ declare _postMetaIndex: number | null;
616
711
  declare _publishPanelFullscreen: boolean;
617
712
  declare _suggestedSlug: string;
618
713
  declare _suggestedSlugLoading: boolean;
@@ -696,8 +791,10 @@ export class JantComposeDialog extends LitElement {
696
791
  this._replyToData = null;
697
792
  this._replyExpanded = false;
698
793
  this._threadItems = [];
794
+ this._rowStatus = new Map();
699
795
  this._focusedThreadIndex = 0;
700
796
  this._replyThreadRootId = null;
797
+ this._replyParentPosition = null;
701
798
  this._replyRefreshKind = null;
702
799
  this._replyRefreshId = null;
703
800
  this._slug = "";
@@ -711,6 +808,7 @@ export class JantComposeDialog extends LitElement {
711
808
  this._sourceCollectionId = null;
712
809
  this._showPublishPanel = false;
713
810
  this._publishPanelFullscreen = false;
811
+ this._postMetaIndex = null;
714
812
  this._suggestedSlug = "";
715
813
  this._suggestedSlugLoading = false;
716
814
  this._slugCheckLoading = false;
@@ -723,11 +821,62 @@ export class JantComposeDialog extends LitElement {
723
821
  return this.querySelector("jant-compose-editor");
724
822
  }
725
823
 
824
+ /**
825
+ * The rows the composer currently has, in order — thread posts by their own
826
+ * id, or the single composer's one row.
827
+ *
828
+ * Everything derived from row content reduces over this list, so a row that
829
+ * has been removed stops being asked the moment `_threadItems` changes. No
830
+ * DOM read is involved, which is the point: a render reads the DOM as it
831
+ * stands *before* that render's own changes are committed.
832
+ */
833
+ private get _rowIds(): string[] {
834
+ return this._threadItems.length > 0
835
+ ? this._threadItems.map((item) => item.id)
836
+ : [SINGLE_ROW_ID];
837
+ }
838
+
839
+ private _handleRowStatus(rowId: string, status: ComposeRowStatus) {
840
+ const previous = this._rowStatus.get(rowId);
841
+ if (
842
+ previous?.hasContent === status.hasContent &&
843
+ previous?.publishable === status.publishable
844
+ ) {
845
+ return;
846
+ }
847
+ const next = new Map(this._rowStatus);
848
+ next.set(rowId, status);
849
+ this._rowStatus = next;
850
+ }
851
+
852
+ protected willUpdate(changed: Map<string, unknown>) {
853
+ super.willUpdate(changed);
854
+
855
+ // An answer belongs to the row that gave it. When the set of rows changes,
856
+ // drop the ones that are gone so a row can never inherit an answer that
857
+ // was never about it — leaving thread mode, in particular, hands the single
858
+ // row's key back to a fresh editor.
859
+ if (changed.has("_threadItems")) {
860
+ const live = new Set(this._rowIds);
861
+ if ([...this._rowStatus.keys()].some((id) => !live.has(id))) {
862
+ this._rowStatus = new Map(
863
+ [...this._rowStatus].filter(([id]) => live.has(id)),
864
+ );
865
+ }
866
+ }
867
+ }
868
+
726
869
  protected updated(changed: Map<string, unknown>) {
727
870
  super.updated(changed);
728
871
  if (this._initialSnapshot === null && this._editor) {
729
872
  this._captureInitialSnapshot();
730
873
  }
874
+ // The visible title used to be the dialog's accessible name. Nothing on
875
+ // screen needs to replace it, but a screen reader announcing an unnamed
876
+ // dialog does — so the name moves to an attribute, where it costs no space.
877
+ if (changed.has("labels") && this._dialogEl && this.labels) {
878
+ this._dialogEl.setAttribute("aria-label", this.labels.composeDialogLabel);
879
+ }
731
880
  if (
732
881
  changed.has("_addCollectionPanelOpen") &&
733
882
  this._addCollectionPanelOpen
@@ -762,6 +911,9 @@ export class JantComposeDialog extends LitElement {
762
911
  if (this._showCollection) {
763
912
  this._updateCollectionPopoverSide();
764
913
  }
914
+ if (this._postMetaIndex !== null) {
915
+ this._updatePostMetaPanelLayout();
916
+ }
765
917
  }
766
918
 
767
919
  reset() {
@@ -789,8 +941,10 @@ export class JantComposeDialog extends LitElement {
789
941
  this._replyToData = null;
790
942
  this._replyExpanded = false;
791
943
  this._threadItems = [];
944
+ this._rowStatus = new Map();
792
945
  this._focusedThreadIndex = 0;
793
946
  this._replyThreadRootId = null;
947
+ this._replyParentPosition = null;
794
948
  this._replyRefreshKind = null;
795
949
  this._replyRefreshId = null;
796
950
  this._slug = "";
@@ -846,7 +1000,7 @@ export class JantComposeDialog extends LitElement {
846
1000
  }
847
1001
  }
848
1002
 
849
- async openEdit(id: string) {
1003
+ async openEdit(id: string, options?: { restoreToast?: boolean }) {
850
1004
  this.reset();
851
1005
  const requestId = ++this._openEditRequestId;
852
1006
  this._openingEdit = true;
@@ -891,7 +1045,7 @@ export class JantComposeDialog extends LitElement {
891
1045
  if (requestId !== this._openEditRequestId) return;
892
1046
  }
893
1047
 
894
- if (post.collectionIds?.length) {
1048
+ if (!post.replyToId && post.collectionIds?.length) {
895
1049
  this._collectionIds = post.collectionIds;
896
1050
  }
897
1051
 
@@ -905,7 +1059,10 @@ export class JantComposeDialog extends LitElement {
905
1059
  if (requestId !== this._openEditRequestId) return;
906
1060
 
907
1061
  // Check for a local edit draft (unsaved changes from a previous session)
908
- const restored = this._restoreEditDraftIfAvailable(id);
1062
+ const restored = this._restoreEditDraftIfAvailable(
1063
+ id,
1064
+ options?.restoreToast,
1065
+ );
909
1066
 
910
1067
  if (!restored) {
911
1068
  this._editor?.populate({
@@ -943,11 +1100,30 @@ export class JantComposeDialog extends LitElement {
943
1100
  }
944
1101
  }
945
1102
 
1103
+ async openDraft(id: string) {
1104
+ const dialog = this.closest("dialog");
1105
+ if (dialog && !dialog.open) {
1106
+ dialog.showModal();
1107
+ }
1108
+ await this.updateComplete;
1109
+ this._focusDialogShell();
1110
+ await this._loadDraft(id);
1111
+ }
1112
+
946
1113
  async openNew(options?: ComposeOpenOptions) {
947
1114
  this.reset();
948
1115
 
1116
+ // `reset()` seeds the toggle from `titleByDefault`, which Lit only refreshes
1117
+ // on the next render — after a reply, that value is still the reply's. Say
1118
+ // it outright here. A restored draft overwrites it below with its own
1119
+ // answer, which is right: that one belongs to the draft, not to the habit.
1120
+ this._editor?.setTitleDefault(JantComposeDialog._getNoteTitleDefault());
1121
+
949
1122
  if (options?.restoreDraft !== false) {
950
- await this.restoreLocalDraft();
1123
+ await this.restoreLocalDraft({
1124
+ notify: options?.restoreToast,
1125
+ extraMedia: options?.restoreMedia,
1126
+ });
951
1127
  }
952
1128
 
953
1129
  if (options?.initialFormat) {
@@ -1006,13 +1182,44 @@ export class JantComposeDialog extends LitElement {
1006
1182
  this._format = options?.initialFormat ?? "note";
1007
1183
 
1008
1184
  if (options?.restoreDraft !== false) {
1009
- await this.restoreLocalDraft({ expectedReplyToId: id });
1185
+ await this.restoreLocalDraft({
1186
+ expectedReplyToId: id,
1187
+ notify: options?.restoreToast,
1188
+ extraMedia: options?.restoreMedia,
1189
+ });
1010
1190
  }
1191
+ // Collection membership belongs to the existing Thread. Reply compose
1192
+ // must not restore or submit stale per-compose Collection state.
1193
+ this._collectionIds = [];
1011
1194
 
1012
1195
  this.closest("dialog")?.showModal();
1013
1196
  await this.updateComplete;
1014
1197
  this._editor?.focusInput();
1015
1198
  this._captureInitialSnapshot();
1199
+ // Deliberately not awaited: this only feeds the `3/3` marker, and blocking
1200
+ // the composer's open on it would trade a visible delay for a decoration.
1201
+ // The marker stays hidden until the number is known rather than counting
1202
+ // from 1 and then correcting itself.
1203
+ void this._loadReplyParentPosition(id);
1204
+ }
1205
+
1206
+ /**
1207
+ * Read back where the parent sits in its own chain, so the editors below it
1208
+ * can be numbered from there. Failure is silent — a missing marker is a much
1209
+ * smaller problem than a wrong one.
1210
+ */
1211
+ private async _loadReplyParentPosition(replyToId: string) {
1212
+ try {
1213
+ const res = await fetch(`/api/posts/${replyToId}`);
1214
+ if (!res.ok) return;
1215
+ const post = (await res.json()) as ComposePostResponse;
1216
+ // The composer may have been closed, or pointed at another post, while
1217
+ // this was in flight.
1218
+ if (this._replyToId !== replyToId) return;
1219
+ this._replyParentPosition = post.threadPosition ?? null;
1220
+ } catch {
1221
+ // Leave the marker hidden.
1222
+ }
1016
1223
  }
1017
1224
 
1018
1225
  /**
@@ -1027,6 +1234,7 @@ export class JantComposeDialog extends LitElement {
1027
1234
  this._replyThreadRootId = (post.replyToId as string | null)
1028
1235
  ? (post.threadId as string)
1029
1236
  : (post.id as string);
1237
+ this._replyParentPosition = post.threadPosition ?? null;
1030
1238
  const dateText = post.publishedAt
1031
1239
  ? new Date(post.publishedAt * 1000).toLocaleDateString(undefined, {
1032
1240
  month: "short",
@@ -1100,39 +1308,16 @@ export class JantComposeDialog extends LitElement {
1100
1308
  return false;
1101
1309
  }
1102
1310
 
1311
+ /**
1312
+ * Whether any row holds something worth keeping.
1313
+ *
1314
+ * Collection selection alone isn't content — it's metadata that only matters
1315
+ * when paired with actual post content.
1316
+ */
1103
1317
  private _hasContent(): boolean {
1104
- if (this._threadItems.length > 0) {
1105
- // Thread mode: check the first editor for content
1106
- const firstEditor = this.querySelector<JantComposeEditor>(
1107
- "jant-compose-editor",
1108
- );
1109
- if (!firstEditor) return false;
1110
- const data = firstEditor.getData();
1111
- return (
1112
- !!data.body ||
1113
- !!data.title.trim() ||
1114
- !!data.url.trim() ||
1115
- !!data.quoteText.trim() ||
1116
- data.attachments.length > 0
1117
- );
1118
- }
1119
-
1120
- const editor = this._editor;
1121
- if (!editor) return false;
1122
-
1123
- const data = editor.getData();
1124
- if (data.body) return true;
1125
- if (data.title.trim()) return true;
1126
- if (data.url.trim()) return true;
1127
- if (data.quoteText.trim()) return true;
1128
- if (data.quoteAuthor.trim()) return true;
1129
- if (data.attachedTexts.length > 0) return true;
1130
- if (data.rating > 0) return true;
1131
- if (data.attachments.length > 0) return true;
1132
- // Collection selection alone isn't content — it's metadata that
1133
- // only matters when paired with actual post content above.
1134
-
1135
- return false;
1318
+ return this._rowIds.some(
1319
+ (id) => this._rowStatus.get(id)?.hasContent === true,
1320
+ );
1136
1321
  }
1137
1322
 
1138
1323
  private _buildSnapshot(): ComposeStateSnapshot | null {
@@ -1141,10 +1326,8 @@ export class JantComposeDialog extends LitElement {
1141
1326
 
1142
1327
  const editorData = editor.getData();
1143
1328
  const attachedTexts = editor.getEffectiveAttachedTexts();
1144
- const showTitle =
1145
- this._format === "note" && editorData.title.trim().length > 0
1146
- ? editor._showTitle
1147
- : false;
1329
+ // No `showTitle` here: a hidden title reads back as an empty one, so the
1330
+ // toggle is already visible in `title`.
1148
1331
  const showRating = editorData.rating > 0 ? editor._showRating : false;
1149
1332
 
1150
1333
  return {
@@ -1160,7 +1343,6 @@ export class JantComposeDialog extends LitElement {
1160
1343
  quoteText: editorData.quoteText,
1161
1344
  quoteAuthor: editorData.quoteAuthor,
1162
1345
  rating: editorData.rating,
1163
- showTitle,
1164
1346
  showRating,
1165
1347
  attachments: editor._attachments.map((attachment) => ({
1166
1348
  clientId: attachment.clientId,
@@ -1363,17 +1545,23 @@ export class JantComposeDialog extends LitElement {
1363
1545
  quoteText: editorData.quoteText,
1364
1546
  quoteAuthor: editorData.quoteAuthor,
1365
1547
  status,
1366
- slug: isRoot ? this._slug.trim() || undefined : undefined,
1367
- publishedAt: isRoot ? this._getPublishedAtSubmitValue(status) : undefined,
1548
+ slug: this._getPostSlug(index).trim() || undefined,
1549
+ // Every post carries its own date; a reply that left it blank sends
1550
+ // undefined and the server fills it from the root.
1551
+ publishedAt: this._getPostPublishedAtSubmitValue(index, status),
1368
1552
  visibility: isRoot
1369
1553
  ? this._visibilityLocked
1370
1554
  ? undefined
1371
1555
  : this._visibility
1372
1556
  : undefined,
1373
1557
  rating: editorData.rating,
1374
- collectionIds: isRoot ? [...this._collectionIds] : [],
1558
+ collectionIds: isRoot && !this._replyToId ? [...this._collectionIds] : [],
1375
1559
  attachments: orderedAttachments,
1376
1560
  replyToId: isRoot ? (this._replyToId ?? undefined) : undefined,
1561
+ quietReply:
1562
+ isRoot && this._canReplyQuietly()
1563
+ ? this._quietReply || undefined
1564
+ : undefined,
1377
1565
  replyThreadRootId: isRoot
1378
1566
  ? (this._replyThreadRootId ?? undefined)
1379
1567
  : undefined,
@@ -1441,11 +1629,14 @@ export class JantComposeDialog extends LitElement {
1441
1629
  status,
1442
1630
  visibility: this._visibilityLocked ? undefined : this._visibility,
1443
1631
  rating: editorData.rating,
1444
- collectionIds: [...this._collectionIds],
1632
+ collectionIds: this._replyToId ? [] : [...this._collectionIds],
1445
1633
  attachments: orderedAttachments,
1446
1634
  editPostId: this._editPostId ?? this._draftSourceId ?? undefined,
1635
+ draftSourceId: this._draftSourceId ?? undefined,
1447
1636
  replyToId: this._replyToId ?? undefined,
1448
- quietReply: this._quietReply || undefined,
1637
+ quietReply: this._canReplyQuietly()
1638
+ ? this._quietReply || undefined
1639
+ : undefined,
1449
1640
  replyThreadRootId: this._replyThreadRootId ?? undefined,
1450
1641
  replyRefreshKind: this._replyRefreshKind ?? undefined,
1451
1642
  replyRefreshId: this._replyRefreshId ?? undefined,
@@ -1453,17 +1644,24 @@ export class JantComposeDialog extends LitElement {
1453
1644
  }
1454
1645
 
1455
1646
  private _focusBlockedSubmitField(status: "published" | "draft"): boolean {
1456
- if (
1457
- status === "published" &&
1458
- this._getPublishedAtValidationMessage() !== null
1459
- ) {
1460
- this._revealPublishedAtField();
1461
- return true;
1647
+ if (status === "published") {
1648
+ const posts = Math.max(1, this._threadItems.length);
1649
+ for (let i = 0; i < posts; i++) {
1650
+ if (this._getPostPublishedAtValidationMessage(i) !== null) {
1651
+ this._revealPublishedAtField(i);
1652
+ return true;
1653
+ }
1654
+ }
1462
1655
  }
1463
1656
 
1464
- if (this._getSlugValidationMessage()) {
1465
- this._revealSlugField();
1466
- return true;
1657
+ {
1658
+ const posts = Math.max(1, this._threadItems.length);
1659
+ for (let i = 0; i < posts; i++) {
1660
+ if (this._getPostSlugValidationMessage(i)) {
1661
+ this._revealSlugField(i);
1662
+ return true;
1663
+ }
1664
+ }
1467
1665
  }
1468
1666
 
1469
1667
  // ── Thread mode: validate each editor against its own format ──────
@@ -1562,6 +1760,7 @@ export class JantComposeDialog extends LitElement {
1562
1760
  detail: {
1563
1761
  ...threadPosts[0],
1564
1762
  editPostId: this._editPostId ?? this._draftSourceId ?? undefined,
1763
+ draftSourceId: this._draftSourceId ?? undefined,
1565
1764
  threadPosts,
1566
1765
  pendingAttachments: allPending,
1567
1766
  },
@@ -1971,7 +2170,9 @@ export class JantComposeDialog extends LitElement {
1971
2170
  }
1972
2171
 
1973
2172
  private _scheduleSuggestedSlugRefresh(immediate = false) {
1974
- if (!this._showPublishPanel || this._hasManualSlug()) return;
2173
+ // Only worth fetching while the slug UI is on screen — which is the post's
2174
+ // own panel now, not the publish panel.
2175
+ if (this._postMetaIndex !== 0 || this._hasManualSlug()) return;
1975
2176
  if (!this._canSuggestSlug()) {
1976
2177
  this._slugSuggestRequestId += 1;
1977
2178
  this._suggestedSlug = "";
@@ -2026,7 +2227,7 @@ export class JantComposeDialog extends LitElement {
2026
2227
  if (
2027
2228
  requestId !== this._slugSuggestRequestId ||
2028
2229
  this._hasManualSlug() ||
2029
- !this._showPublishPanel
2230
+ this._postMetaIndex !== 0
2030
2231
  ) {
2031
2232
  return;
2032
2233
  }
@@ -2041,9 +2242,9 @@ export class JantComposeDialog extends LitElement {
2041
2242
  }
2042
2243
  }
2043
2244
 
2044
- private _scheduleSlugAvailabilityCheck() {
2045
- if (!this._hasManualSlug()) {
2046
- this._slugTaken = false;
2245
+ private _scheduleSlugAvailabilityCheck(index = 0) {
2246
+ if (!this._hasPostManualSlug(index)) {
2247
+ this._setPostSlugTaken(index, false);
2047
2248
  this._slugCheckLoading = false;
2048
2249
  return;
2049
2250
  }
@@ -2053,21 +2254,20 @@ export class JantComposeDialog extends LitElement {
2053
2254
  this._slugCheckTimer = null;
2054
2255
  }
2055
2256
 
2056
- const syncError = this._getSlugSyncValidationMessage();
2057
- if (syncError) {
2058
- this._slugTaken = false;
2257
+ if (this._getPostSlugSyncValidationMessage(index)) {
2258
+ this._setPostSlugTaken(index, false);
2059
2259
  this._slugCheckLoading = false;
2060
2260
  return;
2061
2261
  }
2062
2262
 
2063
2263
  this._slugCheckLoading = true;
2064
- const slug = this._slug.trim();
2264
+ const slug = this._getPostSlug(index).trim();
2065
2265
  this._slugCheckTimer = setTimeout(() => {
2066
- void this._checkSlugAvailability(slug);
2266
+ void this._checkSlugAvailability(slug, index);
2067
2267
  }, 250);
2068
2268
  }
2069
2269
 
2070
- private async _checkSlugAvailability(slug: string) {
2270
+ private async _checkSlugAvailability(slug: string, index = 0) {
2071
2271
  this._slugCheckTimer = null;
2072
2272
  const requestId = ++this._slugCheckRequestId;
2073
2273
 
@@ -2084,11 +2284,11 @@ export class JantComposeDialog extends LitElement {
2084
2284
  const json = (await res.json()) as { available?: boolean };
2085
2285
  if (
2086
2286
  requestId !== this._slugCheckRequestId ||
2087
- this._slug.trim() !== slug
2287
+ this._getPostSlug(index).trim() !== slug
2088
2288
  ) {
2089
2289
  return;
2090
2290
  }
2091
- this._slugTaken = json.available === false;
2291
+ this._setPostSlugTaken(index, json.available === false);
2092
2292
  } catch {
2093
2293
  // Server-side create/update remains the final authority.
2094
2294
  } finally {
@@ -2110,9 +2310,9 @@ export class JantComposeDialog extends LitElement {
2110
2310
  });
2111
2311
  }
2112
2312
 
2113
- private _resetCustomSlug() {
2114
- this._slug = "";
2115
- this._slugTaken = false;
2313
+ private _resetCustomSlug(index = 0) {
2314
+ this._setPostSlug(index, "");
2315
+ this._setPostSlugTaken(index, false);
2116
2316
  this._slugCheckLoading = false;
2117
2317
  this._scheduleSuggestedSlugRefresh(true);
2118
2318
  this.updateComplete.then(() => {
@@ -2136,6 +2336,10 @@ export class JantComposeDialog extends LitElement {
2136
2336
  "jant:compose-content-changed",
2137
2337
  this._onContentChanged,
2138
2338
  );
2339
+ this.addEventListener(
2340
+ "jant:title-toggle",
2341
+ this._handleTitleToggle as EventListener,
2342
+ );
2139
2343
  this.addEventListener("jant:file-picker-open", this._handleFilePickerOpen);
2140
2344
  this.addEventListener(
2141
2345
  "jant:file-picker-close",
@@ -2158,6 +2362,10 @@ export class JantComposeDialog extends LitElement {
2158
2362
  window.addEventListener("scroll", this._handleViewportChange, {
2159
2363
  passive: true,
2160
2364
  });
2365
+ document.addEventListener("scroll", this._handleAnyScroll, {
2366
+ capture: true,
2367
+ passive: true,
2368
+ });
2161
2369
  globalThis.visualViewport?.addEventListener(
2162
2370
  "resize",
2163
2371
  this._handleViewportChange,
@@ -2193,6 +2401,10 @@ export class JantComposeDialog extends LitElement {
2193
2401
  "jant:compose-content-changed",
2194
2402
  this._onContentChanged,
2195
2403
  );
2404
+ this.removeEventListener(
2405
+ "jant:title-toggle",
2406
+ this._handleTitleToggle as EventListener,
2407
+ );
2196
2408
  this.removeEventListener(
2197
2409
  "jant:file-picker-open",
2198
2410
  this._handleFilePickerOpen,
@@ -2213,6 +2425,9 @@ export class JantComposeDialog extends LitElement {
2213
2425
  window.removeEventListener("beforeunload", this._onBeforeUnload);
2214
2426
  window.removeEventListener("resize", this._handleViewportChange);
2215
2427
  window.removeEventListener("scroll", this._handleViewportChange);
2428
+ document.removeEventListener("scroll", this._handleAnyScroll, {
2429
+ capture: true,
2430
+ });
2216
2431
  globalThis.visualViewport?.removeEventListener(
2217
2432
  "resize",
2218
2433
  this._handleViewportChange,
@@ -2269,6 +2484,20 @@ export class JantComposeDialog extends LitElement {
2269
2484
  if (this._showCollection) {
2270
2485
  this.updateComplete.then(() => this._updateCollectionPopoverSide());
2271
2486
  }
2487
+ if (this._postMetaIndex !== null) {
2488
+ this.updateComplete.then(() => this._updatePostMetaPanelLayout());
2489
+ }
2490
+ };
2491
+
2492
+ /**
2493
+ * The date/permalink panel is pinned to a pill inside `.compose-scroll`, and
2494
+ * a `scroll` event on that box never reaches `window` — it doesn't bubble.
2495
+ * Capture phase catches it wherever it fires, so the panel travels with its
2496
+ * pill instead of hanging in place once the composer moves under it.
2497
+ */
2498
+ private _handleAnyScroll = () => {
2499
+ if (this._postMetaIndex === null) return;
2500
+ this._updatePostMetaPanelLayout();
2272
2501
  };
2273
2502
 
2274
2503
  private _handleDialogCancel = (e: Event) => {
@@ -2393,6 +2622,12 @@ export class JantComposeDialog extends LitElement {
2393
2622
  return true;
2394
2623
  }
2395
2624
 
2625
+ if (this._postMetaIndex !== null) {
2626
+ // `_closePostMeta` hands focus back to the post itself.
2627
+ this._closePostMeta();
2628
+ return true;
2629
+ }
2630
+
2396
2631
  if (this._altPanelOpen) {
2397
2632
  this._closeAltPanel();
2398
2633
  this._restorePageEditorFocus();
@@ -2437,15 +2672,7 @@ export class JantComposeDialog extends LitElement {
2437
2672
  this._handleConfirmSave();
2438
2673
  } else if ((ke.metaKey || ke.ctrlKey) && ke.key === "Enter") {
2439
2674
  e.preventDefault();
2440
- if (this._attachedPanelOpen) {
2441
- this._doneAttachedPanel();
2442
- return;
2443
- }
2444
- if (!this._canPublish()) {
2445
- this._focusBlockedSubmitField("published");
2446
- return;
2447
- }
2448
- void this._submit("published");
2675
+ this._publishFromShortcut();
2449
2676
  } else if (
2450
2677
  (ke.metaKey || ke.ctrlKey) &&
2451
2678
  !ke.altKey &&
@@ -2471,6 +2698,18 @@ export class JantComposeDialog extends LitElement {
2471
2698
  }
2472
2699
  };
2473
2700
 
2701
+ private _publishFromShortcut() {
2702
+ if (this._attachedPanelOpen) {
2703
+ this._doneAttachedPanel();
2704
+ return;
2705
+ }
2706
+ if (!this._canPublish()) {
2707
+ this._focusBlockedSubmitField("published");
2708
+ return;
2709
+ }
2710
+ void this._submit("published");
2711
+ }
2712
+
2474
2713
  private _handleAltPanelOpen = (e: Event) => {
2475
2714
  const detail = (e as CustomEvent<{ index: number }>).detail;
2476
2715
  this._altPanelIndex = detail.index;
@@ -2500,7 +2739,10 @@ export class JantComposeDialog extends LitElement {
2500
2739
  private _handleFullscreenClose = (
2501
2740
  e: CustomEvent<ComposeFullscreenCloseDetail>,
2502
2741
  ) => {
2503
- const editor = this._editor;
2742
+ const editors = Array.from(
2743
+ this.querySelectorAll<JantComposeEditor>("jant-compose-editor"),
2744
+ );
2745
+ const editor = editors[e.detail.editorIndex ?? 0] ?? this._editor;
2504
2746
  if (editor) {
2505
2747
  editor.setEditorState(
2506
2748
  e.detail.json as import("@tiptap/core").JSONContent,
@@ -2511,9 +2753,16 @@ export class JantComposeDialog extends LitElement {
2511
2753
  // Adopt any in-flight inline image uploads from the fullscreen editor
2512
2754
  // so blob: placeholder URLs get replaced when uploads complete.
2513
2755
  editor.adoptPendingUploads();
2514
- this.updateComplete.then(() => editor.focusSelection(e.detail.selection));
2756
+ if (e.detail.intent !== "publish") {
2757
+ this.updateComplete.then(() =>
2758
+ editor.focusSelection(e.detail.selection),
2759
+ );
2760
+ }
2515
2761
  }
2516
2762
  this._replyExpanded = e.detail.replyExpanded;
2763
+ if (e.detail.intent === "publish") {
2764
+ this._publishFromShortcut();
2765
+ }
2517
2766
  };
2518
2767
 
2519
2768
  private _buildFullscreenReplyContext(): ComposeFullscreenReplyContext | null {
@@ -2530,6 +2779,11 @@ export class JantComposeDialog extends LitElement {
2530
2779
  e: CustomEvent<ComposeFullscreenOpenDetail>,
2531
2780
  ) => {
2532
2781
  e.detail.replyContext = this._buildFullscreenReplyContext();
2782
+ const editors = Array.from(
2783
+ this.querySelectorAll<JantComposeEditor>("jant-compose-editor"),
2784
+ );
2785
+ const editorIndex = editors.indexOf(e.target as JantComposeEditor);
2786
+ e.detail.editorIndex = editorIndex >= 0 ? editorIndex : 0;
2533
2787
  };
2534
2788
 
2535
2789
  private _handleAttachedPanelOpen = (e: Event) => {
@@ -2551,6 +2805,7 @@ export class JantComposeDialog extends LitElement {
2551
2805
  placeholder: this.labels.attachedTextPlaceholder,
2552
2806
  content,
2553
2807
  toolbarMode: "compose",
2808
+ tableControlLabels: this.labels.tableControls,
2554
2809
  });
2555
2810
  this._focusAttachedEditorBoundary(content);
2556
2811
  });
@@ -2638,6 +2893,7 @@ export class JantComposeDialog extends LitElement {
2638
2893
  const allDraftItems = (posts as Record<string, unknown>[]).map(
2639
2894
  (p): DraftItem => ({
2640
2895
  id: p.id as string,
2896
+ slug: p.slug as string,
2641
2897
  format: p.format as ComposeFormat,
2642
2898
  title:
2643
2899
  ((p.format as ComposeFormat) === "quote"
@@ -2744,7 +3000,7 @@ export class JantComposeDialog extends LitElement {
2744
3000
  this._visibility = post.visibility ?? "public";
2745
3001
  this._visibilityLocked = Boolean(post.replyToId);
2746
3002
 
2747
- if (post.collectionIds?.length) {
3003
+ if (!post.replyToId && post.collectionIds?.length) {
2748
3004
  this._collectionIds = post.collectionIds;
2749
3005
  }
2750
3006
 
@@ -2903,9 +3159,16 @@ export class JantComposeDialog extends LitElement {
2903
3159
  }
2904
3160
 
2905
3161
  private _getDraftPreview(draft: DraftItem): string | null {
2906
- if (draft.bodyText) return draft.bodyText;
3162
+ if (draft.format === "quote") {
3163
+ if (draft.quoteText) return draft.quoteText;
3164
+ if (draft.title) return draft.title;
3165
+ if (draft.bodyText) return draft.bodyText;
3166
+ if (draft.url) return draft.url;
3167
+ return null;
3168
+ }
3169
+
2907
3170
  if (draft.title) return draft.title;
2908
- if (draft.quoteText) return draft.quoteText;
3171
+ if (draft.bodyText) return draft.bodyText;
2909
3172
  if (draft.url) return draft.url;
2910
3173
  return null;
2911
3174
  }
@@ -2941,6 +3204,18 @@ export class JantComposeDialog extends LitElement {
2941
3204
  }
2942
3205
  };
2943
3206
 
3207
+ /**
3208
+ * Remember the note title toggle for the next new note. Same guard as
3209
+ * `_setVisibility`: an edit, a loaded draft, or a reply answers for that one
3210
+ * post, not for how this author writes. Thread rows are excluded too — the
3211
+ * toggle there answers for a position in a thread.
3212
+ */
3213
+ private _handleTitleToggle = (e: CustomEvent<{ showTitle: boolean }>) => {
3214
+ if (this._editPostId || this._draftSourceId || this._replyToId) return;
3215
+ if ((e.target as JantComposeEditor | null)?.threadItem) return;
3216
+ JantComposeDialog._setNoteTitleDefault(e.detail.showTitle);
3217
+ };
3218
+
2944
3219
  private _cancelDraftSaveTimer() {
2945
3220
  if (this._draftSaveTimer !== null) {
2946
3221
  clearTimeout(this._draftSaveTimer);
@@ -3003,6 +3278,9 @@ export class JantComposeDialog extends LitElement {
3003
3278
  const data = editor.getData();
3004
3279
  return {
3005
3280
  format: item.format,
3281
+ publishedAtInput: item.publishedAtInput,
3282
+ publishedAtTimeMinutes: item.publishedAtTimeMinutes,
3283
+ slug: item.slug,
3006
3284
  title: data.title,
3007
3285
  bodyJson: editor.getNormalizedBodyJson(),
3008
3286
  url: data.url,
@@ -3015,6 +3293,9 @@ export class JantComposeDialog extends LitElement {
3015
3293
  summary: t.summary,
3016
3294
  })),
3017
3295
  attachmentOrder: [...data.attachmentOrder],
3296
+ mediaAttachments: JantComposeDialog._mediaSnapshotFromAttachments(
3297
+ data.attachments,
3298
+ ),
3018
3299
  };
3019
3300
  })
3020
3301
  .filter((item): item is NonNullable<typeof item> => item !== null);
@@ -3072,6 +3353,7 @@ export class JantComposeDialog extends LitElement {
3072
3353
  !!data.quoteText.trim() ||
3073
3354
  !!data.quoteAuthor.trim() ||
3074
3355
  data.rating > 0 ||
3356
+ data.attachments.length > 0 ||
3075
3357
  data.attachedTexts.length > 0;
3076
3358
 
3077
3359
  if (!hasContent) {
@@ -3091,10 +3373,10 @@ export class JantComposeDialog extends LitElement {
3091
3373
  publishedAtTimeMinutes: this._publishedAtTimeMinutes,
3092
3374
  visibility: this._visibility,
3093
3375
  rating: data.rating,
3094
- showTitle:
3095
- this._format === "note" && data.title.trim().length > 0
3096
- ? editor._showTitle
3097
- : false,
3376
+ // The toggle as it stands, not "does a title exist" — restoring a draft
3377
+ // should put the editor back the way it was left, including an open but
3378
+ // still-empty title field.
3379
+ showTitle: this._format === "note" ? editor._showTitle : false,
3098
3380
  showRating: data.rating > 0 ? editor._showRating : false,
3099
3381
  collectionIds: [...this._collectionIds],
3100
3382
  replyToId: this._replyToId,
@@ -3105,6 +3387,9 @@ export class JantComposeDialog extends LitElement {
3105
3387
  summary: t.summary,
3106
3388
  })),
3107
3389
  attachmentOrder: [...data.attachmentOrder],
3390
+ mediaAttachments: JantComposeDialog._mediaSnapshotFromAttachments(
3391
+ data.attachments,
3392
+ ),
3108
3393
  savedAt: Date.now(),
3109
3394
  };
3110
3395
 
@@ -3133,7 +3418,67 @@ export class JantComposeDialog extends LitElement {
3133
3418
  );
3134
3419
  }
3135
3420
 
3136
- async restoreLocalDraft(options?: { expectedReplyToId?: string }) {
3421
+ /**
3422
+ * Snapshot attachments whose upload completed. Only these can be restored
3423
+ * later — for the rest the bytes exist nowhere but in-memory.
3424
+ */
3425
+ private static _mediaSnapshotFromAttachments(
3426
+ attachments: ComposeAttachment[],
3427
+ ): LocalDraftMedia[] {
3428
+ return attachments.flatMap((a) =>
3429
+ a.status === "done" && a.mediaId && a.remoteUrl
3430
+ ? [
3431
+ {
3432
+ clientId: a.clientId,
3433
+ mediaId: a.mediaId,
3434
+ url: a.remoteUrl,
3435
+ mimeType: a.file.type,
3436
+ name: a.file.name || undefined,
3437
+ alt: a.alt || undefined,
3438
+ summary: a.summary,
3439
+ chars: a.chars,
3440
+ },
3441
+ ]
3442
+ : [],
3443
+ );
3444
+ }
3445
+
3446
+ /** Convert stored media snapshots into `populate()` media entries. */
3447
+ private static _restoredMediaToPopulate(media: LocalDraftMedia[]) {
3448
+ return media.map((m) => ({
3449
+ id: m.mediaId,
3450
+ clientId: m.clientId,
3451
+ previewUrl: m.url,
3452
+ mimeType: m.mimeType,
3453
+ originalName: m.name,
3454
+ alt: m.alt,
3455
+ summary: m.summary ?? undefined,
3456
+ chars: m.chars ?? undefined,
3457
+ }));
3458
+ }
3459
+
3460
+ /**
3461
+ * Merge a draft's media snapshot with bridge-supplied completed uploads.
3462
+ * Bridge entries win: they include uploads that finished after the dialog
3463
+ * closed, which the autosaved snapshot predates.
3464
+ */
3465
+ private static _mergeRestoredMedia(
3466
+ snapshot: LocalDraftMedia[] | undefined,
3467
+ extra: LocalDraftMedia[] | undefined,
3468
+ ): Map<string, LocalDraftMedia> {
3469
+ const merged = new Map<string, LocalDraftMedia>();
3470
+ for (const m of snapshot ?? []) merged.set(m.clientId, m);
3471
+ for (const m of extra ?? []) merged.set(m.clientId, m);
3472
+ return merged;
3473
+ }
3474
+
3475
+ async restoreLocalDraft(options?: {
3476
+ expectedReplyToId?: string;
3477
+ /** false suppresses the "Draft restored." toast */
3478
+ notify?: boolean;
3479
+ /** Completed uploads known to the bridge at failure time */
3480
+ extraMedia?: LocalDraftMedia[];
3481
+ }) {
3137
3482
  // Don't restore if already in edit or draft-load mode
3138
3483
  if (this._editPostId || this._draftSourceId) return;
3139
3484
  // Don't restore if the editor already has content (e.g. reopened dialog)
@@ -3168,7 +3513,9 @@ export class JantComposeDialog extends LitElement {
3168
3513
  return;
3169
3514
  }
3170
3515
 
3171
- this._collectionIds = [...(draft.collectionIds ?? [])];
3516
+ this._collectionIds = draft.replyToId
3517
+ ? []
3518
+ : [...(draft.collectionIds ?? [])];
3172
3519
  this._slug = draft.slug ?? "";
3173
3520
  this._slugTaken = false;
3174
3521
  this._slugCheckLoading = false;
@@ -3192,6 +3539,9 @@ export class JantComposeDialog extends LitElement {
3192
3539
  this._threadItems = draft.threadItems.map((item) => ({
3193
3540
  id: randomUUID(),
3194
3541
  format: item.format,
3542
+ publishedAtInput: item.publishedAtInput,
3543
+ publishedAtTimeMinutes: item.publishedAtTimeMinutes,
3544
+ slug: item.slug,
3195
3545
  }));
3196
3546
  this._focusedThreadIndex = 0;
3197
3547
 
@@ -3218,6 +3568,18 @@ export class JantComposeDialog extends LitElement {
3218
3568
  ];
3219
3569
  });
3220
3570
 
3571
+ // Bridge entries are a flat list — this item owns the clientIds in
3572
+ // its own attachmentOrder.
3573
+ const itemExtraMedia = options?.extraMedia?.filter((m) =>
3574
+ item.attachmentOrder?.includes(m.clientId),
3575
+ );
3576
+ const media = [
3577
+ ...JantComposeDialog._mergeRestoredMedia(
3578
+ item.mediaAttachments,
3579
+ itemExtraMedia,
3580
+ ).values(),
3581
+ ];
3582
+
3221
3583
  editor.populate({
3222
3584
  format: item.format,
3223
3585
  title: item.title || undefined,
@@ -3225,6 +3587,9 @@ export class JantComposeDialog extends LitElement {
3225
3587
  url: item.url || undefined,
3226
3588
  quoteText: item.quoteText || undefined,
3227
3589
  quoteAuthor: item.quoteAuthor || undefined,
3590
+ media: media.length
3591
+ ? JantComposeDialog._restoredMediaToPopulate(media)
3592
+ : undefined,
3228
3593
  textAttachments: textAttachments?.length
3229
3594
  ? textAttachments
3230
3595
  : undefined,
@@ -3233,7 +3598,7 @@ export class JantComposeDialog extends LitElement {
3233
3598
  }
3234
3599
 
3235
3600
  this._draftRestored = true;
3236
- showToast(this.labels.draftRestored);
3601
+ if (options?.notify !== false) showToast(this.labels.draftRestored);
3237
3602
  globalThis.requestAnimationFrame(() => {
3238
3603
  this._captureInitialSnapshot();
3239
3604
  });
@@ -3258,6 +3623,13 @@ export class JantComposeDialog extends LitElement {
3258
3623
  ];
3259
3624
  });
3260
3625
 
3626
+ const media = [
3627
+ ...JantComposeDialog._mergeRestoredMedia(
3628
+ draft.mediaAttachments,
3629
+ options?.extraMedia,
3630
+ ).values(),
3631
+ ];
3632
+
3261
3633
  this._editor?.populate({
3262
3634
  format: draft.format,
3263
3635
  title: draft.title || undefined,
@@ -3268,12 +3640,15 @@ export class JantComposeDialog extends LitElement {
3268
3640
  rating: draft.rating || undefined,
3269
3641
  showTitle: draft.showTitle,
3270
3642
  showRating: draft.showRating,
3643
+ media: media.length
3644
+ ? JantComposeDialog._restoredMediaToPopulate(media)
3645
+ : undefined,
3271
3646
  textAttachments: textAttachments?.length ? textAttachments : undefined,
3272
3647
  attachmentOrder: draft.attachmentOrder,
3273
3648
  });
3274
3649
 
3275
3650
  this._draftRestored = true;
3276
- showToast(this.labels.draftRestored);
3651
+ if (options?.notify !== false) showToast(this.labels.draftRestored);
3277
3652
  globalThis.requestAnimationFrame(() => {
3278
3653
  this._captureInitialSnapshot();
3279
3654
  });
@@ -3283,7 +3658,10 @@ export class JantComposeDialog extends LitElement {
3283
3658
  * Check for a local edit draft for the given post ID and restore it if valid.
3284
3659
  * Returns true if a draft was restored, false otherwise.
3285
3660
  */
3286
- private _restoreEditDraftIfAvailable(postId: string): boolean {
3661
+ private _restoreEditDraftIfAvailable(
3662
+ postId: string,
3663
+ notify?: boolean,
3664
+ ): boolean {
3287
3665
  const key = JantComposeDialog._EDIT_DRAFT_KEY_PREFIX + postId;
3288
3666
 
3289
3667
  let raw: string | null;
@@ -3309,7 +3687,9 @@ export class JantComposeDialog extends LitElement {
3309
3687
 
3310
3688
  // Restore metadata
3311
3689
  this._format = draft.format;
3312
- this._collectionIds = [...(draft.collectionIds ?? [])];
3690
+ this._collectionIds = this._replyToId
3691
+ ? []
3692
+ : [...(draft.collectionIds ?? [])];
3313
3693
  this._slug = draft.slug ?? "";
3314
3694
  this._publishedAtInput = draft.publishedAtInput ?? "";
3315
3695
  this._publishedAtTimeMinutes = draft.publishedAtTimeMinutes ?? null;
@@ -3344,7 +3724,7 @@ export class JantComposeDialog extends LitElement {
3344
3724
  });
3345
3725
 
3346
3726
  this._draftRestored = true;
3347
- showToast(this.labels.draftRestored);
3727
+ if (notify !== false) showToast(this.labels.draftRestored);
3348
3728
  return true;
3349
3729
  }
3350
3730
 
@@ -3426,9 +3806,12 @@ export class JantComposeDialog extends LitElement {
3426
3806
 
3427
3807
  private _renderDraftItem(draft: DraftItem) {
3428
3808
  const preview = this._getDraftPreview(draft);
3809
+ const formatLabel = this.labels[draft.format];
3810
+ const menuId = `draft-actions-${draft.id}`;
3811
+ const menuOpen = this._draftMenuOpenId === draft.id;
3429
3812
 
3430
3813
  return html`
3431
- <div class="compose-draft-item" @click=${() => this._loadDraft(draft.id)}>
3814
+ <div class="compose-draft-item" @click=${() => this.openDraft(draft.id)}>
3432
3815
  <div class="compose-draft-content">
3433
3816
  ${preview
3434
3817
  ? html`<div class="compose-draft-preview">${preview}</div>`
@@ -3438,11 +3821,13 @@ export class JantComposeDialog extends LitElement {
3438
3821
  Empty draft
3439
3822
  </div>`}
3440
3823
  <div class="compose-draft-meta">
3824
+ <span class="compose-draft-format">${formatLabel}</span>
3825
+ <span aria-hidden="true">·</span>
3441
3826
  ${this._formatDraftDate(draft.updatedAt)}
3442
3827
  </div>
3443
3828
  </div>
3444
3829
  <div class="relative">
3445
- ${this._draftMenuOpenId === draft.id
3830
+ ${menuOpen
3446
3831
  ? html`<div
3447
3832
  class="compose-dropdown-backdrop"
3448
3833
  @click=${(e: Event) => {
@@ -3454,6 +3839,10 @@ export class JantComposeDialog extends LitElement {
3454
3839
  <button
3455
3840
  type="button"
3456
3841
  class="compose-draft-more"
3842
+ aria-label=${this.labels.draftActions}
3843
+ aria-haspopup="menu"
3844
+ aria-expanded=${menuOpen ? "true" : "false"}
3845
+ aria-controls=${menuId}
3457
3846
  @click=${(e: Event) => {
3458
3847
  e.stopPropagation();
3459
3848
  this._draftMenuOpenId =
@@ -3466,12 +3855,34 @@ export class JantComposeDialog extends LitElement {
3466
3855
  <circle cx="12" cy="8" r="1.2" />
3467
3856
  </svg>
3468
3857
  </button>
3469
- ${this._draftMenuOpenId === draft.id
3858
+ ${menuOpen
3470
3859
  ? html`
3471
- <div class="compose-dropdown compose-dropdown-right">
3860
+ <div
3861
+ id=${menuId}
3862
+ class="compose-dropdown compose-dropdown-right"
3863
+ role="menu"
3864
+ >
3865
+ <a
3866
+ class="compose-dropdown-item"
3867
+ href=${publicPath(`/preview/${draft.slug}`)}
3868
+ target="_blank"
3869
+ rel="noopener noreferrer"
3870
+ role="menuitem"
3871
+ @click=${(e: Event) => {
3872
+ e.stopPropagation();
3873
+ this._draftMenuOpenId = null;
3874
+ }}
3875
+ >
3876
+ ${this.labels.previewDraft}
3877
+ </a>
3878
+ <div
3879
+ class="compose-dropdown-separator"
3880
+ role="separator"
3881
+ ></div>
3472
3882
  <button
3473
3883
  type="button"
3474
3884
  class="compose-dropdown-item compose-dropdown-item-danger"
3885
+ role="menuitem"
3475
3886
  @click=${(e: Event) => {
3476
3887
  e.stopPropagation();
3477
3888
  this._deleteDraft(draft.id);
@@ -3592,12 +4003,6 @@ export class JantComposeDialog extends LitElement {
3592
4003
  // already pending from loading the post.
3593
4004
  this._cancelDraftSaveTimer();
3594
4005
  this._format = target;
3595
- // Sync the editor's format this tick. Lit commits the `.format` binding
3596
- // only after this render returns, so `_canPublish()` (which reads
3597
- // `editor.getData()`, keyed on the editor's format) would otherwise compute
3598
- // against the stale format for one render and leave the submit button
3599
- // wrongly disabled.
3600
- if (editor) editor.format = target;
3601
4006
  this._showPublishPanel = false;
3602
4007
  if (this._shouldAutofocusFormatInput()) {
3603
4008
  globalThis.requestAnimationFrame(() => this._editor?.focusInput());
@@ -3606,82 +4011,40 @@ export class JantComposeDialog extends LitElement {
3606
4011
 
3607
4012
  // ── Render helpers ────────────────────────────────────────────────
3608
4013
 
3609
- private _renderHeader() {
3610
- const formats = JantComposeDialog._FORMATS;
3611
- const formatLabels: Record<ComposeFormat, string> = {
3612
- note: this.labels.note,
3613
- link: this.labels.link,
3614
- quote: this.labels.quote,
3615
- };
3616
- const draftButtonLabel = this._hasContent()
3617
- ? this.labels.saveAsDraft
3618
- : this.labels.drafts;
3619
- // Format selector sits inline (above each post) whenever more than one post
3620
- // is on screen — a reply (parent shown above) or a multi-post thread. The
3621
- // header then shows a plain title instead of the format selector.
3622
- const isReply = !!(this._replyToId && this._replyToData);
3623
- const showTitle = isReply || this._threadItems.length > 0;
3624
- const headerTitle = this._editPostId
3625
- ? this.labels.editTitle
3626
- : isReply
3627
- ? this.labels.replyTitle
3628
- : this.labels.newThread;
4014
+ /**
4015
+ * The way out, sitting at the end of the post header row where a thread post
4016
+ * keeps its own ×. Compose has no title bar to hang a Cancel off any more:
4017
+ * "New post" restated what an empty composer already says, and Publish
4018
+ * already reads "Update" when editing, so the row was carrying nothing but
4019
+ * two controls that belong closer to the work.
4020
+ *
4021
+ * A thread's posts each own that slot for removing themselves, so there the
4022
+ * exit lives in the options panel instead — see `_renderCloseComposeRow`.
4023
+ */
4024
+ private _renderCloseComposeControl() {
4025
+ if (this.pageMode || this._threadItems.length > 0) return nothing;
3629
4026
 
3630
4027
  return html`
3631
- <header class="compose-dialog-header">
3632
- <button
3633
- type="button"
3634
- class="compose-dialog-cancel"
3635
- @click=${() => this.requestClose()}
4028
+ <button
4029
+ type="button"
4030
+ class="compose-close-btn"
4031
+ aria-label=${this.labels.cancel}
4032
+ title=${this.labels.cancel}
4033
+ @click=${() => this.requestClose()}
4034
+ >
4035
+ <svg
4036
+ width="14"
4037
+ height="14"
4038
+ viewBox="0 0 16 16"
4039
+ fill="none"
4040
+ stroke="currentColor"
4041
+ stroke-width="1.8"
4042
+ stroke-linecap="round"
4043
+ aria-hidden="true"
3636
4044
  >
3637
- ${this.labels.cancel}
3638
- </button>
3639
-
3640
- <div class="compose-dialog-header-center">
3641
- ${showTitle
3642
- ? html`<span class="compose-dialog-title">${headerTitle}</span>`
3643
- : html`
3644
- <div class="compose-segmented">
3645
- <div
3646
- class=${classMap({
3647
- "compose-format-pill": true,
3648
- "compose-format-pill-link": this._format === "link",
3649
- "compose-format-pill-quote": this._format === "quote",
3650
- })}
3651
- ></div>
3652
- ${formats.map(
3653
- (f) => html`
3654
- <button
3655
- type="button"
3656
- class=${classMap({
3657
- "compose-segmented-item": true,
3658
- "compose-segmented-item-active": this._format === f,
3659
- })}
3660
- @click=${() => this._switchFormat(f)}
3661
- >
3662
- ${formatLabels[f]}
3663
- </button>
3664
- `,
3665
- )}
3666
- </div>
3667
- `}
3668
- </div>
3669
-
3670
- <div class="compose-dialog-header-actions">
3671
- ${this._editPostId
3672
- ? nothing
3673
- : html`<button
3674
- type="button"
3675
- class="compose-dialog-header-btn compose-dialog-draft-btn"
3676
- aria-label=${draftButtonLabel}
3677
- title=${draftButtonLabel}
3678
- ?disabled=${this._loading}
3679
- @click=${() => this._handleDraftButtonClick()}
3680
- >
3681
- ${renderComposeHeaderIcon(COMPOSE_DIALOG_HEADER_ICONS.drafts)}
3682
- </button>`}
3683
- </div>
3684
- </header>
4045
+ <path d="M4.5 4.5 11.5 11.5M11.5 4.5 4.5 11.5" />
4046
+ </svg>
4047
+ </button>
3685
4048
  `;
3686
4049
  }
3687
4050
 
@@ -3738,17 +4101,13 @@ export class JantComposeDialog extends LitElement {
3738
4101
  }
3739
4102
  }}
3740
4103
  >
3741
- <span class="compose-collection-trigger-icon">
3742
- ${renderComposeCollectionPickerIcon(
3743
- COMPOSE_COLLECTION_PICKER_ICONS.collection,
3744
- "compose-collection-trigger-svg",
3745
- )}
3746
- </span>
3747
- <span class="compose-collection-label">${selectedLabel}</span>
4104
+ <!-- No chevron: the glyph already says "picker", and a second one
4105
+ pointing down only made the control wider. -->
3748
4106
  ${renderComposeCollectionPickerIcon(
3749
- COMPOSE_COLLECTION_PICKER_ICONS.chevron,
3750
- "compose-collection-chevron",
4107
+ COMPOSE_COLLECTION_PICKER_ICONS.collection,
4108
+ "compose-collection-trigger-svg",
3751
4109
  )}
4110
+ <span class="compose-collection-label">${selectedLabel}</span>
3752
4111
  </button>
3753
4112
  <div
3754
4113
  class="compose-collection-popover"
@@ -4318,56 +4677,24 @@ export class JantComposeDialog extends LitElement {
4318
4677
  };
4319
4678
  }
4320
4679
 
4321
- private _getSlugSummary(): ComposePublishSummaryChip | null {
4322
- const currentSlug = this._slug.trim();
4323
-
4324
- if (currentSlug && this._getSlugValidationMessage() !== null) {
4325
- return null;
4326
- }
4327
-
4328
- if (this._editPostId || this._draftSourceId) {
4329
- if (currentSlug === this._initialSlug) {
4330
- return null;
4331
- }
4332
-
4333
- if (!currentSlug) {
4334
- return {
4335
- kind: "slug",
4336
- text: this.labels.publishSlugSummaryAuto,
4337
- actionLabel: this.labels.publishSlugSummaryAction,
4338
- value: currentSlug,
4339
- };
4340
- }
4341
- }
4342
-
4343
- if (!currentSlug) return null;
4344
-
4345
- return {
4346
- kind: "slug",
4347
- text: `/${currentSlug}`,
4348
- actionLabel: this.labels.publishSlugSummaryAction,
4349
- value: currentSlug,
4350
- };
4351
- }
4352
-
4353
- private _getPublishSummaryChips(): ComposePublishSummaryChip[] {
4354
- const chips: ComposePublishSummaryChip[] = [];
4355
- const publishedAtSummary = this._getPublishedAtSummary();
4356
- if (publishedAtSummary !== null) {
4357
- chips.push({
4358
- kind: "publishedAt",
4359
- text: publishedAtSummary.text,
4360
- actionLabel: this.labels.publishDateSummaryAction,
4361
- value: publishedAtSummary.input,
4362
- });
4363
- }
4680
+ private _getPostPublishedAtSubmitValue(
4681
+ index: number,
4682
+ status: "published" | "draft",
4683
+ ): number | undefined {
4684
+ if (index === 0) return this._getPublishedAtSubmitValue(status);
4685
+ if (status === "draft") return undefined;
4364
4686
 
4365
- const slugSummary = this._getSlugSummary();
4366
- if (slugSummary !== null) {
4367
- chips.push(slugSummary);
4368
- }
4687
+ const item = this._threadItems[index];
4688
+ const input = item?.publishedAtInput ?? "";
4689
+ if (!input.trim()) return undefined;
4369
4690
 
4370
- return chips;
4691
+ const timestamp = buildTimestampFromLocalDate(
4692
+ input,
4693
+ item?.publishedAtTimeMinutes ??
4694
+ getTimestampTimeMinutes(this._getCurrentTimestamp()),
4695
+ );
4696
+ if (timestamp === null) return undefined;
4697
+ return Math.min(timestamp, this._getCurrentTimestamp());
4371
4698
  }
4372
4699
 
4373
4700
  private _getPublishedAtSubmitValue(
@@ -4403,67 +4730,34 @@ export class JantComposeDialog extends LitElement {
4403
4730
  return undefined;
4404
4731
  }
4405
4732
 
4406
- private _openPublishPanelAndFocus(selector: string) {
4733
+ private _openPublishPanelAndFocus(selector: string, index = 0) {
4407
4734
  this._showCollection = false;
4408
4735
  this._collectionSearch = "";
4409
- this._showPublishPanel = true;
4736
+ this._showPublishPanel = false;
4737
+ this._postMetaIndex = index;
4410
4738
  this._confirmPanelOpen = false;
4411
4739
  this._scheduleSuggestedSlugRefresh(true);
4412
- this.updateComplete.then(() => {
4413
- this.querySelector<HTMLElement>(selector)?.focus();
4414
- });
4740
+ this._placeAndFocusPostMetaPanel(selector);
4415
4741
  }
4416
4742
 
4417
- private _revealSlugField() {
4418
- this._openPublishPanelAndFocus(".compose-publish-slug-input");
4743
+ private _revealSlugField(index = 0) {
4744
+ this._openPublishPanelAndFocus(".compose-publish-slug-input", index);
4419
4745
  }
4420
4746
 
4421
- private _revealPublishedAtField() {
4422
- this._openPublishPanelAndFocus(".compose-publish-date-input");
4747
+ private _revealPublishedAtField(index = 0) {
4748
+ this._openPublishPanelAndFocus(".compose-publish-date-input", index);
4423
4749
  }
4424
4750
 
4751
+ /** Every row ready, and every row's own date and permalink accepted. */
4425
4752
  private _canPublish(): boolean {
4426
4753
  if (this._loading) return false;
4427
- if (this._getPublishedAtValidationMessage()) return false;
4428
- if (this._getSlugValidationMessage()) return false;
4429
-
4430
- if (this._threadItems.length > 0) {
4431
- // Thread mode: validate all editors
4432
- const editors = Array.from(
4433
- this.querySelectorAll<JantComposeEditor>("jant-compose-editor"),
4434
- );
4435
- if (editors.length === 0) return false;
4436
- for (const editor of editors) {
4437
- if (editor.getUrlValidationMessage()) return false;
4438
- if (editor.getLinkTitleValidationMessage()) return false;
4439
- }
4440
- // Every editor in the thread must have content
4441
- return editors.every((editor) => {
4442
- const data = editor.getData();
4443
- return (
4444
- !!data.body ||
4445
- !!data.title.trim() ||
4446
- !!data.url.trim() ||
4447
- !!data.quoteText.trim() ||
4448
- data.attachments.length > 0
4449
- );
4450
- });
4754
+ const rowIds = this._rowIds;
4755
+ for (let i = 0; i < rowIds.length; i++) {
4756
+ if (this._getPostPublishedAtValidationMessage(i)) return false;
4757
+ if (this._getPostSlugValidationMessage(i)) return false;
4451
4758
  }
4452
-
4453
- const editor = this._editor;
4454
- if (!editor) return false;
4455
- if (editor.getUrlValidationMessage()) return false;
4456
- if (editor.getLinkTitleValidationMessage()) return false;
4457
-
4458
- const data = editor.getData();
4459
- if (this._format === "link") {
4460
- return data.url.trim().length > 0;
4461
- }
4462
- if (this._format === "quote") {
4463
- return data.quoteText.trim().length > 0;
4464
- }
4465
- return this._hasContent();
4466
- }
4759
+ return rowIds.every((id) => this._rowStatus.get(id)?.publishable === true);
4760
+ }
4467
4761
 
4468
4762
  private _focusPublishPanelInitialField() {
4469
4763
  const selector = this._visibilityLocked
@@ -4494,6 +4788,9 @@ export class JantComposeDialog extends LitElement {
4494
4788
  private _setVisibility(visibility: ComposeVisibility) {
4495
4789
  if (this._visibilityLocked) return;
4496
4790
  this._visibility = visibility;
4791
+ // Choosing is the whole job of the panel, so dismiss it. Nothing here
4792
+ // needs confirming — a radio selection is not a form.
4793
+ if (this._showPublishPanel) this._closePublishPanel(true);
4497
4794
  if (!this._editPostId && !this._draftSourceId && !this._replyToId) {
4498
4795
  JantComposeDialog._lastNewPostVisibility = visibility;
4499
4796
  if (this._sourceCollectionId) {
@@ -4505,26 +4802,28 @@ export class JantComposeDialog extends LitElement {
4505
4802
  }
4506
4803
  }
4507
4804
 
4508
- private _onSlugInput(e: Event) {
4805
+ private _onSlugInput(e: Event, index = 0) {
4509
4806
  const value = (e.target as HTMLInputElement).value;
4510
- this._slug = value.toLowerCase();
4511
- this._slugTaken = false;
4807
+ this._setPostSlug(index, value.toLowerCase());
4808
+ this._setPostSlugTaken(index, false);
4512
4809
  this._slugCheckLoading = false;
4513
- if (this._hasManualSlug()) {
4514
- this._scheduleSlugAvailabilityCheck();
4810
+ if (this._hasPostManualSlug(index)) {
4811
+ this._scheduleSlugAvailabilityCheck(index);
4515
4812
  return;
4516
4813
  }
4517
4814
  this._scheduleSuggestedSlugRefresh();
4518
4815
  }
4519
4816
 
4520
- private _onPublishedAtInput(e: Event) {
4521
- this._publishedAtInput = (e.target as HTMLInputElement).value;
4522
- }
4523
-
4524
- private _resetPublishedAt() {
4817
+ private _resetPublishedAt(index = 0) {
4525
4818
  const currentTimestamp = this._getCurrentTimestamp();
4526
- this._publishedAtInput = toLocalDateInputValue(currentTimestamp);
4527
- this._publishedAtTimeMinutes = getTimestampTimeMinutes(currentTimestamp);
4819
+ this._setPostPublishedAtInput(
4820
+ index,
4821
+ toLocalDateInputValue(currentTimestamp),
4822
+ );
4823
+ this._setPostPublishedAtTimeMinutes(
4824
+ index,
4825
+ getTimestampTimeMinutes(currentTimestamp),
4826
+ );
4528
4827
  this.updateComplete.then(() => {
4529
4828
  this.querySelector<HTMLInputElement>(
4530
4829
  ".compose-publish-date-input",
@@ -4550,9 +4849,15 @@ export class JantComposeDialog extends LitElement {
4550
4849
  );
4551
4850
  }
4552
4851
 
4553
- private _renderPublishVisibilityChip(
4852
+ /**
4853
+ * One choice in the options sheet: title, one-line explanation, and a check
4854
+ * on the selected row. Every option carries its own hint so they can be
4855
+ * compared before choosing, which a single hint under a chip group can't do.
4856
+ */
4857
+ private _renderVisibilityRow(
4554
4858
  visibility: ComposeVisibility,
4555
4859
  label: string,
4860
+ hint: string,
4556
4861
  ) {
4557
4862
  const selected = this._visibility === visibility;
4558
4863
 
@@ -4560,8 +4865,8 @@ export class JantComposeDialog extends LitElement {
4560
4865
  <button
4561
4866
  type="button"
4562
4867
  class=${classMap({
4563
- "compose-publish-chip": true,
4564
- "compose-publish-chip-selected": selected,
4868
+ "compose-sheet-row": true,
4869
+ "compose-sheet-row-selected": selected,
4565
4870
  })}
4566
4871
  role="radio"
4567
4872
  aria-checked=${selected ? "true" : "false"}
@@ -4569,168 +4874,212 @@ export class JantComposeDialog extends LitElement {
4569
4874
  @click=${() => this._setVisibility(visibility)}
4570
4875
  >
4571
4876
  ${this._renderVisibilityIcon(visibility)}
4572
- <span class="compose-publish-chip-label">${label}</span>
4877
+ <span class="compose-sheet-main">
4878
+ <span class="compose-sheet-title">${label}</span>
4879
+ <span class="compose-sheet-sub">${hint}</span>
4880
+ </span>
4881
+ ${selected
4882
+ ? html`<span class="compose-sheet-check" aria-hidden="true">
4883
+ ${renderComposePublishActionIcon(
4884
+ COMPOSE_PUBLISH_ACTION_ICONS.check,
4885
+ "compose-sheet-check-icon",
4886
+ )}
4887
+ </span>`
4888
+ : nothing}
4573
4889
  </button>
4574
4890
  `;
4575
4891
  }
4576
4892
 
4577
- private _getVisibilityHint(): string {
4578
- switch (this._visibility) {
4579
- case "public":
4580
- return this.labels.publishVisibilityPublicHint;
4581
- case "latest_hidden":
4582
- return this.labels.publishVisibilityHiddenFromLatestHint;
4583
- case "private":
4584
- return this.labels.publishVisibilityPrivateHint;
4893
+ /**
4894
+ * The label above a field, with its reset on the right. The reset only shows
4895
+ * once there is something to reset, so the default state is a bare label.
4896
+ */
4897
+ private _renderMetaFieldHead(label: string, reset: unknown) {
4898
+ return html`
4899
+ <div class="compose-meta-field-head">
4900
+ <span class="compose-meta-label">${label}</span>
4901
+ ${reset}
4902
+ </div>
4903
+ `;
4904
+ }
4905
+
4906
+ /**
4907
+ * Chrome opens the calendar only from its own indicator glyph — a ~13px
4908
+ * target that reads as a browser default next to everything else in this
4909
+ * panel, and that other engines place differently or not at all. The
4910
+ * indicator is hidden in CSS and this button drives the same picker through
4911
+ * `showPicker()`, so there is one target, it is ours, and it is the height of
4912
+ * the field. Typing a date into the field still works either way, which is
4913
+ * why the button is the only thing that opens the picker: taking over the
4914
+ * whole field would put the calendar in front of anyone reaching for the
4915
+ * keyboard.
4916
+ */
4917
+ private _openDatePicker(index: number) {
4918
+ const fields = this.querySelectorAll<HTMLInputElement>(
4919
+ ".compose-publish-date-input",
4920
+ );
4921
+ // One panel is open at a time, so the panel's own field is the only one in
4922
+ // the DOM; `index` is kept for the thread case if that ever changes.
4923
+ const input = fields[index] ?? fields[0];
4924
+ if (!input) return;
4925
+
4926
+ input.focus();
4927
+ try {
4928
+ input.showPicker();
4929
+ } catch {
4930
+ // Older engines, or a browser that declined: the field is still typable.
4585
4931
  }
4586
4932
  }
4587
4933
 
4588
- private _renderPublishDateSection() {
4589
- const publishedAtError = this._getPublishedAtValidationMessage();
4934
+ private _renderPublishDateSection(index: number) {
4935
+ const publishedAtError = this._getPostPublishedAtValidationMessage(index);
4936
+ const hasValue = this._hasPostPublishedAtValue(index);
4590
4937
 
4591
4938
  return html`
4592
- <section class="compose-publish-section">
4593
- <div class="compose-publish-section-header">
4594
- <div class="compose-publish-section-copy">
4595
- <p class="compose-publish-section-label">
4596
- ${this.labels.publishDateLabel}
4597
- </p>
4598
- <p class="compose-publish-section-hint">
4599
- ${this.labels.publishDateHint}
4600
- </p>
4601
- </div>
4602
- ${this._hasPublishedAtValue()
4603
- ? html`
4604
- <button
4605
- type="button"
4606
- class="compose-publish-section-action"
4607
- @click=${() => this._resetPublishedAt()}
4608
- >
4609
- ${this.labels.publishDateReset}
4610
- </button>
4611
- `
4612
- : nothing}
4613
- </div>
4614
- <div class="compose-publish-date-field">
4615
- <div class="compose-publish-date-input-wrap">
4616
- <input
4617
- type="date"
4618
- class="compose-input compose-publish-date-input"
4619
- .value=${this._publishedAtInput}
4620
- max=${this._getPublishedAtMaxInput()}
4621
- aria-invalid=${publishedAtError ? "true" : "false"}
4622
- @input=${(e: Event) => this._onPublishedAtInput(e)}
4623
- />
4624
- </div>
4625
- ${publishedAtError
4626
- ? html`<p
4627
- class=${classMap({
4628
- "compose-publish-date-status": true,
4629
- "compose-publish-date-status-error": true,
4630
- })}
4939
+ <div class="compose-meta-field compose-publish-date-field">
4940
+ ${this._renderMetaFieldHead(
4941
+ this.labels.publishDateLabel,
4942
+ hasValue
4943
+ ? html`<button
4944
+ type="button"
4945
+ class="compose-publish-section-action"
4946
+ @click=${() => this._resetPublishedAt(index)}
4631
4947
  >
4632
- ${publishedAtError}
4633
- </p>`
4634
- : nothing}
4948
+ ${this.labels.publishDateReset}
4949
+ </button>`
4950
+ : nothing,
4951
+ )}
4952
+ <div class="compose-publish-date-input-wrap">
4953
+ <input
4954
+ type="date"
4955
+ class=${classMap({
4956
+ "compose-input": true,
4957
+ "compose-publish-date-input": true,
4958
+ // The browser draws `yyyy-mm-dd` in the value's own colour, so an
4959
+ // empty field reads as a date that has been set. Dim it instead.
4960
+ "compose-publish-date-input-empty": !hasValue,
4961
+ })}
4962
+ .value=${this._getPostPublishedAtInput(index)}
4963
+ max=${this._getPublishedAtMaxInput()}
4964
+ aria-label=${this.labels.publishDateLabel}
4965
+ aria-invalid=${publishedAtError ? "true" : "false"}
4966
+ @input=${(e: Event) =>
4967
+ this._setPostPublishedAtInput(
4968
+ index,
4969
+ (e.target as HTMLInputElement).value,
4970
+ )}
4971
+ />
4972
+ <button
4973
+ type="button"
4974
+ class="compose-publish-date-picker"
4975
+ aria-label=${this.labels.publishDateSummaryAction}
4976
+ data-compose-date-picker
4977
+ @click=${() => this._openDatePicker(index)}
4978
+ >
4979
+ ${renderComposePublishActionIcon(
4980
+ COMPOSE_PUBLISH_ACTION_ICONS.calendar,
4981
+ "compose-publish-date-picker-icon",
4982
+ )}
4983
+ </button>
4635
4984
  </div>
4636
- </section>
4985
+ ${publishedAtError
4986
+ ? html`<p
4987
+ class="compose-publish-date-status compose-publish-date-status-error"
4988
+ >
4989
+ ${publishedAtError}
4990
+ </p>`
4991
+ : hasValue
4992
+ ? nothing
4993
+ : html`<p class="compose-sheet-hint">
4994
+ ${this.labels.publishDateHint}
4995
+ </p>`}
4996
+ </div>
4637
4997
  `;
4638
4998
  }
4639
4999
 
4640
- private _renderPublishSlugSection() {
4641
- const slugError = this._getSlugValidationMessage();
4642
- const slugStatus = this._getSlugStatusMessage();
4643
- const slugPreview = this._getSlugPreviewParts();
5000
+ private _renderPublishSlugSection(index: number) {
5001
+ const isRoot = index === 0;
5002
+ const slugError = this._getPostSlugValidationMessage(index);
5003
+ const slugStatus = isRoot ? this._getSlugStatusMessage() : slugError;
5004
+ const slugPreview = isRoot ? this._getSlugPreviewParts() : null;
5005
+ // Suggestions come from the title, which replies do not have.
4644
5006
  const showSuggestion =
5007
+ isRoot &&
4645
5008
  !this._hasManualSlug() &&
4646
5009
  !this._suggestedSlugLoading &&
4647
5010
  Boolean(this._suggestedSlug);
4648
5011
 
4649
5012
  return html`
4650
- <section class="compose-publish-section">
4651
- <div class="compose-publish-section-header">
4652
- <div class="compose-publish-section-copy">
4653
- <p class="compose-publish-section-label">
4654
- ${this.labels.publishSlugLabel}
4655
- </p>
4656
- <p class="compose-publish-section-hint">
4657
- ${this.labels.publishSlugHint}
4658
- </p>
4659
- </div>
4660
- ${this._hasManualSlug()
4661
- ? html`
4662
- <button
4663
- type="button"
4664
- class="compose-publish-section-action"
4665
- @click=${() => this._resetCustomSlug()}
4666
- >
4667
- ${this.labels.publishSlugReset}
4668
- </button>
4669
- `
4670
- : nothing}
5013
+ <div class="compose-meta-field compose-publish-slug-field">
5014
+ ${this._renderMetaFieldHead(
5015
+ this.labels.publishSlugLabel,
5016
+ this._hasManualSlug()
5017
+ ? html`<button
5018
+ type="button"
5019
+ class="compose-publish-section-action"
5020
+ @click=${() => this._resetCustomSlug()}
5021
+ >
5022
+ ${this.labels.publishSlugReset}
5023
+ </button>`
5024
+ : nothing,
5025
+ )}
5026
+ <div class="compose-publish-slug-input-wrap">
5027
+ <span class="compose-publish-slug-prefix" aria-hidden="true">/</span>
5028
+ <input
5029
+ type="text"
5030
+ class="compose-input compose-publish-slug-input"
5031
+ .value=${this._getPostSlug(index)}
5032
+ placeholder=${this.labels.publishSlugPlaceholder}
5033
+ aria-label=${this.labels.publishSlugLabel}
5034
+ aria-invalid=${slugError ? "true" : "false"}
5035
+ spellcheck="false"
5036
+ autocapitalize="off"
5037
+ autocomplete="off"
5038
+ @input=${(e: Event) => this._onSlugInput(e, index)}
5039
+ />
4671
5040
  </div>
4672
- <div class="compose-publish-slug-field">
4673
- <div class="compose-publish-slug-input-wrap">
4674
- <span class="compose-publish-slug-prefix">/</span>
4675
- <input
4676
- type="text"
4677
- class="compose-input compose-publish-slug-input"
4678
- .value=${this._slug}
4679
- placeholder=${this.labels.publishSlugPlaceholder}
4680
- aria-invalid=${slugError ? "true" : "false"}
4681
- spellcheck="false"
4682
- autocapitalize="off"
4683
- autocomplete="off"
4684
- @input=${(e: Event) => this._onSlugInput(e)}
4685
- />
4686
- </div>
4687
- ${showSuggestion
4688
- ? html`
4689
- <button
4690
- type="button"
4691
- class="compose-slug-suggestion"
4692
- @click=${() => this._useSuggestedSlug()}
4693
- >
4694
- <span class="compose-slug-suggestion-copy">
4695
- <span class="compose-slug-suggestion-label"
4696
- >${this.labels.publishSlugSuggested}</span
4697
- >
4698
- <span class="compose-slug-suggestion-chip">
4699
- <span class="compose-slug-suggestion-value"
4700
- >/${this._suggestedSlug}</span
4701
- >
4702
- </span>
4703
- </span>
4704
- <span class="compose-slug-suggestion-icon" aria-hidden="true">
4705
- <svg
4706
- width="14"
4707
- height="14"
4708
- viewBox="0 0 14 14"
4709
- fill="none"
4710
- stroke="currentColor"
4711
- stroke-width="1.4"
4712
- stroke-linecap="round"
4713
- stroke-linejoin="round"
4714
- >
4715
- <path d="M3 7h8" />
4716
- <path d="m8 3 4 4-4 4" />
4717
- </svg>
4718
- </span>
4719
- </button>
4720
- `
4721
- : nothing}
4722
- ${slugStatus
4723
- ? html`<p
4724
- class=${classMap({
4725
- "compose-publish-slug-status": true,
4726
- "compose-publish-slug-status-error": Boolean(slugError),
4727
- })}
4728
- data-compose-slug-error=${slugError ? "true" : nothing}
5041
+ ${showSuggestion
5042
+ ? html`
5043
+ <button
5044
+ type="button"
5045
+ class="compose-slug-suggestion"
5046
+ @click=${() => this._useSuggestedSlug()}
4729
5047
  >
4730
- ${slugStatus}
4731
- </p>`
4732
- : nothing}
4733
- ${slugPreview
5048
+ <span class="compose-slug-suggestion-label"
5049
+ >${this.labels.publishSlugSuggested}</span
5050
+ >
5051
+ <span class="compose-slug-suggestion-value"
5052
+ >/${this._suggestedSlug}</span
5053
+ >
5054
+ <span class="compose-slug-suggestion-icon" aria-hidden="true">
5055
+ <svg
5056
+ width="13"
5057
+ height="13"
5058
+ viewBox="0 0 14 14"
5059
+ fill="none"
5060
+ stroke="currentColor"
5061
+ stroke-width="1.4"
5062
+ stroke-linecap="round"
5063
+ stroke-linejoin="round"
5064
+ >
5065
+ <path d="M3 7h8" />
5066
+ <path d="m8 3 4 4-4 4" />
5067
+ </svg>
5068
+ </span>
5069
+ </button>
5070
+ `
5071
+ : nothing}
5072
+ ${slugStatus
5073
+ ? html`<p
5074
+ class=${classMap({
5075
+ "compose-publish-slug-status": true,
5076
+ "compose-publish-slug-status-error": Boolean(slugError),
5077
+ })}
5078
+ data-compose-slug-error=${slugError ? "true" : nothing}
5079
+ >
5080
+ ${slugStatus}
5081
+ </p>`
5082
+ : slugPreview
4734
5083
  ? html`<p
4735
5084
  class="compose-publish-slug-preview"
4736
5085
  data-compose-slug-preview
@@ -4742,79 +5091,576 @@ export class JantComposeDialog extends LitElement {
4742
5091
  >${slugPreview.path}</span
4743
5092
  >
4744
5093
  </p>`
4745
- : nothing}
4746
- </div>
4747
- </section>
5094
+ : this._hasPostManualSlug(index)
5095
+ ? nothing
5096
+ : html`<p class="compose-sheet-hint">
5097
+ ${this.labels.publishSlugHint}
5098
+ </p>`}
5099
+ </div>
4748
5100
  `;
4749
5101
  }
4750
5102
 
5103
+ /**
5104
+ * Replying quietly is an instruction for the moment a reply is written: the
5105
+ * server's only job is to skip the thread-activity bump it would otherwise
5106
+ * do on create. Saving an existing post goes through the update path, which
5107
+ * has no such bump to skip and no way to carry the intent — so the switch is
5108
+ * not offered there rather than sitting dead. A thread draft is the one
5109
+ * exception: saving it deletes and recreates every post, so the flag lands.
5110
+ */
5111
+ private _canReplyQuietly(): boolean {
5112
+ if (!this._replyToId) return false;
5113
+ const editsExistingPost = !!(this._editPostId || this._draftSourceId);
5114
+ return !editsExistingPost || this._threadItems.length >= 2;
5115
+ }
5116
+
4751
5117
  private _renderQuietReplySection() {
4752
- if (!this._replyToId) return nothing;
5118
+ if (!this._canReplyQuietly()) return nothing;
4753
5119
 
4754
5120
  return html`
4755
- <section class="compose-publish-section">
4756
- <label class="compose-publish-switch-row">
4757
- <div class="compose-publish-section-copy">
4758
- <p class="compose-publish-section-label">
4759
- ${this.labels.quietReplyLabel}
4760
- </p>
4761
- <p class="compose-publish-section-hint">
4762
- ${this.labels.quietReplyHint}
4763
- </p>
4764
- </div>
4765
- <input
4766
- type="checkbox"
4767
- role="switch"
4768
- class="input"
4769
- .checked=${this._quietReply}
4770
- @change=${(e: Event) => {
4771
- this._quietReply = (e.target as HTMLInputElement).checked;
4772
- }}
4773
- />
4774
- </label>
4775
- </section>
5121
+ <label class="compose-sheet-row compose-sheet-row-switch">
5122
+ <span class="compose-sheet-main">
5123
+ <span class="compose-sheet-title"
5124
+ >${this.labels.quietReplyLabel}</span
5125
+ >
5126
+ <span class="compose-sheet-sub">${this.labels.quietReplyHint}</span>
5127
+ </span>
5128
+ <input
5129
+ type="checkbox"
5130
+ role="switch"
5131
+ class="input"
5132
+ .checked=${this._quietReply}
5133
+ @change=${(e: Event) => {
5134
+ this._quietReply = (e.target as HTMLInputElement).checked;
5135
+ }}
5136
+ />
5137
+ </label>
4776
5138
  `;
4777
5139
  }
4778
5140
 
4779
- private _renderPublishPanelSections() {
5141
+ /**
5142
+ * Value shown on the collapsed "Published on" row. Always reflects the
5143
+ * current date — unlike the old summary chip, which deliberately stayed
5144
+ * silent when nothing had changed.
5145
+ */
5146
+ private _getPublishedAtRowValue(): string {
5147
+ const parsed = parseLocalDateInputValue(this._publishedAtInput);
5148
+ if (parsed === null) return this.labels.publishDateSummaryNow;
5149
+
5150
+ return new Intl.DateTimeFormat(undefined, {
5151
+ month: "short",
5152
+ day: "numeric",
5153
+ year: "numeric",
5154
+ }).format(new Date(parsed.year, parsed.monthIndex, parsed.day));
5155
+ }
5156
+
5157
+ /** Value shown on the collapsed "Custom link" row. */
5158
+ private _getPostSlugRowValue(index: number): string {
5159
+ const slug = this._getPostSlug(index).trim();
5160
+ return slug ? `/${slug}` : this.labels.publishSlugSummaryAuto;
5161
+ }
5162
+
5163
+ /* ── Per-post publish date ───────────────────────────────────────────
5164
+ Only the root carries a permalink control: a reply's slug is a random id
5165
+ assigned at publish time, and overriding it is not a thing people do. The
5166
+ date is — backfilling an old thread is the whole reason this exists. */
5167
+
5168
+ private _getPostSlug(index: number): string {
5169
+ if (index === 0) return this._slug;
5170
+ return this._threadItems[index]?.slug ?? "";
5171
+ }
5172
+
5173
+ private _setPostSlug(index: number, value: string) {
5174
+ if (index === 0) {
5175
+ this._slug = value;
5176
+ return;
5177
+ }
5178
+ this._threadItems = this._threadItems.map((item, i) =>
5179
+ i === index ? { ...item, slug: value } : item,
5180
+ );
5181
+ }
5182
+
5183
+ private _setPostSlugTaken(index: number, taken: boolean) {
5184
+ if (index === 0) {
5185
+ this._slugTaken = taken;
5186
+ return;
5187
+ }
5188
+ this._threadItems = this._threadItems.map((item, i) =>
5189
+ i === index ? { ...item, slugTaken: taken } : item,
5190
+ );
5191
+ }
5192
+
5193
+ private _hasPostManualSlug(index: number): boolean {
5194
+ return this._getPostSlug(index).trim().length > 0;
5195
+ }
5196
+
5197
+ private _getPostSlugSyncValidationMessage(index: number): string | null {
5198
+ const issue = getSlugValidationIssue(this._getPostSlug(index));
5199
+ if (issue === "invalid") return this.labels.publishSlugInvalid;
5200
+ if (issue === "reserved") return this.labels.publishSlugReserved;
5201
+ return null;
5202
+ }
5203
+
5204
+ private _getPostSlugValidationMessage(index: number): string | null {
5205
+ const sync = this._getPostSlugSyncValidationMessage(index);
5206
+ if (sync) return sync;
5207
+ const taken =
5208
+ index === 0 ? this._slugTaken : this._threadItems[index]?.slugTaken;
5209
+ if (this._hasPostManualSlug(index) && taken) {
5210
+ return this.labels.publishSlugTaken;
5211
+ }
5212
+ return null;
5213
+ }
5214
+
5215
+ private _getPostPublishedAtInput(index: number): string {
5216
+ if (index === 0) return this._publishedAtInput;
5217
+ return this._threadItems[index]?.publishedAtInput ?? "";
5218
+ }
5219
+
5220
+ private _setPostPublishedAtInput(index: number, value: string) {
5221
+ if (index === 0) {
5222
+ this._publishedAtInput = value;
5223
+ return;
5224
+ }
5225
+ this._threadItems = this._threadItems.map((item, i) =>
5226
+ i === index ? { ...item, publishedAtInput: value } : item,
5227
+ );
5228
+ }
5229
+
5230
+ private _setPostPublishedAtTimeMinutes(index: number, value: number | null) {
5231
+ if (index === 0) {
5232
+ this._publishedAtTimeMinutes = value;
5233
+ return;
5234
+ }
5235
+ this._threadItems = this._threadItems.map((item, i) =>
5236
+ i === index ? { ...item, publishedAtTimeMinutes: value } : item,
5237
+ );
5238
+ }
5239
+
5240
+ private _hasPostPublishedAtValue(index: number): boolean {
5241
+ return this._getPostPublishedAtInput(index).trim().length > 0;
5242
+ }
5243
+
5244
+ /** Same rules as the root's, applied to any post in the thread. */
5245
+ private _getPostPublishedAtValidationMessage(index: number): string | null {
5246
+ const input = this._getPostPublishedAtInput(index);
5247
+ if (!input.trim()) return null;
5248
+ if (parseLocalDateInputValue(input) === null) {
5249
+ return this.labels.publishDateInvalid;
5250
+ }
5251
+ if (input > this._getPublishedAtMaxInput()) {
5252
+ return this.labels.publishDateFutureError;
5253
+ }
5254
+ return null;
5255
+ }
5256
+
5257
+ private _getPostPublishedAtRowValue(index: number): string {
5258
+ const parsed = parseLocalDateInputValue(
5259
+ this._getPostPublishedAtInput(index),
5260
+ );
5261
+ if (parsed !== null) {
5262
+ return new Intl.DateTimeFormat(undefined, {
5263
+ month: "short",
5264
+ day: "numeric",
5265
+ year: "numeric",
5266
+ }).format(new Date(parsed.year, parsed.monthIndex, parsed.day));
5267
+ }
5268
+ // A reply with no date of its own inherits the root's — say so rather than
5269
+ // implying it publishes "now" independently.
5270
+ if (index > 0 && this._hasPublishedAtValue()) {
5271
+ return this._getPublishedAtRowValue();
5272
+ }
5273
+ return this.labels.publishDateSummaryNow;
5274
+ }
5275
+
5276
+ /**
5277
+ * Date (and, on the root, permalink) describe one post, so they live on that
5278
+ * post rather than in the publish panel, which speaks for the whole
5279
+ * submission. The control stays quiet until the post is hovered, focused, or
5280
+ * carries a non-default value.
5281
+ */
5282
+ private _renderPostMetaControl(index: number) {
5283
+ if (this._openingEdit) return nothing;
5284
+
5285
+ const isRoot = index === 0;
5286
+ const open = this._postMetaIndex === index;
5287
+ const custom =
5288
+ this._hasPostPublishedAtValue(index) || this._hasPostManualSlug(index);
5289
+
4780
5290
  return html`
4781
- ${this._replyToId ? this._renderQuietReplySection() : nothing}
4782
- ${this._visibilityLocked
4783
- ? nothing
4784
- : html`
4785
- <section class="compose-publish-section">
4786
- <div class="compose-publish-section-header">
4787
- <div class="compose-publish-section-copy">
4788
- <p class="compose-publish-section-label">
4789
- ${this.labels.publishVisibilityLabel}
4790
- </p>
4791
- </div>
5291
+ <div
5292
+ class=${classMap({
5293
+ "compose-post-meta": true,
5294
+ "compose-post-meta-set": custom,
5295
+ "compose-post-meta-open": open,
5296
+ })}
5297
+ >
5298
+ ${open
5299
+ ? html`<div
5300
+ class="compose-dropdown-backdrop"
5301
+ @click=${() => this._closePostMeta()}
5302
+ ></div>`
5303
+ : nothing}
5304
+ <button
5305
+ type="button"
5306
+ class="compose-post-meta-pill"
5307
+ aria-haspopup="dialog"
5308
+ aria-expanded=${open ? "true" : "false"}
5309
+ data-compose-post-meta-pill
5310
+ data-post-index=${index}
5311
+ @click=${() => {
5312
+ if (open) return this._closePostMeta();
5313
+ this._showPublishPanel = false;
5314
+ this._showCollection = false;
5315
+ this._postMetaIndex = index;
5316
+ if (isRoot) this._scheduleSuggestedSlugRefresh(true);
5317
+ // Focus the panel, not its first field: a focused `type="date"`
5318
+ // opens with its year segment highlighted in the OS accent colour,
5319
+ // which Chrome will not let us restyle. Tab reaches the fields.
5320
+ this._placeAndFocusPostMetaPanel();
5321
+ }}
5322
+ >
5323
+ ${renderComposePublishActionIcon(
5324
+ COMPOSE_PUBLISH_ACTION_ICONS.calendar,
5325
+ "compose-post-meta-icon",
5326
+ )}
5327
+ <span class="compose-post-meta-value"
5328
+ >${this._getPostPublishedAtRowValue(index)}</span
5329
+ >
5330
+ <span class="compose-post-meta-sep" aria-hidden="true">·</span>
5331
+ <!-- No link icon: the permalink already renders with its leading
5332
+ slash, and two icons in a control this quiet read as clutter. -->
5333
+ <span class="compose-post-meta-value compose-post-meta-value-slug"
5334
+ >${this._getPostSlugRowValue(index)}</span
5335
+ >
5336
+ </button>
5337
+ ${open
5338
+ ? html`<div
5339
+ class="compose-post-meta-panel"
5340
+ role="dialog"
5341
+ aria-label=${this.labels.publishSettings}
5342
+ tabindex="-1"
5343
+ data-compose-post-meta-panel
5344
+ >
5345
+ ${this._renderPublishDateSection(index)}
5346
+ ${this._renderPublishSlugSection(index)}
5347
+ <div class="compose-post-meta-footer">
5348
+ <button
5349
+ type="button"
5350
+ class="compose-publish-done"
5351
+ data-compose-post-meta-done
5352
+ @click=${() => this._closePostMeta()}
5353
+ >
5354
+ ${this.labels.done}
5355
+ </button>
4792
5356
  </div>
4793
- <div class="compose-publish-chips" role="radiogroup">
4794
- ${this._renderPublishVisibilityChip(
5357
+ </div>`
5358
+ : nothing}
5359
+ </div>
5360
+ `;
5361
+ }
5362
+
5363
+ /**
5364
+ * Places the just-opened date/permalink panel, then puts focus in it.
5365
+ *
5366
+ * Both wait on `updateComplete` because the panel is not ours to render: it
5367
+ * is handed to the post's editor row as `headerExtra`, so it only reaches the
5368
+ * DOM on the editor's own update, one below this one. Focus is `preventScroll`
5369
+ * — the panel is already pinned where it should be, and without it the browser
5370
+ * scrolls `.compose-scroll` to "reveal" a panel that is not in that box,
5371
+ * taking the format switcher off the top of the composer with it.
5372
+ */
5373
+ private _placeAndFocusPostMetaPanel(focusSelector?: string) {
5374
+ this.updateComplete.then(() => {
5375
+ this._updatePostMetaPanelLayout();
5376
+ this.querySelector<HTMLElement>(
5377
+ focusSelector ?? "[data-compose-post-meta-panel]",
5378
+ )?.focus({ preventScroll: true });
5379
+ });
5380
+ }
5381
+
5382
+ /**
5383
+ * Pins the open date/permalink panel under the pill it belongs to.
5384
+ *
5385
+ * The pill sits inside `.compose-scroll`, and that box scrolls: an absolutely
5386
+ * positioned panel is clipped by it, and an empty post makes the box shorter
5387
+ * than the panel every time, so the whole footer — Done included — used to be
5388
+ * cut off. `position: fixed` takes the panel out of the scroller, which hands
5389
+ * the coordinates to us.
5390
+ *
5391
+ * They are not viewport coordinates. The dialog's open animation leaves a
5392
+ * transform behind, and a transformed ancestor becomes the containing block
5393
+ * for `fixed` descendants — so the panel is parked at 0,0 first and the rect
5394
+ * that comes back is the origin everything else is measured from. Reading it
5395
+ * beats naming the ancestor: the page-mode composer has no dialog at all.
5396
+ */
5397
+ private _updatePostMetaPanelLayout() {
5398
+ const index = this._postMetaIndex;
5399
+ if (index === null) return;
5400
+
5401
+ const panel = this.querySelector<HTMLElement>(
5402
+ "[data-compose-post-meta-panel]",
5403
+ );
5404
+ const pill = this.querySelector<HTMLElement>(
5405
+ `[data-compose-post-meta-pill][data-post-index="${index}"]`,
5406
+ );
5407
+ if (!panel || !pill) return;
5408
+
5409
+ const visualViewport = globalThis.visualViewport;
5410
+ const viewportTop = visualViewport?.offsetTop ?? 0;
5411
+ const viewportLeft = visualViewport?.offsetLeft ?? 0;
5412
+ const viewportBottom =
5413
+ viewportTop + (visualViewport?.height ?? globalThis.innerHeight);
5414
+ const viewportRight =
5415
+ viewportLeft + (visualViewport?.width ?? globalThis.innerWidth);
5416
+ const edgePadding = 12;
5417
+ const gap = 6;
5418
+
5419
+ // Same rule as the publish panel: open into whichever side has more room,
5420
+ // and cap the panel at what that side actually offers so a short window
5421
+ // scrolls the panel's own body instead of hiding its footer.
5422
+ const pillRect = pill.getBoundingClientRect();
5423
+ const availableAbove = Math.max(
5424
+ 0,
5425
+ pillRect.top - (viewportTop + edgePadding) - gap,
5426
+ );
5427
+ const availableBelow = Math.max(
5428
+ 0,
5429
+ viewportBottom - edgePadding - pillRect.bottom - gap,
5430
+ );
5431
+ const direction = availableBelow >= availableAbove ? "down" : "up";
5432
+ const maxHeight = Math.max(
5433
+ 1,
5434
+ Math.floor(direction === "up" ? availableAbove : availableBelow),
5435
+ );
5436
+
5437
+ panel.style.top = "0px";
5438
+ panel.style.left = "0px";
5439
+ const origin = panel.getBoundingClientRect();
5440
+ // The dialog opens with a `scale(0.97)` animation, so for a third of a
5441
+ // second every measured rect is smaller than the box it describes while the
5442
+ // offsets we write are not. `offsetWidth` is the same box before the
5443
+ // transform, so their ratio is the scale to divide back out.
5444
+ const scale = origin.width / (panel.offsetWidth || origin.width || 1) || 1;
5445
+
5446
+ panel.style.setProperty(
5447
+ "--compose-post-meta-panel-max-height",
5448
+ `${maxHeight / scale}px`,
5449
+ );
5450
+ // Read back rather than reuse `origin.height`: the cap above is what
5451
+ // decides how tall the panel actually is, and a panel opening upwards is
5452
+ // placed from its own bottom edge.
5453
+ const height = panel.offsetHeight * scale;
5454
+
5455
+ const top =
5456
+ direction === "down"
5457
+ ? pillRect.bottom + gap
5458
+ : pillRect.top - gap - height;
5459
+ // Right-aligned to the pill, which is itself flush with the composer's
5460
+ // right edge — then held inside the viewport, since a narrow screen can
5461
+ // put the panel's left edge past it.
5462
+ const left = Math.min(
5463
+ Math.max(pillRect.right - origin.width, viewportLeft + edgePadding),
5464
+ Math.max(viewportRight - edgePadding - origin.width, viewportLeft),
5465
+ );
5466
+
5467
+ panel.style.top = `${(top - origin.top) / scale}px`;
5468
+ panel.style.left = `${(left - origin.left) / scale}px`;
5469
+ }
5470
+
5471
+ /**
5472
+ * Hands focus back to the post the panel belongs to. The panel takes focus
5473
+ * when it opens, so without this the closing panel takes the focus with it as
5474
+ * it leaves the DOM: focus lands on `<body>`, the editor loses
5475
+ * `:focus-within`, and on a wide screen the pill — quiet until hovered or
5476
+ * focused — fades out from under the pointer. It also leaves the compose
5477
+ * element entirely, which matters more: the `keydown` listener is bound to
5478
+ * this component, so Escape would stop closing compose afterwards.
5479
+ */
5480
+ private _closePostMeta() {
5481
+ const index = this._postMetaIndex;
5482
+ if (index === null) return;
5483
+
5484
+ const heldFocus =
5485
+ this.querySelector("[data-compose-post-meta-panel]")?.contains(
5486
+ document.activeElement,
5487
+ ) ?? false;
5488
+ this._postMetaIndex = null;
5489
+ if (!heldFocus) return;
5490
+
5491
+ this.updateComplete.then(() => {
5492
+ const editors = this.querySelectorAll<JantComposeEditor>(
5493
+ "jant-compose-editor",
5494
+ );
5495
+ (editors[index] ?? editors[0])?.focusInput();
5496
+ });
5497
+ }
5498
+
5499
+ /**
5500
+ * Whether the settings panel would have anything in it.
5501
+ *
5502
+ * Asks the row renderers instead of restating their conditions, so it cannot
5503
+ * drift out of sync with what the panel actually shows — the same reason
5504
+ * `_renderQuickActionsRow` inspects its children rather than recomputing
5505
+ * them. Editing a thread reply empties every row (visibility belongs to the
5506
+ * root, the draft rows only apply to unsaved posts), and an Options button
5507
+ * that opens a blank sheet is worse than no button.
5508
+ */
5509
+ private _hasPublishPanelContent(): boolean {
5510
+ if (!this._visibilityLocked) return true;
5511
+ if (this._replyToId && this._renderQuietReplySection() !== nothing) {
5512
+ return true;
5513
+ }
5514
+ return (
5515
+ this._renderSaveDraftRow() !== nothing ||
5516
+ this._renderDraftsRow() !== nothing ||
5517
+ this._renderCloseComposeRow() !== nothing
5518
+ );
5519
+ }
5520
+
5521
+ /**
5522
+ * A vertical list, not a toolbar: grouped by a muted label, one row per
5523
+ * setting, value or control on the right.
5524
+ */
5525
+ private _renderPublishPanelSections() {
5526
+ const divider = html`<div
5527
+ class="compose-sheet-divider"
5528
+ aria-hidden="true"
5529
+ ></div>`;
5530
+ const saveDraftRow = this._renderSaveDraftRow();
5531
+ const draftsRow = this._renderDraftsRow();
5532
+ const closeRow = this._renderCloseComposeRow();
5533
+ const hasSessionRows =
5534
+ saveDraftRow !== nothing || draftsRow !== nothing || closeRow !== nothing;
5535
+
5536
+ return html`
5537
+ <div class="compose-sheet">
5538
+ ${this._visibilityLocked
5539
+ ? nothing
5540
+ : html`
5541
+ <p class="compose-sheet-label">
5542
+ ${this.labels.publishVisibilityLabel}
5543
+ </p>
5544
+ <div
5545
+ role="radiogroup"
5546
+ aria-label=${this.labels.publishVisibilityLabel}
5547
+ >
5548
+ ${this._renderVisibilityRow(
4795
5549
  "public",
4796
5550
  this.labels.publishVisibilityPublic,
5551
+ this.labels.publishVisibilityPublicHint,
4797
5552
  )}
4798
- ${this._renderPublishVisibilityChip(
5553
+ ${this._renderVisibilityRow(
4799
5554
  "latest_hidden",
4800
5555
  this.labels.publishVisibilityHiddenFromLatest,
5556
+ this.labels.publishVisibilityHiddenFromLatestHint,
4801
5557
  )}
4802
- ${this._renderPublishVisibilityChip(
5558
+ ${this._renderVisibilityRow(
4803
5559
  "private",
4804
5560
  this.labels.publishVisibilityPrivate,
5561
+ this.labels.publishVisibilityPrivateHint,
4805
5562
  )}
4806
5563
  </div>
4807
- <p class="compose-publish-chip-hint">
4808
- ${this._getVisibilityHint()}
4809
- </p>
4810
- </section>
4811
- `}
4812
- ${this._visibilityLocked && !this._replyToId
4813
- ? nothing
4814
- : html`<div class="compose-publish-divider" aria-hidden="true"></div>`}
4815
- ${this._renderPublishDateSection()}
4816
- <div class="compose-publish-divider" aria-hidden="true"></div>
4817
- ${this._renderPublishSlugSection()}
5564
+ `}
5565
+ ${this._replyToId
5566
+ ? html`${this._visibilityLocked ? nothing : divider}
5567
+ ${this._renderQuietReplySection()}`
5568
+ : nothing}
5569
+ ${hasSessionRows
5570
+ ? html`${divider} ${saveDraftRow} ${draftsRow} ${closeRow}`
5571
+ : nothing}
5572
+ </div>
5573
+ `;
5574
+ }
5575
+
5576
+ /**
5577
+ * "Save as draft" is an action on what is in front of you; "Drafts" is a
5578
+ * place to go. One row cannot be both, which is what the old title-bar button
5579
+ * tried to do: it read "Save as draft" and then dropped you in the list. Once
5580
+ * there is something to save, both are offered and each does one thing.
5581
+ */
5582
+ private _renderSaveDraftRow() {
5583
+ if (this._editPostId || !this._hasContent()) return nothing;
5584
+
5585
+ return html`
5586
+ <button
5587
+ type="button"
5588
+ class="compose-sheet-row"
5589
+ ?disabled=${this._loading}
5590
+ @click=${() => {
5591
+ this._closePublishPanel(false);
5592
+ void this._submit("draft");
5593
+ }}
5594
+ >
5595
+ ${renderComposeSheetRowIcon(COMPOSE_SHEET_ROW_ICONS.saveDraft)}
5596
+ <span class="compose-sheet-main">
5597
+ <span class="compose-sheet-title">${this.labels.saveAsDraft}</span>
5598
+ </span>
5599
+ </button>
5600
+ `;
5601
+ }
5602
+
5603
+ /**
5604
+ * Drafts moved off the title bar and in here when that bar went away. It is
5605
+ * the rarer of the two things the bar held — a session action, not part of
5606
+ * writing — so it belongs behind the same trigger as the rest of them.
5607
+ *
5608
+ * Leaving unsaved work behind still asks first, but that prompt is now the
5609
+ * fallback rather than the only path: `_renderSaveDraftRow` is sitting right
5610
+ * above it for anyone who already knows what they want.
5611
+ */
5612
+ private _renderDraftsRow() {
5613
+ if (this._editPostId) return nothing;
5614
+
5615
+ return html`
5616
+ <button
5617
+ type="button"
5618
+ class="compose-sheet-row"
5619
+ ?disabled=${this._loading}
5620
+ @click=${() => {
5621
+ this._closePublishPanel(false);
5622
+ this._handleDraftButtonClick();
5623
+ }}
5624
+ >
5625
+ ${renderComposeSheetRowIcon(COMPOSE_SHEET_ROW_ICONS.drafts)}
5626
+ <span class="compose-sheet-main">
5627
+ <span class="compose-sheet-title">${this.labels.drafts}</span>
5628
+ </span>
5629
+ ${renderComposePublishActionIcon(
5630
+ COMPOSE_PUBLISH_ACTION_ICONS.caretRight,
5631
+ "compose-sheet-caret",
5632
+ )}
5633
+ </button>
5634
+ `;
5635
+ }
5636
+
5637
+ /**
5638
+ * A thread's posts each spend their × on removing themselves, so this is the
5639
+ * only visible exit there — and the only one at all on a phone, which has no
5640
+ * Escape key and no backdrop to tap. It routes through `requestClose`, so
5641
+ * unsaved work still gets the "Save to drafts?" prompt.
5642
+ */
5643
+ private _renderCloseComposeRow() {
5644
+ if (this._threadItems.length === 0) return nothing;
5645
+
5646
+ return html`
5647
+ <button
5648
+ type="button"
5649
+ class="compose-sheet-row"
5650
+ ?disabled=${this._loading}
5651
+ @click=${() => {
5652
+ this._closePublishPanel(false);
5653
+ this.requestClose();
5654
+ }}
5655
+ >
5656
+ ${renderComposePublishActionIcon(
5657
+ COMPOSE_PUBLISH_ACTION_ICONS.close,
5658
+ "compose-sheet-row-icon",
5659
+ )}
5660
+ <span class="compose-sheet-main">
5661
+ <span class="compose-sheet-title">${this.labels.closeCompose}</span>
5662
+ </span>
5663
+ </button>
4818
5664
  `;
4819
5665
  }
4820
5666
 
@@ -4833,15 +5679,6 @@ export class JantComposeDialog extends LitElement {
4833
5679
  data-compose-publish-panel-desktop
4834
5680
  >
4835
5681
  ${this._renderPublishPanelSections()}
4836
- <div class="compose-publish-panel-footer">
4837
- <button
4838
- type="button"
4839
- class="compose-publish-done"
4840
- @click=${() => this._closePublishPanel(true)}
4841
- >
4842
- ${this.labels.done}
4843
- </button>
4844
- </div>
4845
5682
  </div>
4846
5683
  `;
4847
5684
  }
@@ -4896,75 +5733,6 @@ export class JantComposeDialog extends LitElement {
4896
5733
  `;
4897
5734
  }
4898
5735
 
4899
- private _renderPublishSummary(summary: ComposePublishSummaryChip) {
4900
- const isPublishedAt = summary.kind === "publishedAt";
4901
-
4902
- return html`
4903
- <button
4904
- type="button"
4905
- class="compose-publish-summary"
4906
- data-compose-publish-summary=${summary.kind}
4907
- data-compose-publish-date-summary=${isPublishedAt ? "" : nothing}
4908
- data-compose-publish-slug-summary=${isPublishedAt ? nothing : ""}
4909
- data-publish-summary-value=${summary.value}
4910
- data-publish-date=${isPublishedAt ? summary.value : nothing}
4911
- data-publish-slug=${isPublishedAt ? nothing : summary.value}
4912
- aria-label=${summary.actionLabel}
4913
- title=${summary.actionLabel}
4914
- ?disabled=${this._loading}
4915
- @click=${() =>
4916
- isPublishedAt
4917
- ? this._revealPublishedAtField()
4918
- : this._revealSlugField()}
4919
- >
4920
- <span class="compose-publish-summary-icon" aria-hidden="true">
4921
- ${isPublishedAt
4922
- ? html`<svg
4923
- width="14"
4924
- height="14"
4925
- viewBox="0 0 16 16"
4926
- fill="none"
4927
- stroke="currentColor"
4928
- stroke-width="1.35"
4929
- stroke-linecap="round"
4930
- stroke-linejoin="round"
4931
- >
4932
- <rect x="2.75" y="3.45" width="10.5" height="9.8" rx="2.2" />
4933
- <path d="M5.35 2.55v2.1" />
4934
- <path d="M10.65 2.55v2.1" />
4935
- <path d="M2.75 6.2h10.5" />
4936
- </svg>`
4937
- : html`<svg
4938
- width="14"
4939
- height="14"
4940
- viewBox="0 0 16 16"
4941
- fill="none"
4942
- stroke="currentColor"
4943
- stroke-width="1.35"
4944
- stroke-linecap="round"
4945
- stroke-linejoin="round"
4946
- >
4947
- <path d="M9.75 4.15h1.35a2.75 2.75 0 0 1 0 5.5H9.75" />
4948
- <path d="M6.25 9.65H4.9a2.75 2.75 0 1 1 0-5.5h1.35" />
4949
- <path d="M5.65 8h4.7" />
4950
- </svg>`}
4951
- </span>
4952
- <span class="compose-publish-summary-text">${summary.text}</span>
4953
- </button>
4954
- `;
4955
- }
4956
-
4957
- private _renderPublishSummaries() {
4958
- const summaries = this._getPublishSummaryChips();
4959
- if (summaries.length === 0) return nothing;
4960
-
4961
- return html`
4962
- <div class="compose-publish-summaries">
4963
- ${summaries.map((summary) => this._renderPublishSummary(summary))}
4964
- </div>
4965
- `;
4966
- }
4967
-
4968
5736
  private _updatePublishPanelLayout() {
4969
5737
  if (this._publishPanelFullscreen) return;
4970
5738
 
@@ -5018,7 +5786,6 @@ export class JantComposeDialog extends LitElement {
5018
5786
 
5019
5787
  return html`
5020
5788
  <div class="compose-publish-shell">
5021
- ${this._renderPublishSummaries()}
5022
5789
  <div
5023
5790
  class=${classMap({
5024
5791
  "compose-publish-group": true,
@@ -5049,31 +5816,50 @@ export class JantComposeDialog extends LitElement {
5049
5816
  >
5050
5817
  ${this._loading ? spinner : nothing} ${this._getSubmitLabel()}
5051
5818
  </button>
5052
- <button
5053
- type="button"
5054
- class=${classMap({
5055
- "compose-publish-toggle": true,
5056
- "compose-publish-toggle-loading": this._loading,
5057
- })}
5058
- ?disabled=${this._loading}
5059
- aria-haspopup="dialog"
5060
- aria-expanded=${this._showPublishPanel ? "true" : "false"}
5061
- aria-label=${this.labels.publishSettings}
5062
- title=${this.labels.publishSettings}
5063
- @click=${() => this._togglePublishPanel()}
5064
- >
5065
- ${renderComposePublishActionIcon(
5066
- COMPOSE_PUBLISH_ACTION_ICONS.chevron,
5067
- "compose-publish-toggle-chevron",
5068
- )}
5069
- </button>
5070
5819
  </div>
5820
+ <!-- Options sits past Publish as its own control rather than a
5821
+ chevron welded to it: a split button reads as one button, which
5822
+ is why nobody found the settings. -->
5823
+ ${this._hasPublishPanelContent()
5824
+ ? html`<button
5825
+ type="button"
5826
+ class=${classMap({
5827
+ "compose-options-trigger": true,
5828
+ "compose-options-trigger-open": this._showPublishPanel,
5829
+ })}
5830
+ ?disabled=${this._loading}
5831
+ aria-haspopup="dialog"
5832
+ aria-expanded=${this._showPublishPanel ? "true" : "false"}
5833
+ aria-label=${this.labels.publishSettings}
5834
+ title=${this.labels.publishSettings}
5835
+ @click=${() => this._togglePublishPanel()}
5836
+ >
5837
+ <svg
5838
+ class="compose-options-trigger-icon"
5839
+ width="22"
5840
+ height="22"
5841
+ viewBox="0 0 24 24"
5842
+ fill="none"
5843
+ stroke="currentColor"
5844
+ stroke-linecap="round"
5845
+ stroke-linejoin="round"
5846
+ aria-hidden="true"
5847
+ >
5848
+ ${unsafeSVG(COMPOSE_PUBLISH_ACTION_ICONS.options)}
5849
+ </svg>
5850
+ </button>`
5851
+ : nothing}
5071
5852
  ${this._renderDesktopPublishPanel()}
5072
5853
  </div>
5073
5854
  </div>
5074
5855
  `;
5075
5856
  }
5076
5857
 
5858
+ /**
5859
+ * Shortcuts for the two settings that get reached for constantly, sitting
5860
+ * under Publish where they can be flipped without opening the panel. Both are
5861
+ * mirrors of a row inside it, not a second source of truth.
5862
+ */
5077
5863
  private _renderQuickActionsRow() {
5078
5864
  const hideFromLatest = this._renderHideFromLatestQuickToggle();
5079
5865
  const quietReply = this._renderQuietReplyQuickToggle();
@@ -5086,7 +5872,7 @@ export class JantComposeDialog extends LitElement {
5086
5872
  }
5087
5873
 
5088
5874
  private _renderQuietReplyQuickToggle() {
5089
- if (!this._replyToId) return nothing;
5875
+ if (!this._canReplyQuietly()) return nothing;
5090
5876
  return html`
5091
5877
  <label class="compose-publish-quick-toggle">
5092
5878
  <input
@@ -5103,6 +5889,11 @@ export class JantComposeDialog extends LitElement {
5103
5889
  `;
5104
5890
  }
5105
5891
 
5892
+ /**
5893
+ * Visibility has three states, so a checkbox cannot speak for all of them: it
5894
+ * covers the public ↔ hidden-from-Latest pair and steps aside once the post
5895
+ * is private, where the panel's radio list is the only honest control.
5896
+ */
5106
5897
  private _renderHideFromLatestQuickToggle() {
5107
5898
  if (this._visibilityLocked) return nothing;
5108
5899
  if (this._visibility === "private") return nothing;
@@ -5133,6 +5924,12 @@ export class JantComposeDialog extends LitElement {
5133
5924
  aria-live="polite"
5134
5925
  aria-busy="true"
5135
5926
  >
5927
+ <!-- The post header this normally rides on has not rendered yet, so the
5928
+ close control gets its own row rather than leaving a fetch with no
5929
+ way out on a phone, where there is no Escape key. -->
5930
+ <div class="compose-edit-loading-close">
5931
+ ${this._renderCloseComposeControl()}
5932
+ </div>
5136
5933
  <div class="compose-edit-loading-status">
5137
5934
  <svg
5138
5935
  class="animate-spin size-5"
@@ -5254,6 +6051,10 @@ export class JantComposeDialog extends LitElement {
5254
6051
  "jant-compose-editor",
5255
6052
  );
5256
6053
  editors[this._focusedThreadIndex]?.focusInput();
6054
+ const scroller = this.querySelector<HTMLElement>(".compose-scroll");
6055
+ if (scroller) {
6056
+ scroller.scrollTop = scroller.scrollHeight;
6057
+ }
5257
6058
  });
5258
6059
  }
5259
6060
 
@@ -5323,7 +6124,6 @@ export class JantComposeDialog extends LitElement {
5323
6124
  singleEditor._showRating = capturedShowRating;
5324
6125
  }
5325
6126
  singleEditor.focusInput();
5326
- this.requestUpdate();
5327
6127
  });
5328
6128
  } else {
5329
6129
  this._threadItems = newItems;
@@ -5334,14 +6134,46 @@ export class JantComposeDialog extends LitElement {
5334
6134
  }
5335
6135
  }
5336
6136
 
6137
+ /**
6138
+ * Where this editor sits in the thread it is extending, as `3/3`.
6139
+ *
6140
+ * A reply continues a chain that already exists, so it counts from the end of
6141
+ * that chain, not from 1: replying to the second post of a thread makes the
6142
+ * third. Only the parent's own depth can tell us this, so the marker stays
6143
+ * hidden until `_loadReplyParentPosition` has read it back — a number that
6144
+ * starts at 2 and jumps to 3 is worse than one that arrives late.
6145
+ *
6146
+ * Empty when there is no chain to place the post in: a lone new post is not
6147
+ * "1/1", and an edit says the more useful thing through its "Editing" marker
6148
+ * (the post being edited may have replies below that compose cannot see, so a
6149
+ * total would be a guess).
6150
+ */
6151
+ private _positionLabel(index: number): string {
6152
+ if (this._editPostId) return "";
6153
+ const isReply = !!(this._replyToId && this._replyToData);
6154
+ if (isReply && this._replyParentPosition === null) return "";
6155
+ const posted = isReply ? (this._replyParentPosition ?? 0) : 0;
6156
+ const total = posted + (this._threadItems.length || 1);
6157
+ if (total < 2) return "";
6158
+ return `${posted + index + 1}/${total}`;
6159
+ }
6160
+
5337
6161
  private _renderThreadPost(
5338
6162
  item: ThreadItem,
5339
6163
  index: number,
5340
6164
  showRemove: boolean,
6165
+ startsThread: boolean,
5341
6166
  ) {
5342
6167
  return html`
5343
6168
  <div
5344
- class="compose-editor-row compose-thread-post"
6169
+ class=${classMap({
6170
+ "compose-editor-row": true,
6171
+ "compose-thread-post": true,
6172
+ // Marks the post the cursor is in, which is what the rail's accent
6173
+ // dot points at. Every post in a thread is equally "new", so being an
6174
+ // editor row is not what makes one of them current.
6175
+ "is-current": index === this._focusedThreadIndex,
6176
+ })}
5345
6177
  data-thread-index=${index}
5346
6178
  @focusin=${() => {
5347
6179
  this._focusedThreadIndex = index;
@@ -5369,6 +6201,10 @@ export class JantComposeDialog extends LitElement {
5369
6201
  e.stopPropagation();
5370
6202
  this._removeThreadItem(index);
5371
6203
  }}
6204
+ @jant:compose-status=${(e: CustomEvent<ComposeRowStatus>) => {
6205
+ e.stopPropagation();
6206
+ this._handleRowStatus(item.id, e.detail);
6207
+ }}
5372
6208
  >
5373
6209
  <div class="compose-thread-dot"></div>
5374
6210
  <jant-compose-editor
@@ -5377,6 +6213,9 @@ export class JantComposeDialog extends LitElement {
5377
6213
  .uploadMaxFileSize=${this.uploadMaxFileSize}
5378
6214
  .threadItem=${true}
5379
6215
  .removable=${showRemove}
6216
+ .titleByDefault=${startsThread}
6217
+ .positionLabel=${this._positionLabel(index)}
6218
+ .headerExtra=${this._renderPostMetaControl(index)}
5380
6219
  .slashCommandDiscovered=${this.slashCommandDiscovered}
5381
6220
  data-thread-id=${item.id}
5382
6221
  ></jant-compose-editor>
@@ -5385,19 +6224,9 @@ export class JantComposeDialog extends LitElement {
5385
6224
  }
5386
6225
 
5387
6226
  private _renderAddToThreadRow() {
5388
- const PLUS_ICON = `<circle cx="8" cy="8" r="5.7"/><path d="M8 5.55v4.9M5.55 8h4.9"/>`;
5389
- const editors = this.querySelectorAll<JantComposeEditor>(
5390
- "jant-compose-editor",
5391
- );
5392
- const lastEditor = editors[editors.length - 1];
5393
- const lastData = lastEditor?.getData();
5394
- const lastEmpty =
5395
- !lastData ||
5396
- (!lastData.body &&
5397
- !lastData.title.trim() &&
5398
- !lastData.url.trim() &&
5399
- !lastData.quoteText.trim() &&
5400
- lastData.attachments.length === 0);
6227
+ const rowIds = this._rowIds;
6228
+ const lastId = rowIds[rowIds.length - 1];
6229
+ const lastEmpty = !this._rowStatus.get(lastId)?.hasContent;
5401
6230
  const atLimit = this._threadItems.length >= MAX_THREAD_POSTS;
5402
6231
  return html`
5403
6232
  <div class="compose-thread-add-row">
@@ -5408,19 +6237,6 @@ export class JantComposeDialog extends LitElement {
5408
6237
  ?disabled=${lastEmpty || atLimit}
5409
6238
  @click=${() => this._addThreadItem()}
5410
6239
  >
5411
- <svg
5412
- width="14"
5413
- height="14"
5414
- viewBox="0 0 16 16"
5415
- fill="none"
5416
- stroke="currentColor"
5417
- stroke-width="1.65"
5418
- stroke-linecap="round"
5419
- stroke-linejoin="round"
5420
- aria-hidden="true"
5421
- >
5422
- ${unsafeSVG(PLUS_ICON)}
5423
- </svg>
5424
6240
  Add to thread
5425
6241
  </button>
5426
6242
  </div>
@@ -5442,40 +6258,54 @@ export class JantComposeDialog extends LitElement {
5442
6258
  @jant:compose-content-changed=${() => this._scheduleDraftSave()}
5443
6259
  >
5444
6260
  ${isReply ? this._renderReplyContext() : nothing}
5445
- ${items.map((item, i) => this._renderThreadPost(item, i, showRemove))}
6261
+ <!-- Keyed by row id, not by position: an editor holds its own content,
6262
+ so an unkeyed list would reuse elements by position and drop the
6263
+ last one when a middle post is removed — taking that post's text
6264
+ with it, and pairing the survivors with the wrong dates and
6265
+ permalinks. -->
6266
+ ${repeat(
6267
+ items,
6268
+ (item) => item.id,
6269
+ (item, i) =>
6270
+ this._renderThreadPost(
6271
+ item,
6272
+ i,
6273
+ showRemove,
6274
+ // Only the post that opens a thread of its own gets a title field
6275
+ // by default — everything downstream of it continues a thought
6276
+ // that is already named.
6277
+ i === 0 && !isReply,
6278
+ ),
6279
+ )}
5446
6280
  ${this._renderAddToThreadRow()}
5447
6281
  </div>
5448
6282
  `;
5449
6283
  }
5450
6284
 
5451
- private _renderAddThreadTrigger() {
5452
- const PLUS_ICON = `<circle cx="8" cy="8" r="5.7"/><path d="M8 5.55v4.9M5.55 8h4.9"/>`;
6285
+ // `onRail` is for the reply composer, which is already a thread layout: the
6286
+ // row joins the rail with the same dashed dot thread compose uses, so the
6287
+ // placeholder sits where the next post will land instead of floating out at
6288
+ // the dialog's own left edge, a rail's width clear of everything above it.
6289
+ private _renderAddThreadTrigger(onRail: boolean) {
5453
6290
  const disabled = !this._hasContent();
5454
- return html`
5455
- <div class="compose-add-thread-trigger">
5456
- <button
5457
- type="button"
5458
- class="compose-add-thread-btn"
5459
- ?disabled=${disabled}
5460
- @click=${() => this._addThreadItem()}
5461
- >
5462
- <svg
5463
- width="14"
5464
- height="14"
5465
- viewBox="0 0 16 16"
5466
- fill="none"
5467
- stroke="currentColor"
5468
- stroke-width="1.65"
5469
- stroke-linecap="round"
5470
- stroke-linejoin="round"
5471
- aria-hidden="true"
5472
- >
5473
- ${unsafeSVG(PLUS_ICON)}
5474
- </svg>
5475
- Add to thread
5476
- </button>
5477
- </div>
6291
+ const button = html`
6292
+ <button
6293
+ type="button"
6294
+ class="compose-thread-add-btn"
6295
+ ?disabled=${disabled}
6296
+ @click=${() => this._addThreadItem()}
6297
+ >
6298
+ Add to thread
6299
+ </button>
5478
6300
  `;
6301
+ return onRail
6302
+ ? html`
6303
+ <div class="compose-thread-add-row">
6304
+ <div class="compose-thread-add-dot"></div>
6305
+ ${button}
6306
+ </div>
6307
+ `
6308
+ : html`<div class="compose-add-thread-trigger">${button}</div>`;
5479
6309
  }
5480
6310
 
5481
6311
  render() {
@@ -5487,8 +6317,34 @@ export class JantComposeDialog extends LitElement {
5487
6317
  .labels=${this.labels}
5488
6318
  .uploadMaxFileSize=${this.uploadMaxFileSize}
5489
6319
  .inlineFormat=${isReply}
6320
+ .titleByDefault=${!isReply && JantComposeDialog._getNoteTitleDefault()}
6321
+ .positionLabel=${this._positionLabel(0)}
6322
+ .badgeLabel=${this._editPostId ? this.labels.editing : ""}
6323
+ .headerExtra=${html`${this._renderPostMetaControl(0)}
6324
+ ${this._renderCloseComposeControl()}`}
5490
6325
  .slashCommandDiscovered=${this.slashCommandDiscovered}
6326
+ @jant:format-change=${(e: CustomEvent<{ format: ComposeFormat }>) => {
6327
+ e.stopPropagation();
6328
+ this._switchFormat(e.detail.format);
6329
+ }}
6330
+ @jant:compose-status=${(e: CustomEvent<ComposeRowStatus>) => {
6331
+ e.stopPropagation();
6332
+ this._handleRowStatus(SINGLE_ROW_ID, e.detail);
6333
+ }}
5491
6334
  ></jant-compose-editor>`;
6335
+ // The handler sits on the editor itself rather than a delegating wrapper.
6336
+ // Single-post mode renders this editor as a direct child of
6337
+ // `.compose-scroll`, and the rules that lay it out are keyed on that child
6338
+ // relationship — a wrapper breaks them even at `display: contents`, which
6339
+ // drops the box but not the DOM parentage a selector matches on.
6340
+ // (Thread mode builds its own editors in `_renderThreadPost`.)
6341
+
6342
+ // Thread mode grows its own row at the end of the layout, and editing an
6343
+ // existing post has no next slot to offer at all.
6344
+ const addThreadRow =
6345
+ isOpeningEdit || isThreadMode || this._editPostId
6346
+ ? nothing
6347
+ : this._renderAddThreadTrigger(isReply);
5492
6348
 
5493
6349
  return html`
5494
6350
  <div
@@ -5501,45 +6357,37 @@ export class JantComposeDialog extends LitElement {
5501
6357
  aria-hidden=${this._addCollectionPanelOpen ? "true" : "false"}
5502
6358
  ?inert=${this._addCollectionPanelOpen}
5503
6359
  >
5504
- ${this._renderHeader()}
5505
- ${isOpeningEdit
5506
- ? this._renderEditLoadingState()
5507
- : isThreadMode
5508
- ? this._renderThreadComposeLayout()
5509
- : isReply
5510
- ? html`
5511
- <div
5512
- class="compose-thread-layout compose-reply-compose-layout"
5513
- >
5514
- ${this._renderReplyContext()}
6360
+ <div class="compose-scroll">
6361
+ ${isOpeningEdit
6362
+ ? this._renderEditLoadingState()
6363
+ : isThreadMode
6364
+ ? this._renderThreadComposeLayout()
6365
+ : isReply
6366
+ ? html`
5515
6367
  <div
5516
- class="compose-editor-row"
5517
- @jant:format-change=${(
5518
- e: CustomEvent<{ format: ComposeFormat }>,
5519
- ) => {
5520
- e.stopPropagation();
5521
- this._switchFormat(e.detail.format);
5522
- }}
6368
+ class="compose-thread-layout compose-reply-compose-layout"
5523
6369
  >
5524
- <div class="compose-thread-dot"></div>
5525
- ${editor}
6370
+ ${this._renderReplyContext()}
6371
+ <div class="compose-editor-row is-current">
6372
+ <div class="compose-thread-dot"></div>
6373
+ ${editor}
6374
+ </div>
6375
+ ${addThreadRow}
5526
6376
  </div>
5527
- </div>
5528
- `
5529
- : editor}
5530
- ${isOpeningEdit || isThreadMode || this._editPostId
5531
- ? nothing
5532
- : this._renderAddThreadTrigger()}
6377
+ `
6378
+ : html`${editor}${addThreadRow}`}
6379
+ </div>
5533
6380
  ${isOpeningEdit
5534
6381
  ? nothing
5535
6382
  : html`<div
5536
6383
  class=${classMap({
5537
6384
  "compose-action-row": true,
6385
+ "compose-action-row-without-collection": !!this._replyToId,
5538
6386
  "compose-action-row-overlay-open":
5539
6387
  this._showPublishPanel || this._showCollection,
5540
6388
  })}
5541
6389
  >
5542
- ${this._renderCollectionSelector()}
6390
+ ${this._replyToId ? nothing : this._renderCollectionSelector()}
5543
6391
  ${this._renderPublishButton()}
5544
6392
  </div>
5545
6393
  ${this._renderQuickActionsRow()}`}