@jant/core 0.6.15 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (537) hide show
  1. package/README.md +11 -11
  2. package/bin/commands/assets/upload.js +192 -15
  3. package/bin/commands/import-site.js +67 -4
  4. package/bin/commands/reset-password.js +21 -8
  5. package/bin/commands/site/snapshot/export.js +1 -1
  6. package/bin/commands/site/snapshot/import.js +1 -1
  7. package/bin/lib/d1-query.js +5 -16
  8. package/bin/lib/node-env.js +40 -4
  9. package/bin/lib/site-selection.js +85 -29
  10. package/bin/lib/wrangler-cli.js +28 -2
  11. package/bin/lib/wrangler-json.js +35 -0
  12. package/dist/{app-C-oi_t8W.js → app-K_Aa1MMn.js} +52623 -13193
  13. package/dist/client/.vite/manifest.json +255 -90
  14. package/dist/client/_assets/chunks/confirm-D7GrwsCN.js +45 -0
  15. package/dist/client/_assets/chunks/create-editor-CD3FhrOB.js +257 -0
  16. package/dist/client/_assets/chunks/{native-DpcrFAPh.js → emoji-mart-C4RpRKcU.js} +716 -1
  17. package/dist/client/_assets/chunks/footnote-rail-CmeoVtnU.js +1 -0
  18. package/dist/client/_assets/chunks/heic-to-CuP2Qlsw.js +1 -0
  19. package/dist/client/_assets/chunks/jant-locale-picker-DL6mIOT5.js +69 -0
  20. package/dist/client/_assets/chunks/lit-DW8VmJAT.js +2 -0
  21. package/dist/client/_assets/chunks/mediabunny-DRaCQ1r-.js +31 -0
  22. package/dist/client/_assets/chunks/rolldown-runtime-hePW80VL.js +1 -0
  23. package/dist/client/_assets/chunks/settings-paths-BcdySNx6.js +84 -0
  24. package/dist/client/_assets/chunks/site-header-nav-DzXsFk05.js +1 -0
  25. package/dist/client/_assets/chunks/slug-format-CRXO1AL5.js +1 -0
  26. package/dist/client/_assets/chunks/slugify-373citbs.js +1 -0
  27. package/dist/client/_assets/chunks/sortable-list-CgaL2jCs.js +2 -0
  28. package/dist/client/_assets/chunks/types-D5iF3H1a.js +1 -0
  29. package/dist/client/_assets/chunks/unsafe-html-B5ZEXaxg.js +1 -0
  30. package/dist/client/_assets/chunks/unsafe-svg-0QCkP0vZ.js +2 -0
  31. package/dist/client/_assets/client-GiYENVw8.css +2 -0
  32. package/dist/client/_assets/client-VnFxJN7G.js +295 -0
  33. package/dist/client/_assets/client-auth-k8gJ6QJj.js +828 -0
  34. package/dist/client/_assets/client-author-DQ8R-8KR.css +2 -0
  35. package/dist/client/_assets/client-compose-B_kDtWkW.js +2004 -0
  36. package/dist/client/_assets/client-manage-M90aSTOk.js +2170 -0
  37. package/dist/client/_assets/client-settings-DfYs9n1F.js +1112 -0
  38. package/dist/{export-CsFx6F_M.js → github-sync-BPAvT999.js} +2343 -110
  39. package/dist/index.js +2 -5
  40. package/dist/node.js +5 -7
  41. package/package.json +3 -3
  42. package/src/__tests__/auth-session-freshness.test.ts +57 -0
  43. package/src/__tests__/basecoat-variants.test.ts +121 -0
  44. package/src/__tests__/bin/assets-upload.test.ts +68 -0
  45. package/src/__tests__/export-hugo-build.test.ts +260 -0
  46. package/src/__tests__/export-service.test.ts +231 -0
  47. package/src/__tests__/helpers/component-css.ts +91 -0
  48. package/src/__tests__/helpers/db.ts +33 -0
  49. package/src/__tests__/helpers/export-fixtures.ts +3 -0
  50. package/src/__tests__/mise-config.test.ts +22 -0
  51. package/src/__tests__/stylesheet-audience.test.ts +159 -0
  52. package/src/__tests__/stylesheet-breakpoints.test.ts +183 -0
  53. package/src/app.tsx +71 -16
  54. package/src/auth.ts +18 -1
  55. package/src/client/__tests__/collection-page-actions.test.ts +51 -2
  56. package/src/client/__tests__/compose-bridge-image-upload.test.ts +196 -0
  57. package/src/client/__tests__/compose-bridge-video-poster.test.ts +159 -0
  58. package/src/client/__tests__/compose-bridge.test.ts +524 -2
  59. package/src/client/__tests__/compose-launch.test.ts +62 -1
  60. package/src/client/__tests__/compose-shortcuts.test.ts +57 -8
  61. package/src/client/__tests__/compose-triggers.test.ts +90 -0
  62. package/src/client/__tests__/copy-field.test.ts +162 -0
  63. package/src/client/__tests__/custom-url-menu.test.ts +127 -6
  64. package/src/client/__tests__/image-processor.test.ts +324 -2
  65. package/src/client/__tests__/lazy-entries.test.ts +92 -0
  66. package/src/client/__tests__/media-scroll-hint.test.ts +155 -0
  67. package/src/client/__tests__/runtime-paths.test.ts +84 -0
  68. package/src/client/__tests__/smart-collection-page-actions.test.ts +158 -0
  69. package/src/client/__tests__/toast.test.ts +88 -1
  70. package/src/client/__tests__/video-processor-probe.test.ts +175 -0
  71. package/src/client/__tests__/video-processor.test.ts +181 -0
  72. package/src/client/audio-processor.ts +24 -15
  73. package/src/client/audio-waveform.ts +49 -0
  74. package/src/client/collection-dialog-host.ts +72 -0
  75. package/src/client/collection-navigation.ts +51 -16
  76. package/src/client/collection-page-actions.ts +26 -0
  77. package/src/client/components/__tests__/compose-format-convert.test.ts +0 -19
  78. package/src/client/components/__tests__/jant-collection-dialog.test.ts +329 -0
  79. package/src/client/components/__tests__/jant-collection-directory.test.ts +274 -10
  80. package/src/client/components/__tests__/jant-collection-form.test.ts +59 -178
  81. package/src/client/components/__tests__/jant-command-palette.test.ts +79 -0
  82. package/src/client/components/__tests__/jant-compose-dialog-draft-storage.test.ts +340 -0
  83. package/src/client/components/__tests__/jant-compose-dialog.test.ts +3283 -389
  84. package/src/client/components/__tests__/jant-compose-editor.test.ts +581 -86
  85. package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +34 -89
  86. package/src/client/components/__tests__/jant-locale-picker.test.ts +207 -0
  87. package/src/client/components/__tests__/jant-nav-manager.test.ts +234 -15
  88. package/src/client/components/__tests__/jant-post-menu.test.ts +413 -2
  89. package/src/client/components/__tests__/jant-post-picker.test.ts +197 -0
  90. package/src/client/components/__tests__/jant-settings-avatar.test.ts +15 -12
  91. package/src/client/components/__tests__/jant-settings-general.test.ts +541 -172
  92. package/src/client/components/__tests__/jant-settings-language.test.ts +851 -0
  93. package/src/client/components/__tests__/jant-smart-collection-dialog.test.ts +665 -0
  94. package/src/client/components/collection-dialog-types.ts +49 -0
  95. package/src/client/components/collection-manager-types.ts +32 -1
  96. package/src/client/components/collection-types.ts +1 -15
  97. package/src/client/components/compose-format-convert.ts +5 -1
  98. package/src/client/components/compose-types.ts +121 -3
  99. package/src/client/components/jant-collection-dialog.ts +722 -0
  100. package/src/client/components/jant-collection-directory.ts +567 -182
  101. package/src/client/components/jant-collection-form.ts +53 -232
  102. package/src/client/components/jant-command-palette.ts +106 -49
  103. package/src/client/components/jant-compose-dialog.ts +2757 -1194
  104. package/src/client/components/jant-compose-editor.ts +866 -397
  105. package/src/client/components/jant-compose-fullscreen.ts +53 -47
  106. package/src/client/components/jant-config-editor.ts +65 -53
  107. package/src/client/components/jant-confirm-dialog.ts +13 -11
  108. package/src/client/components/jant-locale-picker.ts +418 -0
  109. package/src/client/components/jant-media-lightbox.ts +146 -136
  110. package/src/client/components/jant-nav-manager.ts +690 -366
  111. package/src/client/components/jant-post-menu.ts +1142 -268
  112. package/src/client/components/jant-post-picker.ts +324 -0
  113. package/src/client/components/jant-repo-picker.ts +138 -106
  114. package/src/client/components/jant-settings-avatar.ts +31 -27
  115. package/src/client/components/jant-settings-general.ts +617 -543
  116. package/src/client/components/jant-settings-language.ts +1087 -0
  117. package/src/client/components/jant-smart-collection-dialog.ts +1021 -0
  118. package/src/client/components/jant-text-preview.ts +94 -86
  119. package/src/client/components/nav-manager-types.ts +62 -1
  120. package/src/client/components/settings-types.ts +14 -29
  121. package/src/client/components/smart-collection-conditions.ts +285 -0
  122. package/src/client/components/smart-collection-dialog-types.ts +68 -0
  123. package/src/client/compose-bridge.ts +336 -242
  124. package/src/client/compose-launch.ts +89 -2
  125. package/src/client/compose-shortcuts.ts +9 -7
  126. package/src/client/compose-triggers.ts +50 -0
  127. package/src/client/copy-field.ts +108 -0
  128. package/src/client/custom-url-menu.ts +60 -4
  129. package/src/client/icons.ts +14 -0
  130. package/src/client/image-plan.ts +165 -0
  131. package/src/client/image-processor.ts +195 -117
  132. package/src/client/image-worker-client.ts +45 -0
  133. package/src/client/image-worker.ts +135 -0
  134. package/src/client/lazy-entries.ts +95 -0
  135. package/src/client/lazy-slugify.ts +2 -2
  136. package/src/client/media-scroll-hint.ts +43 -2
  137. package/src/client/mediabunny.ts +29 -0
  138. package/src/client/post-picker.ts +41 -0
  139. package/src/client/post-refresh.ts +135 -0
  140. package/src/client/runtime-paths.ts +74 -1
  141. package/src/client/settings-bridge.ts +4 -10
  142. package/src/client/site-header-nav.js +18 -6
  143. package/src/client/smart-collection-dialog-host.ts +116 -0
  144. package/src/client/smart-collection-page-actions.ts +208 -0
  145. package/src/client/tiptap/__tests__/bubble-menu.test.ts +110 -0
  146. package/src/client/tiptap/__tests__/floating-position.test.ts +136 -1
  147. package/src/client/tiptap/__tests__/inline-image-upload.test.ts +37 -0
  148. package/src/client/tiptap/__tests__/link-input-rules.test.ts +121 -0
  149. package/src/client/tiptap/bubble-menu.ts +47 -16
  150. package/src/client/tiptap/floating-position.ts +216 -2
  151. package/src/client/tiptap/inline-image-upload.ts +7 -3
  152. package/src/client/tiptap/link-input-rules.ts +4 -4
  153. package/src/client/tiptap/link-toolbar.ts +22 -9
  154. package/src/client/toast.ts +143 -38
  155. package/src/client/types/vite-worker.d.ts +12 -0
  156. package/src/client/upload-with-metadata.ts +3 -13
  157. package/src/client/video-processor.ts +351 -107
  158. package/src/client-auth.ts +14 -31
  159. package/src/client-compose.ts +19 -0
  160. package/src/client-manage.ts +13 -0
  161. package/src/client-settings.ts +14 -0
  162. package/src/client-site.ts +10 -3
  163. package/src/client.ts +4 -0
  164. package/src/db/__tests__/backfill-nav-label-mirrors.test.ts +149 -0
  165. package/src/db/__tests__/backfill-thread-activity.test.ts +234 -0
  166. package/src/db/__tests__/schema-enum-sources.test.ts +33 -0
  167. package/src/db/__tests__/smart-collection-placement.test.ts +402 -0
  168. package/src/db/__tests__/thread-activity.test.ts +113 -0
  169. package/src/db/backfills/0005_split_thread_activity_from_quiet_replies.sql +68 -0
  170. package/src/db/backfills/0006_clear_mirrored_page_nav_labels.sql +31 -0
  171. package/src/db/migrations/0031_many_ego.sql +3 -0
  172. package/src/db/migrations/0032_married_tomas.sql +3 -0
  173. package/src/db/migrations/0033_busy_johnny_storm.sql +198 -0
  174. package/src/db/migrations/0034_cheerful_rumiko_fujikawa.sql +35 -0
  175. package/src/db/migrations/meta/0031_snapshot.json +2562 -0
  176. package/src/db/migrations/meta/0032_snapshot.json +2582 -0
  177. package/src/db/migrations/meta/0033_snapshot.json +2816 -0
  178. package/src/db/migrations/meta/0034_snapshot.json +2816 -0
  179. package/src/db/migrations/meta/_journal.json +28 -0
  180. package/src/db/migrations/pg/0029_rare_hairball.sql +3 -0
  181. package/src/db/migrations/pg/0030_abandoned_nighthawk.sql +3 -0
  182. package/src/db/migrations/pg/0031_strong_ultimatum.sql +128 -0
  183. package/src/db/migrations/pg/0032_furry_multiple_man.sql +6 -0
  184. package/src/db/migrations/pg/meta/0029_snapshot.json +3289 -0
  185. package/src/db/migrations/pg/meta/0030_snapshot.json +3329 -0
  186. package/src/db/migrations/pg/meta/0031_snapshot.json +3660 -0
  187. package/src/db/migrations/pg/meta/0032_snapshot.json +3660 -0
  188. package/src/db/migrations/pg/meta/_journal.json +28 -0
  189. package/src/db/pg/schema.ts +213 -27
  190. package/src/db/post-visibility.ts +117 -0
  191. package/src/db/schema.ts +242 -27
  192. package/src/db/thread-activity.ts +101 -0
  193. package/src/i18n/__tests__/detect.test.ts +26 -81
  194. package/src/i18n/__tests__/supported-locales.test.ts +83 -0
  195. package/src/i18n/context.tsx +12 -2
  196. package/src/i18n/detect.ts +7 -66
  197. package/src/i18n/locales/glossary.zh-Hans.yml +44 -4
  198. package/src/i18n/locales/glossary.zh-Hant.yml +44 -4
  199. package/src/i18n/locales/public/en.po +568 -168
  200. package/src/i18n/locales/public/en.ts +1 -1
  201. package/src/i18n/locales/public/zh-Hans.po +562 -162
  202. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  203. package/src/i18n/locales/public/zh-Hant.po +562 -162
  204. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  205. package/src/i18n/locales/settings/en.po +551 -69
  206. package/src/i18n/locales/settings/en.ts +1 -1
  207. package/src/i18n/locales/settings/zh-Hans.po +597 -115
  208. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  209. package/src/i18n/locales/settings/zh-Hant.po +610 -128
  210. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  211. package/src/i18n/locales.ts +94 -0
  212. package/src/i18n/middleware.ts +12 -2
  213. package/src/i18n/supported-locales.ts +80 -0
  214. package/src/lib/__tests__/api-settings.test.ts +11 -2
  215. package/src/lib/__tests__/asset-path.test.ts +92 -0
  216. package/src/lib/__tests__/deferred.test.ts +90 -0
  217. package/src/lib/__tests__/discover-ping.test.ts +89 -0
  218. package/src/lib/__tests__/discover.test.ts +410 -0
  219. package/src/lib/__tests__/feed-policy.test.ts +26 -0
  220. package/src/lib/__tests__/feed.test.ts +1691 -12
  221. package/src/lib/__tests__/filter-dimensions.test.ts +341 -0
  222. package/src/lib/__tests__/hosted-signin.test.ts +1 -1
  223. package/src/lib/__tests__/http-cache.test.ts +162 -0
  224. package/src/lib/__tests__/jant-docs-dev.test.ts +21 -0
  225. package/src/lib/__tests__/jant-docs.test.ts +23 -0
  226. package/src/lib/__tests__/lang-detect.test.ts +231 -0
  227. package/src/lib/__tests__/markdown.test.ts +16 -0
  228. package/src/lib/__tests__/media-helpers.test.ts +81 -0
  229. package/src/lib/__tests__/navigation.test.ts +135 -1
  230. package/src/lib/__tests__/resolve-config.test.ts +64 -3
  231. package/src/lib/__tests__/schemas.test.ts +42 -4
  232. package/src/lib/__tests__/site-unavailable-page.test.ts +25 -0
  233. package/src/lib/__tests__/slug.test.ts +88 -0
  234. package/src/lib/__tests__/slugify.test.ts +85 -0
  235. package/src/lib/__tests__/storage.test.ts +32 -0
  236. package/src/lib/__tests__/thread-fold.test.ts +113 -0
  237. package/src/lib/__tests__/translit.test.ts +87 -0
  238. package/src/lib/__tests__/upload-sideload.test.ts +14 -0
  239. package/src/lib/__tests__/url-fetch.test.ts +1 -2
  240. package/src/lib/__tests__/url.test.ts +93 -34
  241. package/src/lib/__tests__/view-language.test.ts +413 -0
  242. package/src/lib/__tests__/worker-response-cache.test.ts +90 -0
  243. package/src/lib/address.ts +57 -0
  244. package/src/lib/api-posts.ts +12 -0
  245. package/src/lib/asset-path.ts +98 -1
  246. package/src/lib/build-env.ts +16 -0
  247. package/src/lib/collection-groups.ts +5 -1
  248. package/src/lib/collection-paths.ts +3 -10
  249. package/src/lib/constants.ts +28 -3
  250. package/src/lib/copy-field.ts +36 -0
  251. package/src/lib/crypto.ts +40 -0
  252. package/src/lib/csp-builder.ts +2 -1
  253. package/src/lib/deferred.ts +79 -0
  254. package/src/lib/discover-ping.ts +141 -0
  255. package/src/lib/discover.ts +437 -0
  256. package/src/lib/env.ts +134 -0
  257. package/src/lib/errors.ts +33 -0
  258. package/src/lib/feed-policy.ts +236 -2
  259. package/src/lib/feed.ts +1021 -66
  260. package/src/lib/filter-dimensions.ts +1349 -0
  261. package/src/lib/github-sync-site-config.ts +3 -0
  262. package/src/lib/hosted-signin.ts +1 -1
  263. package/src/lib/http-cache.ts +148 -0
  264. package/src/lib/hugo-markdown.ts +21 -0
  265. package/src/lib/icons.ts +9 -2
  266. package/src/lib/ids.ts +3 -0
  267. package/src/lib/jant-docs.ts +42 -0
  268. package/src/lib/lang-detect.ts +308 -0
  269. package/src/lib/link-preview.ts +25 -0
  270. package/src/lib/markdown-manager.ts +6 -1
  271. package/src/lib/media-helpers.ts +13 -1
  272. package/src/lib/navigation.ts +78 -11
  273. package/src/lib/pagination.ts +28 -24
  274. package/src/lib/per-language-surfaces.ts +67 -0
  275. package/src/lib/post-display.ts +42 -9
  276. package/src/lib/post-meta.ts +19 -0
  277. package/src/lib/render.tsx +55 -7
  278. package/src/lib/resolve-config.ts +53 -5
  279. package/src/lib/schemas.ts +201 -10
  280. package/src/lib/site-header-fragment.tsx +2 -1
  281. package/src/lib/site-unavailable-page.ts +47 -0
  282. package/src/lib/slug.ts +51 -7
  283. package/src/lib/slugify.ts +113 -0
  284. package/src/lib/startup-config.ts +4 -6
  285. package/src/lib/storage.ts +7 -0
  286. package/src/lib/summary.ts +50 -0
  287. package/src/lib/thread-fold.ts +161 -0
  288. package/src/lib/time.ts +32 -0
  289. package/src/lib/timeline.ts +81 -10
  290. package/src/lib/tiptap-render.ts +5 -4
  291. package/src/lib/translit.ts +288 -0
  292. package/src/lib/upload.ts +16 -13
  293. package/src/lib/url.ts +188 -24
  294. package/src/lib/version.ts +10 -8
  295. package/src/lib/view-language.ts +436 -0
  296. package/src/lib/view.ts +79 -23
  297. package/src/lib/viewer-context.ts +62 -0
  298. package/src/middleware/__tests__/auth.test.ts +70 -2
  299. package/src/middleware/__tests__/cache-control.test.ts +71 -3
  300. package/src/middleware/__tests__/onboarding.test.ts +76 -5
  301. package/src/middleware/__tests__/secure-headers.test.ts +1 -1
  302. package/src/middleware/__tests__/session.test.ts +181 -33
  303. package/src/middleware/auth.ts +50 -31
  304. package/src/middleware/cache-control.ts +75 -10
  305. package/src/middleware/config.ts +6 -6
  306. package/src/middleware/error-handler.ts +12 -0
  307. package/src/middleware/onboarding.ts +32 -10
  308. package/src/middleware/secure-headers.ts +1 -1
  309. package/src/middleware/session.ts +96 -8
  310. package/src/node/__tests__/cli-migrate.test.ts +21 -19
  311. package/src/node/__tests__/cli-reset-password.test.ts +81 -8
  312. package/src/node/__tests__/cli-runtime-target.test.ts +48 -1
  313. package/src/node/__tests__/cli-site-selection.test.ts +128 -38
  314. package/src/node/__tests__/runtime.test.ts +37 -0
  315. package/src/node/index.ts +1 -0
  316. package/src/preset.css +76 -22
  317. package/src/routes/__tests__/feed-validator.test.ts +156 -0
  318. package/src/routes/api/__tests__/collections.test.ts +114 -0
  319. package/src/routes/api/__tests__/discover.test.ts +222 -0
  320. package/src/routes/api/__tests__/nav-items.test.ts +212 -2
  321. package/src/routes/api/__tests__/palette.test.ts +44 -0
  322. package/src/routes/api/__tests__/post-translations.test.ts +829 -0
  323. package/src/routes/api/__tests__/settings.test.ts +24 -3
  324. package/src/routes/api/__tests__/telegram.test.ts +6 -5
  325. package/src/routes/api/collections.ts +49 -6
  326. package/src/routes/api/custom-urls.ts +0 -1
  327. package/src/routes/api/discover.ts +95 -0
  328. package/src/routes/api/export.ts +3 -0
  329. package/src/routes/api/internal/__tests__/sites.test.ts +42 -2
  330. package/src/routes/api/nav-items.ts +55 -8
  331. package/src/routes/api/posts.ts +172 -9
  332. package/src/routes/api/public/__tests__/archive.test.ts +111 -0
  333. package/src/routes/api/public/__tests__/posts.test.ts +38 -0
  334. package/src/routes/api/public/archive.ts +77 -81
  335. package/src/routes/api/public/posts.ts +30 -6
  336. package/src/routes/api/smart-collections.ts +132 -0
  337. package/src/routes/api/telegram.ts +2 -40
  338. package/src/routes/api/uploads.ts +1 -2
  339. package/src/routes/auth/__tests__/setup-auth.test.ts +137 -0
  340. package/src/routes/auth/__tests__/setup-discover.test.ts +204 -0
  341. package/src/routes/auth/__tests__/setup-flow.test.ts +270 -0
  342. package/src/routes/auth/__tests__/setup-page.test.tsx +329 -0
  343. package/src/routes/auth/__tests__/setup-steps.test.ts +310 -0
  344. package/src/routes/auth/__tests__/setup.test.ts +278 -26
  345. package/src/routes/auth/__tests__/signin.test.ts +135 -0
  346. package/src/routes/auth/setup.tsx +934 -186
  347. package/src/routes/auth/signin.tsx +10 -8
  348. package/src/routes/compose.tsx +11 -2
  349. package/src/routes/dash/__tests__/custom-urls-archive.test.ts +44 -0
  350. package/src/routes/dash/__tests__/custom-urls-page.test.ts +49 -0
  351. package/src/routes/dash/__tests__/delete-account.test.ts +106 -0
  352. package/src/routes/dash/__tests__/settings-discover.test.ts +213 -0
  353. package/src/routes/dash/custom-urls.tsx +1 -37
  354. package/src/routes/dash/settings.tsx +502 -41
  355. package/src/routes/discover-announce.ts +96 -0
  356. package/src/routes/feed/__tests__/feed.test.ts +165 -2
  357. package/src/routes/feed/__tests__/sitemap.test.ts +68 -1
  358. package/src/routes/feed/feed.ts +69 -40
  359. package/src/routes/feed/sitemap.ts +95 -11
  360. package/src/routes/pages/__tests__/archive-indexing.test.ts +200 -0
  361. package/src/routes/pages/__tests__/archive-params.test.ts +657 -1
  362. package/src/routes/pages/__tests__/collection-routing.test.ts +16 -0
  363. package/src/routes/pages/__tests__/collections-feed-links.test.ts +72 -0
  364. package/src/routes/pages/__tests__/collections.test.ts +9 -3
  365. package/src/routes/pages/__tests__/custom-archive-url.test.ts +119 -0
  366. package/src/routes/pages/__tests__/featured.test.ts +6 -2
  367. package/src/routes/pages/__tests__/feed-autodiscovery.test.ts +148 -0
  368. package/src/routes/pages/__tests__/language-routing.test.ts +679 -0
  369. package/src/routes/pages/__tests__/post-preview-partial.test.ts +96 -0
  370. package/src/routes/pages/__tests__/preview.test.ts +3 -1
  371. package/src/routes/pages/__tests__/smart-collection.test.ts +298 -0
  372. package/src/routes/pages/__tests__/subscribe.test.ts +248 -0
  373. package/src/routes/pages/archive.tsx +507 -357
  374. package/src/routes/pages/collection.tsx +94 -96
  375. package/src/routes/pages/collections.tsx +38 -50
  376. package/src/routes/pages/featured.tsx +32 -13
  377. package/src/routes/pages/home.tsx +25 -6
  378. package/src/routes/pages/language.tsx +113 -0
  379. package/src/routes/pages/latest.tsx +26 -16
  380. package/src/routes/pages/new.tsx +3 -0
  381. package/src/routes/pages/page.tsx +183 -19
  382. package/src/routes/pages/partials.tsx +41 -0
  383. package/src/routes/pages/search.tsx +18 -3
  384. package/src/routes/pages/smart-collection.tsx +340 -0
  385. package/src/routes/pages/subscribe.tsx +182 -0
  386. package/src/runtime/__tests__/index.test.ts +19 -0
  387. package/src/runtime/__tests__/node.test.ts +129 -4
  388. package/src/runtime/cloudflare.ts +21 -2
  389. package/src/runtime/index.ts +27 -1
  390. package/src/runtime/node.ts +18 -1
  391. package/src/runtime/site.ts +95 -24
  392. package/src/services/__tests__/auth.test.ts +116 -7
  393. package/src/services/__tests__/collection.test.ts +139 -4
  394. package/src/services/__tests__/language.test.ts +495 -0
  395. package/src/services/__tests__/navigation.test.ts +213 -10
  396. package/src/services/__tests__/path.test.ts +180 -0
  397. package/src/services/__tests__/post-language.test.ts +830 -0
  398. package/src/services/__tests__/post-timeline.test.ts +142 -11
  399. package/src/services/__tests__/post.test.ts +323 -9
  400. package/src/services/__tests__/settings.test.ts +228 -17
  401. package/src/services/__tests__/site-admin.test.ts +5 -5
  402. package/src/services/__tests__/smart-collection.test.ts +651 -0
  403. package/src/services/about-page.ts +22 -1
  404. package/src/services/auth.ts +51 -49
  405. package/src/services/bootstrap.ts +188 -38
  406. package/src/services/collection-directory-position.ts +76 -0
  407. package/src/services/collection.ts +330 -47
  408. package/src/services/custom-url.ts +27 -22
  409. package/src/services/export-theme/assets/client-site.css +1 -1
  410. package/src/services/export-theme/assets/client-site.js +123 -123
  411. package/src/services/export-theme/layouts/_default/rss.xml +77 -1
  412. package/src/services/export-theme/layouts/partials/feed-post-content.xml +105 -2
  413. package/src/services/export-theme/layouts/partials/media-gallery.html +35 -21
  414. package/src/services/export-theme/layouts/partials/pagination.html +14 -13
  415. package/src/services/export-theme/layouts/partials/post-card.html +8 -4
  416. package/src/services/export-theme/layouts/partials/reply.html +2 -1
  417. package/src/services/export-theme/layouts/partials/thread-preview.html +40 -30
  418. package/src/services/export-theme/styles/main.css +13 -1
  419. package/src/services/export.ts +281 -24
  420. package/src/services/index.ts +34 -3
  421. package/src/services/language.ts +412 -0
  422. package/src/services/mcp.ts +3 -1
  423. package/src/services/media.ts +14 -6
  424. package/src/services/navigation.ts +429 -67
  425. package/src/services/path.ts +225 -7
  426. package/src/services/post.ts +1318 -252
  427. package/src/services/search.ts +48 -2
  428. package/src/services/settings.ts +238 -43
  429. package/src/services/site-admin.ts +24 -7
  430. package/src/services/site.ts +77 -1
  431. package/src/services/smart-collection.ts +636 -0
  432. package/src/services/telegram.ts +20 -22
  433. package/src/services/upload-session.ts +1 -3
  434. package/src/style-author.css +27 -0
  435. package/src/styles/components-author.css +1622 -0
  436. package/src/styles/components.css +24 -1585
  437. package/src/styles/site-media.css +20 -6
  438. package/src/styles/tokens.css +144 -13
  439. package/src/styles/ui-author.css +7602 -0
  440. package/src/styles/ui.css +1224 -7276
  441. package/src/types/app-context.ts +7 -0
  442. package/src/types/bindings.ts +3 -0
  443. package/src/types/config.ts +94 -15
  444. package/src/types/constants.ts +188 -16
  445. package/src/types/entities.ts +92 -1
  446. package/src/types/operations.ts +64 -2
  447. package/src/types/props.ts +168 -30
  448. package/src/types/views.ts +61 -1
  449. package/src/ui/__tests__/font-themes.test.ts +52 -12
  450. package/src/ui/compose/ComposeDialog.tsx +215 -21
  451. package/src/ui/compose/ComposePrompt.tsx +1 -5
  452. package/src/ui/compose/__tests__/ComposeDialog.test.tsx +125 -0
  453. package/src/ui/dash/appearance/AdvancedContent.tsx +2 -2
  454. package/src/ui/dash/appearance/CodeInjectionContent.tsx +12 -16
  455. package/src/ui/dash/appearance/NavigationContent.tsx +117 -16
  456. package/src/ui/dash/appearance/__tests__/NavigationContent.test.tsx +33 -11
  457. package/src/ui/dash/appearance/__tests__/system-nav-descriptions.test.ts +44 -0
  458. package/src/ui/dash/appearance/system-nav-descriptions.ts +75 -0
  459. package/src/ui/dash/settings/AccountMenuContent.tsx +2 -2
  460. package/src/ui/dash/settings/ApiTokensContent.tsx +2 -1
  461. package/src/ui/dash/settings/ConfigEditorContent.tsx +35 -16
  462. package/src/ui/dash/settings/GeneralContent.tsx +275 -113
  463. package/src/ui/dash/settings/LanguageContent.tsx +409 -0
  464. package/src/ui/dash/settings/SettingsRootContent.tsx +19 -1
  465. package/src/ui/dash/settings/__tests__/GeneralContent.test.tsx +324 -4
  466. package/src/ui/dash/settings/__tests__/LanguageContent.test.tsx +144 -0
  467. package/src/ui/dash/settings/__tests__/discover-copy.test.ts +66 -0
  468. package/src/ui/dash/settings/discover-copy.ts +116 -0
  469. package/src/ui/feed/LinkCard.tsx +55 -45
  470. package/src/ui/feed/LinkPreview.tsx +2 -7
  471. package/src/ui/feed/NoteCard.tsx +9 -4
  472. package/src/ui/feed/PostStatusBadges.tsx +103 -17
  473. package/src/ui/feed/QuoteCard.tsx +55 -30
  474. package/src/ui/feed/ThreadPreview.tsx +6 -2
  475. package/src/ui/feed/__tests__/thread-preview.test.ts +13 -16
  476. package/src/ui/feed/__tests__/timeline-cards.test.ts +314 -49
  477. package/src/ui/feed/thread-preview-state.ts +20 -6
  478. package/src/ui/font-themes.ts +39 -29
  479. package/src/ui/layouts/BaseLayout.tsx +155 -10
  480. package/src/ui/layouts/SiteLayout.tsx +192 -34
  481. package/src/ui/layouts/__tests__/BaseLayout.test.tsx +196 -17
  482. package/src/ui/layouts/__tests__/SiteLayout.test.tsx +109 -85
  483. package/src/ui/pages/ArchivePage.tsx +401 -301
  484. package/src/ui/pages/CollectionPage.tsx +86 -76
  485. package/src/ui/pages/CollectionsPage.tsx +5 -0
  486. package/src/ui/pages/ComposePage.tsx +8 -0
  487. package/src/ui/pages/PostPage.tsx +47 -0
  488. package/src/ui/pages/SearchPage.tsx +3 -3
  489. package/src/ui/pages/SmartCollectionPage.tsx +434 -0
  490. package/src/ui/pages/SubscribePage.tsx +236 -0
  491. package/src/ui/pages/__tests__/ArchivePage.test.tsx +122 -3
  492. package/src/ui/pages/__tests__/collections-directory-rows.test.tsx +92 -0
  493. package/src/ui/pages/__tests__/collections-view-links.test.tsx +81 -0
  494. package/src/ui/shared/CollectionDirectory.tsx +178 -3
  495. package/src/ui/shared/CollectionsManager.tsx +70 -17
  496. package/src/ui/shared/CopyField.tsx +127 -0
  497. package/src/ui/shared/DraftPreviewBar.tsx +25 -9
  498. package/src/ui/shared/EmptyPostContent.tsx +23 -0
  499. package/src/ui/shared/MediaGallery.tsx +125 -46
  500. package/src/ui/shared/Pagination.tsx +20 -5
  501. package/src/ui/shared/PostFooter.tsx +55 -23
  502. package/src/ui/shared/__tests__/archive-labels.test.ts +100 -0
  503. package/src/ui/shared/__tests__/media-gallery.test.ts +210 -2
  504. package/src/ui/shared/__tests__/navigation-labels.test.ts +16 -22
  505. package/src/ui/shared/__tests__/pagination.test.ts +38 -24
  506. package/src/ui/shared/__tests__/post-footer.test.ts +32 -0
  507. package/src/ui/shared/archive-labels.ts +98 -0
  508. package/src/ui/shared/collection-management-labels.ts +94 -16
  509. package/src/ui/shared/custom-icons.ts +5 -0
  510. package/src/ui/shared/navigation-labels.ts +45 -71
  511. package/src/ui/shared/post-article-attributes.ts +8 -0
  512. package/src/ui/shared/smart-collection-labels.ts +343 -0
  513. package/dist/app-BxCOR3Uc.js +0 -6
  514. package/dist/client/_assets/chunks/heic-to-DUUaO23q.js +0 -1
  515. package/dist/client/_assets/chunks/module-DcsAZQZ_.js +0 -716
  516. package/dist/client/_assets/chunks/url-pLre2DM_.js +0 -1
  517. package/dist/client/_assets/client-B_eGKN5p.css +0 -2
  518. package/dist/client/_assets/client-DxY5BFe8.js +0 -296
  519. package/dist/client/_assets/client-auth-CakPESv9.js +0 -5533
  520. package/dist/env-OHRKGcMj.js +0 -281
  521. package/dist/github-api-BgSiE71w.js +0 -176
  522. package/dist/github-app-BbklkFmU.js +0 -275
  523. package/dist/github-sync-Cq1pzzOI.js +0 -431
  524. package/dist/github-sync-ppWXN3jb.js +0 -4
  525. package/dist/url-BMYO-Zlt.js +0 -395
  526. package/src/client/__tests__/collection-created-notice.test.ts +0 -36
  527. package/src/client/__tests__/collection-form-bridge.test.ts +0 -216
  528. package/src/client/__tests__/post-form-bridge.test.ts +0 -82
  529. package/src/client/collection-created-notice.ts +0 -66
  530. package/src/client/collection-form-bridge.ts +0 -191
  531. package/src/client/components/__tests__/jant-post-form.test.ts +0 -193
  532. package/src/client/components/jant-post-form.ts +0 -422
  533. package/src/client/components/post-form-template.ts +0 -352
  534. package/src/client/components/post-form-types.ts +0 -96
  535. package/src/client/media-metadata.ts +0 -247
  536. package/src/client/post-form-bridge.ts +0 -193
  537. package/src/ui/pages/CollectionEditorPage.tsx +0 -175
@@ -1,8 +1,7 @@
1
1
  // @vitest-environment happy-dom
2
2
 
3
- import { readFileSync } from "node:fs";
4
- import { resolve } from "node:path";
5
3
  import { describe, it, expect, beforeEach, vi } from "vitest";
4
+ import { readComponentCss } from "../../../__tests__/helpers/component-css.js";
6
5
  import type { Editor } from "@tiptap/core";
7
6
  import { MAX_THREAD_POSTS } from "../../../types.js";
8
7
 
@@ -69,6 +68,45 @@ async function flushUpdates(el?: JantComposeDialog) {
69
68
  }
70
69
  }
71
70
 
71
+ async function openPublishPanel(el: JantComposeDialog) {
72
+ requireElement(
73
+ el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
74
+ "expected publish settings toggle",
75
+ ).click();
76
+ await flushUpdates(el);
77
+ }
78
+
79
+ /** Options-panel rows are found by their title — the drafts entries moved off
80
+ * the title bar and into that panel. */
81
+ function sheetRow(
82
+ el: JantComposeDialog,
83
+ title: string,
84
+ ): HTMLButtonElement | null {
85
+ return (
86
+ Array.from(
87
+ el.querySelectorAll<HTMLButtonElement>(
88
+ ".compose-publish-panel .compose-sheet-row",
89
+ ),
90
+ ).find(
91
+ (row) =>
92
+ row.querySelector(".compose-sheet-title")?.textContent?.trim() ===
93
+ title,
94
+ ) ?? null
95
+ );
96
+ }
97
+
98
+ function sheetRowTitles(el: JantComposeDialog): (string | undefined)[] {
99
+ return Array.from(
100
+ el.querySelectorAll<HTMLElement>(
101
+ ".compose-publish-panel .compose-sheet-title",
102
+ ),
103
+ ).map((n) => n.textContent?.trim());
104
+ }
105
+
106
+ function draftsRow(el: JantComposeDialog): HTMLButtonElement | null {
107
+ return sheetRow(el, "Drafts");
108
+ }
109
+
72
110
  function deferred<T>() {
73
111
  let resolve!: (value: T) => void;
74
112
  const promise = new Promise<T>((res) => {
@@ -120,6 +158,7 @@ function mockSlugApi(
120
158
 
121
159
  const labels: ComposeLabels = {
122
160
  cancel: "Cancel",
161
+ close: "Close",
123
162
  note: "Note",
124
163
  link: "Link",
125
164
  quote: "Quote",
@@ -158,7 +197,11 @@ const labels: ComposeLabels = {
158
197
  altHint: "Alt text improves accessibility",
159
198
  addMore: "Add",
160
199
  removeAttachment: "Remove attachment",
200
+ moveAttachmentEarlier: "Move attachment earlier",
201
+ moveAttachmentLater: "Move attachment later",
161
202
  uploading: "Uploading...",
203
+ publishing: "Publishing...",
204
+ saving: "Saving...",
162
205
  loadingPost: "Loading post...",
163
206
  loadPostFailed: "Couldn't load this post. Try again.",
164
207
  published: "Published!",
@@ -169,7 +212,7 @@ const labels: ComposeLabels = {
169
212
  confirmCloseTitle: "Save to drafts?",
170
213
  confirmCloseSubtitle: "Save to drafts to edit and post at a later time.",
171
214
  confirmCloseSave: "Save",
172
- confirmCloseCancel: "Cancel",
215
+ confirmCloseCancel: "Keep editing",
173
216
  confirmCloseDiscard: "Don't save",
174
217
  confirmAttachedTitle: "Save text attachment?",
175
218
  confirmAttachedSubtitle:
@@ -188,14 +231,32 @@ const labels: ComposeLabels = {
188
231
  deleteDraft: "Delete Draft",
189
232
  draftDeleted: "Draft deleted.",
190
233
  publishFailedDraft: "Couldn't publish. Saved as draft.",
234
+ publishFailed: "Couldn't publish. Try again.",
235
+ publishFailedOffline: "Couldn't reach the server. Try again.",
191
236
  uploadFailedDraft: "Some uploads failed. Saved as draft.",
192
237
  addCollection: "Add Collection",
193
238
  collectionCountLabel: "%name% + %count% more",
194
239
  draftRestored: "Draft restored.",
240
+ draftStoreFailed: "This browser can't keep a local copy. Save as a draft.",
195
241
  reply: "Reply",
196
242
  publishHideFromLatest: "Hide from Latest",
197
243
  publishPrivate: "Post as Private",
198
244
  publishSettings: "Publish settings",
245
+ languageLabel: "Language",
246
+ languageAuto: "Detect",
247
+ languageAutoHint: "Read from what you write",
248
+ languageAutoDetected: "Read from what you write — looks like {language}",
249
+ languageAutoPending: "Not enough text to tell yet — publishes in {language}",
250
+ languageTriggerLabel: "Language: {language}",
251
+ translationOf: "Translation of “{title}”",
252
+ translationContext: "Translating",
253
+ translationContextInLanguage: "Translating into {language}",
254
+ translationContextOpen: "Open the original in a new tab",
255
+ translationContextOriginal: "The original",
256
+ translationContextHide: "Hide",
257
+ translationContextHideLong: "Hide the original",
258
+ translationContextShow: "Show",
259
+ translationContextShowLong: "Show the original",
199
260
  publishVisibilityLabel: "Visibility",
200
261
  publishVisibilityPublic: "Public",
201
262
  publishVisibilityPublicHint: "Appears in Latest.",
@@ -211,13 +272,13 @@ const labels: ComposeLabels = {
211
272
  publishDateInvalid: "Enter a valid date.",
212
273
  publishDateFutureError:
213
274
  "Choose today or an earlier date, or leave it blank to publish now.",
214
- publishDateSummaryNow: "Publish now",
275
+ publishDateSummaryNow: "Now",
215
276
  publishDateSummaryAction: "Edit publish date",
216
277
  publishSlugLabel: "Custom link",
217
278
  publishSlugPlaceholder: "your-post-link",
218
279
  publishSlugHint: "Leave blank to generate one automatically.",
219
280
  publishSlugAuto: "Generate automatically",
220
- publishSlugSummaryAuto: "Auto link",
281
+ publishSlugSummaryAuto: "Auto",
221
282
  publishSlugSummaryAction: "Edit custom link",
222
283
  publishSlugReset: "Reset link",
223
284
  publishSlugSuggested: "Suggested link",
@@ -233,9 +294,8 @@ const labels: ComposeLabels = {
233
294
  threadLimitReached: "Threads can include up to 20 posts.",
234
295
  showMore: "Show more",
235
296
  showLess: "Show less",
236
- newThread: "New Thread",
237
- replyTitle: "Reply",
238
- editTitle: "Edit",
297
+ editing: "Editing",
298
+ composeDialogLabel: "Compose",
239
299
  slashHint: "Type / for commands",
240
300
  tableControls: {
241
301
  toolbarLabel: "Table controls",
@@ -263,13 +323,6 @@ const labels: ComposeLabels = {
263
323
  quickHint: "More options are available after you create it.",
264
324
  quickSubmitLabel: "Done",
265
325
  createdLabel: "Collection created.",
266
- descriptionLabel: "Description (optional)",
267
- descriptionPlaceholder: "What's this collection about?",
268
- sortOrderLabel: "Sort Order",
269
- sortNewest: "Newest first",
270
- sortOldest: "Oldest first",
271
- sortRatingDesc: "Highest rated",
272
- submitLabel: "Save",
273
326
  cancelLabel: "Cancel",
274
327
  },
275
328
  };
@@ -279,6 +332,35 @@ const collections: ComposeCollection[] = [
279
332
  { id: "col-2", title: "Movies", slug: "movies" },
280
333
  ];
281
334
 
335
+ /**
336
+ * Date and permalink hang off the post itself, not the publish panel. Both
337
+ * fields are open as soon as the panel is, so there is nothing further to
338
+ * expand. Idempotent.
339
+ */
340
+ async function openPostMeta(el: JantComposeDialog, index = 0) {
341
+ const pill = el.querySelectorAll<HTMLButtonElement>(
342
+ "[data-compose-post-meta-pill]",
343
+ )[index];
344
+ if (pill && pill.getAttribute("aria-expanded") !== "true") pill.click();
345
+ // The slug suggestion is fetched when the panel opens; let it settle.
346
+ await flushUpdates(el);
347
+ await flushUpdates(el);
348
+ }
349
+
350
+ /** The date or permalink as the post's own pill states it. */
351
+ function pillValue(
352
+ el: JantComposeDialog,
353
+ kind: "date" | "slug",
354
+ index = 0,
355
+ ): string | undefined {
356
+ const pill = el.querySelectorAll("[data-compose-post-meta-pill]")[index];
357
+ const selector =
358
+ kind === "date"
359
+ ? ".compose-post-meta-value:not(.compose-post-meta-value-slug)"
360
+ : ".compose-post-meta-value-slug";
361
+ return pill?.querySelector(selector)?.textContent?.trim();
362
+ }
363
+
282
364
  async function createElement(
283
365
  cols: ComposeCollection[] = collections,
284
366
  ): Promise<JantComposeDialog> {
@@ -331,8 +413,11 @@ describe("JantComposeDialog", () => {
331
413
  it("renders with collections and labels", async () => {
332
414
  const el = await createElement();
333
415
 
334
- // Header present
335
- expect(el.querySelector(".compose-dialog-header")).not.toBeNull();
416
+ // No title bar: the composer opens straight onto the post header row. A new
417
+ // post gets no marker either — an empty composer already says that much.
418
+ expect(el.querySelector(".compose-dialog-header")).toBeNull();
419
+ expect(el.querySelector(".compose-thread-post-header")).not.toBeNull();
420
+ expect(el.querySelector(".compose-post-badge")).toBeNull();
336
421
 
337
422
  // Format buttons present
338
423
  const segmentedItems = el.querySelectorAll(".compose-segmented-item");
@@ -350,7 +435,7 @@ describe("JantComposeDialog", () => {
350
435
  expect(postBtn.disabled).toBe(true);
351
436
  expect(
352
437
  requireElement(
353
- el.querySelector<HTMLButtonElement>(".compose-publish-toggle"),
438
+ el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
354
439
  "expected publish settings toggle",
355
440
  ).disabled,
356
441
  ).toBe(false);
@@ -382,7 +467,6 @@ describe("JantComposeDialog", () => {
382
467
  ],
383
468
  },
384
469
  title: "",
385
- showTitle: false,
386
470
  selection: { from: 3, to: 3 },
387
471
  replyExpanded: false,
388
472
  },
@@ -490,7 +574,7 @@ describe("JantComposeDialog", () => {
490
574
  const el = await createElement();
491
575
 
492
576
  requireElement(
493
- el.querySelector<HTMLButtonElement>(".compose-publish-toggle"),
577
+ el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
494
578
  "expected publish settings toggle",
495
579
  ).click();
496
580
  await el.updateComplete;
@@ -498,15 +582,13 @@ describe("JantComposeDialog", () => {
498
582
  expect(el.querySelector(".compose-publish-panel")).not.toBeNull();
499
583
  });
500
584
 
501
- it("updates the draft button label to match its current action", async () => {
585
+ it("offers saving separately from browsing once there is something to save", async () => {
502
586
  const el = await createElement();
587
+ await openPublishPanel(el);
503
588
 
504
- const draftButton = requireElement(
505
- el.querySelector<HTMLButtonElement>(".compose-dialog-draft-btn"),
506
- "expected draft button",
507
- );
508
- expect(draftButton.title).toBe("Drafts");
509
- expect(draftButton.getAttribute("aria-label")).toBe("Drafts");
589
+ // Nothing written yet, so there is only a place to go.
590
+ expect(sheetRowTitles(el)).toContain("Drafts");
591
+ expect(sheetRowTitles(el)).not.toContain("Save as draft");
510
592
 
511
593
  const editor = requireElement(
512
594
  el.querySelector<JantComposeEditor>("jant-compose-editor"),
@@ -519,201 +601,1673 @@ describe("JantComposeDialog", () => {
519
601
  ],
520
602
  };
521
603
  await editor.updateComplete;
522
- await el.updateComplete;
604
+ await flushUpdates(el);
523
605
 
524
- expect(draftButton.title).toBe("Save as draft");
525
- expect(draftButton.getAttribute("aria-label")).toBe("Save as draft");
606
+ // Now both: an action on this post, and the list. Neither label lies about
607
+ // what its row does.
608
+ const titles = sheetRowTitles(el);
609
+ expect(titles).toContain("Save as draft");
610
+ expect(titles).toContain("Drafts");
611
+ expect(titles.indexOf("Save as draft")).toBeLessThan(
612
+ titles.indexOf("Drafts"),
613
+ );
526
614
  });
527
615
 
528
- it("shows the selected visibility hint in the publish settings panel", async () => {
616
+ it("saves straight to a draft from its own row, with no prompt in the way", async () => {
529
617
  const el = await createElement();
618
+ const editor = requireElement(
619
+ el.querySelector<JantComposeEditor>("jant-compose-editor"),
620
+ "expected compose editor",
621
+ );
622
+ editor._bodyJson = {
623
+ type: "doc",
624
+ content: [
625
+ { type: "paragraph", content: [{ type: "text", text: "Hello world" }] },
626
+ ],
627
+ };
628
+ await editor.updateComplete;
629
+ await flushUpdates(el);
630
+
631
+ let detail: ComposeSubmitDetail | null = null;
632
+ el.addEventListener("jant:compose-submit-deferred", (event) => {
633
+ detail = (event as CustomEvent<ComposeSubmitDetail>).detail;
634
+ });
530
635
 
636
+ await openPublishPanel(el);
531
637
  requireElement(
532
- el.querySelector<HTMLButtonElement>(".compose-publish-toggle"),
533
- "expected publish settings toggle",
638
+ sheetRow(el, "Save as draft"),
639
+ "expected save-as-draft row",
534
640
  ).click();
535
- await el.updateComplete;
641
+ await flushUpdates(el);
536
642
 
537
- // Default is public — only public hint shown
538
- expect(el.textContent).toContain("Appears in Latest.");
539
- expect(el.textContent).not.toContain("Only visible when signed in.");
643
+ expect(el._confirmPanelOpen).toBe(false);
644
+ expect(el._draftsPanelOpen).toBe(false);
645
+ expect(detail).not.toBeNull();
646
+ expect(detail!.status).toBe("draft");
647
+ });
540
648
 
541
- // Switch to private — hint updates
542
- const chips = el.querySelectorAll<HTMLButtonElement>(
543
- ".compose-publish-chip[role='radio']",
649
+ it("opens options from its own button, not a chevron on Publish", async () => {
650
+ const el = await createElement();
651
+
652
+ // The split button is gone: Publish is one button, options is another.
653
+ expect(el.querySelector(".compose-publish-toggle")).toBeNull();
654
+
655
+ const trigger = requireElement(
656
+ el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
657
+ "expected options trigger",
544
658
  );
545
- chips[2]?.click();
546
- await el.updateComplete;
659
+ expect(trigger.getAttribute("aria-expanded")).toBe("false");
660
+ expect(el.querySelector(".compose-sheet")).toBeNull();
661
+
662
+ trigger.click();
663
+ await flushUpdates(el);
664
+
665
+ expect(trigger.getAttribute("aria-expanded")).toBe("true");
666
+ expect(el.querySelector(".compose-sheet")).not.toBeNull();
667
+ });
668
+
669
+ describe("language", () => {
670
+ function languageTrigger(el: JantComposeDialog): HTMLButtonElement | null {
671
+ return el.querySelector<HTMLButtonElement>(".compose-language-trigger");
672
+ }
673
+
674
+ async function openLanguagePicker(el: JantComposeDialog) {
675
+ requireElement(languageTrigger(el), "expected language pill").click();
676
+ await flushUpdates(el);
677
+ }
678
+
679
+ function languageRowTitles(el: JantComposeDialog): string[] {
680
+ const group = el.querySelector(
681
+ ".compose-language-popover [role='radiogroup']",
682
+ );
683
+ return Array.from(
684
+ group?.querySelectorAll(".compose-sheet-title") ?? [],
685
+ ).map((node) => node.textContent?.trim() ?? "");
686
+ }
687
+
688
+ it("shows nothing on a site that publishes one language", async () => {
689
+ // The whole feature is opt-in: an author who never turned it on should
690
+ // not meet a language control.
691
+ const el = await createElement();
692
+
693
+ expect(languageTrigger(el)).toBeNull();
694
+ expect(el.textContent).not.toContain("Detect");
695
+ });
696
+
697
+ it("offers Detect plus each language once the site is multilingual", async () => {
698
+ const el = await createElement();
699
+ el.languages = [
700
+ { tag: "zh-Hans", label: "简体中文" },
701
+ { tag: "en", label: "English" },
702
+ ];
703
+ await flushUpdates(el);
704
+ await openLanguagePicker(el);
705
+
706
+ expect(languageRowTitles(el)).toEqual(["Detect", "简体中文", "English"]);
707
+ });
708
+
709
+ it("stays a bare globe while the answer is the page you are on", async () => {
710
+ const el = await createElement();
711
+ el.languages = [
712
+ { tag: "zh-Hans", label: "简体中文" },
713
+ { tag: "en", label: "English" },
714
+ ];
715
+ el.contextLanguage = "en";
716
+ await flushUpdates(el);
717
+
718
+ // Writing English from /en: the globe is the whole message. The answer
719
+ // is still named for anyone who asks for it.
720
+ const trigger = requireElement(
721
+ languageTrigger(el),
722
+ "expected language pill",
723
+ );
724
+ expect(trigger.textContent?.trim()).toBe("");
725
+ expect(trigger.getAttribute("aria-label")).toBe("Language: English");
726
+ expect(trigger.getAttribute("title")).toBe("Language: English");
727
+ expect(trigger.dataset.chosen).toBeUndefined();
728
+ });
729
+
730
+ it("grows the language name once detection moves it", async () => {
731
+ const el = await createElement();
732
+ el.languages = [
733
+ { tag: "zh-Hans", label: "简体中文" },
734
+ { tag: "en", label: "English" },
735
+ ];
736
+ el.contextLanguage = "en";
737
+ await flushUpdates(el);
738
+
739
+ const editor = requireElement(
740
+ el.querySelector<JantComposeEditor>("jant-compose-editor"),
741
+ "expected compose editor",
742
+ );
743
+ const write = async (text: string) => {
744
+ editor._bodyJson = {
745
+ type: "doc",
746
+ content: [{ type: "paragraph", content: [{ type: "text", text }] }],
747
+ };
748
+ await editor.updateComplete;
749
+ await flushUpdates(el);
750
+ };
751
+
752
+ // A fragment is not evidence: two characters leave the pill alone.
753
+ await write("这会");
754
+ expect(languageTrigger(el)?.textContent?.trim()).toBe("");
755
+
756
+ // A sentence is. The pill says so itself, with nothing opened.
757
+ await write("这会说国学时间");
758
+ expect(languageTrigger(el)?.textContent?.trim()).toBe("简体中文");
759
+ expect(languageTrigger(el)?.getAttribute("aria-label")).toBe(
760
+ "Language: 简体中文",
761
+ );
762
+ });
763
+
764
+ it("marks the pill once the author picks a language themselves", async () => {
765
+ const el = await createElement();
766
+ el.languages = [
767
+ { tag: "zh-Hans", label: "简体中文" },
768
+ { tag: "en", label: "English" },
769
+ ];
770
+ el.contextLanguage = "en";
771
+ await flushUpdates(el);
772
+ await openLanguagePicker(el);
773
+
774
+ const rows = Array.from(
775
+ el.querySelectorAll<HTMLButtonElement>(
776
+ ".compose-language-popover .compose-sheet-row",
777
+ ),
778
+ );
779
+ requireElement(rows[1] ?? null, "expected the 简体中文 row").click();
780
+ await flushUpdates(el);
781
+
782
+ // Choosing closes the list behind the choice.
783
+ expect(el.querySelector(".compose-language-popover")).toBeNull();
784
+ const trigger = requireElement(
785
+ languageTrigger(el),
786
+ "expected language pill",
787
+ );
788
+ expect(trigger.textContent?.trim()).toBe("简体中文");
789
+ expect(trigger.dataset.chosen).toBe("true");
790
+ });
791
+
792
+ it("says the text is too short rather than claiming a reading", async () => {
793
+ // Below the detector's threshold there is no reading to report. Naming
794
+ // the page's language here would pass a fallback off as an answer.
795
+ const el = await createElement();
796
+ el.languages = [
797
+ { tag: "zh-Hans", label: "简体中文" },
798
+ { tag: "en", label: "English" },
799
+ ];
800
+ el.contextLanguage = "zh-Hans";
801
+ await flushUpdates(el);
802
+ await openLanguagePicker(el);
803
+
804
+ const hint = () =>
805
+ el
806
+ .querySelector(".compose-language-popover .compose-sheet-sub")
807
+ ?.textContent?.trim();
808
+ expect(hint()).toBe(
809
+ "Not enough text to tell yet — publishes in 简体中文",
810
+ );
811
+
812
+ const editor = requireElement(
813
+ el.querySelector<JantComposeEditor>("jant-compose-editor"),
814
+ "expected compose editor",
815
+ );
816
+ editor._bodyJson = {
817
+ type: "doc",
818
+ content: [
819
+ {
820
+ type: "paragraph",
821
+ content: [{ type: "text", text: "Hello there, in English." }],
822
+ },
823
+ ],
824
+ };
825
+ await editor.updateComplete;
826
+ await flushUpdates(el);
827
+
828
+ expect(hint()).toBe("Read from what you write — looks like English");
829
+ });
830
+
831
+ it("keeps the Detect row honest after an explicit choice", async () => {
832
+ // The row offers to read the text, so it has to report what reading it
833
+ // produces — not echo back the language the author picked instead.
834
+ const el = await createElement();
835
+ el.languages = [
836
+ { tag: "zh-Hans", label: "简体中文" },
837
+ { tag: "en", label: "English" },
838
+ ];
839
+ el.contextLanguage = "en";
840
+ await flushUpdates(el);
841
+
842
+ const editor = requireElement(
843
+ el.querySelector<JantComposeEditor>("jant-compose-editor"),
844
+ "expected compose editor",
845
+ );
846
+ editor._bodyJson = {
847
+ type: "doc",
848
+ content: [
849
+ {
850
+ type: "paragraph",
851
+ content: [{ type: "text", text: "Hello there, in English." }],
852
+ },
853
+ ],
854
+ };
855
+ await editor.updateComplete;
856
+ await flushUpdates(el);
857
+ await openLanguagePicker(el);
858
+
859
+ const rows = Array.from(
860
+ el.querySelectorAll<HTMLButtonElement>(
861
+ ".compose-language-popover .compose-sheet-row",
862
+ ),
863
+ );
864
+ requireElement(rows[1] ?? null, "expected the 简体中文 row").click();
865
+ await flushUpdates(el);
866
+ await openLanguagePicker(el);
867
+
868
+ const hint = el.querySelector(
869
+ ".compose-language-popover .compose-sheet-sub",
870
+ );
871
+ expect(hint?.textContent?.trim()).toBe(
872
+ "Read from what you write — looks like English",
873
+ );
874
+ });
875
+
876
+ it("closes the list on Escape and hands focus back to the pill", async () => {
877
+ const el = await createElement();
878
+ el.languages = [
879
+ { tag: "zh-Hans", label: "简体中文" },
880
+ { tag: "en", label: "English" },
881
+ ];
882
+ await flushUpdates(el);
883
+ await openLanguagePicker(el);
884
+ expect(el.querySelector(".compose-language-popover")).not.toBeNull();
885
+
886
+ el.dispatchEvent(
887
+ new KeyboardEvent("keydown", { key: "Escape", bubbles: true }),
888
+ );
889
+ await flushUpdates(el);
890
+
891
+ expect(el.querySelector(".compose-language-popover")).toBeNull();
892
+ expect(document.activeElement).toBe(languageTrigger(el));
893
+ });
894
+
895
+ it("closes the list when another control opens", async () => {
896
+ const el = await createElement();
897
+ el.languages = [
898
+ { tag: "zh-Hans", label: "简体中文" },
899
+ { tag: "en", label: "English" },
900
+ ];
901
+ await flushUpdates(el);
902
+ await openLanguagePicker(el);
903
+
904
+ await openPublishPanel(el);
905
+
906
+ expect(el.querySelector(".compose-language-popover")).toBeNull();
907
+ expect(el.querySelector(".compose-sheet")).not.toBeNull();
908
+ });
909
+
910
+ it("keeps language out of the options sheet", async () => {
911
+ // One setting, one place. The sheet speaks for the whole submission; the
912
+ // language belongs to what is being written.
913
+ const el = await createElement();
914
+ el.languages = [
915
+ { tag: "zh-Hans", label: "简体中文" },
916
+ { tag: "en", label: "English" },
917
+ ];
918
+ await flushUpdates(el);
919
+
920
+ await openPublishPanel(el);
921
+
922
+ expect(
923
+ el.querySelector(".compose-sheet")?.textContent ?? "",
924
+ ).not.toContain("Detect");
925
+ });
926
+
927
+ /**
928
+ * Open the composer on a translation of `post`. The composer asks for two
929
+ * things: the post's fields, to seed from, and its server-rendered markup,
930
+ * to show.
931
+ */
932
+ async function openTranslationOf(
933
+ el: JantComposeDialog,
934
+ post: Record<string, unknown>,
935
+ options: { language?: string; previewHtml?: string | null } = {},
936
+ ) {
937
+ const { language = "en", previewHtml = null } = options;
938
+ vi.spyOn(globalThis, "fetch").mockImplementation((input: unknown) => {
939
+ if (String(input).includes("/_/post-preview/")) {
940
+ return Promise.resolve({
941
+ ok: previewHtml !== null,
942
+ text: async () => previewHtml ?? "",
943
+ } as Response);
944
+ }
945
+ return Promise.resolve({
946
+ ok: true,
947
+ json: async () => post,
948
+ } as Response);
949
+ });
950
+
951
+ await el.openTranslation((post.id as string) ?? "pst_source", language);
952
+ await flushUpdates(el);
953
+ await flushUpdates(el);
954
+ await flushUpdates(el);
955
+ return el;
956
+ }
957
+
958
+ async function multilingualElement() {
959
+ const el = await createElement();
960
+ el.languages = [
961
+ { tag: "zh-Hans", label: "简体中文" },
962
+ { tag: "en", label: "English" },
963
+ ];
964
+ await flushUpdates(el);
965
+ return el;
966
+ }
967
+
968
+ const PREVIEW_HTML =
969
+ '<article class="h-entry post-menu-target" data-post data-post-id="pst_source" data-page="post">' +
970
+ '<h1 class="post-detail-title">咖啡笔记</h1>' +
971
+ '<div class="post-header-meta-row"><a href="/coffee" class="post-header-meta-link">Aug 8, 2026</a></div>' +
972
+ '<div class="e-content prose post-detail-body"><h2>耶加雪菲</h2>' +
973
+ '<p>今天试了一支,<a href="https://example.com/beans">花香</a>很明显。</p></div>' +
974
+ "</article>";
975
+
976
+ it("says what it was opened to translate, before anything is typed", async () => {
977
+ // Opened from a post, so the composer should carry that context above the
978
+ // editor — not two panels down in the publish sheet.
979
+ const el = await multilingualElement();
980
+ await openTranslationOf(
981
+ el,
982
+ {
983
+ id: "pst_source",
984
+ format: "note",
985
+ displayTitle: "咖啡笔记",
986
+ slug: "coffee",
987
+ },
988
+ { previewHtml: PREVIEW_HTML },
989
+ );
990
+
991
+ const banner = requireElement(
992
+ el.querySelector(".compose-translation-context"),
993
+ "expected the translation context",
994
+ );
995
+ // The language sits on the seam between the two posts, not above both:
996
+ // it reads as "…and below is that, in English".
997
+ expect(
998
+ banner
999
+ .querySelector(".compose-translation-seam-label")
1000
+ ?.textContent?.trim(),
1001
+ ).toBe("Translating into English");
1002
+ });
1003
+
1004
+ it("shows the original as the site renders it, inside a frame to scroll", async () => {
1005
+ // Server-rendered rather than rebuilt here, so a Quote arrives with its
1006
+ // attribution and a Link with its card. The frame is fixed because an
1007
+ // original handed its full height pushes the editor off the screen
1008
+ // exactly when both need to be visible.
1009
+ const el = await multilingualElement();
1010
+ await openTranslationOf(
1011
+ el,
1012
+ {
1013
+ id: "pst_source",
1014
+ format: "note",
1015
+ displayTitle: "咖啡笔记",
1016
+ slug: "coffee",
1017
+ },
1018
+ { previewHtml: PREVIEW_HTML },
1019
+ );
1020
+
1021
+ const original = requireElement(
1022
+ el.querySelector(".compose-translation-original"),
1023
+ "expected the original's frame",
1024
+ );
1025
+ // Keyboard users have to be able to scroll it, and a screen reader has to
1026
+ // be able to name it.
1027
+ expect(original.getAttribute("tabindex")).toBe("0");
1028
+ expect(original.getAttribute("role")).toBe("region");
1029
+ expect(original.getAttribute("aria-label")).toBe("The original");
1030
+ // Structure is part of what is being translated, so it survives intact.
1031
+ expect(original.querySelector("h1")?.textContent).toBe("咖啡笔记");
1032
+ expect(original.querySelector("h2")?.textContent).toBe("耶加雪菲");
1033
+
1034
+ // Nothing left of the expand-in-place preview it replaced, and no bolted
1035
+ // on external-link glyph — that made the card read as a Link post.
1036
+ expect(el.querySelector(".compose-translation-toggle")).toBeNull();
1037
+ expect(el.querySelector(".compose-translation-source-icon")).toBeNull();
1038
+ });
1039
+
1040
+ it("folds the original away and back, keeping the seam either way", async () => {
1041
+ // The frame costs vertical room the editor may want back mid-draft. What
1042
+ // it is being translated into stays on screen while it is folded — that
1043
+ // is about the two posts, not about how much of one is showing.
1044
+ const el = await multilingualElement();
1045
+ await openTranslationOf(
1046
+ el,
1047
+ {
1048
+ id: "pst_source",
1049
+ format: "note",
1050
+ displayTitle: "咖啡笔记",
1051
+ slug: "coffee",
1052
+ },
1053
+ { previewHtml: PREVIEW_HTML },
1054
+ );
1055
+
1056
+ const toggle = () =>
1057
+ requireElement(
1058
+ el.querySelector<HTMLButtonElement>(
1059
+ ".compose-translation-seam-toggle",
1060
+ ),
1061
+ "expected the fold toggle",
1062
+ );
1063
+
1064
+ expect(el.querySelector(".compose-translation-original")).not.toBeNull();
1065
+ expect(toggle().getAttribute("aria-expanded")).toBe("true");
1066
+ expect(toggle().textContent?.trim()).toBe("Hide");
1067
+
1068
+ toggle().click();
1069
+ await flushUpdates(el);
1070
+
1071
+ expect(el.querySelector(".compose-translation-original")).toBeNull();
1072
+ expect(toggle().getAttribute("aria-expanded")).toBe("false");
1073
+ expect(toggle().textContent?.trim()).toBe("Show");
1074
+ expect(toggle().getAttribute("aria-label")).toBe("Show the original");
1075
+ expect(
1076
+ el
1077
+ .querySelector(".compose-translation-seam-label")
1078
+ ?.textContent?.trim(),
1079
+ ).toBe("Translating into English");
1080
+
1081
+ toggle().click();
1082
+ await flushUpdates(el);
1083
+
1084
+ // Unfolding re-creates the markup, so the links have to be sent to a new
1085
+ // tab again — the adopt pass runs on every update, not just the first.
1086
+ const link = requireElement(
1087
+ el.querySelector<HTMLAnchorElement>(
1088
+ ".compose-translation-preview a[href]",
1089
+ ),
1090
+ "expected the original back",
1091
+ );
1092
+ expect(link.getAttribute("target")).toBe("_blank");
1093
+ });
1094
+
1095
+ it("sends every link in the original to a new tab", async () => {
1096
+ // A link followed in place navigates the composer away and takes the
1097
+ // unsaved translation with it.
1098
+ const el = await multilingualElement();
1099
+ await openTranslationOf(
1100
+ el,
1101
+ {
1102
+ id: "pst_source",
1103
+ format: "note",
1104
+ displayTitle: "咖啡笔记",
1105
+ slug: "coffee",
1106
+ },
1107
+ { previewHtml: PREVIEW_HTML },
1108
+ );
1109
+
1110
+ const links = Array.from(
1111
+ el.querySelectorAll<HTMLAnchorElement>(
1112
+ ".compose-translation-preview a[href]",
1113
+ ),
1114
+ );
1115
+ expect(links.length).toBe(2);
1116
+ for (const link of links) {
1117
+ expect(link.getAttribute("target")).toBe("_blank");
1118
+ expect(link.getAttribute("rel")).toBe("noopener noreferrer");
1119
+ }
1120
+ });
1121
+
1122
+ it("strips the original's identity so nothing else can act on it", async () => {
1123
+ // `data-post-id` is how the post menu, the keyboard shortcuts and
1124
+ // `refreshArticleView` find a post. A second copy of the original's id in
1125
+ // the DOM lets any of them act on the preview believing it is the card.
1126
+ const el = await multilingualElement();
1127
+ await openTranslationOf(
1128
+ el,
1129
+ {
1130
+ id: "pst_source",
1131
+ format: "note",
1132
+ displayTitle: "咖啡笔记",
1133
+ slug: "coffee",
1134
+ },
1135
+ { previewHtml: PREVIEW_HTML },
1136
+ );
1137
+
1138
+ const preview = requireElement(
1139
+ el.querySelector(".compose-translation-preview"),
1140
+ "expected the preview",
1141
+ );
1142
+ expect(preview.querySelector("[data-post-id]")).toBeNull();
1143
+ expect(preview.querySelector(".post-menu-target")).toBeNull();
1144
+ expect(
1145
+ document.querySelectorAll('[data-post-id="pst_source"]').length,
1146
+ ).toBe(0);
1147
+ });
1148
+
1149
+ it("falls back to a plain link when the rendering cannot be fetched", async () => {
1150
+ const el = await multilingualElement();
1151
+ await openTranslationOf(
1152
+ el,
1153
+ {
1154
+ id: "pst_source",
1155
+ format: "note",
1156
+ displayTitle: "咖啡笔记",
1157
+ slug: "coffee",
1158
+ },
1159
+ { previewHtml: null },
1160
+ );
1161
+
1162
+ expect(el.querySelector(".compose-translation-original")).toBeNull();
1163
+ const link = requireElement(
1164
+ el.querySelector<HTMLAnchorElement>(".compose-translation-fallback a"),
1165
+ "expected a fallback link to the original",
1166
+ );
1167
+ expect(link.textContent?.trim()).toBe("咖啡笔记");
1168
+ expect(link.getAttribute("href")).toBe("/coffee");
1169
+ expect(link.getAttribute("target")).toBe("_blank");
1170
+ // The seam still names the language: it is about the two posts, not about
1171
+ // how much of one of them could be shown.
1172
+ expect(
1173
+ el
1174
+ .querySelector(".compose-translation-seam-label")
1175
+ ?.textContent?.trim(),
1176
+ ).toBe("Translating into English");
1177
+ });
1178
+
1179
+ it("starts a translation in the original's shape, down to its meta", async () => {
1180
+ // Everything that describes the post's subject or its place travels:
1181
+ // format, citation, collections, visibility, rating. Only the words are
1182
+ // language-specific, so only the words start empty.
1183
+ const el = await multilingualElement();
1184
+ await openTranslationOf(el, {
1185
+ id: "pst_source",
1186
+ format: "quote",
1187
+ displayTitle: "关于专注",
1188
+ slug: "focus",
1189
+ quoteText: "专注是一种拒绝。",
1190
+ sourceName: "Some Author",
1191
+ sourceUrl: "https://example.com/focus",
1192
+ collectionIds: ["col_reading", "col_notes"],
1193
+ visibility: "latest_hidden",
1194
+ rating: 4,
1195
+ });
1196
+
1197
+ const editor = requireElement(
1198
+ el.querySelector<JantComposeEditor>("jant-compose-editor"),
1199
+ "expected the editor",
1200
+ );
1201
+ expect(editor.format).toBe("quote");
1202
+ const data = editor.getData();
1203
+ expect(data.url).toBe("https://example.com/focus");
1204
+ expect(data.quoteAuthor).toBe("Some Author");
1205
+ expect(data.rating).toBe(4);
1206
+ // The prose is the part the author is here to write.
1207
+ expect(data.quoteText).toBe("");
1208
+
1209
+ const internals = el as unknown as {
1210
+ _collectionIds: string[];
1211
+ _visibility: string;
1212
+ _hasUnsavedChanges: () => boolean;
1213
+ };
1214
+ expect(internals._collectionIds).toEqual(["col_reading", "col_notes"]);
1215
+ expect(internals._visibility).toBe("latest_hidden");
1216
+
1217
+ // None of that counts as something to discard: the author typed nothing.
1218
+ expect(internals._hasUnsavedChanges()).toBe(false);
1219
+ });
1220
+
1221
+ it("closes a seeded translation without offering to save it", async () => {
1222
+ // The citation arrived from the original, not from the author. Being
1223
+ // asked to save a draft of a URL you never typed is worse than the
1224
+ // prompt is worth — but the moment anything is written, it comes back.
1225
+ const el = await multilingualElement();
1226
+ await openTranslationOf(el, {
1227
+ id: "pst_source",
1228
+ format: "quote",
1229
+ displayTitle: "关于专注",
1230
+ slug: "focus",
1231
+ sourceUrl: "https://example.com/focus",
1232
+ });
1233
+ const internals = el as unknown as {
1234
+ _confirmPanelOpen: boolean;
1235
+ _hasContent: () => boolean;
1236
+ };
1237
+
1238
+ el.requestClose();
1239
+ await flushUpdates(el);
1240
+ expect(internals._confirmPanelOpen).toBe(false);
1241
+
1242
+ await openTranslationOf(el, {
1243
+ id: "pst_source",
1244
+ format: "quote",
1245
+ displayTitle: "关于专注",
1246
+ slug: "focus",
1247
+ sourceUrl: "https://example.com/focus",
1248
+ });
1249
+ const editor = requireElement(
1250
+ el.querySelector<JantComposeEditor>("jant-compose-editor"),
1251
+ "expected the editor",
1252
+ );
1253
+ editor._quoteText = "Focus is about saying no.";
1254
+ await flushUpdates(el);
1255
+
1256
+ el.requestClose();
1257
+ await flushUpdates(el);
1258
+ expect(internals._confirmPanelOpen).toBe(true);
1259
+ });
1260
+
1261
+ it("keeps the citation, the original and the language when a translation grows into a thread", async () => {
1262
+ // The thread path used to be a copy of the single-post one: "Add to
1263
+ // thread" dropped the quote's source link on the way into the thread
1264
+ // editor, and the submit never learned about language or translations.
1265
+ const el = await multilingualElement();
1266
+ await openTranslationOf(el, {
1267
+ id: "pst_source",
1268
+ format: "quote",
1269
+ displayTitle: "关于专注",
1270
+ slug: "focus",
1271
+ quoteText: "专注是一种拒绝。",
1272
+ sourceName: "Some Author",
1273
+ sourceUrl: "https://example.com/focus",
1274
+ });
1275
+ (el as unknown as { _language: string | null })._language = "en";
1276
+ const single = requireElement(
1277
+ el.querySelector<JantComposeEditor>("jant-compose-editor"),
1278
+ "expected the editor",
1279
+ );
1280
+ single._quoteText = "Focus is a refusal.";
1281
+ await flushUpdates(el);
1282
+
1283
+ (el as unknown as { _addThreadItem: () => void })._addThreadItem();
1284
+ await flushUpdates(el);
1285
+ const editors = Array.from(
1286
+ el.querySelectorAll<JantComposeEditor>("jant-compose-editor"),
1287
+ );
1288
+ expect(editors).toHaveLength(2);
1289
+ // The next post starts in the format of the one before it.
1290
+ editors[1]!._quoteText = "And its answer.";
1291
+ await flushUpdates(el);
1292
+
1293
+ let detail: ComposeSubmitDetail | null = null;
1294
+ el.addEventListener("jant:compose-submit-deferred", (event) => {
1295
+ detail = (event as CustomEvent<ComposeSubmitDetail>).detail;
1296
+ });
1297
+ requireElement(
1298
+ el.querySelector<HTMLButtonElement>(".compose-publish-main"),
1299
+ "expected publish button",
1300
+ ).click();
1301
+
1302
+ const [root, reply] = (detail as ComposeSubmitDetail | null)
1303
+ ?.threadPosts ?? [undefined, undefined];
1304
+ expect(root).toMatchObject({
1305
+ format: "quote",
1306
+ quoteText: "Focus is a refusal.",
1307
+ quoteAuthor: "Some Author",
1308
+ url: "https://example.com/focus",
1309
+ translationOfId: "pst_source",
1310
+ language: "en",
1311
+ });
1312
+ // Thread-level facts: only the root states them.
1313
+ expect(reply?.translationOfId).toBeUndefined();
1314
+ expect(reply?.language).toBeUndefined();
1315
+ });
1316
+
1317
+ it("leaves the format alone once the author has started writing", async () => {
1318
+ // The fetch lands after the composer opens. Overwriting a first sentence
1319
+ // to save a format click is a bad trade.
1320
+ const el = await multilingualElement();
1321
+ vi.spyOn(
1322
+ el as unknown as { _hasContent: () => boolean },
1323
+ "_hasContent",
1324
+ ).mockReturnValue(true);
1325
+
1326
+ await openTranslationOf(el, {
1327
+ id: "pst_source",
1328
+ format: "quote",
1329
+ displayTitle: "关于专注",
1330
+ slug: "focus",
1331
+ sourceUrl: "https://example.com/focus",
1332
+ });
1333
+
1334
+ const editor = requireElement(
1335
+ el.querySelector<JantComposeEditor>("jant-compose-editor"),
1336
+ "expected the editor",
1337
+ );
1338
+ expect(editor.format).toBe("note");
1339
+ expect(editor.getData().url).toBe("");
1340
+ // The context still shows — it is the one thing that never fights the
1341
+ // author for the page.
1342
+ expect(el.querySelector(".compose-translation-context")).not.toBeNull();
1343
+ });
1344
+
1345
+ it("sends the chosen language, and the page's language on Detect", async () => {
1346
+ const el = await createElement();
1347
+ el.languages = [
1348
+ { tag: "zh-Hans", label: "简体中文" },
1349
+ { tag: "en", label: "English" },
1350
+ ];
1351
+ el.contextLanguage = "en";
1352
+ await flushUpdates(el);
1353
+ await openLanguagePicker(el);
1354
+
1355
+ const build = (
1356
+ el as unknown as {
1357
+ _buildSubmitDetail: (
1358
+ status: "published" | "draft",
1359
+ ) => ComposeSubmitDetail | null;
1360
+ }
1361
+ )._buildSubmitDetail.bind(el);
1362
+
1363
+ // Left on Detect with no text signal: the post belongs to the page it
1364
+ // was written from, so the page's language goes out explicitly.
1365
+ expect(build("published")?.language).toBe("en");
1366
+
1367
+ const rows = Array.from(
1368
+ el.querySelectorAll<HTMLButtonElement>(
1369
+ ".compose-language-popover .compose-sheet-row",
1370
+ ),
1371
+ );
1372
+ requireElement(rows[1] ?? null, "expected the 简体中文 row").click();
1373
+ await flushUpdates(el);
1374
+
1375
+ expect(build("published")?.language).toBe("zh-Hans");
1376
+ });
1377
+
1378
+ it("keeps a published post's own language when editing it", async () => {
1379
+ // A post that is already filed in Japanese must not be re-read from the
1380
+ // English page it is being edited on — the language is a fact about the
1381
+ // post, not a guess to redo.
1382
+ mockEditPost({
1383
+ format: "note",
1384
+ title: "Hi",
1385
+ body: null,
1386
+ language: "ja",
1387
+ });
1388
+
1389
+ const el = await createElement();
1390
+ el.languages = [
1391
+ { tag: "en", label: "English" },
1392
+ { tag: "ja", label: "日本語" },
1393
+ ];
1394
+ el.contextLanguage = "en";
1395
+ await flushUpdates(el);
1396
+ await el.openEdit("pst_123");
1397
+ await flushUpdates(el);
1398
+
1399
+ const trigger = requireElement(
1400
+ languageTrigger(el),
1401
+ "expected language pill",
1402
+ );
1403
+ expect(trigger.textContent?.trim()).toBe("日本語");
1404
+ expect(trigger.dataset.chosen).toBe("true");
1405
+
1406
+ const build = (
1407
+ el as unknown as {
1408
+ _buildSubmitDetail: (
1409
+ status: "published" | "draft",
1410
+ ) => ComposeSubmitDetail | null;
1411
+ }
1412
+ )._buildSubmitDetail.bind(el);
1413
+ expect(build("published")?.language).toBe("ja");
1414
+ });
1415
+
1416
+ it("falls back to the primary language when the page has none", async () => {
1417
+ const el = await createElement();
1418
+ el.languages = [
1419
+ { tag: "zh-Hans", label: "简体中文" },
1420
+ { tag: "en", label: "English" },
1421
+ ];
1422
+ await flushUpdates(el);
1423
+
1424
+ const build = (
1425
+ el as unknown as {
1426
+ _buildSubmitDetail: (
1427
+ status: "published" | "draft",
1428
+ ) => ComposeSubmitDetail | null;
1429
+ }
1430
+ )._buildSubmitDetail.bind(el);
1431
+
1432
+ expect(build("published")?.language).toBe("zh-Hans");
1433
+ });
1434
+
1435
+ describe("publishing on automatic", () => {
1436
+ /** A composer whose text reads as a different language than the page. */
1437
+ async function createMismatch(overrides?: {
1438
+ contextLanguage?: string;
1439
+ text?: string;
1440
+ }) {
1441
+ const el = await createElement();
1442
+ el.languages = [
1443
+ { tag: "zh-Hans", label: "简体中文" },
1444
+ { tag: "ja", label: "日本語" },
1445
+ ];
1446
+ el.contextLanguage = overrides?.contextLanguage ?? "zh-Hans";
1447
+ await flushUpdates(el);
1448
+
1449
+ const editor = requireElement(
1450
+ el.querySelector<JantComposeEditor>("jant-compose-editor"),
1451
+ "expected compose editor",
1452
+ );
1453
+ editor._bodyJson = {
1454
+ type: "doc",
1455
+ content: [
1456
+ {
1457
+ type: "paragraph",
1458
+ content: [
1459
+ { type: "text", text: overrides?.text ?? "これはテストです" },
1460
+ ],
1461
+ },
1462
+ ],
1463
+ };
1464
+ await editor.updateComplete;
1465
+ await flushUpdates(el);
1466
+
1467
+ const details: ComposeSubmitDetail[] = [];
1468
+ el.addEventListener("jant:compose-submit-deferred", (event) => {
1469
+ details.push((event as CustomEvent<ComposeSubmitDetail>).detail);
1470
+ });
1471
+ return { el, details };
1472
+ }
1473
+
1474
+ function clickPublish(el: JantComposeDialog) {
1475
+ requireElement(
1476
+ el.querySelector<HTMLButtonElement>(".compose-publish-main"),
1477
+ "expected post button",
1478
+ ).click();
1479
+ }
1480
+
1481
+ it("publishes what the pill says, without stopping to ask", async () => {
1482
+ // The pill grew the word 日本語 the moment detection moved off the
1483
+ // page's language — the same moment a sheet used to interrupt. There
1484
+ // is nothing left for one to add.
1485
+ const { el, details } = await createMismatch();
1486
+ expect(
1487
+ el.querySelector(".compose-language-trigger")?.textContent?.trim(),
1488
+ ).toBe("日本語");
1489
+
1490
+ clickPublish(el);
1491
+ await flushUpdates(el);
1492
+
1493
+ expect(details).toHaveLength(1);
1494
+ expect(details[0]?.language).toBe("ja");
1495
+ });
1496
+
1497
+ it("publishes in the page's language when detection agrees", async () => {
1498
+ const { el, details } = await createMismatch({ contextLanguage: "ja" });
1499
+
1500
+ clickPublish(el);
1501
+ await flushUpdates(el);
1502
+
1503
+ expect(details).toHaveLength(1);
1504
+ expect(details[0]?.language).toBe("ja");
1505
+ });
1506
+
1507
+ it("never second-guesses an explicit choice", async () => {
1508
+ const { el, details } = await createMismatch();
1509
+ // The author picked 简体中文 themselves; the Japanese text is theirs
1510
+ // to own — a quote, perhaps.
1511
+ (el as unknown as { _language: string | null })._language = "zh-Hans";
1512
+ await flushUpdates(el);
1513
+
1514
+ clickPublish(el);
1515
+ await flushUpdates(el);
1516
+
1517
+ expect(details).toHaveLength(1);
1518
+ expect(details[0]?.language).toBe("zh-Hans");
1519
+ });
1520
+ });
1521
+ });
1522
+
1523
+ it("keeps date and permalink on the post, not in the publish panel", async () => {
1524
+ const el = await createElement();
1525
+
1526
+ // They describe one post, so they hang off the post — not the publish
1527
+ // panel, which speaks for the whole submission.
1528
+ requireElement(
1529
+ el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
1530
+ "expected options trigger",
1531
+ ).click();
1532
+ await flushUpdates(el);
1533
+ expect(el.querySelector(".compose-publish-date-input")).toBeNull();
1534
+ expect(el.querySelector(".compose-publish-slug-input")).toBeNull();
1535
+
1536
+ // The pill states both values while the panel is shut.
1537
+ expect(pillValue(el, "date")).toBe("Now");
1538
+ expect(pillValue(el, "slug")).toBe("Auto");
1539
+
1540
+ // Two settings is not a list to drill through: opening the pill opens both
1541
+ // fields at once, with focus on the panel so Tab lands in them.
1542
+ await openPostMeta(el);
1543
+ expect(el.querySelector(".compose-publish-date-input")).not.toBeNull();
1544
+ expect(el.querySelector(".compose-publish-slug-input")).not.toBeNull();
1545
+ expect(el.querySelector("[data-compose-post-meta-panel]")).toBe(
1546
+ document.activeElement,
1547
+ );
1548
+ });
1549
+
1550
+ it("pins the date panel to its pill instead of inside the scroller", async () => {
1551
+ const el = await createElement();
1552
+
1553
+ // The pill lives in `.compose-scroll`, which clips and scrolls. An
1554
+ // absolutely positioned panel is taller than that box on an empty post, so
1555
+ // it lost its footer, and focusing it scrolled the format switcher off the
1556
+ // top to bring it into view. Fixed positioning leaves the scroller behind,
1557
+ // which makes placing the panel our job.
1558
+ const focusCalls: Array<{
1559
+ target: unknown;
1560
+ options?: globalThis.FocusOptions;
1561
+ }> = [];
1562
+ const nativeFocus = globalThis.HTMLElement.prototype.focus;
1563
+ globalThis.HTMLElement.prototype.focus = function (
1564
+ this: globalThis.HTMLElement,
1565
+ options?: globalThis.FocusOptions,
1566
+ ) {
1567
+ focusCalls.push({ target: this, options });
1568
+ return nativeFocus.call(this, options);
1569
+ };
1570
+
1571
+ try {
1572
+ await openPostMeta(el);
1573
+ } finally {
1574
+ globalThis.HTMLElement.prototype.focus = nativeFocus;
1575
+ }
1576
+
1577
+ const panel = requireElement(
1578
+ el.querySelector<globalThis.HTMLElement>(
1579
+ "[data-compose-post-meta-panel]",
1580
+ ),
1581
+ "expected post meta panel",
1582
+ );
1583
+
1584
+ expect(focusCalls.find((call) => call.target === panel)?.options).toEqual({
1585
+ preventScroll: true,
1586
+ });
1587
+
1588
+ // Placed under the pill, and capped at the room the viewport actually has
1589
+ // so a short window scrolls the panel rather than cropping it.
1590
+ expect(panel.style.top).toBe("6px");
1591
+ expect(panel.style.left).not.toBe("");
1592
+ expect(
1593
+ panel.style.getPropertyValue("--compose-post-meta-panel-max-height"),
1594
+ ).toBe(`${globalThis.innerHeight - 12 - 6}px`);
1595
+
1596
+ // Inline coordinates only mean anything if the panel is out of the
1597
+ // scroller — `position: absolute` here is the bug, not a style choice.
1598
+ const css = readComponentCss();
1599
+ expect(css).toMatch(
1600
+ /\.compose-post-meta-panel\s*\{\s*position:\s*fixed;[\s\S]*max-height:\s*var\(--compose-post-meta-panel-max-height,\s*none\);\s*overflow-y:\s*auto;/,
1601
+ );
1602
+ });
1603
+
1604
+ it("keeps the date panel on its pill when the composer scrolls", async () => {
1605
+ const el = await createElement();
1606
+ await openPostMeta(el);
1607
+
1608
+ const panel = requireElement(
1609
+ el.querySelector<globalThis.HTMLElement>(
1610
+ "[data-compose-post-meta-panel]",
1611
+ ),
1612
+ "expected post meta panel",
1613
+ );
1614
+ const pill = requireElement(
1615
+ el.querySelector<globalThis.HTMLElement>("[data-compose-post-meta-pill]"),
1616
+ "expected post meta pill",
1617
+ );
1618
+ pill.getBoundingClientRect = () =>
1619
+ ({
1620
+ top: 100,
1621
+ bottom: 130,
1622
+ left: 300,
1623
+ right: 400,
1624
+ width: 100,
1625
+ height: 30,
1626
+ }) as globalThis.DOMRect;
1627
+
1628
+ // A `scroll` event on the composer's own box never reaches `window` — it
1629
+ // doesn't bubble — so a pinned panel would hang in place while the pill it
1630
+ // belongs to slid away underneath it.
1631
+ const scroller = requireElement(
1632
+ el.querySelector(".compose-scroll"),
1633
+ "expected compose scroller",
1634
+ );
1635
+ scroller.dispatchEvent(new globalThis.Event("scroll"));
1636
+
1637
+ expect(panel.style.top).toBe("136px");
1638
+ expect(panel.style.left).toBe("400px");
1639
+ });
1640
+
1641
+ it("opens the calendar from its own button, not the UA glyph", async () => {
1642
+ const el = await createElement();
1643
+ await openPostMeta(el);
1644
+
1645
+ const input = requireElement(
1646
+ el.querySelector<HTMLInputElement>(".compose-publish-date-input"),
1647
+ "expected publish date input",
1648
+ );
1649
+ // Chrome only opens the picker from its ~13px indicator, which is hidden
1650
+ // here — the button has to drive the same picker itself.
1651
+ const showPicker = vi.fn();
1652
+ (input as HTMLInputElement & { showPicker: () => void }).showPicker =
1653
+ showPicker;
1654
+
1655
+ requireElement(
1656
+ el.querySelector<HTMLButtonElement>("[data-compose-date-picker]"),
1657
+ "expected date picker button",
1658
+ ).click();
1659
+ await flushUpdates(el);
1660
+
1661
+ expect(showPicker).toHaveBeenCalledTimes(1);
1662
+ expect(input).toBe(document.activeElement);
1663
+ });
1664
+
1665
+ it("keeps the date field usable when showPicker is unsupported", async () => {
1666
+ const el = await createElement();
1667
+ await openPostMeta(el);
1668
+
1669
+ const input = requireElement(
1670
+ el.querySelector<HTMLInputElement>(".compose-publish-date-input"),
1671
+ "expected publish date input",
1672
+ );
1673
+ // Engines without `showPicker` must still get a focused, typable field
1674
+ // rather than an uncaught TypeError from the button.
1675
+ expect(
1676
+ (input as HTMLInputElement & { showPicker?: () => void }).showPicker,
1677
+ ).toBeUndefined();
1678
+
1679
+ expect(() =>
1680
+ requireElement(
1681
+ el.querySelector<HTMLButtonElement>("[data-compose-date-picker]"),
1682
+ "expected date picker button",
1683
+ ).click(),
1684
+ ).not.toThrow();
1685
+ await flushUpdates(el);
1686
+
1687
+ expect(input).toBe(document.activeElement);
1688
+ });
1689
+
1690
+ it("closes the date panel from its Done button", async () => {
1691
+ const el = await createElement();
1692
+ await openPostMeta(el);
1693
+
1694
+ // Escape and an outside click both close it, but neither announces itself,
1695
+ // and touch has no Escape key at all.
1696
+ requireElement(
1697
+ el.querySelector<HTMLButtonElement>("[data-compose-post-meta-done]"),
1698
+ "expected Done button",
1699
+ ).click();
1700
+ await flushUpdates(el);
1701
+
1702
+ expect(el.querySelector("[data-compose-post-meta-panel]")).toBeNull();
1703
+ expect(el.querySelector(".compose-publish-date-input")).toBeNull();
1704
+ });
1705
+
1706
+ it("hands focus back to the post when the date panel closes", async () => {
1707
+ const el = await createElement();
1708
+ await openPostMeta(el);
1709
+ expect(el.querySelector("[data-compose-post-meta-panel]")).toBe(
1710
+ document.activeElement,
1711
+ );
1712
+
1713
+ // The panel holds focus while it is open, so it would take focus out of the
1714
+ // document with it on the way out. The pill is only visible on hover, focus
1715
+ // or a non-default value — and on touch there is no hover — so losing focus
1716
+ // to `<body>` hides the only way back in.
1717
+ const editor = requireElement(
1718
+ el.querySelector<JantComposeEditor>("jant-compose-editor"),
1719
+ "expected editor",
1720
+ );
1721
+ const focusInput = vi.spyOn(editor, "focusInput");
1722
+
1723
+ requireElement(
1724
+ el.querySelector<HTMLButtonElement>("[data-compose-post-meta-pill]"),
1725
+ "expected post meta pill",
1726
+ ).click();
1727
+ await flushUpdates(el);
1728
+
1729
+ expect(el.querySelector("[data-compose-post-meta-panel]")).toBeNull();
1730
+ expect(focusInput).toHaveBeenCalled();
1731
+ });
1732
+
1733
+ it("explains every visibility option and checks the selected one", async () => {
1734
+ const el = await createElement();
1735
+
1736
+ requireElement(
1737
+ el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
1738
+ "expected publish settings toggle",
1739
+ ).click();
1740
+ await el.updateComplete;
1741
+
1742
+ // Each row carries its own hint, so the options can be compared before
1743
+ // one is picked — a single hint under a chip group could not do that.
1744
+ expect(el.textContent).toContain("Appears in Latest.");
1745
+ expect(el.textContent).toContain("Only visible when signed in.");
1746
+
1747
+ const rows = el.querySelectorAll<HTMLButtonElement>(
1748
+ ".compose-sheet-row[role='radio']",
1749
+ );
1750
+ expect(rows).toHaveLength(3);
1751
+ expect(rows[0].getAttribute("aria-checked")).toBe("true");
1752
+ expect(rows[0].querySelector(".compose-sheet-check")).not.toBeNull();
1753
+ expect(rows[2].querySelector(".compose-sheet-check")).toBeNull();
1754
+
1755
+ rows[2].click();
1756
+ await flushUpdates(el);
1757
+
1758
+ // Picking is the whole job of this panel, so it dismisses itself — no
1759
+ // Done button to confirm a radio selection.
1760
+ expect(el._visibility).toBe("private");
1761
+ expect(el.querySelector(".compose-sheet")).toBeNull();
1762
+ expect(el.querySelector(".compose-publish-done")).toBeNull();
1763
+
1764
+ // Reopening shows the new choice checked.
1765
+ requireElement(
1766
+ el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
1767
+ "expected publish settings toggle",
1768
+ ).click();
1769
+ await flushUpdates(el);
1770
+ const updated = el.querySelectorAll<HTMLButtonElement>(
1771
+ ".compose-sheet-row[role='radio']",
1772
+ );
1773
+ expect(updated[2].getAttribute("aria-checked")).toBe("true");
1774
+ expect(updated[2].querySelector(".compose-sheet-check")).not.toBeNull();
1775
+ expect(updated[0].querySelector(".compose-sheet-check")).toBeNull();
1776
+ });
1777
+
1778
+ it("format switching updates active state", async () => {
1779
+ const el = await createElement();
1780
+
1781
+ // Note is active by default
1782
+ const noteBtn = el.querySelectorAll<HTMLButtonElement>(
1783
+ ".compose-segmented-item",
1784
+ )[0];
1785
+ expect(noteBtn.classList.contains("compose-segmented-item-active")).toBe(
1786
+ true,
1787
+ );
1788
+
1789
+ // Click link
1790
+ const linkBtn = el.querySelectorAll<HTMLButtonElement>(
1791
+ ".compose-segmented-item",
1792
+ )[1];
1793
+ linkBtn.click();
1794
+ await el.updateComplete;
1795
+
1796
+ expect(el._format).toBe("link");
1797
+ expect(linkBtn.classList.contains("compose-segmented-item-active")).toBe(
1798
+ true,
1799
+ );
1800
+ expect(noteBtn.classList.contains("compose-segmented-item-active")).toBe(
1801
+ false,
1802
+ );
1803
+ expect(
1804
+ el.querySelector('.compose-tool-btn-view[aria-label="Fullscreen"]'),
1805
+ ).toBeNull();
1806
+ });
1807
+
1808
+ it("autofocuses the primary field on desktop format switches", async () => {
1809
+ vi.spyOn(globalThis, "requestAnimationFrame").mockImplementation((cb) => {
1810
+ cb(0);
1811
+ return 1;
1812
+ });
1813
+ const el = await createElement();
1814
+ const editor = requireElement(
1815
+ el.querySelector<JantComposeEditor>("jant-compose-editor"),
1816
+ "expected compose editor",
1817
+ );
1818
+ const focusSpy = vi.spyOn(editor, "focusInput");
1819
+
1820
+ const linkBtn = el.querySelectorAll<HTMLButtonElement>(
1821
+ ".compose-segmented-item",
1822
+ )[1];
1823
+ linkBtn.click();
1824
+ await flushUpdates(el);
1825
+
1826
+ expect(focusSpy).toHaveBeenCalledTimes(1);
1827
+ });
1828
+
1829
+ it("does not autofocus the primary field on touch format switches", async () => {
1830
+ mockMatchMedia(true);
1831
+ vi.spyOn(globalThis, "requestAnimationFrame").mockImplementation((cb) => {
1832
+ cb(0);
1833
+ return 1;
1834
+ });
1835
+ const el = await createElement();
1836
+ const editor = requireElement(
1837
+ el.querySelector<JantComposeEditor>("jant-compose-editor"),
1838
+ "expected compose editor",
1839
+ );
1840
+ const focusSpy = vi.spyOn(editor, "focusInput");
1841
+
1842
+ const linkBtn = el.querySelectorAll<HTMLButtonElement>(
1843
+ ".compose-segmented-item",
1844
+ )[1];
1845
+ linkBtn.click();
1846
+ await flushUpdates(el);
1847
+
1848
+ expect(focusSpy).not.toHaveBeenCalled();
1849
+ });
1850
+
1851
+ function mockEditPost(post: Record<string, unknown>) {
1852
+ return vi.spyOn(globalThis, "fetch").mockResolvedValue(
1853
+ new Response(JSON.stringify({ id: "pst_123", ...post }), {
1854
+ headers: { "Content-Type": "application/json" },
1855
+ }),
1856
+ );
1857
+ }
1858
+
1859
+ it("shows the format switcher above the post while editing", async () => {
1860
+ vi.spyOn(globalThis, "requestAnimationFrame").mockImplementation((cb) => {
1861
+ cb(0);
1862
+ return 1;
1863
+ });
1864
+ mockEditPost({ format: "note", title: "Hello", body: null });
1865
+
1866
+ const el = await createElement();
1867
+ await el.openEdit("pst_123");
1868
+ await flushUpdates(el);
1869
+
1870
+ // The switcher has exactly one home: above its own post. Editing says so
1871
+ // through the submit label and a marker in the header row, not a title bar.
1872
+ expect(el.querySelector(".compose-thread-post-header")).toBeTruthy();
1873
+ expect(el.querySelector(".compose-dialog-header")).toBeNull();
1874
+ expect(
1875
+ el
1876
+ .querySelector(".compose-thread-post-header .compose-post-badge")
1877
+ ?.textContent?.trim(),
1878
+ ).toBe(labels.editing);
1879
+ expect(el.querySelector(".compose-publish-main")?.textContent?.trim()).toBe(
1880
+ labels.update,
1881
+ );
1882
+ });
1883
+
1884
+ it("shows the format selector above the post when replying", async () => {
1885
+ const el = await createElement();
1886
+ await el.openReply("019ce8ce-d6d8-7fda-a5df-c2da2bef5ade", {
1887
+ contentHtml: "<p>Parent</p>",
1888
+ dateText: "Mar 14",
1889
+ });
1890
+ await flushUpdates(el);
1891
+
1892
+ expect(el.querySelector(".compose-dialog-header")).toBeNull();
1893
+ // The selector sits inline above the reply editor.
1894
+ expect(el.querySelector(".compose-thread-post-header")).not.toBeNull();
1895
+ expect(
1896
+ el.querySelector(".compose-thread-layout.compose-reply-compose-layout"),
1897
+ ).not.toBeNull();
1898
+ expect(el.querySelector(".compose-collection-trigger")).toBeNull();
1899
+ expect(
1900
+ el.querySelector(".compose-action-row-without-collection"),
1901
+ ).not.toBeNull();
1902
+ expect(el.querySelector(".compose-publish-main")).not.toBeNull();
1903
+ });
1904
+
1905
+ it("puts a reply's add-to-thread row on the rail with the other posts", async () => {
1906
+ const el = await createElement();
1907
+ await el.openReply("019ce8ce-d6d8-7fda-a5df-c2da2bef5ade", {
1908
+ contentHtml: "<p>Parent</p>",
1909
+ dateText: "Mar 14",
1910
+ });
1911
+ await flushUpdates(el);
1912
+
1913
+ // Inside the layout, not a sibling of it: out there it misses the rail's
1914
+ // indent and lands a rail's width left of everything above it.
1915
+ const layout = el.querySelector(
1916
+ ".compose-thread-layout.compose-reply-compose-layout",
1917
+ );
1918
+ const addRow = layout?.querySelector(":scope > .compose-thread-add-row");
1919
+ expect(addRow).not.toBeNull();
1920
+ expect(el.querySelector(".compose-add-thread-trigger")).toBeNull();
1921
+ // The dashed dot marks where the next post lands, as in thread compose.
1922
+ expect(addRow?.querySelector(".compose-thread-add-dot")).not.toBeNull();
1923
+ // ...and it stays the last row, so the rail still stops at the reply.
1924
+ expect(layout?.lastElementChild).toBe(addRow);
1925
+ });
1926
+
1927
+ it("keeps a single post's add-to-thread row off the rail", async () => {
1928
+ const el = await createElement();
1929
+ await flushUpdates(el);
1930
+
1931
+ // No rail to join, so no dot: the row's position says "next slot" alone.
1932
+ const trigger = el.querySelector(".compose-add-thread-trigger");
1933
+ expect(trigger).not.toBeNull();
1934
+ expect(el.querySelector(".compose-thread-add-dot")).toBeNull();
1935
+ expect(trigger?.querySelector(".compose-thread-add-btn")).not.toBeNull();
1936
+ });
1937
+
1938
+ it("shows an Edit title with the format selector above the post when editing a reply", async () => {
1939
+ vi.spyOn(globalThis, "requestAnimationFrame").mockImplementation((cb) => {
1940
+ cb(0);
1941
+ return 1;
1942
+ });
1943
+ const parentId = "019ce8ce-d6d8-7fda-a5df-c2da2bef5ade";
1944
+ // A fresh Response per call: openEdit reads the edited post, then
1945
+ // _fetchReplyContext reads the parent — a single shared Response body can
1946
+ // only be consumed once.
1947
+ vi.spyOn(globalThis, "fetch").mockImplementation((input) => {
1948
+ const url = String(input);
1949
+ const json = url.includes(parentId)
1950
+ ? { id: parentId, bodyHtml: "<p>Parent</p>", format: "note" }
1951
+ : {
1952
+ id: "pst_123",
1953
+ format: "note",
1954
+ title: "Hello",
1955
+ body: null,
1956
+ replyToId: parentId,
1957
+ collectionIds: ["col-thread"],
1958
+ };
1959
+ return Promise.resolve(
1960
+ new Response(JSON.stringify(json), {
1961
+ headers: { "Content-Type": "application/json" },
1962
+ }),
1963
+ );
1964
+ });
1965
+
1966
+ const el = await createElement();
1967
+ await el.openEdit("pst_123");
1968
+ await flushUpdates(el);
1969
+
1970
+ expect(el.querySelector(".compose-publish-main")?.textContent?.trim()).toBe(
1971
+ labels.update,
1972
+ );
1973
+ expect(el.querySelector(".compose-dialog-header")).toBeNull();
1974
+ expect(el.querySelector(".compose-thread-post-header")).not.toBeNull();
1975
+ expect(el._collectionIds).toEqual([]);
1976
+ expect(el.querySelector(".compose-collection-trigger")).toBeNull();
1977
+ });
1978
+
1979
+ it("keeps the format selector above the post in single-post mode", async () => {
1980
+ const el = await createElement();
1981
+ await flushUpdates(el);
1982
+
1983
+ // One selector, one place — above its own post.
1984
+ const items = el.querySelectorAll<HTMLButtonElement>(
1985
+ ".compose-thread-post-header .compose-segmented-item",
1986
+ );
1987
+ expect(items.length).toBe(3);
1988
+
1989
+ items[1].click(); // link
1990
+ await flushUpdates(el);
1991
+ expect(el._format).toBe("link");
1992
+
1993
+ // A single post has no "which one", so no position marker.
1994
+ expect(el.querySelector(".compose-post-position")).toBeNull();
1995
+ });
1996
+
1997
+ it("gives every thread post its own date and permalink control", async () => {
1998
+ const el = await createElement();
1999
+ el._threadItems = [
2000
+ { id: "thread-1", format: "note" },
2001
+ { id: "thread-2", format: "note" },
2002
+ ];
2003
+ await flushUpdates(el);
2004
+
2005
+ // Every post is separately addressable (`path_registry` has a row each),
2006
+ // so every post gets both controls — not just the root.
2007
+ const pills = el.querySelectorAll("[data-compose-post-meta-pill]");
2008
+ expect(pills).toHaveLength(2);
2009
+ for (const pill of pills) {
2010
+ expect(
2011
+ pill.querySelector(".compose-post-meta-value-slug"),
2012
+ ).not.toBeNull();
2013
+ }
2014
+ });
2015
+
2016
+ it("sends a reply's own permalink and blocks an invalid one", async () => {
2017
+ const el = await createElement();
2018
+ el._threadItems = [
2019
+ { id: "thread-1", format: "note" },
2020
+ { id: "thread-2", format: "note", slug: "part-two" },
2021
+ ];
2022
+ await flushUpdates(el);
2023
+
2024
+ const build = (
2025
+ el as unknown as {
2026
+ _buildEditorPostDetail: (
2027
+ editor: JantComposeEditor,
2028
+ format: string,
2029
+ index: number,
2030
+ status: string,
2031
+ ) => ComposeSubmitDetail;
2032
+ }
2033
+ )._buildEditorPostDetail;
2034
+ const editors = el.querySelectorAll<JantComposeEditor>(
2035
+ "jant-compose-editor",
2036
+ );
2037
+
2038
+ expect(build.call(el, editors[1], "note", 1, "published").slug).toBe(
2039
+ "part-two",
2040
+ );
2041
+ expect(build.call(el, editors[0], "note", 0, "published").slug).toBe(
2042
+ undefined,
2043
+ );
2044
+
2045
+ // A reply's bad slug blocks publish just like the root's would.
2046
+ el._threadItems = [
2047
+ { id: "thread-1", format: "note" },
2048
+ { id: "thread-2", format: "note", slug: "not a slug!" },
2049
+ ];
2050
+ await flushUpdates(el);
2051
+ expect(
2052
+ (el as unknown as { _canPublish: () => boolean })._canPublish(),
2053
+ ).toBe(false);
2054
+ });
2055
+
2056
+ it("sends a reply's own date and leaves the rest for the server to inherit", async () => {
2057
+ const el = await createElement();
2058
+ el._threadItems = [
2059
+ { id: "thread-1", format: "note" },
2060
+ { id: "thread-2", format: "note", publishedAtInput: "2024-03-09" },
2061
+ { id: "thread-3", format: "note" },
2062
+ ];
2063
+ await flushUpdates(el);
2064
+
2065
+ const detail = (
2066
+ el as unknown as {
2067
+ _buildEditorPostDetail: (
2068
+ editor: JantComposeEditor,
2069
+ format: string,
2070
+ index: number,
2071
+ status: string,
2072
+ ) => ComposeSubmitDetail;
2073
+ }
2074
+ )._buildEditorPostDetail;
2075
+ const editors = el.querySelectorAll<JantComposeEditor>(
2076
+ "jant-compose-editor",
2077
+ );
2078
+
2079
+ const second = detail.call(el, editors[1], "note", 1, "published");
2080
+ const third = detail.call(el, editors[2], "note", 2, "published");
2081
+
2082
+ expect(second.publishedAt).toBeGreaterThan(0);
2083
+ // Undefined, not "now" — the server fills it from the root.
2084
+ expect(third.publishedAt).toBeUndefined();
2085
+ });
2086
+
2087
+ it("blocks publishing when any thread post has a future date", async () => {
2088
+ const el = await createElement();
2089
+ const future = new Date(Date.now() + 86400000 * 3)
2090
+ .toISOString()
2091
+ .slice(0, 10);
2092
+ el._threadItems = [
2093
+ { id: "thread-1", format: "note" },
2094
+ { id: "thread-2", format: "note", publishedAtInput: future },
2095
+ ];
2096
+ await flushUpdates(el);
2097
+
2098
+ expect(
2099
+ (el as unknown as { _canPublish: () => boolean })._canPublish(),
2100
+ ).toBe(false);
2101
+ });
2102
+
2103
+ /**
2104
+ * Publish reads each row's own answer, so a row that has been removed stops
2105
+ * being asked. These used to fail: the button was computed during render from
2106
+ * the editors still in the DOM — including the one that render was about to
2107
+ * remove.
2108
+ */
2109
+ describe("publish reflects the rows that are left", () => {
2110
+ const publishButton = (el: JantComposeDialog) =>
2111
+ requireElement(
2112
+ el.querySelector<HTMLButtonElement>(".compose-publish-main"),
2113
+ "expected publish button",
2114
+ );
2115
+
2116
+ async function typeInto(
2117
+ el: JantComposeDialog,
2118
+ index: number,
2119
+ text: string,
2120
+ ) {
2121
+ const editor = el.querySelectorAll<JantComposeEditor>(
2122
+ "jant-compose-editor",
2123
+ )[index];
2124
+ requireEditor(
2125
+ requireElement(editor, `expected editor ${index}`),
2126
+ ).commands.insertContent(text);
2127
+ await flushUpdates(el);
2128
+ }
2129
+
2130
+ async function addPost(el: JantComposeDialog) {
2131
+ requireElement(
2132
+ el.querySelector<HTMLButtonElement>(".compose-thread-add-btn"),
2133
+ "expected add-to-thread button",
2134
+ ).click();
2135
+ await flushUpdates(el);
2136
+ await flushUpdates(el);
2137
+ }
2138
+
2139
+ async function removePost(el: JantComposeDialog, index: number) {
2140
+ requireElement(
2141
+ el.querySelectorAll<HTMLButtonElement>(".compose-thread-post-remove")[
2142
+ index
2143
+ ],
2144
+ `expected remove button ${index}`,
2145
+ ).click();
2146
+ await flushUpdates(el);
2147
+ await flushUpdates(el);
2148
+ }
2149
+
2150
+ it("re-enables publish when the empty last post is removed", async () => {
2151
+ const el = await createElement();
2152
+ await typeInto(el, 0, "one");
2153
+ await addPost(el);
2154
+ await typeInto(el, 1, "two");
2155
+ await addPost(el);
2156
+
2157
+ expect(publishButton(el).disabled).toBe(true);
547
2158
 
548
- expect(el.textContent).toContain("Only visible when signed in.");
549
- expect(el.textContent).not.toContain("Appears in Latest.");
550
- });
2159
+ await removePost(el, 2);
551
2160
 
552
- it("format switching updates active state", async () => {
553
- const el = await createElement();
2161
+ expect(publishButton(el).disabled).toBe(false);
2162
+ });
554
2163
 
555
- // Note is active by default
556
- const noteBtn = el.querySelectorAll<HTMLButtonElement>(
557
- ".compose-segmented-item",
558
- )[0];
559
- expect(noteBtn.classList.contains("compose-segmented-item-active")).toBe(
560
- true,
561
- );
2164
+ it("re-enables publish when the emptied post is the first of two", async () => {
2165
+ const el = await createElement();
2166
+ await typeInto(el, 0, "one");
2167
+ await addPost(el);
2168
+ await typeInto(el, 1, "two");
562
2169
 
563
- // Click link
564
- const linkBtn = el.querySelectorAll<HTMLButtonElement>(
565
- ".compose-segmented-item",
566
- )[1];
567
- linkBtn.click();
568
- await el.updateComplete;
2170
+ requireEditor(
2171
+ el.querySelectorAll<JantComposeEditor>("jant-compose-editor")[0],
2172
+ ).commands.clearContent();
2173
+ await flushUpdates(el);
2174
+ expect(publishButton(el).disabled).toBe(true);
569
2175
 
570
- expect(el._format).toBe("link");
571
- expect(linkBtn.classList.contains("compose-segmented-item-active")).toBe(
572
- true,
573
- );
574
- expect(noteBtn.classList.contains("compose-segmented-item-active")).toBe(
575
- false,
576
- );
577
- expect(
578
- el.querySelector('.compose-tool-btn-view[aria-label="Fullscreen"]'),
579
- ).toBeNull();
580
- });
2176
+ await removePost(el, 0);
581
2177
 
582
- it("autofocuses the primary field on desktop format switches", async () => {
583
- vi.spyOn(globalThis, "requestAnimationFrame").mockImplementation((cb) => {
584
- cb(0);
585
- return 1;
2178
+ expect(publishButton(el).disabled).toBe(false);
586
2179
  });
587
- const el = await createElement();
588
- const editor = requireElement(
589
- el.querySelector<JantComposeEditor>("jant-compose-editor"),
590
- "expected compose editor",
591
- );
592
- const focusSpy = vi.spyOn(editor, "focusInput");
593
-
594
- const linkBtn = el.querySelectorAll<HTMLButtonElement>(
595
- ".compose-segmented-item",
596
- )[1];
597
- linkBtn.click();
598
- await flushUpdates(el);
599
2180
 
600
- expect(focusSpy).toHaveBeenCalledTimes(1);
2181
+ it("keeps each post's own text when the middle post is removed", async () => {
2182
+ const el = await createElement();
2183
+ await typeInto(el, 0, "AAA");
2184
+ await addPost(el);
2185
+ await typeInto(el, 1, "BBB");
2186
+ await addPost(el);
2187
+ await typeInto(el, 2, "CCC");
2188
+
2189
+ await removePost(el, 1);
2190
+
2191
+ const text = Array.from(
2192
+ el.querySelectorAll<JantComposeEditor>("jant-compose-editor"),
2193
+ ).map((editor) => requireEditor(editor).getText());
2194
+ expect(text).toEqual(["AAA", "CCC"]);
2195
+ });
601
2196
  });
602
2197
 
603
- it("does not autofocus the primary field on touch format switches", async () => {
604
- mockMatchMedia(true);
605
- vi.spyOn(globalThis, "requestAnimationFrame").mockImplementation((cb) => {
606
- cb(0);
607
- return 1;
608
- });
2198
+ it("numbers each post in a thread", async () => {
609
2199
  const el = await createElement();
610
- const editor = requireElement(
611
- el.querySelector<JantComposeEditor>("jant-compose-editor"),
612
- "expected compose editor",
613
- );
614
- const focusSpy = vi.spyOn(editor, "focusInput");
2200
+ el._threadItems = [
2201
+ { id: "thread-1", format: "note" },
2202
+ { id: "thread-2", format: "note" },
2203
+ ];
2204
+ await flushUpdates(el);
615
2205
 
616
- const linkBtn = el.querySelectorAll<HTMLButtonElement>(
617
- ".compose-segmented-item",
618
- )[1];
619
- linkBtn.click();
2206
+ const positions = () =>
2207
+ Array.from(el.querySelectorAll(".compose-post-position")).map((n) =>
2208
+ n.textContent?.trim(),
2209
+ );
2210
+ expect(positions()).toEqual(["1/2", "2/2"]);
2211
+
2212
+ (el as unknown as { _addThreadItem: () => void })._addThreadItem();
620
2213
  await flushUpdates(el);
621
2214
 
622
- expect(focusSpy).not.toHaveBeenCalled();
2215
+ // The total re-renders on every post, not just the new one.
2216
+ expect(positions()).toEqual(["1/3", "2/3", "3/3"]);
623
2217
  });
624
2218
 
625
- function mockEditPost(post: Record<string, unknown>) {
626
- return vi.spyOn(globalThis, "fetch").mockResolvedValue(
627
- new Response(JSON.stringify({ id: "pst_123", ...post }), {
2219
+ const positionsIn = (el: JantComposeDialog) =>
2220
+ Array.from(el.querySelectorAll(".compose-post-position")).map((n) =>
2221
+ n.textContent?.trim(),
2222
+ );
2223
+
2224
+ it("numbers a reply from the end of the thread it continues", async () => {
2225
+ // The parent is the second post of a thread, so the reply is the third —
2226
+ // not the first, and not the second.
2227
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(
2228
+ new Response(JSON.stringify({ id: "pst_parent", threadPosition: 2 }), {
628
2229
  headers: { "Content-Type": "application/json" },
629
2230
  }),
630
2231
  );
631
- }
632
2232
 
633
- it("shows the format switcher instead of a title while editing", async () => {
634
- vi.spyOn(globalThis, "requestAnimationFrame").mockImplementation((cb) => {
635
- cb(0);
636
- return 1;
2233
+ const el = await createElement();
2234
+ await el.openReply("pst_parent", {
2235
+ contentHtml: "<p>Parent</p>",
2236
+ dateText: "Mar 14",
637
2237
  });
638
- mockEditPost({ format: "note", title: "Hello", body: null });
2238
+ await flushUpdates(el);
2239
+ await flushUpdates(el);
639
2240
 
640
- const el = await createElement();
641
- await el.openEdit("pst_123");
2241
+ expect(positionsIn(el)).toEqual(["3/3"]);
2242
+
2243
+ (el as unknown as { _addThreadItem: () => void })._addThreadItem();
642
2244
  await flushUpdates(el);
643
2245
 
644
- expect(el.querySelector(".compose-segmented")).not.toBeNull();
645
- // The "Edit post" title is gone — the switcher takes the center slot.
646
- expect(el.querySelector(".compose-dialog-title")).toBeNull();
2246
+ expect(positionsIn(el)).toEqual(["3/4", "4/4"]);
647
2247
  });
648
2248
 
649
- it("shows a Reply title with the format selector above the post when replying", async () => {
2249
+ it("shows no number for a reply until the parent's position is known", async () => {
2250
+ // Counting from 1 and then correcting to 3 is worse than arriving late, so
2251
+ // the marker stays away while the lookup is in flight — and stays away for
2252
+ // good if it fails.
2253
+ vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("offline"));
2254
+
650
2255
  const el = await createElement();
651
- await el.openReply("019ce8ce-d6d8-7fda-a5df-c2da2bef5ade", {
2256
+ await el.openReply("pst_parent", {
652
2257
  contentHtml: "<p>Parent</p>",
653
2258
  dateText: "Mar 14",
654
2259
  });
655
2260
  await flushUpdates(el);
2261
+ await flushUpdates(el);
656
2262
 
657
- expect(el.querySelector(".compose-dialog-title")?.textContent?.trim()).toBe(
658
- "Reply",
659
- );
660
- // The header center no longer hosts the format selector...
661
- expect(
662
- el.querySelector(".compose-dialog-header-center .compose-segmented"),
663
- ).toBeNull();
664
- // ...it sits inline above the reply editor instead.
665
- expect(el.querySelector(".compose-thread-post-header")).not.toBeNull();
666
- expect(
667
- el.querySelector(".compose-thread-layout.compose-reply-compose-layout"),
668
- ).not.toBeNull();
669
- expect(el.querySelector(".compose-collection-trigger")).toBeNull();
670
- expect(
671
- el.querySelector(".compose-action-row-without-collection"),
672
- ).not.toBeNull();
673
- expect(el.querySelector(".compose-publish-main")).not.toBeNull();
2263
+ expect(positionsIn(el)).toEqual([]);
674
2264
  });
675
2265
 
676
- it("shows an Edit title with the format selector above the post when editing a reply", async () => {
677
- vi.spyOn(globalThis, "requestAnimationFrame").mockImplementation((cb) => {
678
- cb(0);
679
- return 1;
680
- });
681
- const parentId = "019ce8ce-d6d8-7fda-a5df-c2da2bef5ade";
682
- // A fresh Response per call: openEdit reads the edited post, then
683
- // _fetchReplyContext reads the parent — a single shared Response body can
684
- // only be consumed once.
685
- vi.spyOn(globalThis, "fetch").mockImplementation((input) => {
686
- const url = String(input);
687
- const json = url.includes(parentId)
688
- ? { id: parentId, bodyHtml: "<p>Parent</p>", format: "note" }
689
- : {
690
- id: "pst_123",
691
- format: "note",
692
- title: "Hello",
693
- body: null,
694
- replyToId: parentId,
695
- collectionIds: ["col-thread"],
696
- };
697
- return Promise.resolve(
698
- new Response(JSON.stringify(json), {
699
- headers: { "Content-Type": "application/json" },
700
- }),
701
- );
702
- });
703
-
2266
+ it("leaves a lone new post unnumbered — there is no chain to place it in", async () => {
704
2267
  const el = await createElement();
705
- await el.openEdit("pst_123");
706
2268
  await flushUpdates(el);
707
2269
 
708
- expect(el.querySelector(".compose-dialog-title")?.textContent?.trim()).toBe(
709
- "Edit",
710
- );
711
- expect(
712
- el.querySelector(".compose-dialog-header-center .compose-segmented"),
713
- ).toBeNull();
714
- expect(el.querySelector(".compose-thread-post-header")).not.toBeNull();
715
- expect(el._collectionIds).toEqual([]);
716
- expect(el.querySelector(".compose-collection-trigger")).toBeNull();
2270
+ expect(el.querySelector(".compose-post-position")).toBeNull();
717
2271
  });
718
2272
 
719
2273
  it("switches format from the inline selector when replying", async () => {
@@ -959,8 +2513,7 @@ describe("JantComposeDialog", () => {
959
2513
  await editor.updateComplete;
960
2514
 
961
2515
  let receivedDetail:
962
- | (ComposeSubmitDetail & { pendingAttachments: unknown[] })
963
- | null = null;
2516
+ (ComposeSubmitDetail & { pendingAttachments: unknown[] }) | null = null;
964
2517
  el.addEventListener("jant:compose-submit-deferred", (event) => {
965
2518
  const customEvent = event as CustomEvent<
966
2519
  ComposeSubmitDetail & { pendingAttachments: unknown[] }
@@ -1040,13 +2593,13 @@ describe("JantComposeDialog", () => {
1040
2593
  await editor.updateComplete;
1041
2594
 
1042
2595
  requireElement(
1043
- el.querySelector<HTMLButtonElement>(".compose-publish-toggle"),
2596
+ el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
1044
2597
  "expected publish settings toggle",
1045
2598
  ).click();
1046
2599
  await el.updateComplete;
1047
2600
 
1048
2601
  const options = el.querySelectorAll<HTMLButtonElement>(
1049
- ".compose-publish-chip[role='radio']",
2602
+ ".compose-sheet-row[role='radio']",
1050
2603
  );
1051
2604
  expect(options).toHaveLength(3);
1052
2605
  options[1]?.click();
@@ -1100,11 +2653,12 @@ describe("JantComposeDialog", () => {
1100
2653
  await editor.updateComplete;
1101
2654
 
1102
2655
  requireElement(
1103
- el.querySelector<HTMLButtonElement>(".compose-publish-toggle"),
2656
+ el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
1104
2657
  "expected publish settings toggle",
1105
2658
  ).click();
1106
2659
  await el.updateComplete;
1107
2660
 
2661
+ await openPostMeta(el);
1108
2662
  const publishedAtInput = requireElement(
1109
2663
  el.querySelector<HTMLInputElement>(".compose-publish-date-input"),
1110
2664
  "expected publish date input",
@@ -1145,16 +2699,17 @@ describe("JantComposeDialog", () => {
1145
2699
  }
1146
2700
  });
1147
2701
 
1148
- it("shows a publish date summary near the submit controls after the date changes", async () => {
2702
+ it("keeps the chosen publish date readable on the post's pill", async () => {
1149
2703
  const el = await createElement();
1150
2704
 
1151
2705
  const publishToggle = requireElement(
1152
- el.querySelector<HTMLButtonElement>(".compose-publish-toggle"),
2706
+ el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
1153
2707
  "expected publish settings toggle",
1154
2708
  );
1155
2709
  publishToggle.click();
1156
2710
  await el.updateComplete;
1157
2711
 
2712
+ await openPostMeta(el);
1158
2713
  const publishedAtInput = requireElement(
1159
2714
  el.querySelector<HTMLInputElement>(".compose-publish-date-input"),
1160
2715
  "expected publish date input",
@@ -1163,31 +2718,25 @@ describe("JantComposeDialog", () => {
1163
2718
  publishedAtInput.dispatchEvent(new Event("input", { bubbles: true }));
1164
2719
  await el.updateComplete;
1165
2720
 
1166
- publishToggle.click();
1167
- await el.updateComplete;
1168
-
1169
- const summary = requireElement(
1170
- el.querySelector<HTMLButtonElement>(
1171
- "[data-compose-publish-date-summary]",
1172
- ),
1173
- "expected publish date summary",
1174
- );
1175
- expect(summary.dataset.publishDate).toBe("2024-01-15");
1176
- expect(summary.textContent).toContain("2024");
1177
-
1178
- summary.click();
1179
- await el.updateComplete;
2721
+ // Close the panel: the chosen date stays readable on the pill.
2722
+ requireElement(
2723
+ el.querySelector<HTMLButtonElement>("[data-compose-post-meta-pill]"),
2724
+ "expected post meta pill",
2725
+ ).click();
1180
2726
  await flushUpdates(el);
2727
+ expect(el.querySelector(".compose-publish-date-input")).toBeNull();
2728
+ expect(pillValue(el, "date")).toContain("2024");
1181
2729
 
2730
+ await openPostMeta(el);
1182
2731
  expect(
1183
2732
  requireElement(
1184
2733
  el.querySelector<HTMLInputElement>(".compose-publish-date-input"),
1185
2734
  "expected publish date input after reopening publish settings",
1186
- ),
1187
- ).toBe(document.activeElement);
2735
+ ).value,
2736
+ ).toBe("2024-01-15");
1188
2737
  });
1189
2738
 
1190
- it("does not show a publish date summary for an unchanged date while editing a post", async () => {
2739
+ it("shows the existing publish date on the post pill while editing a post", async () => {
1191
2740
  const el = await createElement();
1192
2741
  (
1193
2742
  el as unknown as {
@@ -1213,10 +2762,12 @@ describe("JantComposeDialog", () => {
1213
2762
  el.requestUpdate();
1214
2763
  await el.updateComplete;
1215
2764
 
1216
- expect(el.querySelector("[data-compose-publish-date-summary]")).toBeNull();
2765
+ await openPostMeta(el);
2766
+
2767
+ expect(pillValue(el, "date")).toContain("2024");
1217
2768
  });
1218
2769
 
1219
- it("shows a publish now summary after clearing the publish date while editing a post", async () => {
2770
+ it("shows Now on the post date control after clearing the date while editing", async () => {
1220
2771
  const el = await createElement();
1221
2772
  (
1222
2773
  el as unknown as {
@@ -1242,15 +2793,12 @@ describe("JantComposeDialog", () => {
1242
2793
  el.requestUpdate();
1243
2794
  await el.updateComplete;
1244
2795
 
1245
- expect(
1246
- requireElement(
1247
- el.querySelector<HTMLElement>("[data-compose-publish-date-summary]"),
1248
- "expected publish now summary",
1249
- ).textContent,
1250
- ).toContain("Publish now");
2796
+ await openPostMeta(el);
2797
+
2798
+ expect(pillValue(el, "date")).toBe("Now");
1251
2799
  });
1252
2800
 
1253
- it("shows a custom link summary near the submit controls after the link changes", async () => {
2801
+ it("keeps the custom link readable on the post's pill", async () => {
1254
2802
  mockSlugApi((url) => {
1255
2803
  if (url.searchParams.get("mode") === "suggest") {
1256
2804
  return { body: { slug: "configured-post" } };
@@ -1263,7 +2811,7 @@ describe("JantComposeDialog", () => {
1263
2811
 
1264
2812
  const el = await createElement();
1265
2813
  const publishToggle = requireElement(
1266
- el.querySelector<HTMLButtonElement>(".compose-publish-toggle"),
2814
+ el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
1267
2815
  "expected publish settings toggle",
1268
2816
  );
1269
2817
 
@@ -1271,6 +2819,7 @@ describe("JantComposeDialog", () => {
1271
2819
  await el.updateComplete;
1272
2820
  await flushUpdates(el);
1273
2821
 
2822
+ await openPostMeta(el);
1274
2823
  const slugInput = requireElement(
1275
2824
  el.querySelector<HTMLInputElement>(".compose-publish-slug-input"),
1276
2825
  "expected custom link input",
@@ -1280,31 +2829,25 @@ describe("JantComposeDialog", () => {
1280
2829
  await new Promise((resolve) => setTimeout(resolve, 300));
1281
2830
  await el.updateComplete;
1282
2831
 
1283
- publishToggle.click();
1284
- await el.updateComplete;
1285
-
1286
- const summary = requireElement(
1287
- el.querySelector<HTMLButtonElement>(
1288
- "[data-compose-publish-slug-summary]",
1289
- ),
1290
- "expected custom link summary",
1291
- );
1292
- expect(summary.dataset.publishSlug).toBe("reading-notes");
1293
- expect(summary.textContent).toContain("/reading-notes");
1294
-
1295
- summary.click();
1296
- await el.updateComplete;
2832
+ // Close the panel: the custom link stays readable on the pill.
2833
+ requireElement(
2834
+ el.querySelector<HTMLButtonElement>("[data-compose-post-meta-pill]"),
2835
+ "expected post meta pill",
2836
+ ).click();
1297
2837
  await flushUpdates(el);
2838
+ expect(el.querySelector(".compose-publish-slug-input")).toBeNull();
2839
+ expect(pillValue(el, "slug")).toBe("/reading-notes");
1298
2840
 
2841
+ await openPostMeta(el);
1299
2842
  expect(
1300
2843
  requireElement(
1301
2844
  el.querySelector<HTMLInputElement>(".compose-publish-slug-input"),
1302
2845
  "expected custom link input after reopening publish settings",
1303
- ),
1304
- ).toBe(document.activeElement);
2846
+ ).value,
2847
+ ).toBe("reading-notes");
1305
2848
  });
1306
2849
 
1307
- it("does not show a custom link summary for an unchanged link while editing a post", async () => {
2850
+ it("shows the existing custom link on the post pill while editing a post", async () => {
1308
2851
  const el = await createElement();
1309
2852
  (
1310
2853
  el as unknown as {
@@ -1330,10 +2873,12 @@ describe("JantComposeDialog", () => {
1330
2873
  el.requestUpdate();
1331
2874
  await el.updateComplete;
1332
2875
 
1333
- expect(el.querySelector("[data-compose-publish-slug-summary]")).toBeNull();
2876
+ await openPostMeta(el);
2877
+
2878
+ expect(pillValue(el, "slug")).toBe("/reading-notes");
1334
2879
  });
1335
2880
 
1336
- it("shows an auto link summary after clearing the custom link while editing a post", async () => {
2881
+ it("shows Auto on the post permalink pill after clearing the link while editing", async () => {
1337
2882
  const el = await createElement();
1338
2883
  (
1339
2884
  el as unknown as {
@@ -1359,12 +2904,9 @@ describe("JantComposeDialog", () => {
1359
2904
  el.requestUpdate();
1360
2905
  await el.updateComplete;
1361
2906
 
1362
- expect(
1363
- requireElement(
1364
- el.querySelector<HTMLElement>("[data-compose-publish-slug-summary]"),
1365
- "expected auto link summary",
1366
- ).textContent,
1367
- ).toContain("Auto link");
2907
+ await openPostMeta(el);
2908
+
2909
+ expect(pillValue(el, "slug")).toBe("Auto");
1368
2910
  });
1369
2911
 
1370
2912
  it("blocks publishing with a future publish date", async () => {
@@ -1389,11 +2931,12 @@ describe("JantComposeDialog", () => {
1389
2931
  await editor.updateComplete;
1390
2932
 
1391
2933
  requireElement(
1392
- el.querySelector<HTMLButtonElement>(".compose-publish-toggle"),
2934
+ el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
1393
2935
  "expected publish settings toggle",
1394
2936
  ).click();
1395
2937
  await el.updateComplete;
1396
2938
 
2939
+ await openPostMeta(el);
1397
2940
  const publishedAtInput = requireElement(
1398
2941
  el.querySelector<HTMLInputElement>(".compose-publish-date-input"),
1399
2942
  "expected publish date input",
@@ -1420,13 +2963,13 @@ describe("JantComposeDialog", () => {
1420
2963
  const el = await createElement();
1421
2964
 
1422
2965
  requireElement(
1423
- el.querySelector<HTMLButtonElement>(".compose-publish-toggle"),
2966
+ el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
1424
2967
  "expected publish settings toggle",
1425
2968
  ).click();
1426
2969
  await el.updateComplete;
1427
2970
 
1428
2971
  const options = el.querySelectorAll<HTMLButtonElement>(
1429
- ".compose-publish-chip[role='radio']",
2972
+ ".compose-sheet-row[role='radio']",
1430
2973
  );
1431
2974
  expect(options).toHaveLength(3);
1432
2975
  options[2]?.click();
@@ -1444,24 +2987,153 @@ describe("JantComposeDialog", () => {
1444
2987
  const el = await createElement();
1445
2988
 
1446
2989
  requireElement(
1447
- el.querySelector<HTMLButtonElement>(".compose-publish-toggle"),
2990
+ el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
1448
2991
  "expected publish settings toggle",
1449
2992
  ).click();
1450
2993
  await el.updateComplete;
1451
2994
 
1452
2995
  const options = el.querySelectorAll<HTMLButtonElement>(
1453
- ".compose-publish-chip[role='radio']",
2996
+ ".compose-sheet-row[role='radio']",
1454
2997
  );
1455
2998
  expect(options).toHaveLength(3);
1456
2999
  options[1]?.click();
1457
3000
  await el.updateComplete;
1458
3001
 
1459
3002
  expect(
1460
- requireElement(
1461
- el.querySelector<HTMLButtonElement>(".compose-publish-main"),
1462
- "expected publish button",
1463
- ).textContent?.trim(),
1464
- ).toBe("Post hidden");
3003
+ requireElement(
3004
+ el.querySelector<HTMLButtonElement>(".compose-publish-main"),
3005
+ "expected publish button",
3006
+ ).textContent?.trim(),
3007
+ ).toBe("Post hidden");
3008
+ });
3009
+
3010
+ it("flips visibility from the Hide from Latest shortcut", async () => {
3011
+ const el = await createElement();
3012
+
3013
+ const toggle = requireElement(
3014
+ el.querySelector<HTMLInputElement>(
3015
+ ".compose-quick-actions-row .compose-publish-quick-toggle-input",
3016
+ ),
3017
+ "expected hide-from-latest shortcut",
3018
+ );
3019
+ expect(toggle.checked).toBe(false);
3020
+
3021
+ toggle.checked = true;
3022
+ toggle.dispatchEvent(new globalThis.Event("change"));
3023
+ await el.updateComplete;
3024
+ expect(el._visibility).toBe("latest_hidden");
3025
+
3026
+ toggle.checked = false;
3027
+ toggle.dispatchEvent(new globalThis.Event("change"));
3028
+ await el.updateComplete;
3029
+ expect(el._visibility).toBe("public");
3030
+ });
3031
+
3032
+ it("drops the Hide from Latest shortcut once the post is private", async () => {
3033
+ const el = await createElement();
3034
+
3035
+ requireElement(
3036
+ el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
3037
+ "expected publish settings toggle",
3038
+ ).click();
3039
+ await el.updateComplete;
3040
+
3041
+ // A checkbox cannot speak for a third state, so it steps aside.
3042
+ el.querySelectorAll<HTMLButtonElement>(
3043
+ ".compose-sheet-row[role='radio']",
3044
+ )[2]?.click();
3045
+ await el.updateComplete;
3046
+
3047
+ expect(el._visibility).toBe("private");
3048
+ expect(el.querySelector(".compose-quick-actions-row")).toBeNull();
3049
+ });
3050
+
3051
+ it("offers the quiet reply shortcut on a reply, in place of visibility", async () => {
3052
+ const el = await createElement();
3053
+
3054
+ await el.openReply("019ce8ce-d6d8-7fda-a5df-c2da2bef5ade", {
3055
+ contentHtml: "<p>Parent</p>",
3056
+ dateText: "Mar 14",
3057
+ });
3058
+ await flushUpdates(el);
3059
+
3060
+ const toggles = el.querySelectorAll<HTMLInputElement>(
3061
+ ".compose-quick-actions-row .compose-publish-quick-toggle-input",
3062
+ );
3063
+ // A reply inherits the root's visibility, so only quiet reply is offered.
3064
+ expect(toggles).toHaveLength(1);
3065
+
3066
+ const toggle = requireElement(
3067
+ toggles.item(0),
3068
+ "expected quiet reply shortcut",
3069
+ );
3070
+ toggle.checked = true;
3071
+ toggle.dispatchEvent(new globalThis.Event("change"));
3072
+ await el.updateComplete;
3073
+
3074
+ expect(el._quietReply).toBe(true);
3075
+ expect(
3076
+ requireElement(
3077
+ el.querySelector<HTMLButtonElement>(".compose-publish-main"),
3078
+ "expected publish button",
3079
+ ).textContent?.trim(),
3080
+ ).toBe("Reply quietly");
3081
+ });
3082
+
3083
+ it("drops the quiet reply switch when editing an existing reply", async () => {
3084
+ const parentId = "019ce8ce-d6d8-7fda-a5df-c2da2bef5ade";
3085
+ vi.spyOn(globalThis, "fetch").mockImplementation((input) => {
3086
+ const url = String(input);
3087
+ const json = url.includes(parentId)
3088
+ ? { id: parentId, bodyHtml: "<p>Parent</p>", format: "note" }
3089
+ : {
3090
+ id: "pst_123",
3091
+ format: "note",
3092
+ title: null,
3093
+ body: null,
3094
+ replyToId: parentId,
3095
+ };
3096
+ return Promise.resolve(
3097
+ new Response(JSON.stringify(json), {
3098
+ headers: { "Content-Type": "application/json" },
3099
+ }),
3100
+ );
3101
+ });
3102
+
3103
+ const el = await createElement();
3104
+ await el.openEdit("pst_123");
3105
+ await flushUpdates(el);
3106
+
3107
+ // Saving an edit goes through the update path, which has no create-time
3108
+ // thread bump to skip — so the switch is gone rather than dead.
3109
+ expect(el.querySelector(".compose-quick-actions-row")).toBeNull();
3110
+
3111
+ el._showPublishPanel = true;
3112
+ await flushUpdates(el);
3113
+ expect(
3114
+ Array.from(el.querySelectorAll(".compose-sheet-title")).some(
3115
+ (row) => row.textContent?.trim() === labels.quietReplyLabel,
3116
+ ),
3117
+ ).toBe(false);
3118
+ });
3119
+
3120
+ it("keeps the quiet reply switch on a thread draft, which saves by recreating", async () => {
3121
+ const el = await createElement();
3122
+ await el.openReply("019ce8ce-d6d8-7fda-a5df-c2da2bef5ade", {
3123
+ contentHtml: "<p>Parent</p>",
3124
+ dateText: "Mar 14",
3125
+ });
3126
+ (el as unknown as { _addThreadItem: () => void })._addThreadItem();
3127
+ // Re-editing a saved thread draft deletes and recreates every post, so the
3128
+ // create-time flag still lands.
3129
+ el._draftSourceId = "pst_draft";
3130
+ await flushUpdates(el);
3131
+
3132
+ expect(
3133
+ el.querySelectorAll(
3134
+ ".compose-quick-actions-row .compose-publish-quick-toggle-input",
3135
+ ),
3136
+ ).toHaveLength(1);
1465
3137
  });
1466
3138
 
1467
3139
  it("opens a new post with the requested collection and keeps the last visibility until refresh", async () => {
@@ -1474,13 +3146,13 @@ describe("JantComposeDialog", () => {
1474
3146
  expect(el._visibility).toBe("public");
1475
3147
 
1476
3148
  requireElement(
1477
- el.querySelector<HTMLButtonElement>(".compose-publish-toggle"),
3149
+ el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
1478
3150
  "expected publish settings toggle",
1479
3151
  ).click();
1480
3152
  await el.updateComplete;
1481
3153
 
1482
3154
  const options = el.querySelectorAll<HTMLButtonElement>(
1483
- ".compose-publish-chip[role='radio']",
3155
+ ".compose-sheet-row[role='radio']",
1484
3156
  );
1485
3157
  options[1]?.click();
1486
3158
  await el.updateComplete;
@@ -1522,7 +3194,6 @@ describe("JantComposeDialog", () => {
1522
3194
  slug: "",
1523
3195
  visibility: "public",
1524
3196
  rating: 0,
1525
- showTitle: false,
1526
3197
  showRating: false,
1527
3198
  collectionIds: ["col-1"],
1528
3199
  attachedTexts: [],
@@ -1537,6 +3208,250 @@ describe("JantComposeDialog", () => {
1537
3208
  expect(el._collectionIds).toEqual(["col-2", "col-1"]);
1538
3209
  });
1539
3210
 
3211
+ function seedDraftWithMedia() {
3212
+ globalThis.localStorage.setItem(
3213
+ "jant:compose-draft",
3214
+ JSON.stringify({
3215
+ format: "note",
3216
+ title: "",
3217
+ bodyJson: {
3218
+ type: "doc",
3219
+ content: [
3220
+ {
3221
+ type: "paragraph",
3222
+ content: [{ type: "text", text: "Draft with images" }],
3223
+ },
3224
+ ],
3225
+ },
3226
+ url: "",
3227
+ quoteText: "",
3228
+ quoteAuthor: "",
3229
+ slug: "",
3230
+ visibility: "public",
3231
+ rating: 0,
3232
+ showTitle: false,
3233
+ showRating: false,
3234
+ collectionIds: [],
3235
+ replyToId: null,
3236
+ attachedTexts: [],
3237
+ attachmentOrder: ["client-a", "client-b"],
3238
+ mediaAttachments: [
3239
+ {
3240
+ clientId: "client-a",
3241
+ mediaId: "med_aaa",
3242
+ url: "/media/aaa.jpg",
3243
+ mimeType: "image/jpeg",
3244
+ name: "aaa.jpg",
3245
+ alt: "first",
3246
+ },
3247
+ ],
3248
+ savedAt: Date.now(),
3249
+ }),
3250
+ );
3251
+ }
3252
+
3253
+ it("restores uploaded media from the draft snapshot plus bridge-supplied uploads", async () => {
3254
+ const el = await createElement();
3255
+ seedDraftWithMedia();
3256
+
3257
+ // client-b's upload finished after the dialog closed — only the bridge
3258
+ // knows about it, so it arrives via restoreMedia instead of the snapshot.
3259
+ await el.openNew({
3260
+ restoreDraft: true,
3261
+ restoreToast: false,
3262
+ restoreMedia: [
3263
+ {
3264
+ clientId: "client-b",
3265
+ mediaId: "med_bbb",
3266
+ url: "/media/bbb.png",
3267
+ mimeType: "image/png",
3268
+ name: "bbb.png",
3269
+ },
3270
+ ],
3271
+ });
3272
+ await flushUpdates(el);
3273
+
3274
+ const editor = requireElement(
3275
+ el.querySelector<JantComposeEditor>("jant-compose-editor"),
3276
+ "expected compose editor",
3277
+ );
3278
+ const attachments = editor._attachments;
3279
+ expect(attachments.map((a) => a.clientId)).toEqual([
3280
+ "client-a",
3281
+ "client-b",
3282
+ ]);
3283
+ expect(attachments.map((a) => a.mediaId)).toEqual(["med_aaa", "med_bbb"]);
3284
+ expect(attachments.every((a) => a.status === "done")).toBe(true);
3285
+ expect(attachments[0].remoteUrl).toBe("/media/aaa.jpg");
3286
+ expect(attachments[0].alt).toBe("first");
3287
+ expect(editor._attachmentOrder).toEqual(["client-a", "client-b"]);
3288
+ });
3289
+
3290
+ it("suppresses the draft-restored toast when restoreToast is false", async () => {
3291
+ const container = document.createElement("div");
3292
+ container.id = "toast-container";
3293
+ document.body.appendChild(container);
3294
+ const el = await createElement();
3295
+ seedDraftWithMedia();
3296
+
3297
+ await el.openNew({ restoreDraft: true, restoreToast: false });
3298
+ await flushUpdates(el);
3299
+ expect(container.querySelector(".toast")).toBeNull();
3300
+
3301
+ el.reset();
3302
+ await el.openNew({ restoreDraft: true });
3303
+ await flushUpdates(el);
3304
+ expect(container.querySelector(".toast span")?.textContent).toBe(
3305
+ "Draft restored.",
3306
+ );
3307
+ });
3308
+
3309
+ const NOTE_TITLE_KEY = "jant:compose-note-title";
3310
+
3311
+ function titleToggle(el: JantComposeDialog, index = 0): HTMLButtonElement {
3312
+ return requireElement(
3313
+ el.querySelectorAll<HTMLButtonElement>(
3314
+ '.compose-tool-btn[title="Title"]',
3315
+ )[index] ?? null,
3316
+ "expected title toggle",
3317
+ );
3318
+ }
3319
+
3320
+ it("remembers a note's title toggle for the next new post", async () => {
3321
+ const el = await createElement();
3322
+ await el.openNew({ restoreDraft: false });
3323
+ await flushUpdates(el);
3324
+
3325
+ expect(el.querySelector(".compose-note-title")).not.toBeNull();
3326
+
3327
+ titleToggle(el).click();
3328
+ await flushUpdates(el);
3329
+
3330
+ expect(globalThis.localStorage.getItem(NOTE_TITLE_KEY)).toBe("0");
3331
+ expect(el.querySelector(".compose-note-title")).toBeNull();
3332
+
3333
+ await el.openNew({ restoreDraft: false });
3334
+ await flushUpdates(el);
3335
+
3336
+ expect(el.querySelector(".compose-note-title")).toBeNull();
3337
+
3338
+ titleToggle(el).click();
3339
+ await flushUpdates(el);
3340
+
3341
+ expect(globalThis.localStorage.getItem(NOTE_TITLE_KEY)).toBe("1");
3342
+
3343
+ await el.openNew({ restoreDraft: false });
3344
+ await flushUpdates(el);
3345
+
3346
+ expect(el.querySelector(".compose-note-title")).not.toBeNull();
3347
+ });
3348
+
3349
+ it("opens a fresh composer without the title field when it was last turned off", async () => {
3350
+ globalThis.localStorage.setItem(NOTE_TITLE_KEY, "0");
3351
+
3352
+ const el = await createElement();
3353
+
3354
+ expect(el.querySelector(".compose-note-title")).toBeNull();
3355
+ });
3356
+
3357
+ it("does not remember the title toggle from an edit", async () => {
3358
+ mockEditPost({ format: "note", title: "Hello", body: null });
3359
+
3360
+ const el = await createElement();
3361
+ await el.openEdit("pst_123");
3362
+ await flushUpdates(el);
3363
+
3364
+ titleToggle(el).click();
3365
+ await flushUpdates(el);
3366
+
3367
+ // Dropping this post's title says nothing about the next post's.
3368
+ expect(globalThis.localStorage.getItem(NOTE_TITLE_KEY)).toBeNull();
3369
+ });
3370
+
3371
+ it("does not remember the title toggle from a reply", async () => {
3372
+ const el = await createElement();
3373
+ await el.openReply("019ce8ce-d6d8-7fda-a5df-c2da2bef5ade", {
3374
+ contentHtml: "<p>Parent</p>",
3375
+ dateText: "Mar 14",
3376
+ });
3377
+ await flushUpdates(el);
3378
+
3379
+ titleToggle(el).click();
3380
+ await flushUpdates(el);
3381
+
3382
+ expect(el.querySelector(".compose-note-title")).not.toBeNull();
3383
+ expect(globalThis.localStorage.getItem(NOTE_TITLE_KEY)).toBeNull();
3384
+ });
3385
+
3386
+ it("does not remember the title toggle from a thread post", async () => {
3387
+ const el = await createElement();
3388
+ (el as unknown as { _addThreadItem: () => void })._addThreadItem();
3389
+ await flushUpdates(el);
3390
+
3391
+ titleToggle(el).click();
3392
+ await flushUpdates(el);
3393
+
3394
+ expect(globalThis.localStorage.getItem(NOTE_TITLE_KEY)).toBeNull();
3395
+ });
3396
+
3397
+ it("does not remember a title the editor reveals on its own", async () => {
3398
+ const el = await createElement();
3399
+ await el.openNew({ restoreDraft: false });
3400
+ await flushUpdates(el);
3401
+
3402
+ const editor = requireElement(
3403
+ el.querySelector<JantComposeEditor>("jant-compose-editor"),
3404
+ "expected compose editor",
3405
+ );
3406
+ // The fullscreen and H1-promotion paths land here. Neither is the author
3407
+ // stating a habit, so neither writes one down.
3408
+ editor.setEditorState(null, "Promoted", true);
3409
+ await flushUpdates(el);
3410
+
3411
+ expect(el.querySelector(".compose-note-title")).not.toBeNull();
3412
+ expect(globalThis.localStorage.getItem(NOTE_TITLE_KEY)).toBeNull();
3413
+ });
3414
+
3415
+ it("lets a restored draft's title state win over the remembered default", async () => {
3416
+ globalThis.localStorage.setItem(NOTE_TITLE_KEY, "0");
3417
+ globalThis.localStorage.setItem(
3418
+ "jant:compose-draft",
3419
+ JSON.stringify({
3420
+ format: "note",
3421
+ title: "Draft title",
3422
+ showTitle: true,
3423
+ bodyJson: {
3424
+ type: "doc",
3425
+ content: [
3426
+ {
3427
+ type: "paragraph",
3428
+ content: [{ type: "text", text: "Draft body" }],
3429
+ },
3430
+ ],
3431
+ },
3432
+ url: "",
3433
+ quoteText: "",
3434
+ quoteAuthor: "",
3435
+ slug: "",
3436
+ visibility: "public",
3437
+ rating: 0,
3438
+ showRating: false,
3439
+ collectionIds: [],
3440
+ attachedTexts: [],
3441
+ attachmentOrder: [],
3442
+ savedAt: Date.now(),
3443
+ }),
3444
+ );
3445
+
3446
+ const el = await createElement();
3447
+ await el.openNew();
3448
+ await flushUpdates(el);
3449
+
3450
+ expect(
3451
+ el.querySelector<HTMLInputElement>(".compose-note-title")?.value,
3452
+ ).toBe("Draft title");
3453
+ });
3454
+
1540
3455
  it("includes a custom slug from the publish settings panel in the submit payload", async () => {
1541
3456
  mockSlugApi((url) => {
1542
3457
  if (url.searchParams.get("mode") === "suggest") {
@@ -1565,13 +3480,14 @@ describe("JantComposeDialog", () => {
1565
3480
  await editor.updateComplete;
1566
3481
 
1567
3482
  const publishToggle = requireElement(
1568
- el.querySelector<HTMLButtonElement>(".compose-publish-toggle"),
3483
+ el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
1569
3484
  "expected publish settings toggle",
1570
3485
  );
1571
3486
  publishToggle.click();
1572
3487
  await el.updateComplete;
1573
3488
  await flushUpdates(el);
1574
3489
 
3490
+ await openPostMeta(el);
1575
3491
  const slugInput = requireElement(
1576
3492
  el.querySelector<HTMLInputElement>(".compose-publish-slug-input"),
1577
3493
  "expected custom link input",
@@ -1611,7 +3527,7 @@ describe("JantComposeDialog", () => {
1611
3527
  const el = await createElement();
1612
3528
 
1613
3529
  const publishToggle = requireElement(
1614
- el.querySelector<HTMLButtonElement>(".compose-publish-toggle"),
3530
+ el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
1615
3531
  "expected publish settings toggle",
1616
3532
  );
1617
3533
 
@@ -1619,6 +3535,7 @@ describe("JantComposeDialog", () => {
1619
3535
  await el.updateComplete;
1620
3536
  await flushUpdates(el);
1621
3537
 
3538
+ await openPostMeta(el);
1622
3539
  const slugInput = requireElement(
1623
3540
  el.querySelector<HTMLInputElement>(".compose-publish-slug-input"),
1624
3541
  "expected custom link input",
@@ -1633,6 +3550,7 @@ describe("JantComposeDialog", () => {
1633
3550
  publishToggle.click();
1634
3551
  await el.updateComplete;
1635
3552
 
3553
+ await openPostMeta(el);
1636
3554
  expect(el.querySelector(".compose-publish-slug-input")).not.toBeNull();
1637
3555
  });
1638
3556
 
@@ -1691,7 +3609,7 @@ describe("JantComposeDialog", () => {
1691
3609
  } as never);
1692
3610
 
1693
3611
  requireElement(
1694
- el.querySelector<HTMLButtonElement>(".compose-publish-toggle"),
3612
+ el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
1695
3613
  "expected publish settings toggle",
1696
3614
  ).click();
1697
3615
  await el.updateComplete;
@@ -1762,7 +3680,7 @@ describe("JantComposeDialog", () => {
1762
3680
  } as never);
1763
3681
 
1764
3682
  requireElement(
1765
- el.querySelector<HTMLButtonElement>(".compose-publish-toggle"),
3683
+ el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
1766
3684
  "expected publish settings toggle",
1767
3685
  ).click();
1768
3686
  await el.updateComplete;
@@ -1784,7 +3702,7 @@ describe("JantComposeDialog", () => {
1784
3702
  const el = await createElement();
1785
3703
 
1786
3704
  requireElement(
1787
- el.querySelector<HTMLButtonElement>(".compose-publish-toggle"),
3705
+ el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
1788
3706
  "expected publish settings toggle",
1789
3707
  ).click();
1790
3708
  await el.updateComplete;
@@ -1812,13 +3730,14 @@ describe("JantComposeDialog", () => {
1812
3730
  await editor.updateComplete;
1813
3731
 
1814
3732
  const publishToggle = requireElement(
1815
- el.querySelector<HTMLButtonElement>(".compose-publish-toggle"),
3733
+ el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
1816
3734
  "expected publish settings toggle",
1817
3735
  );
1818
3736
  publishToggle.click();
1819
3737
  await el.updateComplete;
1820
3738
  await flushUpdates(el);
1821
3739
 
3740
+ await openPostMeta(el);
1822
3741
  const slugInput = requireElement(
1823
3742
  el.querySelector<HTMLInputElement>(".compose-publish-slug-input"),
1824
3743
  "expected custom link input",
@@ -1863,7 +3782,6 @@ describe("JantComposeDialog", () => {
1863
3782
  el.querySelector<JantComposeEditor>("jant-compose-editor"),
1864
3783
  "expected compose editor",
1865
3784
  );
1866
- editor._showTitle = true;
1867
3785
  editor._title = "Hello World";
1868
3786
  editor._bodyJson = {
1869
3787
  type: "doc",
@@ -1874,13 +3792,14 @@ describe("JantComposeDialog", () => {
1874
3792
  await editor.updateComplete;
1875
3793
 
1876
3794
  const publishToggle = requireElement(
1877
- el.querySelector<HTMLButtonElement>(".compose-publish-toggle"),
3795
+ el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
1878
3796
  "expected publish settings toggle",
1879
3797
  );
1880
3798
  publishToggle.click();
1881
3799
  await el.updateComplete;
1882
3800
  await flushUpdates(el);
1883
3801
 
3802
+ await openPostMeta(el);
1884
3803
  expect(
1885
3804
  el.querySelector(".compose-slug-suggestion-value")?.textContent?.trim(),
1886
3805
  ).toBe("/hello-world");
@@ -1901,11 +3820,11 @@ describe("JantComposeDialog", () => {
1901
3820
  ).toBeUndefined();
1902
3821
  });
1903
3822
 
1904
- it("shows visibility and custom link settings in the publish panel", async () => {
3823
+ it("keeps the publish panel to whole-submission settings", async () => {
1905
3824
  const el = await createElement();
1906
3825
 
1907
3826
  const publishToggle = requireElement(
1908
- el.querySelector<HTMLButtonElement>(".compose-publish-toggle"),
3827
+ el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
1909
3828
  "expected publish settings toggle",
1910
3829
  );
1911
3830
  publishToggle.click();
@@ -1917,12 +3836,16 @@ describe("JantComposeDialog", () => {
1917
3836
  );
1918
3837
  expect(panel.textContent).not.toContain("Publish settings");
1919
3838
  expect(panel.textContent).toContain("Visibility");
1920
- expect(panel.textContent).toContain("Published on");
1921
- expect(panel.textContent).toContain("Custom link");
1922
3839
  expect(panel.textContent).not.toContain("Save as draft");
1923
3840
  expect(panel.textContent).not.toContain("Discard");
1924
- expect(panel.querySelector(".compose-publish-date-input")).not.toBeNull();
1925
- expect(panel.querySelector(".compose-publish-slug-input")).not.toBeNull();
3841
+ // Post-scoped settings are not in here — they live on the post.
3842
+ expect(panel.textContent).not.toContain("Published on");
3843
+ expect(panel.textContent).not.toContain("Custom link");
3844
+
3845
+ await openPostMeta(el);
3846
+ expect(el.querySelector(".compose-publish-date-input")).not.toBeNull();
3847
+ await openPostMeta(el);
3848
+ expect(el.querySelector(".compose-publish-slug-input")).not.toBeNull();
1926
3849
  });
1927
3850
 
1928
3851
  it("clears the custom slug with the reset action", async () => {
@@ -1938,13 +3861,14 @@ describe("JantComposeDialog", () => {
1938
3861
 
1939
3862
  const el = await createElement();
1940
3863
  const publishToggle = requireElement(
1941
- el.querySelector<HTMLButtonElement>(".compose-publish-toggle"),
3864
+ el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
1942
3865
  "expected publish settings toggle",
1943
3866
  );
1944
3867
  publishToggle.click();
1945
3868
  await el.updateComplete;
1946
3869
  await flushUpdates(el);
1947
3870
 
3871
+ await openPostMeta(el);
1948
3872
  const slugInput = requireElement(
1949
3873
  el.querySelector<HTMLInputElement>(".compose-publish-slug-input"),
1950
3874
  "expected custom link input",
@@ -1964,6 +3888,7 @@ describe("JantComposeDialog", () => {
1964
3888
  ).click();
1965
3889
  await flushUpdates(el);
1966
3890
 
3891
+ await openPostMeta(el);
1967
3892
  expect(
1968
3893
  requireElement(
1969
3894
  el.querySelector<HTMLInputElement>(".compose-publish-slug-input"),
@@ -1986,7 +3911,7 @@ describe("JantComposeDialog", () => {
1986
3911
  await el.updateComplete;
1987
3912
 
1988
3913
  const publishToggle = requireElement(
1989
- el.querySelector<HTMLButtonElement>(".compose-publish-toggle"),
3914
+ el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
1990
3915
  "expected publish settings toggle",
1991
3916
  );
1992
3917
  publishToggle.click();
@@ -2018,18 +3943,18 @@ describe("JantComposeDialog", () => {
2018
3943
  el.querySelector<JantComposeEditor>("jant-compose-editor"),
2019
3944
  "expected compose editor",
2020
3945
  );
2021
- editor._showTitle = true;
2022
3946
  editor._title = "Hello World";
2023
3947
  await editor.updateComplete;
2024
3948
 
2025
3949
  const publishToggle = requireElement(
2026
- el.querySelector<HTMLButtonElement>(".compose-publish-toggle"),
3950
+ el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
2027
3951
  "expected publish settings toggle",
2028
3952
  );
2029
3953
  publishToggle.click();
2030
3954
  await el.updateComplete;
2031
3955
  await flushUpdates(el);
2032
3956
 
3957
+ await openPostMeta(el);
2033
3958
  expect(
2034
3959
  el.querySelector(".compose-slug-suggestion-value")?.textContent?.trim(),
2035
3960
  ).toBe("/hello-world");
@@ -2079,13 +4004,14 @@ describe("JantComposeDialog", () => {
2079
4004
  await editor.updateComplete;
2080
4005
 
2081
4006
  const publishToggle = requireElement(
2082
- el.querySelector<HTMLButtonElement>(".compose-publish-toggle"),
4007
+ el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
2083
4008
  "expected publish settings toggle",
2084
4009
  );
2085
4010
  publishToggle.click();
2086
4011
  await el.updateComplete;
2087
4012
  await flushUpdates(el);
2088
4013
 
4014
+ await openPostMeta(el);
2089
4015
  const slugInput = requireElement(
2090
4016
  el.querySelector<HTMLInputElement>(".compose-publish-slug-input"),
2091
4017
  "expected custom link input",
@@ -2136,11 +4062,12 @@ describe("JantComposeDialog", () => {
2136
4062
  await editor.updateComplete;
2137
4063
 
2138
4064
  requireElement(
2139
- el.querySelector<HTMLButtonElement>(".compose-publish-toggle"),
4065
+ el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
2140
4066
  "expected publish settings toggle",
2141
4067
  ).click();
2142
4068
  await el.updateComplete;
2143
4069
 
4070
+ await openPostMeta(el);
2144
4071
  const slugInput = requireElement(
2145
4072
  el.querySelector<HTMLInputElement>(".compose-publish-slug-input"),
2146
4073
  "expected custom link input",
@@ -2188,11 +4115,12 @@ describe("JantComposeDialog", () => {
2188
4115
  await editor.updateComplete;
2189
4116
 
2190
4117
  requireElement(
2191
- el.querySelector<HTMLButtonElement>(".compose-publish-toggle"),
4118
+ el.querySelector<HTMLButtonElement>(".compose-options-trigger"),
2192
4119
  "expected publish settings toggle",
2193
4120
  ).click();
2194
4121
  await el.updateComplete;
2195
4122
 
4123
+ await openPostMeta(el);
2196
4124
  const slugInput = requireElement(
2197
4125
  el.querySelector<HTMLInputElement>(".compose-publish-slug-input"),
2198
4126
  "expected custom link input",
@@ -2235,8 +4163,7 @@ describe("JantComposeDialog", () => {
2235
4163
  await el.updateComplete;
2236
4164
 
2237
4165
  let receivedDetail:
2238
- | (ComposeSubmitDetail & { pendingAttachments: unknown[] })
2239
- | null = null;
4166
+ (ComposeSubmitDetail & { pendingAttachments: unknown[] }) | null = null;
2240
4167
  el.addEventListener("jant:compose-submit-deferred", (event) => {
2241
4168
  const customEvent = event as CustomEvent<
2242
4169
  ComposeSubmitDetail & { pendingAttachments: unknown[] }
@@ -2306,7 +4233,6 @@ describe("JantComposeDialog", () => {
2306
4233
  slug: "",
2307
4234
  visibility: "public",
2308
4235
  rating: 0,
2309
- showTitle: false,
2310
4236
  showRating: false,
2311
4237
  collectionIds: ["col-stale"],
2312
4238
  replyToId,
@@ -2427,48 +4353,278 @@ describe("JantComposeDialog", () => {
2427
4353
  expect(detail.threadPosts?.[0]?.quietReply).toBe(true);
2428
4354
  });
2429
4355
 
2430
- it("does not add more thread items than the shared limit", async () => {
2431
- const el = await createElement();
2432
- el._threadItems = Array.from({ length: MAX_THREAD_POSTS }, (_, index) => ({
2433
- id: `thread-${index}`,
2434
- format: "note",
2435
- }));
4356
+ it("does not add more thread items than the shared limit", async () => {
4357
+ const el = await createElement();
4358
+ el._threadItems = Array.from({ length: MAX_THREAD_POSTS }, (_, index) => ({
4359
+ id: `thread-${index}`,
4360
+ format: "note",
4361
+ }));
4362
+
4363
+ (
4364
+ el as unknown as {
4365
+ _addThreadItem: () => void;
4366
+ }
4367
+ )._addThreadItem();
4368
+
4369
+ expect(el._threadItems).toHaveLength(MAX_THREAD_POSTS);
4370
+ });
4371
+
4372
+ it("scrolls the thread composer to the bottom after adding an item", async () => {
4373
+ const el = await createElement();
4374
+ el._threadItems = [
4375
+ { id: "thread-1", format: "note" },
4376
+ { id: "thread-2", format: "note" },
4377
+ ];
4378
+ await el.updateComplete;
4379
+
4380
+ const scroller = requireElement(
4381
+ el.querySelector<HTMLElement>(".compose-scroll"),
4382
+ "expected compose scroll region",
4383
+ );
4384
+ Object.defineProperty(scroller, "scrollHeight", {
4385
+ configurable: true,
4386
+ value: 640,
4387
+ });
4388
+ scroller.scrollTop = 120;
4389
+
4390
+ (
4391
+ el as unknown as {
4392
+ _addThreadItem: () => void;
4393
+ }
4394
+ )._addThreadItem();
4395
+ await flushUpdates(el);
4396
+
4397
+ expect(scroller.scrollTop).toBe(640);
4398
+ });
4399
+
4400
+ describe("thread mode keeps what each post holds", () => {
4401
+ type ThreadInternals = {
4402
+ _addThreadItem: () => void;
4403
+ _removeThreadItem: (index: number) => void;
4404
+ _checkSlugAvailability: (slug: string, index?: number) => Promise<void>;
4405
+ _slug: string;
4406
+ _publishedAtInput: string;
4407
+ _publishedAtTimeMinutes: number | null;
4408
+ };
4409
+
4410
+ const internals = (el: JantComposeDialog) =>
4411
+ el as unknown as ThreadInternals;
4412
+
4413
+ const editorsOf = (el: JantComposeDialog) =>
4414
+ Array.from(el.querySelectorAll<JantComposeEditor>("jant-compose-editor"));
4415
+
4416
+ const paragraph = (text: string) => ({
4417
+ type: "doc",
4418
+ content: [{ type: "paragraph", content: [{ type: "text", text }] }],
4419
+ });
4420
+
4421
+ function submit(el: JantComposeDialog): ComposeSubmitDetail {
4422
+ let detail: ComposeSubmitDetail | null = null;
4423
+ el.addEventListener(
4424
+ "jant:compose-submit-deferred",
4425
+ (event) => {
4426
+ detail = (event as CustomEvent<ComposeSubmitDetail>).detail;
4427
+ },
4428
+ { once: true },
4429
+ );
4430
+ requireElement(
4431
+ el.querySelector<HTMLButtonElement>(".compose-publish-main"),
4432
+ "expected publish button",
4433
+ ).click();
4434
+ if (!detail) throw new Error("expected a submit");
4435
+ return detail;
4436
+ }
4437
+
4438
+ it("keeps a quote's source link on the way into a thread and back out", async () => {
4439
+ const el = await createElement();
4440
+ el._format = "quote";
4441
+ await flushUpdates(el);
4442
+ const single = editorsOf(el)[0]!;
4443
+ single._quoteText = "A borrowed sentence.";
4444
+ single._quoteAuthor = "Someone Else";
4445
+ single._url = "https://example.com/source";
4446
+ await flushUpdates(el);
4447
+
4448
+ internals(el)._addThreadItem();
4449
+ await flushUpdates(el);
4450
+ const root = editorsOf(el)[0]!;
4451
+ expect(root.getData()).toMatchObject({
4452
+ url: "https://example.com/source",
4453
+ quoteText: "A borrowed sentence.",
4454
+ quoteAuthor: "Someone Else",
4455
+ });
4456
+
4457
+ internals(el)._removeThreadItem(1);
4458
+ await flushUpdates(el);
4459
+ expect(el._threadItems).toHaveLength(0);
4460
+ expect(editorsOf(el)[0]!.getData().url).toBe(
4461
+ "https://example.com/source",
4462
+ );
4463
+ expect(submit(el).url).toBe("https://example.com/source");
4464
+ });
4465
+
4466
+ it("publishes an attached text that has no media beside it after entering a thread", async () => {
4467
+ // The strip and the submit both walk the attachment order, which was
4468
+ // only carried across when the post also had media.
4469
+ const el = await createElement();
4470
+ const single = editorsOf(el)[0]!;
4471
+ single._bodyJson = paragraph("See the notes below.");
4472
+ single._attachedTexts = [
4473
+ {
4474
+ clientId: "text-1",
4475
+ bodyJson: paragraph("The notes."),
4476
+ bodyHtml: "",
4477
+ summary: "The notes.",
4478
+ },
4479
+ ];
4480
+ single._attachmentOrder = ["text-1"];
4481
+ await flushUpdates(el);
4482
+
4483
+ internals(el)._addThreadItem();
4484
+ await flushUpdates(el);
4485
+ const [root, next] = editorsOf(el);
4486
+ expect(root!.querySelectorAll("[data-attachment-list] > *")).toHaveLength(
4487
+ 1,
4488
+ );
4489
+ next!._bodyJson = paragraph("More.");
4490
+ await flushUpdates(el);
4491
+
4492
+ const detail = submit(el);
4493
+ expect(detail.threadPosts?.[0]?.attachments).toEqual([
4494
+ expect.objectContaining({ type: "text", clientId: "text-1" }),
4495
+ ]);
4496
+ });
4497
+
4498
+ it("moves the next post's own permalink and date up when the first post is removed", async () => {
4499
+ // The root's permalink and date live on the dialog, every other post's
4500
+ // on its thread item. Removing the root used to leave its values on the
4501
+ // post that took its place, and drop that post's own.
4502
+ const el = await createElement();
4503
+ el._threadItems = [
4504
+ { id: "a", format: "note" },
4505
+ {
4506
+ id: "b",
4507
+ format: "note",
4508
+ slug: "part-two",
4509
+ publishedAtInput: "2025-01-02",
4510
+ publishedAtTimeMinutes: 600,
4511
+ },
4512
+ { id: "c", format: "note" },
4513
+ ];
4514
+ internals(el)._slug = "part-one";
4515
+ internals(el)._publishedAtInput = "2025-01-01";
4516
+ internals(el)._publishedAtTimeMinutes = 480;
4517
+ await flushUpdates(el);
4518
+ editorsOf(el).forEach((editor, i) => {
4519
+ editor._bodyJson = paragraph(`Post ${i + 1}`);
4520
+ });
4521
+ await flushUpdates(el);
4522
+
4523
+ internals(el)._removeThreadItem(0);
4524
+ await flushUpdates(el);
2436
4525
 
2437
- (
2438
- el as unknown as {
2439
- _addThreadItem: () => void;
2440
- }
2441
- )._addThreadItem();
4526
+ expect(internals(el)._slug).toBe("part-two");
4527
+ expect(internals(el)._publishedAtInput).toBe("2025-01-02");
4528
+ expect(internals(el)._publishedAtTimeMinutes).toBe(600);
4529
+ const detail = submit(el);
4530
+ expect(detail.threadPosts?.map((post) => post.slug)).toEqual([
4531
+ "part-two",
4532
+ undefined,
4533
+ ]);
4534
+ });
2442
4535
 
2443
- expect(el._threadItems).toHaveLength(MAX_THREAD_POSTS);
2444
- });
4536
+ function mockThreadDraft(requests: string[]) {
4537
+ vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => {
4538
+ const url = String(input);
4539
+ requests.push(url);
4540
+ const json = (body: unknown) =>
4541
+ new Response(JSON.stringify(body), {
4542
+ headers: { "Content-Type": "application/json" },
4543
+ });
4544
+ if (url === "/api/posts/pst_draft_root") {
4545
+ return json({
4546
+ id: "pst_draft_root",
4547
+ threadId: "pst_draft_root",
4548
+ format: "note",
4549
+ status: "draft",
4550
+ slug: "draft-thread",
4551
+ title: "Draft thread",
4552
+ body: null,
4553
+ attachments: [],
4554
+ collectionIds: [],
4555
+ });
4556
+ }
4557
+ if (url === "/api/posts?status=draft&limit=50") {
4558
+ return json({
4559
+ posts: [
4560
+ {
4561
+ id: "pst_draft_reply",
4562
+ threadId: "pst_draft_root",
4563
+ replyToId: "pst_draft_root",
4564
+ format: "quote",
4565
+ status: "draft",
4566
+ slug: "part-two",
4567
+ quoteText: "Draft reply",
4568
+ sourceName: "Someone Else",
4569
+ attachments: [],
4570
+ },
4571
+ ],
4572
+ });
4573
+ }
4574
+ if (url.startsWith("/api/posts/slug?")) {
4575
+ return json({ available: true });
4576
+ }
4577
+ throw new Error(`Unexpected fetch: ${url}`);
4578
+ });
4579
+ }
2445
4580
 
2446
- it("scrolls the thread composer to the bottom after adding an item", async () => {
2447
- const el = await createElement();
2448
- el._threadItems = [
2449
- { id: "thread-1", format: "note" },
2450
- { id: "thread-2", format: "note" },
2451
- ];
2452
- await el.updateComplete;
4581
+ it("keeps each reply's permalink when a saved thread draft is reopened", async () => {
4582
+ // Saving a thread draft recreates it, so a slug not loaded back into
4583
+ // the composer came back as a random id on the next save.
4584
+ const requests: string[] = [];
4585
+ mockThreadDraft(requests);
4586
+ const el = await createElement();
4587
+ await el.openDraft("pst_draft_root");
4588
+ await flushUpdates(el);
2453
4589
 
2454
- const threadLayout = requireElement(
2455
- el.querySelector<HTMLElement>(".compose-thread-compose-layout"),
2456
- "expected thread compose layout",
2457
- );
2458
- Object.defineProperty(threadLayout, "scrollHeight", {
2459
- configurable: true,
2460
- value: 640,
4590
+ expect(el._threadItems[1]?.slug).toBe("part-two");
4591
+
4592
+ // A reply's slug already belongs to the saved reply, not to the root.
4593
+ await internals(el)._checkSlugAvailability("part-two", 1);
4594
+ await internals(el)._checkSlugAvailability("draft-thread", 0);
4595
+ const checks = requests
4596
+ .filter((url) => url.startsWith("/api/posts/slug?"))
4597
+ .map((url) => new URL(url, "http://x").searchParams.get("postId"));
4598
+ expect(checks).toEqual(["pst_draft_reply", "pst_draft_root"]);
4599
+
4600
+ expect(submit(el).threadPosts?.map((post) => post.slug)).toEqual([
4601
+ "draft-thread",
4602
+ "part-two",
4603
+ ]);
2461
4604
  });
2462
- threadLayout.scrollTop = 120;
2463
4605
 
2464
- (
2465
- el as unknown as {
2466
- _addThreadItem: () => void;
2467
- }
2468
- )._addThreadItem();
2469
- await flushUpdates(el);
4606
+ it("folds a thread post's quote into its body when its format changes on a saved draft", async () => {
4607
+ // Single-post drafts already did this; a thread post kept the quote in
4608
+ // a hidden field, which the next save then dropped.
4609
+ mockThreadDraft([]);
4610
+ const el = await createElement();
4611
+ await el.openDraft("pst_draft_root");
4612
+ await flushUpdates(el);
4613
+
4614
+ const reply = editorsOf(el)[1]!;
4615
+ const noteButton = requireElement(
4616
+ Array.from(
4617
+ reply.querySelectorAll<HTMLButtonElement>(".compose-segmented-item"),
4618
+ ).find((button) => button.textContent?.trim() === labels.note) ?? null,
4619
+ "expected the reply's Note button",
4620
+ );
4621
+ noteButton.click();
4622
+ await flushUpdates(el);
2470
4623
 
2471
- expect(threadLayout.scrollTop).toBe(640);
4624
+ expect(el._threadItems[1]?.format).toBe("note");
4625
+ expect(reply.getData().body).toContain("Draft reply");
4626
+ expect(submit(el).threadPosts?.[1]?.body).toContain("Draft reply");
4627
+ });
2472
4628
  });
2473
4629
 
2474
4630
  it("omits visibility from locked edit submissions", async () => {
@@ -2512,6 +4668,20 @@ describe("JantComposeDialog", () => {
2512
4668
  );
2513
4669
  });
2514
4670
 
4671
+ it("gives the collection trigger one glyph and no chevron", async () => {
4672
+ const el = await createElement();
4673
+ const trigger = requireElement(
4674
+ el.querySelector<HTMLButtonElement>(".compose-collection-trigger"),
4675
+ "expected collection trigger",
4676
+ );
4677
+
4678
+ expect(trigger.querySelectorAll("svg").length).toBe(1);
4679
+ expect(
4680
+ trigger.querySelector(".compose-collection-trigger-svg"),
4681
+ ).not.toBeNull();
4682
+ expect(trigger.querySelector(".compose-collection-chevron")).toBeNull();
4683
+ });
4684
+
2515
4685
  it("collection selector toggles IDs", async () => {
2516
4686
  const el = await createElement();
2517
4687
 
@@ -3042,13 +5212,10 @@ describe("JantComposeDialog", () => {
3042
5212
  };
3043
5213
  await editor.updateComplete;
3044
5214
 
3045
- // Click the draft header button — should show confirm panel
3046
- const draftBtn = requireElement(
3047
- el.querySelector<HTMLButtonElement>(".compose-dialog-header-btn"),
3048
- "expected draft button",
3049
- );
3050
- draftBtn.click();
3051
- await el.updateComplete;
5215
+ // Click the drafts row in the options panel — should show confirm panel
5216
+ await openPublishPanel(el);
5217
+ requireElement(draftsRow(el), "expected drafts row").click();
5218
+ await flushUpdates(el);
3052
5219
 
3053
5220
  expect(el._confirmPanelOpen).toBe(true);
3054
5221
  expect(el.querySelector(".compose-confirm-panel")).not.toBeNull();
@@ -3065,13 +5232,10 @@ describe("JantComposeDialog", () => {
3065
5232
  }),
3066
5233
  );
3067
5234
 
3068
- // Click the draft header button — should open drafts panel
3069
- const draftBtn = requireElement(
3070
- el.querySelector<HTMLButtonElement>(".compose-dialog-header-btn"),
3071
- "expected draft button",
3072
- );
3073
- draftBtn.click();
3074
- await el.updateComplete;
5235
+ // Click the drafts row in the options panel — should open drafts panel
5236
+ await openPublishPanel(el);
5237
+ requireElement(draftsRow(el), "expected drafts row").click();
5238
+ await flushUpdates(el);
3075
5239
 
3076
5240
  expect(el._draftsPanelOpen).toBe(true);
3077
5241
 
@@ -3146,6 +5310,71 @@ describe("JantComposeDialog", () => {
3146
5310
  expect(el._draftMenuOpenId).toBeNull();
3147
5311
  });
3148
5312
 
5313
+ it("uses title or quote text for draft previews and identifies each format", async () => {
5314
+ const el = await createElement();
5315
+ el._draftsPanelOpen = true;
5316
+ el._draftsLoading = false;
5317
+ el._drafts = [
5318
+ {
5319
+ id: "pst_note",
5320
+ slug: "note-draft",
5321
+ format: "note",
5322
+ title: "A titled note",
5323
+ bodyText: "This body should not be the preview",
5324
+ bodyHtml: "<p>This body should not be the preview</p>",
5325
+ url: null,
5326
+ quoteText: null,
5327
+ replyToId: null,
5328
+ updatedAt: 0,
5329
+ mediaAttachments: [],
5330
+ },
5331
+ {
5332
+ id: "pst_quote",
5333
+ slug: "quote-draft",
5334
+ format: "quote",
5335
+ title: "Quote title",
5336
+ bodyText: "Supporting note",
5337
+ bodyHtml: "<p>Supporting note</p>",
5338
+ url: null,
5339
+ quoteText: "The quoted words come first.",
5340
+ replyToId: null,
5341
+ updatedAt: 0,
5342
+ mediaAttachments: [],
5343
+ },
5344
+ {
5345
+ id: "pst_link",
5346
+ slug: "link-draft",
5347
+ format: "link",
5348
+ title: null,
5349
+ bodyText: "A link note",
5350
+ bodyHtml: "<p>A link note</p>",
5351
+ url: "https://example.com",
5352
+ quoteText: null,
5353
+ replyToId: null,
5354
+ updatedAt: 0,
5355
+ mediaAttachments: [],
5356
+ },
5357
+ ];
5358
+ await el.updateComplete;
5359
+
5360
+ const rows = el.querySelectorAll<HTMLElement>(".compose-draft-item");
5361
+ expect(rows).toHaveLength(3);
5362
+ expect(rows[0]?.querySelector(".compose-draft-preview")?.textContent).toBe(
5363
+ "A titled note",
5364
+ );
5365
+ expect(rows[1]?.querySelector(".compose-draft-preview")?.textContent).toBe(
5366
+ "The quoted words come first.",
5367
+ );
5368
+ expect(rows[2]?.querySelector(".compose-draft-preview")?.textContent).toBe(
5369
+ "A link note",
5370
+ );
5371
+ expect(
5372
+ Array.from(el.querySelectorAll(".compose-draft-format")).map(
5373
+ (format) => format.textContent,
5374
+ ),
5375
+ ).toEqual(["Note", "Quote", "Link"]);
5376
+ });
5377
+
3149
5378
  it("does not dispatch submit when loading", async () => {
3150
5379
  const el = await createElement();
3151
5380
  el._loading = true;
@@ -3188,91 +5417,503 @@ describe("JantComposeDialog", () => {
3188
5417
  "expected compose editor",
3189
5418
  );
3190
5419
 
3191
- // Simulate adding an attachment
3192
- const blob = new Blob(["fake-image"], { type: "image/png" });
3193
- const file = new File([blob], "test.png", { type: "image/png" });
3194
- const previewUrl = URL.createObjectURL(blob);
5420
+ // Simulate adding an attachment
5421
+ const blob = new Blob(["fake-image"], { type: "image/png" });
5422
+ const file = new File([blob], "test.png", { type: "image/png" });
5423
+ const previewUrl = URL.createObjectURL(blob);
5424
+
5425
+ editor._attachments = [
5426
+ {
5427
+ clientId: "test-id-1",
5428
+ file,
5429
+ previewUrl,
5430
+ status: "done",
5431
+ progress: null,
5432
+ mediaId: "media-1",
5433
+ alt: "",
5434
+ error: null,
5435
+ posterUrl: null,
5436
+ remoteUrl: null,
5437
+ summary: null,
5438
+ chars: null,
5439
+ },
5440
+ ];
5441
+ editor._attachmentOrder = ["test-id-1"];
5442
+ await editor.updateComplete;
5443
+
5444
+ // Thumbnail strip should be visible
5445
+ expect(editor.querySelector(".compose-attachments")).not.toBeNull();
5446
+ expect(editor.querySelector(".compose-attachment-thumb")).not.toBeNull();
5447
+ // ALT button should be visible
5448
+ expect(editor.querySelector(".compose-attachment-alt")).not.toBeNull();
5449
+ // Media tool button should show inline "Add" label
5450
+ const mediaBtn =
5451
+ editor.querySelector<HTMLButtonElement>(".compose-tool-btn");
5452
+ expect(mediaBtn?.querySelector(".compose-tool-label")?.textContent).toBe(
5453
+ "Add",
5454
+ );
5455
+
5456
+ URL.revokeObjectURL(previewUrl);
5457
+ });
5458
+
5459
+ it("puts the single-post editor straight inside the one scroll region", async () => {
5460
+ const el = await createElement();
5461
+
5462
+ const scroller = requireElement(
5463
+ el.querySelector<HTMLElement>(".compose-scroll"),
5464
+ "expected compose scroll region",
5465
+ );
5466
+ const editor = requireElement(
5467
+ el.querySelector<JantComposeEditor>("jant-compose-editor"),
5468
+ "expected compose editor",
5469
+ );
5470
+
5471
+ // The rules that lay the editor out are keyed on
5472
+ // `.compose-scroll > jant-compose-editor`. Any wrapper breaks them —
5473
+ // including a `display: contents` one, which drops the box but not the DOM
5474
+ // parentage the selector matches on.
5475
+ expect(editor.parentElement).toBe(scroller);
5476
+ // The action row is the composer's pinned floor; it must stay outside.
5477
+ expect(scroller.querySelector(".compose-action-row")).toBeNull();
5478
+ expect(el.querySelector(".compose-action-row")).not.toBeNull();
5479
+ });
5480
+
5481
+ it("scrolls in exactly one place across every compose mode", async () => {
5482
+ const css = readComponentCss();
5483
+
5484
+ // `.compose-scroll` owns the scrolling for single-post, reply and thread
5485
+ // alike. Anything nested inside it that scrolls on its own is the bug this
5486
+ // replaced: three stacked scrollers, and previews shrunk to fit them.
5487
+ expect(css).toMatch(
5488
+ /\.compose-scroll\s*\{[\s\S]*flex:\s*1;[\s\S]*min-height:\s*0;[\s\S]*overflow-y:\s*auto;/,
5489
+ );
5490
+ expect(css).not.toMatch(/\.compose-body\s*\{[^}]*overflow-y:\s*auto;/);
5491
+ expect(css).not.toMatch(
5492
+ /\.compose-thread-compose-layout\s*\{[^}]*overflow-y:\s*auto;/,
5493
+ );
5494
+ expect(css).not.toMatch(
5495
+ /\.compose-editor-row\s+\.compose-attachments-dock\s*\{[^}]*overflow-y:\s*auto;/,
5496
+ );
5497
+
5498
+ // Nothing between the scroll region and the text may clip or claim a fixed
5499
+ // share of the height — every block is sized by its own content.
5500
+ expect(css).not.toMatch(
5501
+ /\.compose-editor-row\s*\{[^}]*overflow:\s*hidden;/,
5502
+ );
5503
+ expect(css).not.toMatch(
5504
+ /\.compose-thread-layout\s*\{[^}]*overflow:\s*hidden;/,
5505
+ );
5506
+ });
5507
+
5508
+ it("lets attachment previews take the room the pinned toolbar used to need", () => {
5509
+ const css = readComponentCss();
5510
+
5511
+ expect(css).toMatch(
5512
+ /\.compose-attachment-img\s*\{[\s\S]*max-height:\s*min\(420px,\s*46dvh\);/,
5513
+ );
5514
+ expect(css).toMatch(
5515
+ /\.compose-attachment:only-child\s+\.compose-attachment-img\s*\{[\s\S]*max-height:\s*min\(460px,\s*52dvh\);/,
5516
+ );
5517
+ // The strip's row height is one variable every kind of attachment reads,
5518
+ // so the number is asserted where it is declared.
5519
+ expect(css).toMatch(
5520
+ /\.compose-attachments\s*\{[\s\S]*?--compose-strip-height:\s*min\(340px,\s*40dvh\);/,
5521
+ );
5522
+ expect(css).toMatch(
5523
+ /\.compose-attachment:not\(:only-child\)\s+\.compose-attachment-img\s*\{[\s\S]*height:\s*var\(--compose-strip-height\);/,
5524
+ );
5525
+ // Reply compose no longer shrinks its previews to 96×72 to spare the
5526
+ // toolbar — one preview size everywhere.
5527
+ expect(css).not.toMatch(/--compose-reply-attachment-width/);
5528
+ });
5529
+
5530
+ it("gives a card in the strip the same height as the pictures beside it", () => {
5531
+ const css = readComponentCss();
5532
+
5533
+ // A card is chrome, not content: it fills the height a picture sets, so a
5534
+ // mixed strip reads as one row and every control row lands on one line.
5535
+ expect(css).toMatch(
5536
+ /\.compose-attachments:has\(\.compose-attachment-img\)\s+\.compose-attachment:not\(:only-child\)\s+:is\(\s*\.compose-attachment-file-card,\s*\.compose-attachment-text-card,\s*\.compose-attachment-preview-fallback\s*\)\s*\{\s*height:\s*var\(--compose-strip-height\);/,
5537
+ );
5538
+ // With no picture to match, cards keep their own compact height rather
5539
+ // than growing into a row of empty boxes.
5540
+ expect(css).toMatch(
5541
+ /\.compose-attachment:not\(:only-child\)\s+\.compose-attachment-file-card\s*\{[\s\S]*?height:\s*200px;/,
5542
+ );
5543
+ expect(css).toMatch(
5544
+ /\.compose-attachment:not\(:only-child\)\s+\.compose-attachment-text-card\s*\{[\s\S]*?height:\s*200px;/,
5545
+ );
5546
+ });
5547
+
5548
+ it("routes a format change straight off the single-post editor", async () => {
5549
+ const el = await createElement();
5550
+ const editor = requireElement(
5551
+ el.querySelector<JantComposeEditor>("jant-compose-editor"),
5552
+ "expected compose editor",
5553
+ );
5554
+
5555
+ editor.dispatchEvent(
5556
+ new CustomEvent("jant:format-change", {
5557
+ detail: { format: "quote" },
5558
+ bubbles: true,
5559
+ }),
5560
+ );
5561
+ await flushUpdates(el);
5562
+
5563
+ expect(el._format).toBe("quote");
5564
+ });
5565
+
5566
+ it("shows the replied-to post at the same scale as the reply", () => {
5567
+ const css = readComponentCss();
5568
+
5569
+ // Same ceiling as the composer's own previews — the parent is not a
5570
+ // thumbnail strip.
5571
+ expect(css).toMatch(
5572
+ /\.compose-reply-context-body img,[\s\S]*?video\s*\{[\s\S]*?max-height:\s*min\(420px,\s*46dvh\);/,
5573
+ );
5574
+ // One rule governs every picture in the context. A second one keyed on
5575
+ // `.compose-reply-context-media-img` would sit at lower specificity than
5576
+ // the `…-body img` rule above it and never apply — the media strip is
5577
+ // inside the body.
5578
+ expect(css).not.toMatch(/\.compose-reply-context-media-img\s*\{/);
5579
+
5580
+ // Expanding hands the post its full height; a capped, separately scrolling
5581
+ // box is what left no room for full-size pictures.
5582
+ expect(css).toMatch(
5583
+ /\.compose-reply-context\.expanded\s*\{\s*max-height:\s*none;\s*overflow:\s*visible;/,
5584
+ );
5585
+ // Fullscreen's collapsed cap must not outrank that.
5586
+ expect(css).toMatch(
5587
+ /\.compose-fullscreen-thread-layout\s+\.compose-reply-context:not\(\.expanded\)\s*\{\s*max-height:\s*140px;/,
5588
+ );
5589
+ });
5590
+
5591
+ it("keeps reply compose tools inside the editor's text column", () => {
5592
+ const css = readComponentCss();
5593
+
5594
+ // `flex: 1` on the editor is horizontal — it claims the width the rail dot
5595
+ // leaves. Height comes from content.
5596
+ expect(css).toMatch(
5597
+ /\.compose-editor-row\s*>\s*jant-compose-editor\s*\{[\s\S]*flex:\s*1;[\s\S]*min-width:\s*0;/,
5598
+ );
5599
+ expect(css).toMatch(
5600
+ /\.compose-reply-compose-layout\s+\.compose-thread-post-header\s*\+\s*\.compose-body\s*\{[\s\S]*padding-top:\s*12px;/,
5601
+ );
5602
+ expect(css).toMatch(
5603
+ /@media \(max-width:\s*699px\),\s*\(hover:\s*none\) and \(pointer:\s*coarse\)\s*\{[\s\S]*\.compose-thread-layout\s*\{[\s\S]*padding-top:\s*0\.75rem;/,
5604
+ );
5605
+ expect(css).toMatch(
5606
+ /\.compose-dialog,[\s\S]*\.compose-page-shell\s*>\s*jant-compose-dialog\s*\{[\s\S]*--compose-quote-input-size:\s*var\(--type-content-subtitle\);[\s\S]*--compose-quote-input-leading:\s*1\.32;/,
5607
+ );
5608
+ // On the page the card's own padding is the text column, so every row
5609
+ // inside it reads a zero gutter and only restates its right.
5610
+ expect(css).toMatch(
5611
+ /\.compose-page-shell\s+\.compose-dialog-inner-page\s*\{[\s\S]*--compose-gutter:\s*0px;/,
5612
+ );
5613
+ expect(css).toMatch(
5614
+ /\.compose-page-shell\s+\.compose-dialog-inner-page\s+\.compose-thread-post-header\s*\{\s*padding-inline-end:\s*0;/,
5615
+ );
5616
+ expect(css).toMatch(
5617
+ /\.compose-quote-text\s*\{[\s\S]*font-size:\s*var\(--compose-quote-input-size\);[\s\S]*line-height:\s*var\(--compose-quote-input-leading\);/,
5618
+ );
5619
+ expect(css).toMatch(
5620
+ /\.compose-reply-compose-layout\s*\{[\s\S]*--compose-title-input-size:\s*calc\(var\(--type-content-body\) \* 1\.2\);[\s\S]*--compose-quote-input-size:\s*calc\(var\(--type-content-body\) \* 1\.06\);[\s\S]*--compose-quote-input-leading:\s*1\.42;[\s\S]*--compose-inline-input-size:\s*var\(--type-base\);/,
5621
+ );
5622
+ expect(css).toMatch(
5623
+ /\.compose-reply-compose-layout\s+\.compose-quote-wrap\s*\{[\s\S]*margin-top:\s*0\.4rem;[\s\S]*padding:\s*24px 18px 18px;[\s\S]*border-radius:\s*0\.95rem;/,
5624
+ );
5625
+ expect(css).toMatch(
5626
+ /\.compose-reply-compose-layout\s+\.compose-link-url-wrap\s*\{[\s\S]*margin-top:\s*0\.4rem;/,
5627
+ );
5628
+ // The reply layout tunes only the lead-in. Height is the shared four-line
5629
+ // floor, which reads this layout's own leading and type size, so a
5630
+ // `min-height` here would just restate what the base rule already knows.
5631
+ expect(css).toMatch(
5632
+ /\.compose-reply-compose-layout\s+\.compose-quote-text\s*\{\s*padding-top:\s*0\.35rem;\s*\}/,
5633
+ );
5634
+ expect(css).not.toMatch(
5635
+ /\.compose-reply-compose-layout\s+\.compose-quote-text\s*\{[^}]*min-height:/,
5636
+ );
5637
+ expect(css).toMatch(
5638
+ /\.compose-reply-compose-layout\s+\.compose-tiptap-body\s+\.tiptap\s*\{[\s\S]*font-size:\s*var\(--type-content-body\);/,
5639
+ );
5640
+ });
5641
+
5642
+ it("opens every compose text surface at a floor stated in lines", () => {
5643
+ const css = readComponentCss();
5644
+
5645
+ // Two lines for the editor, four for the quote — as multiples of the
5646
+ // leading, not pixels, so the floor follows the type instead of going
5647
+ // stale next to it.
5648
+ expect(css).toMatch(
5649
+ /\.compose-tiptap-body \.tiptap\s*\{[\s\S]*min-height:\s*calc\(var\(--type-body-leading\) \* 2em\);/,
5650
+ );
5651
+ expect(css).toMatch(
5652
+ /\.compose-quote-text\s*\{[\s\S]*min-height:\s*calc\(var\(--compose-quote-input-leading\) \* 4em\);/,
5653
+ );
5654
+
5655
+ // One floor for every editor: a note's body and the "thoughts" under a
5656
+ // link or a quote all open the same way.
5657
+ expect(css).not.toMatch(/\.compose-tiptap-thoughts/);
5658
+ });
5659
+
5660
+ it("runs the thread rail from the first dot to the last post's, and no further", () => {
5661
+ const css = readComponentCss();
5662
+
5663
+ // Drawn per row rather than as one strip down the layout, which is what
5664
+ // lets both ends be trimmed back to a dot.
5665
+ expect(css).not.toMatch(/\.compose-thread-layout::before\s*\{/);
5666
+ expect(css).toMatch(
5667
+ /\.compose-thread-layout > \*::before\s*\{[\s\S]*top:\s*0;[\s\S]*bottom:\s*0;/,
5668
+ );
5669
+ expect(css).toMatch(
5670
+ /\.compose-thread-layout > :first-child::before\s*\{\s*top:\s*var\(--compose-thread-dot-center\);/,
5671
+ );
5672
+ expect(css).toMatch(
5673
+ /\.compose-editor-row:not\(:has\(~ \.compose-editor-row\)\)::before\s*\{\s*bottom:\s*auto;\s*height:\s*var\(--compose-thread-dot-center\);/,
5674
+ );
5675
+ // The "add" row is a placeholder, not a post — the rail stops before it.
5676
+ expect(css).toMatch(
5677
+ /\.compose-thread-layout > \.compose-thread-add-row::before\s*\{\s*display:\s*none;/,
5678
+ );
5679
+
5680
+ // The dot reads its offset off the same centre the rail is trimmed to, so
5681
+ // resizing the marker cannot leave the line pointing past it.
5682
+ expect(css).toMatch(
5683
+ /\.compose-thread-dot\s*\{[\s\S]*margin-top:\s*calc\(\s*var\(--compose-thread-dot-center\) -\s*var\(--compose-thread-row-padding-top\) -\s*var\(--site-thread-marker-size\) \/ 2\s*\);/,
5684
+ );
5685
+ });
3195
5686
 
3196
- editor._attachments = [
3197
- {
3198
- clientId: "test-id-1",
3199
- file,
3200
- previewUrl,
3201
- status: "done",
3202
- progress: null,
3203
- mediaId: "media-1",
3204
- alt: "",
3205
- error: null,
3206
- posterUrl: null,
3207
- summary: null,
3208
- chars: null,
3209
- },
3210
- ];
3211
- editor._attachmentOrder = ["test-id-1"];
3212
- await editor.updateComplete;
5687
+ it("starts every compose row's ink on one text column", () => {
5688
+ const css = readComponentCss();
3213
5689
 
3214
- // Thumbnail strip should be visible
3215
- expect(editor.querySelector(".compose-attachments")).not.toBeNull();
3216
- expect(editor.querySelector(".compose-attachment-thumb")).not.toBeNull();
3217
- // ALT button should be visible
3218
- expect(editor.querySelector(".compose-attachment-alt")).not.toBeNull();
3219
- // Media tool button should show inline "Add" label
3220
- const mediaBtn =
3221
- editor.querySelector<HTMLButtonElement>(".compose-tool-btn");
3222
- expect(mediaBtn?.querySelector(".compose-tool-label")?.textContent).toBe(
3223
- "Add",
5690
+ // One knob for the column itself, read by every row rather than restated
5691
+ // as a literal per row.
5692
+ expect(css).toMatch(
5693
+ /\.compose-dialog,\s*\.compose-page-shell\s*>\s*jant-compose-dialog\s*\{[\s\S]*--compose-gutter:\s*20px;/,
3224
5694
  );
5695
+ expect(css).toMatch(
5696
+ /@media \(min-width:\s*700px\)\s*\{[\s\S]*--compose-gutter:\s*24px;/,
5697
+ );
5698
+ // Zero wherever the indent already comes from outside the editor.
5699
+ expect(css).toMatch(
5700
+ /\.compose-editor-row\s*\{\s*\/\*[\s\S]*?\*\/\s*--compose-gutter:\s*0px;/,
5701
+ );
5702
+
5703
+ for (const rule of [
5704
+ /\.compose-body\s*\{[^}]*padding:\s*16px 20px 12px var\(--compose-gutter\);/,
5705
+ /\.compose-tools-row\s*\{[\s\S]*padding:\s*6px 10px 6px var\(--compose-gutter\);/,
5706
+ /\.compose-attachments-dock\s*\{\s*padding:\s*8px 20px 4px var\(--compose-gutter\);/,
5707
+ /\.compose-thread-post-header\s*\{[\s\S]*padding:\s*10px 16px 2px var\(--compose-gutter\);/,
5708
+ /\.compose-add-thread-trigger\s*\{\s*padding:\s*1px 10px 4px var\(--compose-gutter\);/,
5709
+ /\.compose-action-row\s*\{\s*padding:\s*10px 12px 14px var\(--compose-gutter\);/,
5710
+ ]) {
5711
+ expect(css).toMatch(rule);
5712
+ }
3225
5713
 
3226
- URL.revokeObjectURL(previewUrl);
5714
+ // Each control hangs its box back out of the column by its own optical
5715
+ // inset, so the glyph or the label lands on the edge and not the box.
5716
+ expect(css).toMatch(
5717
+ /\.compose-tools-row\s*\{[\s\S]*--compose-tool-hang:\s*12px;/,
5718
+ );
5719
+ expect(css).toMatch(
5720
+ /\.compose-tools-row > :first-child\s*\{\s*margin-inline-start:\s*calc\(-1 \* var\(--compose-tool-hang\)\);/,
5721
+ );
5722
+ expect(css).toMatch(
5723
+ /\.compose-thread-add-btn\s*\{[\s\S]*padding:\s*5px var\(--compose-add-hang\);\s*margin-inline-start:\s*calc\(-1 \* var\(--compose-add-hang\)\);/,
5724
+ );
5725
+ expect(css).toMatch(
5726
+ /\.compose-collection-select\s*\{[\s\S]*margin-inline-start:\s*calc\(\s*-1 \* \(var\(--compose-collection-pad-icon\) \+ 1px\)\s*\);/,
5727
+ );
5728
+
5729
+ // The ad-hoc thread-only indents these replaced are gone — they were what
5730
+ // let each mode drift to its own edge in the first place.
5731
+ expect(css).not.toMatch(/margin-inline-start:\s*-10px;/);
5732
+ expect(css).not.toMatch(/\.compose-thread-layout \.compose-tools-row\s*\{/);
5733
+ // One "Add to thread" button, not two rule sets that have to stay in step.
5734
+ expect(css).not.toMatch(/\.compose-add-thread-btn/);
3227
5735
  });
3228
5736
 
3229
- it("keeps reply compose tools inside the constrained editor surface", () => {
3230
- const css = readFileSync(resolve("src/styles/ui.css"), "utf8");
5737
+ it("declares one marker vocabulary for the compose and reading rails", () => {
5738
+ const css = readComponentCss();
3231
5739
 
5740
+ // Both rails draw the same dot; only the surface behind it differs.
5741
+ // `[^}]*` rather than `[\s\S]*` throughout: a greedy any-char run walks past
5742
+ // the rule's closing brace and happily matches a later rule's declaration.
3232
5743
  expect(css).toMatch(
3233
- /\.compose-editor-row\s*>\s*jant-compose-editor\s*\{[\s\S]*align-self:\s*stretch;[\s\S]*overflow:\s*hidden;/,
5744
+ /\.thread-group,\s*\.compose-thread-layout\s*\{\s*--site-thread-marker-size:\s*7px;[^}]*--site-thread-marker-gap:\s*9px;/,
3234
5745
  );
3235
- expect(css).toMatch(/\.compose-tools-row\s*\{[\s\S]*flex-shrink:\s*0;/);
3236
5746
  expect(css).toMatch(
3237
- /\.compose-editor-row\s+\.compose-attachments-dock\s*\{[\s\S]*max-height:\s*min\(240px,\s*34dvh\);[\s\S]*overflow-y:\s*auto;/,
5747
+ /\.compose-thread-layout\s*\{\s*--site-thread-marker-surface:\s*var\(--compose-paper-bg\);\s*\}/,
5748
+ );
5749
+ // 7:1.5 marker-to-rail on both, from one token — compose used to hardcode
5750
+ // its own 1.5px while reading ran a 1px hairline under the same 7px dot.
5751
+ expect(css).toMatch(/--site-thread-rail-line-width:\s*1\.5px;/);
5752
+ expect(css).not.toMatch(
5753
+ /\.thread-group-preview,\s*\.thread-group-detail\s*\{[^}]*--site-thread-rail-line-width:/,
3238
5754
  );
3239
5755
  expect(css).toMatch(
3240
- /\.compose-reply-compose-layout\s+\.compose-editor-row\s+\.compose-attachments-dock\s*\{[\s\S]*--compose-reply-attachment-width:\s*96px;[\s\S]*--compose-reply-attachment-height:\s*72px;[\s\S]*max-height:\s*min\(116px,\s*18dvh\);/,
5756
+ /\.compose-thread-layout > \*::before\s*\{[^}]*width:\s*var\(--site-thread-rail-line-width\);\s*margin-left:\s*calc\(var\(--site-thread-rail-line-width\) \/ -2\);/,
3241
5757
  );
5758
+ // The reading rail's fade-in gradient stays — it softens where collapsed
5759
+ // ancestor context begins, which compose solves by trimming per row.
3242
5760
  expect(css).toMatch(
3243
- /\.compose-reply-compose-layout[\s\S]*\.compose-attachment:only-child[\s\S]*\.compose-attachment-img,[\s\S]*\.compose-reply-compose-layout[\s\S]*\.compose-attachment:not\(:only-child\)[\s\S]*\.compose-attachment-img\s*\{[\s\S]*height:\s*var\(--compose-reply-attachment-height\);[\s\S]*object-fit:\s*cover;/,
5761
+ /\.thread-group-preview::before,\s*\.thread-group-detail::before\s*\{\s*background:\s*linear-gradient\(/,
3244
5762
  );
5763
+ // Both rails now stop at the last dot. Reading masks its group-wide line
5764
+ // from that dot down; `> ` keeps the mask off the last ancestor inside
5765
+ // `.thread-context-shell`, which the rail must run straight through.
3245
5766
  expect(css).toMatch(
3246
- /\.compose-reply-compose-layout\s+\.compose-thread-post-header\s*\+\s*\.compose-body\s*\{[\s\S]*padding-top:\s*12px;/,
5767
+ /\.thread-group > \.thread-item:last-child::after\s*\{[^}]*top:\s*50%;\s*bottom:\s*0;\s*width:\s*var\(--site-thread-rail-line-width\);\s*background-color:\s*var\(--site-thread-marker-surface\);/,
3247
5768
  );
5769
+ expect(css).not.toMatch(/\.thread-group \.thread-item:last-child::after/);
5770
+ // The ring is reading's alone: the token is declared on `.thread-group`, and
5771
+ // compose's dot rule never reads it. The compose rail is quiet by design —
5772
+ // it already has a card edge, a format switcher and a "1/3" per row.
5773
+ expect(css).toMatch(
5774
+ /\.thread-group\s*\{[^}]*--site-thread-marker-ring-inset:\s*2px;\s*--site-thread-marker-ring-width:\s*2px;/,
5775
+ );
5776
+ expect(css).not.toMatch(
5777
+ /\.compose-thread-layout\s*\{[^}]*--site-thread-marker-ring/,
5778
+ );
5779
+ expect(css).not.toMatch(
5780
+ /\.compose-thread-dot::before\s*\{[^}]*--site-thread-(dot-ring|marker-ring)/,
5781
+ );
5782
+ // The per-dot border and the size-based hero markers stay gone: a border
5783
+ // eats into the fill instead of sitting outside it, and a bigger dot
5784
+ // repeats what the full card beside it already says.
5785
+ for (const dead of [
5786
+ "--site-thread-marker-border-width",
5787
+ "--site-thread-marker-hero-size",
5788
+ "--site-thread-marker-hero-border-width",
5789
+ ]) {
5790
+ expect(css).not.toContain(dead);
5791
+ }
5792
+ expect(css).not.toMatch(/\.compose-thread-dot(::before)?\s*\{[^}]*border:/);
5793
+ });
5794
+
5795
+ it("punches the dot's gap with the surface colour on both rails", () => {
5796
+ const css = readComponentCss();
5797
+
5798
+ // Compose stops at the punch-out; reading layers its ring inside the same
5799
+ // radius, so the outermost layer — the one that breaks the rail line — is
5800
+ // still the gap at its full size on both rails.
5801
+ const punchOut =
5802
+ /0 0 0 var\(--site-thread-marker-gap\)\s*var\(--site-thread-marker-surface\);/;
3248
5803
  expect(css).toMatch(
3249
- /@media \(max-width:\s*760px\),\s*\(hover:\s*none\) and \(pointer:\s*coarse\)\s*\{[\s\S]*\.compose-thread-layout\s*\{[\s\S]*padding-top:\s*0\.75rem;[\s\S]*\.compose-thread-layout::before\s*\{[\s\S]*top:\s*0\.75rem;/,
5804
+ new RegExp(
5805
+ `\\.compose-thread-dot::before\\s*\\{[^}]*box-shadow:\\s*${punchOut.source}`,
5806
+ ),
3250
5807
  );
3251
5808
  expect(css).toMatch(
3252
- /\.compose-dialog,[\s\S]*\.compose-page-shell\s*>\s*jant-compose-dialog\s*\{[\s\S]*--compose-quote-input-size:\s*var\(--type-content-subtitle\);[\s\S]*--compose-quote-input-leading:\s*1\.32;/,
5809
+ new RegExp(`\\.thread-item::before\\s*\\{[^}]*${punchOut.source}`),
3253
5810
  );
5811
+ // Reading's ring: a band of surface, then the accent tint, both drawn as
5812
+ // spread inside the punch. A border would eat the fill instead.
3254
5813
  expect(css).toMatch(
3255
- /\.compose-quote-text\s*\{[\s\S]*font-size:\s*var\(--compose-quote-input-size\);[\s\S]*line-height:\s*var\(--compose-quote-input-leading\);/,
5814
+ /\.thread-item::before\s*\{[^}]*box-shadow:\s*0 0 0 var\(--site-thread-marker-ring-inset\)\s*var\(--site-thread-marker-surface\),\s*0 0 0\s*calc\(\s*var\(--site-thread-marker-ring-inset\) \+\s*var\(--site-thread-marker-ring-width\)\s*\)\s*var\(--site-thread-dot-ring\),/,
3256
5815
  );
5816
+ expect(css).not.toMatch(/\.thread-item::before\s*\{[^}]*border:\s/);
5817
+ });
5818
+
5819
+ it("marks the post a view is about with an accent fill, not a bigger dot", () => {
5820
+ const css = readComponentCss();
5821
+
5822
+ // Feed hero, curated highlight and the opened detail post share one rule,
5823
+ // matching compose's `.is-current`. Size is not a second signal.
3257
5824
  expect(css).toMatch(
3258
- /\.compose-reply-compose-layout\s*\{[\s\S]*--compose-title-input-size:\s*var\(--type-content-subtitle\);[\s\S]*--compose-quote-input-size:\s*calc\(var\(--type-content-body\) \* 1\.06\);[\s\S]*--compose-quote-input-leading:\s*1\.42;[\s\S]*--compose-inline-input-size:\s*var\(--type-base\);/,
5825
+ /\.thread-item-hero::before,\s*\.thread-item-curated::before,\s*\.thread-item-current::before\s*\{\s*background-color:\s*var\(--site-thread-marker-current\);\s*\}/,
3259
5826
  );
5827
+ // Both rails take the current-post colour from one token, and that token is
5828
+ // a tint — the raw accent made a 7px dot the loudest thing on the page.
3260
5829
  expect(css).toMatch(
3261
- /\.compose-reply-compose-layout\s+\.compose-quote-wrap\s*\{[\s\S]*margin-top:\s*0\.4rem;[\s\S]*padding:\s*24px 18px 18px;[\s\S]*border-radius:\s*0\.95rem;/,
5830
+ /\.compose-editor-row\.is-current \.compose-thread-dot::before\s*\{\s*background-color:\s*var\(--site-thread-marker-current\);/,
3262
5831
  );
3263
5832
  expect(css).toMatch(
3264
- /\.compose-reply-compose-layout\s+\.compose-link-url-wrap\s*\{[\s\S]*margin-top:\s*0\.4rem;/,
5833
+ /--site-thread-marker-current:\s*color-mix\(\s*in srgb,\s*var\(--site-accent\) 90%,\s*var\(--site-threadline\)\s*\);/,
5834
+ );
5835
+ expect(css).not.toMatch(
5836
+ /\.thread-item-current::before\s*\{[^}]*var\(--site-accent\)[^-]/,
3265
5837
  );
5838
+ // Every dot's geometry now derives from the shared token.
3266
5839
  expect(css).toMatch(
3267
- /\.compose-reply-compose-layout\s+\.compose-quote-text\s*\{[\s\S]*min-height:\s*6\.5rem;[\s\S]*padding-top:\s*0\.35rem;/,
5840
+ /\.thread-item::before\s*\{[^}]*width:\s*var\(--site-thread-marker-size\);/,
3268
5841
  );
5842
+ expect(css).not.toMatch(/\.thread-item::before\s*\{[^}]*width:\s*\d+px;/);
5843
+ });
5844
+
5845
+ it("centres every rail child on the line by giving them a shared track", () => {
5846
+ const css = readComponentCss();
5847
+
5848
+ // The line's x is derived from the track, never from a dot's own size — a
5849
+ // hardcoded half-marker is what put the line down the dot's edge before.
3269
5850
  expect(css).toMatch(
3270
- /\.compose-reply-compose-layout\s+\.compose-tiptap-body\s+\.tiptap\s*\{[\s\S]*font-size:\s*var\(--type-content-body\);/,
5851
+ /--compose-thread-rail-left:\s*calc\(\s*var\(--compose-thread-padding-left\) \+ var\(--compose-thread-rail-width\) \/ 2\s*\);/,
5852
+ );
5853
+ expect(css).not.toMatch(/--compose-thread-rail-left:[^;]*\+ \d/);
5854
+
5855
+ // Post dot: fills the track, centres the circle in it.
5856
+ expect(css).toMatch(
5857
+ /\.compose-thread-dot\s*\{[\s\S]*justify-content:\s*center;[\s\S]*width:\s*var\(--compose-thread-rail-width\);/,
5858
+ );
5859
+ // "Add to thread" placeholder: fills the track outright, so no offset.
5860
+ expect(css).toMatch(
5861
+ /\.compose-thread-add-dot\s*\{[\s\S]*width:\s*var\(--compose-thread-rail-width\);[\s\S]*height:\s*var\(--compose-thread-rail-width\);/,
5862
+ );
5863
+ expect(css).not.toMatch(/\.compose-thread-add-dot\s*\{[^}]*margin-left:/);
5864
+ // Reply meta hangs off the same track as the rows above it.
5865
+ expect(css).toMatch(
5866
+ /\.compose-reply-meta\s*\{[\s\S]*var\(--compose-thread-gap\) \+\s*var\(--compose-thread-rail-width\)/,
5867
+ );
5868
+ });
5869
+
5870
+ it("accents the dot of the post holding the cursor, not every editor row", () => {
5871
+ const css = readComponentCss();
5872
+
5873
+ // Which selector carries the highlight — the colour itself is pinned by
5874
+ // "marks the post a view is about…", so this stays token-agnostic.
5875
+ expect(css).toMatch(
5876
+ /\.compose-editor-row\.is-current \.compose-thread-dot::before\s*\{\s*background-color:/,
5877
+ );
5878
+ // The old rule lit every post in a thread at once.
5879
+ expect(css).not.toMatch(
5880
+ /\.compose-editor-row \.compose-thread-dot\s*\{\s*background-color:/,
5881
+ );
5882
+ });
5883
+
5884
+ it("moves the current-post marker as focus moves between thread posts", async () => {
5885
+ const el = await createElement();
5886
+ el._threadItems = [
5887
+ { id: "thread-1", format: "note" },
5888
+ { id: "thread-2", format: "note" },
5889
+ { id: "thread-3", format: "note" },
5890
+ ];
5891
+ await flushUpdates(el);
5892
+
5893
+ const currentIndexes = () =>
5894
+ Array.from(el.querySelectorAll<HTMLElement>(".compose-thread-post"))
5895
+ .map((row, index) =>
5896
+ row.classList.contains("is-current") ? index : -1,
5897
+ )
5898
+ .filter((index) => index >= 0);
5899
+
5900
+ // Exactly one at a time — the old rule accented all three.
5901
+ expect(currentIndexes()).toEqual([0]);
5902
+
5903
+ const second = requireElement(
5904
+ el.querySelector<HTMLElement>(
5905
+ '.compose-thread-post[data-thread-index="1"]',
5906
+ ),
5907
+ "expected the second thread post",
3271
5908
  );
5909
+ second.dispatchEvent(new Event("focusin", { bubbles: true }));
5910
+ await flushUpdates(el);
5911
+
5912
+ expect(currentIndexes()).toEqual([1]);
3272
5913
  });
3273
5914
 
3274
5915
  it("keeps passive footnote references quiet until the editor selects them", () => {
3275
- const css = readFileSync(resolve("src/styles/ui.css"), "utf8");
5916
+ const css = readComponentCss();
3276
5917
 
3277
5918
  expect(css).toMatch(
3278
5919
  /\.compose-tiptap-body \.tiptap \.tiptap-footnote-reference\s*\{[^}]*color:\s*var\(--site-footnote-marker\);/,
@@ -3304,6 +5945,7 @@ describe("JantComposeDialog", () => {
3304
5945
  alt: "",
3305
5946
  error: null,
3306
5947
  posterUrl: null,
5948
+ remoteUrl: null,
3307
5949
  summary: null,
3308
5950
  chars: null,
3309
5951
  },
@@ -3346,6 +5988,7 @@ describe("JantComposeDialog", () => {
3346
5988
  alt: "",
3347
5989
  error: null,
3348
5990
  posterUrl: null,
5991
+ remoteUrl: null,
3349
5992
  summary: null,
3350
5993
  chars: null,
3351
5994
  },
@@ -3401,6 +6044,7 @@ describe("JantComposeDialog", () => {
3401
6044
  alt: "A test image",
3402
6045
  error: null,
3403
6046
  posterUrl: null,
6047
+ remoteUrl: null,
3404
6048
  summary: null,
3405
6049
  chars: null,
3406
6050
  },
@@ -3418,8 +6062,7 @@ describe("JantComposeDialog", () => {
3418
6062
  await editor.updateComplete;
3419
6063
 
3420
6064
  let receivedDetail:
3421
- | (ComposeSubmitDetail & { pendingAttachments: unknown[] })
3422
- | null = null;
6065
+ (ComposeSubmitDetail & { pendingAttachments: unknown[] }) | null = null;
3423
6066
  el.addEventListener("jant:compose-submit-deferred", (event) => {
3424
6067
  const customEvent = event as CustomEvent<
3425
6068
  ComposeSubmitDetail & { pendingAttachments: unknown[] }
@@ -3471,6 +6114,7 @@ describe("JantComposeDialog", () => {
3471
6114
  alt: "Alt for pending",
3472
6115
  error: null,
3473
6116
  posterUrl: null,
6117
+ remoteUrl: null,
3474
6118
  summary: null,
3475
6119
  chars: null,
3476
6120
  },
@@ -3546,7 +6190,116 @@ describe("JantComposeDialog", () => {
3546
6190
  );
3547
6191
  });
3548
6192
 
3549
- // ── Close confirmation ─────────────────────────────────────────────
6193
+ // ── Leaving compose ────────────────────────────────────────────────
6194
+
6195
+ it("names the dialog for assistive tech now that no title is drawn", async () => {
6196
+ // The visible title carried the dialog's accessible name; it moves to an
6197
+ // attribute rather than disappearing with the row.
6198
+ const dialog = document.createElement("dialog");
6199
+ document.body.appendChild(dialog);
6200
+ const el = document.createElement(
6201
+ "jant-compose-dialog",
6202
+ ) as JantComposeDialog;
6203
+ el.collections = collections;
6204
+ el.labels = labels;
6205
+ dialog.appendChild(el);
6206
+ await flushUpdates(el);
6207
+
6208
+ expect(dialog.getAttribute("aria-label")).toBe(labels.composeDialogLabel);
6209
+
6210
+ // Tear the dialog down here: happy-dom throws when `body.innerHTML = ""`
6211
+ // in the shared beforeEach removes one it did not itself create.
6212
+ el.remove();
6213
+ dialog.remove();
6214
+ });
6215
+
6216
+ it("closes from the × in the post header row", async () => {
6217
+ const el = await createElement();
6218
+ await flushUpdates(el);
6219
+ const requestCloseSpy = vi.spyOn(el, "requestClose");
6220
+
6221
+ const closeBtn = requireElement(
6222
+ el.querySelector<HTMLButtonElement>(
6223
+ ".compose-thread-post-header .compose-close-btn",
6224
+ ),
6225
+ "expected close button in the post header",
6226
+ );
6227
+ expect(closeBtn.getAttribute("aria-label")).toBe(labels.close);
6228
+
6229
+ closeBtn.click();
6230
+ await flushUpdates(el);
6231
+
6232
+ expect(requestCloseSpy).toHaveBeenCalledTimes(1);
6233
+ });
6234
+
6235
+ it("hands the header slot back to per-post remove in a thread, and moves the exit to the quick actions row", async () => {
6236
+ const el = await createElement();
6237
+ el._threadItems = [
6238
+ { id: "thread-1", format: "note" },
6239
+ { id: "thread-2", format: "note" },
6240
+ ];
6241
+ await flushUpdates(el);
6242
+
6243
+ // Each post owns its × for removing itself, so no close button competes.
6244
+ expect(el.querySelector(".compose-close-btn")).toBeNull();
6245
+ expect(
6246
+ el.querySelectorAll(".compose-thread-post-remove").length,
6247
+ ).toBeGreaterThan(0);
6248
+
6249
+ const closeBtn = requireElement(
6250
+ el.querySelector<HTMLButtonElement>(
6251
+ ".compose-quick-actions-row .compose-quick-actions-close",
6252
+ ),
6253
+ "expected a close button in the quick actions row",
6254
+ );
6255
+ expect(closeBtn.textContent?.trim()).toBe(labels.close);
6256
+
6257
+ const requestCloseSpy = vi.spyOn(el, "requestClose");
6258
+ closeBtn.click();
6259
+ await flushUpdates(el);
6260
+
6261
+ expect(requestCloseSpy).toHaveBeenCalledTimes(1);
6262
+ });
6263
+
6264
+ it("keeps the thread exit out of single-post mode, where the × already offers one", async () => {
6265
+ const el = await createElement();
6266
+ expect(el.querySelector(".compose-quick-actions-close")).toBeNull();
6267
+
6268
+ await openPublishPanel(el);
6269
+ const titles = Array.from(
6270
+ el.querySelectorAll<HTMLElement>(
6271
+ ".compose-publish-panel .compose-sheet-title",
6272
+ ),
6273
+ ).map((n) => n.textContent?.trim());
6274
+
6275
+ // The exit is never a row in the options sheet, in either mode.
6276
+ expect(titles).not.toContain(labels.close);
6277
+ expect(titles).toContain("Drafts");
6278
+ });
6279
+
6280
+ it("keeps the exit visible in a thread whose visibility row is locked", async () => {
6281
+ const el = await createElement();
6282
+ el._threadItems = [
6283
+ { id: "thread-1", format: "note" },
6284
+ { id: "thread-2", format: "note" },
6285
+ ];
6286
+ el._visibilityLocked = true;
6287
+ await flushUpdates(el);
6288
+
6289
+ expect(el.querySelector(".compose-quick-actions-close")).not.toBeNull();
6290
+ });
6291
+
6292
+ it("leaves the exit to the page's own Back link in page mode", async () => {
6293
+ const el = await createElement();
6294
+ el.pageMode = true;
6295
+ el._threadItems = [
6296
+ { id: "thread-1", format: "note" },
6297
+ { id: "thread-2", format: "note" },
6298
+ ];
6299
+ await flushUpdates(el);
6300
+
6301
+ expect(el.querySelector(".compose-quick-actions-close")).toBeNull();
6302
+ });
3550
6303
 
3551
6304
  it("requestClose on empty form closes immediately without confirmation", async () => {
3552
6305
  const el = await createElement();
@@ -4541,6 +7294,66 @@ describe("JantComposeDialog", () => {
4541
7294
  expect(el.querySelector("[data-compose-link-title-error]")).toBeNull();
4542
7295
  });
4543
7296
 
7297
+ it("explains the missing link title once publish is attempted", async () => {
7298
+ const el = await createElement();
7299
+ el._format = "link";
7300
+ await el.updateComplete;
7301
+
7302
+ const editor = requireElement(
7303
+ el.querySelector<JantComposeEditor>("jant-compose-editor"),
7304
+ "expected compose editor",
7305
+ );
7306
+ editor._url = "https://example.com";
7307
+ await editor.updateComplete;
7308
+
7309
+ el.dispatchEvent(
7310
+ new globalThis.KeyboardEvent("keydown", {
7311
+ key: "Enter",
7312
+ metaKey: true,
7313
+ bubbles: true,
7314
+ }),
7315
+ );
7316
+ await el.updateComplete;
7317
+ await editor.updateComplete;
7318
+
7319
+ const error = requireElement(
7320
+ el.querySelector("[data-compose-link-title-error]"),
7321
+ "expected link title error",
7322
+ );
7323
+ expect(error.textContent?.trim()).toBe(
7324
+ "Add a title before posting this link.",
7325
+ );
7326
+ });
7327
+
7328
+ it("explains the missing link URL once publish is attempted", async () => {
7329
+ const el = await createElement();
7330
+ el._format = "link";
7331
+ await el.updateComplete;
7332
+
7333
+ const editor = requireElement(
7334
+ el.querySelector<JantComposeEditor>("jant-compose-editor"),
7335
+ "expected compose editor",
7336
+ );
7337
+
7338
+ el.dispatchEvent(
7339
+ new globalThis.KeyboardEvent("keydown", {
7340
+ key: "Enter",
7341
+ metaKey: true,
7342
+ bubbles: true,
7343
+ }),
7344
+ );
7345
+ await el.updateComplete;
7346
+ await editor.updateComplete;
7347
+
7348
+ const error = requireElement(
7349
+ el.querySelector("[data-compose-url-status]"),
7350
+ "expected link url error",
7351
+ );
7352
+ expect(error.textContent?.trim()).toBe(
7353
+ "Add a URL before posting this link.",
7354
+ );
7355
+ });
7356
+
4544
7357
  it("Cmd/Ctrl+Enter finishes an attached text editor instead of publishing", async () => {
4545
7358
  const el = await createElement();
4546
7359
  (
@@ -4754,7 +7567,7 @@ describe("JantComposeDialog", () => {
4754
7567
  ).toBe("Don't save");
4755
7568
  expect(
4756
7569
  el.querySelector(".compose-confirm-cancel")?.textContent?.trim(),
4757
- ).toBe("Cancel");
7570
+ ).toBe("Keep editing");
4758
7571
  });
4759
7572
 
4760
7573
  it("cancel on attached text confirm returns focus to the attached editor", async () => {
@@ -5008,9 +7821,12 @@ describe("JantComposeDialog", () => {
5008
7821
  el._format = "note";
5009
7822
  await el.updateComplete;
5010
7823
 
5011
- // Format switcher should be visible (not "Edit post" title)
7824
+ // Format switcher should be visible, and the submit label says this is a
7825
+ // new post rather than an edit.
5012
7826
  expect(el.querySelector(".compose-segmented")).not.toBeNull();
5013
- expect(el.querySelector(".compose-dialog-title")).toBeNull();
7827
+ expect(el.querySelector(".compose-publish-main")?.textContent?.trim()).toBe(
7828
+ "Post",
7829
+ );
5014
7830
 
5015
7831
  // Button should say "Post", not "Done"
5016
7832
  const postBtn = requireElement(
@@ -5095,11 +7911,94 @@ describe("JantComposeDialog", () => {
5095
7911
  expect((receivedDetail as unknown as ComposeSubmitDetail).editPostId).toBe(
5096
7912
  "draft789",
5097
7913
  );
7914
+ expect(
7915
+ (receivedDetail as unknown as ComposeSubmitDetail).draftSourceId,
7916
+ ).toBe("draft789");
5098
7917
  expect((receivedDetail as unknown as ComposeSubmitDetail).status).toBe(
5099
7918
  "published",
5100
7919
  );
5101
7920
  });
5102
7921
 
7922
+ it("opens a thread draft without deleting it and preserves its draft identity", async () => {
7923
+ const rootId = "pst_draft_root";
7924
+ const replyId = "pst_draft_reply";
7925
+ const requests: Array<{ url: string; method: string }> = [];
7926
+ vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => {
7927
+ const url = String(input);
7928
+ requests.push({ url, method: init?.method ?? "GET" });
7929
+
7930
+ if (url === `/api/posts/${rootId}`) {
7931
+ return new Response(
7932
+ JSON.stringify({
7933
+ id: rootId,
7934
+ threadId: rootId,
7935
+ format: "note",
7936
+ status: "draft",
7937
+ slug: "draft-thread",
7938
+ title: "Draft thread",
7939
+ body: null,
7940
+ attachments: [],
7941
+ collectionIds: [],
7942
+ }),
7943
+ { headers: { "Content-Type": "application/json" } },
7944
+ );
7945
+ }
7946
+
7947
+ if (url === "/api/posts?status=draft&limit=50") {
7948
+ return new Response(
7949
+ JSON.stringify({
7950
+ posts: [
7951
+ {
7952
+ id: replyId,
7953
+ threadId: rootId,
7954
+ replyToId: rootId,
7955
+ format: "quote",
7956
+ status: "draft",
7957
+ quoteText: "Draft reply",
7958
+ attachments: [],
7959
+ },
7960
+ ],
7961
+ }),
7962
+ { headers: { "Content-Type": "application/json" } },
7963
+ );
7964
+ }
7965
+
7966
+ throw new Error(`Unexpected fetch: ${url}`);
7967
+ });
7968
+
7969
+ const el = await createElement();
7970
+ await el.openDraft(rootId);
7971
+ await flushUpdates(el);
7972
+
7973
+ expect(el._draftSourceId).toBe(rootId);
7974
+ expect(el.querySelectorAll("jant-compose-editor")).toHaveLength(2);
7975
+ expect(requests).toEqual([
7976
+ { url: `/api/posts/${rootId}`, method: "GET" },
7977
+ { url: "/api/posts?status=draft&limit=50", method: "GET" },
7978
+ ]);
7979
+
7980
+ let receivedDetail: ComposeSubmitDetail | null = null;
7981
+ el.addEventListener("jant:compose-submit-deferred", (event) => {
7982
+ receivedDetail = (event as CustomEvent<ComposeSubmitDetail>).detail;
7983
+ });
7984
+ requireElement(
7985
+ el.querySelector<HTMLButtonElement>(".compose-publish-main"),
7986
+ "expected publish button",
7987
+ ).click();
7988
+
7989
+ expect(receivedDetail).not.toBeNull();
7990
+ expect(
7991
+ (receivedDetail as unknown as ComposeSubmitDetail).draftSourceId,
7992
+ ).toBe(rootId);
7993
+ expect((receivedDetail as unknown as ComposeSubmitDetail).editPostId).toBe(
7994
+ rootId,
7995
+ );
7996
+ expect(
7997
+ (receivedDetail as unknown as ComposeSubmitDetail).threadPosts,
7998
+ ).toHaveLength(2);
7999
+ expect(requests.every((request) => request.method === "GET")).toBe(true);
8000
+ });
8001
+
5103
8002
  it("draft button confirm save dispatches draft then opens drafts panel", async () => {
5104
8003
  const el = await createElement();
5105
8004
  const editor = requireElement(
@@ -5129,13 +8028,10 @@ describe("JantComposeDialog", () => {
5129
8028
  receivedDetail = (event as CustomEvent<ComposeSubmitDetail>).detail;
5130
8029
  });
5131
8030
 
5132
- // Click draft button → confirm panel
5133
- const draftBtn = requireElement(
5134
- el.querySelector<HTMLButtonElement>(".compose-dialog-header-btn"),
5135
- "expected draft button",
5136
- );
5137
- draftBtn.click();
5138
- await el.updateComplete;
8031
+ // Click the drafts row in the options panel → confirm panel
8032
+ await openPublishPanel(el);
8033
+ requireElement(draftsRow(el), "expected drafts row").click();
8034
+ await flushUpdates(el);
5139
8035
  expect(el._confirmPanelOpen).toBe(true);
5140
8036
 
5141
8037
  // Click "Save"
@@ -5200,13 +8096,10 @@ describe("JantComposeDialog", () => {
5200
8096
  submitFired = true;
5201
8097
  });
5202
8098
 
5203
- // Click draft button → confirm panel
5204
- const draftBtn = requireElement(
5205
- el.querySelector<HTMLButtonElement>(".compose-dialog-header-btn"),
5206
- "expected draft button",
5207
- );
5208
- draftBtn.click();
5209
- await el.updateComplete;
8099
+ // Click the drafts row in the options panel → confirm panel
8100
+ await openPublishPanel(el);
8101
+ requireElement(draftsRow(el), "expected drafts row").click();
8102
+ await flushUpdates(el);
5210
8103
 
5211
8104
  // Click "Don't save"
5212
8105
  requireElement(
@@ -5248,6 +8141,7 @@ describe("JantComposeDialog", () => {
5248
8141
  alt: "",
5249
8142
  error: null,
5250
8143
  posterUrl: null,
8144
+ remoteUrl: null,
5251
8145
  summary: null,
5252
8146
  chars: null,
5253
8147
  },