@jant/core 0.6.16 → 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 (508) hide show
  1. package/README.md +11 -11
  2. package/bin/commands/assets/upload.js +192 -15
  3. package/bin/commands/import-site.js +60 -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-C7_4oab9.js → app-K_Aa1MMn.js} +52476 -13330
  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-Dl5KCiEs.js → github-sync-BPAvT999.js} +2254 -107
  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__/stylesheet-audience.test.ts +159 -0
  51. package/src/__tests__/stylesheet-breakpoints.test.ts +183 -0
  52. package/src/app.tsx +71 -16
  53. package/src/auth.ts +18 -1
  54. package/src/client/__tests__/collection-page-actions.test.ts +51 -2
  55. package/src/client/__tests__/compose-bridge-image-upload.test.ts +196 -0
  56. package/src/client/__tests__/compose-bridge-video-poster.test.ts +159 -0
  57. package/src/client/__tests__/compose-bridge.test.ts +272 -0
  58. package/src/client/__tests__/compose-launch.test.ts +62 -1
  59. package/src/client/__tests__/compose-shortcuts.test.ts +38 -9
  60. package/src/client/__tests__/compose-triggers.test.ts +90 -0
  61. package/src/client/__tests__/copy-field.test.ts +162 -0
  62. package/src/client/__tests__/custom-url-menu.test.ts +127 -6
  63. package/src/client/__tests__/image-processor.test.ts +324 -2
  64. package/src/client/__tests__/lazy-entries.test.ts +92 -0
  65. package/src/client/__tests__/media-scroll-hint.test.ts +155 -0
  66. package/src/client/__tests__/runtime-paths.test.ts +84 -0
  67. package/src/client/__tests__/smart-collection-page-actions.test.ts +158 -0
  68. package/src/client/__tests__/toast.test.ts +23 -0
  69. package/src/client/__tests__/video-processor-probe.test.ts +175 -0
  70. package/src/client/__tests__/video-processor.test.ts +181 -0
  71. package/src/client/audio-processor.ts +24 -15
  72. package/src/client/audio-waveform.ts +49 -0
  73. package/src/client/collection-dialog-host.ts +72 -0
  74. package/src/client/collection-navigation.ts +51 -16
  75. package/src/client/collection-page-actions.ts +26 -0
  76. package/src/client/components/__tests__/jant-collection-dialog.test.ts +329 -0
  77. package/src/client/components/__tests__/jant-collection-directory.test.ts +274 -10
  78. package/src/client/components/__tests__/jant-collection-form.test.ts +59 -178
  79. package/src/client/components/__tests__/jant-command-palette.test.ts +28 -0
  80. package/src/client/components/__tests__/jant-compose-dialog-draft-storage.test.ts +340 -0
  81. package/src/client/components/__tests__/jant-compose-dialog.test.ts +1250 -53
  82. package/src/client/components/__tests__/jant-compose-editor.test.ts +544 -40
  83. package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +0 -1
  84. package/src/client/components/__tests__/jant-locale-picker.test.ts +207 -0
  85. package/src/client/components/__tests__/jant-nav-manager.test.ts +234 -15
  86. package/src/client/components/__tests__/jant-post-menu.test.ts +413 -2
  87. package/src/client/components/__tests__/jant-post-picker.test.ts +197 -0
  88. package/src/client/components/__tests__/jant-settings-avatar.test.ts +15 -12
  89. package/src/client/components/__tests__/jant-settings-general.test.ts +541 -172
  90. package/src/client/components/__tests__/jant-settings-language.test.ts +851 -0
  91. package/src/client/components/__tests__/jant-smart-collection-dialog.test.ts +665 -0
  92. package/src/client/components/collection-dialog-types.ts +49 -0
  93. package/src/client/components/collection-manager-types.ts +32 -1
  94. package/src/client/components/collection-types.ts +1 -15
  95. package/src/client/components/compose-types.ts +75 -1
  96. package/src/client/components/jant-collection-dialog.ts +722 -0
  97. package/src/client/components/jant-collection-directory.ts +567 -182
  98. package/src/client/components/jant-collection-form.ts +53 -232
  99. package/src/client/components/jant-command-palette.ts +100 -49
  100. package/src/client/components/jant-compose-dialog.ts +1486 -707
  101. package/src/client/components/jant-compose-editor.ts +673 -336
  102. package/src/client/components/jant-compose-fullscreen.ts +51 -43
  103. package/src/client/components/jant-config-editor.ts +65 -53
  104. package/src/client/components/jant-confirm-dialog.ts +13 -11
  105. package/src/client/components/jant-locale-picker.ts +418 -0
  106. package/src/client/components/jant-media-lightbox.ts +146 -136
  107. package/src/client/components/jant-nav-manager.ts +690 -366
  108. package/src/client/components/jant-post-menu.ts +1086 -282
  109. package/src/client/components/jant-post-picker.ts +324 -0
  110. package/src/client/components/jant-repo-picker.ts +138 -106
  111. package/src/client/components/jant-settings-avatar.ts +31 -27
  112. package/src/client/components/jant-settings-general.ts +617 -543
  113. package/src/client/components/jant-settings-language.ts +1087 -0
  114. package/src/client/components/jant-smart-collection-dialog.ts +1021 -0
  115. package/src/client/components/jant-text-preview.ts +94 -86
  116. package/src/client/components/nav-manager-types.ts +62 -1
  117. package/src/client/components/settings-types.ts +14 -29
  118. package/src/client/components/smart-collection-conditions.ts +285 -0
  119. package/src/client/components/smart-collection-dialog-types.ts +68 -0
  120. package/src/client/compose-bridge.ts +214 -141
  121. package/src/client/compose-launch.ts +76 -2
  122. package/src/client/compose-shortcuts.ts +6 -8
  123. package/src/client/compose-triggers.ts +50 -0
  124. package/src/client/copy-field.ts +108 -0
  125. package/src/client/custom-url-menu.ts +60 -4
  126. package/src/client/icons.ts +14 -0
  127. package/src/client/image-plan.ts +165 -0
  128. package/src/client/image-processor.ts +195 -117
  129. package/src/client/image-worker-client.ts +45 -0
  130. package/src/client/image-worker.ts +135 -0
  131. package/src/client/lazy-entries.ts +95 -0
  132. package/src/client/lazy-slugify.ts +2 -2
  133. package/src/client/media-scroll-hint.ts +43 -2
  134. package/src/client/mediabunny.ts +29 -0
  135. package/src/client/post-picker.ts +41 -0
  136. package/src/client/runtime-paths.ts +74 -1
  137. package/src/client/settings-bridge.ts +4 -10
  138. package/src/client/site-header-nav.js +18 -6
  139. package/src/client/smart-collection-dialog-host.ts +116 -0
  140. package/src/client/smart-collection-page-actions.ts +208 -0
  141. package/src/client/tiptap/__tests__/bubble-menu.test.ts +110 -0
  142. package/src/client/tiptap/__tests__/floating-position.test.ts +136 -1
  143. package/src/client/tiptap/__tests__/inline-image-upload.test.ts +37 -0
  144. package/src/client/tiptap/bubble-menu.ts +47 -16
  145. package/src/client/tiptap/floating-position.ts +216 -2
  146. package/src/client/tiptap/inline-image-upload.ts +7 -3
  147. package/src/client/tiptap/link-toolbar.ts +22 -9
  148. package/src/client/toast.ts +13 -0
  149. package/src/client/types/vite-worker.d.ts +12 -0
  150. package/src/client/upload-with-metadata.ts +3 -13
  151. package/src/client/video-processor.ts +351 -107
  152. package/src/client-auth.ts +14 -31
  153. package/src/client-compose.ts +19 -0
  154. package/src/client-manage.ts +13 -0
  155. package/src/client-settings.ts +14 -0
  156. package/src/client-site.ts +10 -3
  157. package/src/client.ts +4 -0
  158. package/src/db/__tests__/backfill-nav-label-mirrors.test.ts +149 -0
  159. package/src/db/__tests__/schema-enum-sources.test.ts +33 -0
  160. package/src/db/__tests__/smart-collection-placement.test.ts +402 -0
  161. package/src/db/backfills/0006_clear_mirrored_page_nav_labels.sql +31 -0
  162. package/src/db/migrations/0032_married_tomas.sql +3 -0
  163. package/src/db/migrations/0033_busy_johnny_storm.sql +198 -0
  164. package/src/db/migrations/0034_cheerful_rumiko_fujikawa.sql +35 -0
  165. package/src/db/migrations/meta/0032_snapshot.json +2582 -0
  166. package/src/db/migrations/meta/0033_snapshot.json +2816 -0
  167. package/src/db/migrations/meta/0034_snapshot.json +2816 -0
  168. package/src/db/migrations/meta/_journal.json +21 -0
  169. package/src/db/migrations/pg/0030_abandoned_nighthawk.sql +3 -0
  170. package/src/db/migrations/pg/0031_strong_ultimatum.sql +128 -0
  171. package/src/db/migrations/pg/0032_furry_multiple_man.sql +6 -0
  172. package/src/db/migrations/pg/meta/0030_snapshot.json +3329 -0
  173. package/src/db/migrations/pg/meta/0031_snapshot.json +3660 -0
  174. package/src/db/migrations/pg/meta/0032_snapshot.json +3660 -0
  175. package/src/db/migrations/pg/meta/_journal.json +21 -0
  176. package/src/db/pg/schema.ts +195 -27
  177. package/src/db/post-visibility.ts +117 -0
  178. package/src/db/schema.ts +222 -27
  179. package/src/i18n/__tests__/detect.test.ts +26 -81
  180. package/src/i18n/__tests__/supported-locales.test.ts +83 -0
  181. package/src/i18n/context.tsx +12 -2
  182. package/src/i18n/detect.ts +7 -66
  183. package/src/i18n/locales/glossary.zh-Hans.yml +44 -4
  184. package/src/i18n/locales/glossary.zh-Hant.yml +44 -4
  185. package/src/i18n/locales/public/en.po +490 -158
  186. package/src/i18n/locales/public/en.ts +1 -1
  187. package/src/i18n/locales/public/zh-Hans.po +487 -155
  188. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  189. package/src/i18n/locales/public/zh-Hant.po +487 -155
  190. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  191. package/src/i18n/locales/settings/en.po +551 -69
  192. package/src/i18n/locales/settings/en.ts +1 -1
  193. package/src/i18n/locales/settings/zh-Hans.po +597 -115
  194. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  195. package/src/i18n/locales/settings/zh-Hant.po +610 -128
  196. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  197. package/src/i18n/locales.ts +94 -0
  198. package/src/i18n/middleware.ts +12 -2
  199. package/src/i18n/supported-locales.ts +80 -0
  200. package/src/lib/__tests__/api-settings.test.ts +11 -2
  201. package/src/lib/__tests__/asset-path.test.ts +92 -0
  202. package/src/lib/__tests__/deferred.test.ts +90 -0
  203. package/src/lib/__tests__/discover-ping.test.ts +89 -0
  204. package/src/lib/__tests__/discover.test.ts +410 -0
  205. package/src/lib/__tests__/feed-policy.test.ts +26 -0
  206. package/src/lib/__tests__/feed.test.ts +1526 -15
  207. package/src/lib/__tests__/filter-dimensions.test.ts +341 -0
  208. package/src/lib/__tests__/hosted-signin.test.ts +1 -1
  209. package/src/lib/__tests__/http-cache.test.ts +162 -0
  210. package/src/lib/__tests__/jant-docs-dev.test.ts +21 -0
  211. package/src/lib/__tests__/jant-docs.test.ts +23 -0
  212. package/src/lib/__tests__/lang-detect.test.ts +231 -0
  213. package/src/lib/__tests__/media-helpers.test.ts +81 -0
  214. package/src/lib/__tests__/navigation.test.ts +135 -1
  215. package/src/lib/__tests__/resolve-config.test.ts +64 -3
  216. package/src/lib/__tests__/schemas.test.ts +42 -4
  217. package/src/lib/__tests__/site-unavailable-page.test.ts +25 -0
  218. package/src/lib/__tests__/slug.test.ts +70 -0
  219. package/src/lib/__tests__/slugify.test.ts +85 -0
  220. package/src/lib/__tests__/storage.test.ts +32 -0
  221. package/src/lib/__tests__/thread-fold.test.ts +113 -0
  222. package/src/lib/__tests__/upload-sideload.test.ts +14 -0
  223. package/src/lib/__tests__/url-fetch.test.ts +1 -2
  224. package/src/lib/__tests__/url.test.ts +78 -72
  225. package/src/lib/__tests__/view-language.test.ts +413 -0
  226. package/src/lib/__tests__/worker-response-cache.test.ts +90 -0
  227. package/src/lib/address.ts +57 -0
  228. package/src/lib/api-posts.ts +12 -0
  229. package/src/lib/asset-path.ts +98 -1
  230. package/src/lib/build-env.ts +16 -0
  231. package/src/lib/collection-groups.ts +5 -1
  232. package/src/lib/collection-paths.ts +3 -10
  233. package/src/lib/constants.ts +28 -3
  234. package/src/lib/copy-field.ts +36 -0
  235. package/src/lib/crypto.ts +40 -0
  236. package/src/lib/csp-builder.ts +2 -1
  237. package/src/lib/deferred.ts +79 -0
  238. package/src/lib/discover-ping.ts +141 -0
  239. package/src/lib/discover.ts +437 -0
  240. package/src/lib/env.ts +134 -0
  241. package/src/lib/errors.ts +33 -0
  242. package/src/lib/feed-policy.ts +236 -2
  243. package/src/lib/feed.ts +926 -73
  244. package/src/lib/filter-dimensions.ts +1349 -0
  245. package/src/lib/github-sync-site-config.ts +3 -0
  246. package/src/lib/hosted-signin.ts +1 -1
  247. package/src/lib/http-cache.ts +148 -0
  248. package/src/lib/hugo-markdown.ts +16 -0
  249. package/src/lib/icons.ts +9 -2
  250. package/src/lib/ids.ts +3 -0
  251. package/src/lib/jant-docs.ts +42 -0
  252. package/src/lib/lang-detect.ts +308 -0
  253. package/src/lib/media-helpers.ts +13 -1
  254. package/src/lib/navigation.ts +78 -11
  255. package/src/lib/pagination.ts +28 -24
  256. package/src/lib/per-language-surfaces.ts +67 -0
  257. package/src/lib/post-meta.ts +19 -0
  258. package/src/lib/render.tsx +55 -7
  259. package/src/lib/resolve-config.ts +53 -5
  260. package/src/lib/schemas.ts +201 -10
  261. package/src/lib/site-header-fragment.tsx +2 -1
  262. package/src/lib/site-unavailable-page.ts +47 -0
  263. package/src/lib/slug.ts +51 -7
  264. package/src/lib/slugify.ts +113 -0
  265. package/src/lib/startup-config.ts +4 -6
  266. package/src/lib/storage.ts +7 -0
  267. package/src/lib/summary.ts +50 -0
  268. package/src/lib/thread-fold.ts +161 -0
  269. package/src/lib/time.ts +32 -0
  270. package/src/lib/timeline.ts +42 -1
  271. package/src/lib/tiptap-render.ts +1 -2
  272. package/src/lib/upload.ts +16 -13
  273. package/src/lib/url.ts +139 -86
  274. package/src/lib/version.ts +10 -8
  275. package/src/lib/view-language.ts +436 -0
  276. package/src/lib/view.ts +51 -23
  277. package/src/lib/viewer-context.ts +62 -0
  278. package/src/middleware/__tests__/auth.test.ts +70 -2
  279. package/src/middleware/__tests__/cache-control.test.ts +71 -3
  280. package/src/middleware/__tests__/onboarding.test.ts +76 -5
  281. package/src/middleware/__tests__/secure-headers.test.ts +1 -1
  282. package/src/middleware/__tests__/session.test.ts +181 -33
  283. package/src/middleware/auth.ts +50 -31
  284. package/src/middleware/cache-control.ts +75 -10
  285. package/src/middleware/config.ts +6 -6
  286. package/src/middleware/error-handler.ts +12 -0
  287. package/src/middleware/onboarding.ts +32 -10
  288. package/src/middleware/secure-headers.ts +1 -1
  289. package/src/middleware/session.ts +96 -8
  290. package/src/node/__tests__/cli-migrate.test.ts +21 -19
  291. package/src/node/__tests__/cli-reset-password.test.ts +81 -8
  292. package/src/node/__tests__/cli-runtime-target.test.ts +48 -1
  293. package/src/node/__tests__/cli-site-selection.test.ts +128 -38
  294. package/src/node/__tests__/runtime.test.ts +37 -0
  295. package/src/node/index.ts +1 -0
  296. package/src/preset.css +76 -22
  297. package/src/routes/__tests__/feed-validator.test.ts +156 -0
  298. package/src/routes/api/__tests__/collections.test.ts +114 -0
  299. package/src/routes/api/__tests__/discover.test.ts +222 -0
  300. package/src/routes/api/__tests__/nav-items.test.ts +212 -2
  301. package/src/routes/api/__tests__/post-translations.test.ts +829 -0
  302. package/src/routes/api/__tests__/settings.test.ts +24 -3
  303. package/src/routes/api/__tests__/telegram.test.ts +6 -5
  304. package/src/routes/api/collections.ts +49 -6
  305. package/src/routes/api/custom-urls.ts +0 -1
  306. package/src/routes/api/discover.ts +95 -0
  307. package/src/routes/api/export.ts +3 -0
  308. package/src/routes/api/internal/__tests__/sites.test.ts +42 -2
  309. package/src/routes/api/nav-items.ts +55 -8
  310. package/src/routes/api/posts.ts +159 -0
  311. package/src/routes/api/public/__tests__/archive.test.ts +111 -0
  312. package/src/routes/api/public/archive.ts +77 -81
  313. package/src/routes/api/public/posts.ts +23 -6
  314. package/src/routes/api/smart-collections.ts +132 -0
  315. package/src/routes/api/telegram.ts +2 -40
  316. package/src/routes/api/uploads.ts +1 -2
  317. package/src/routes/auth/__tests__/setup-auth.test.ts +137 -0
  318. package/src/routes/auth/__tests__/setup-discover.test.ts +204 -0
  319. package/src/routes/auth/__tests__/setup-flow.test.ts +270 -0
  320. package/src/routes/auth/__tests__/setup-page.test.tsx +329 -0
  321. package/src/routes/auth/__tests__/setup-steps.test.ts +310 -0
  322. package/src/routes/auth/__tests__/setup.test.ts +278 -26
  323. package/src/routes/auth/__tests__/signin.test.ts +135 -0
  324. package/src/routes/auth/setup.tsx +934 -186
  325. package/src/routes/auth/signin.tsx +10 -8
  326. package/src/routes/compose.tsx +5 -0
  327. package/src/routes/dash/__tests__/custom-urls-archive.test.ts +44 -0
  328. package/src/routes/dash/__tests__/custom-urls-page.test.ts +49 -0
  329. package/src/routes/dash/__tests__/delete-account.test.ts +106 -0
  330. package/src/routes/dash/__tests__/settings-discover.test.ts +213 -0
  331. package/src/routes/dash/custom-urls.tsx +1 -37
  332. package/src/routes/dash/settings.tsx +502 -41
  333. package/src/routes/discover-announce.ts +96 -0
  334. package/src/routes/feed/__tests__/feed.test.ts +165 -2
  335. package/src/routes/feed/feed.ts +69 -40
  336. package/src/routes/feed/sitemap.ts +95 -11
  337. package/src/routes/pages/__tests__/archive-indexing.test.ts +200 -0
  338. package/src/routes/pages/__tests__/archive-params.test.ts +370 -6
  339. package/src/routes/pages/__tests__/collection-routing.test.ts +16 -0
  340. package/src/routes/pages/__tests__/collections-feed-links.test.ts +72 -0
  341. package/src/routes/pages/__tests__/collections.test.ts +9 -3
  342. package/src/routes/pages/__tests__/custom-archive-url.test.ts +119 -0
  343. package/src/routes/pages/__tests__/feed-autodiscovery.test.ts +148 -0
  344. package/src/routes/pages/__tests__/language-routing.test.ts +679 -0
  345. package/src/routes/pages/__tests__/post-preview-partial.test.ts +96 -0
  346. package/src/routes/pages/__tests__/smart-collection.test.ts +298 -0
  347. package/src/routes/pages/__tests__/subscribe.test.ts +248 -0
  348. package/src/routes/pages/archive.tsx +471 -369
  349. package/src/routes/pages/collection.tsx +94 -96
  350. package/src/routes/pages/collections.tsx +38 -50
  351. package/src/routes/pages/featured.tsx +32 -13
  352. package/src/routes/pages/home.tsx +25 -6
  353. package/src/routes/pages/language.tsx +113 -0
  354. package/src/routes/pages/latest.tsx +26 -16
  355. package/src/routes/pages/new.tsx +3 -0
  356. package/src/routes/pages/page.tsx +178 -17
  357. package/src/routes/pages/partials.tsx +41 -0
  358. package/src/routes/pages/search.tsx +18 -3
  359. package/src/routes/pages/smart-collection.tsx +340 -0
  360. package/src/routes/pages/subscribe.tsx +182 -0
  361. package/src/runtime/__tests__/index.test.ts +19 -0
  362. package/src/runtime/__tests__/node.test.ts +129 -4
  363. package/src/runtime/cloudflare.ts +21 -2
  364. package/src/runtime/index.ts +27 -1
  365. package/src/runtime/node.ts +18 -1
  366. package/src/runtime/site.ts +95 -24
  367. package/src/services/__tests__/auth.test.ts +116 -7
  368. package/src/services/__tests__/collection.test.ts +108 -4
  369. package/src/services/__tests__/language.test.ts +495 -0
  370. package/src/services/__tests__/navigation.test.ts +213 -10
  371. package/src/services/__tests__/path.test.ts +180 -0
  372. package/src/services/__tests__/post-language.test.ts +830 -0
  373. package/src/services/__tests__/post.test.ts +46 -9
  374. package/src/services/__tests__/settings.test.ts +228 -17
  375. package/src/services/__tests__/site-admin.test.ts +5 -5
  376. package/src/services/__tests__/smart-collection.test.ts +651 -0
  377. package/src/services/about-page.ts +22 -1
  378. package/src/services/auth.ts +51 -49
  379. package/src/services/bootstrap.ts +188 -38
  380. package/src/services/collection-directory-position.ts +76 -0
  381. package/src/services/collection.ts +331 -46
  382. package/src/services/custom-url.ts +27 -22
  383. package/src/services/export-theme/assets/client-site.css +1 -1
  384. package/src/services/export-theme/assets/client-site.js +123 -123
  385. package/src/services/export-theme/layouts/_default/rss.xml +77 -1
  386. package/src/services/export-theme/layouts/partials/feed-post-content.xml +105 -2
  387. package/src/services/export-theme/layouts/partials/media-gallery.html +35 -21
  388. package/src/services/export-theme/layouts/partials/pagination.html +14 -13
  389. package/src/services/export-theme/layouts/partials/post-card.html +8 -4
  390. package/src/services/export-theme/layouts/partials/reply.html +2 -1
  391. package/src/services/export-theme/layouts/partials/thread-preview.html +40 -30
  392. package/src/services/export-theme/styles/main.css +13 -1
  393. package/src/services/export.ts +278 -19
  394. package/src/services/index.ts +34 -3
  395. package/src/services/language.ts +412 -0
  396. package/src/services/mcp.ts +3 -1
  397. package/src/services/media.ts +14 -6
  398. package/src/services/navigation.ts +429 -67
  399. package/src/services/path.ts +221 -6
  400. package/src/services/post.ts +1021 -152
  401. package/src/services/search.ts +39 -2
  402. package/src/services/settings.ts +238 -43
  403. package/src/services/site-admin.ts +24 -7
  404. package/src/services/site.ts +77 -1
  405. package/src/services/smart-collection.ts +636 -0
  406. package/src/services/telegram.ts +20 -22
  407. package/src/services/upload-session.ts +1 -3
  408. package/src/style-author.css +27 -0
  409. package/src/styles/components-author.css +1622 -0
  410. package/src/styles/components.css +24 -1571
  411. package/src/styles/site-media.css +16 -2
  412. package/src/styles/tokens.css +82 -12
  413. package/src/styles/ui-author.css +7602 -0
  414. package/src/styles/ui.css +1087 -7219
  415. package/src/types/app-context.ts +7 -0
  416. package/src/types/bindings.ts +3 -0
  417. package/src/types/config.ts +94 -15
  418. package/src/types/constants.ts +188 -16
  419. package/src/types/entities.ts +87 -1
  420. package/src/types/operations.ts +64 -2
  421. package/src/types/props.ts +157 -31
  422. package/src/types/views.ts +55 -1
  423. package/src/ui/__tests__/font-themes.test.ts +52 -12
  424. package/src/ui/compose/ComposeDialog.tsx +201 -7
  425. package/src/ui/compose/ComposePrompt.tsx +1 -5
  426. package/src/ui/compose/__tests__/ComposeDialog.test.tsx +125 -0
  427. package/src/ui/dash/appearance/AdvancedContent.tsx +2 -2
  428. package/src/ui/dash/appearance/CodeInjectionContent.tsx +12 -16
  429. package/src/ui/dash/appearance/NavigationContent.tsx +117 -16
  430. package/src/ui/dash/appearance/__tests__/NavigationContent.test.tsx +33 -11
  431. package/src/ui/dash/appearance/__tests__/system-nav-descriptions.test.ts +44 -0
  432. package/src/ui/dash/appearance/system-nav-descriptions.ts +75 -0
  433. package/src/ui/dash/settings/AccountMenuContent.tsx +2 -2
  434. package/src/ui/dash/settings/ApiTokensContent.tsx +2 -1
  435. package/src/ui/dash/settings/ConfigEditorContent.tsx +35 -16
  436. package/src/ui/dash/settings/GeneralContent.tsx +275 -113
  437. package/src/ui/dash/settings/LanguageContent.tsx +409 -0
  438. package/src/ui/dash/settings/SettingsRootContent.tsx +19 -1
  439. package/src/ui/dash/settings/__tests__/GeneralContent.test.tsx +324 -4
  440. package/src/ui/dash/settings/__tests__/LanguageContent.test.tsx +144 -0
  441. package/src/ui/dash/settings/__tests__/discover-copy.test.ts +66 -0
  442. package/src/ui/dash/settings/discover-copy.ts +116 -0
  443. package/src/ui/feed/LinkCard.tsx +55 -45
  444. package/src/ui/feed/NoteCard.tsx +9 -4
  445. package/src/ui/feed/PostStatusBadges.tsx +90 -43
  446. package/src/ui/feed/QuoteCard.tsx +55 -30
  447. package/src/ui/feed/ThreadPreview.tsx +6 -2
  448. package/src/ui/feed/__tests__/thread-preview.test.ts +13 -16
  449. package/src/ui/feed/__tests__/timeline-cards.test.ts +314 -49
  450. package/src/ui/feed/thread-preview-state.ts +20 -6
  451. package/src/ui/font-themes.ts +39 -29
  452. package/src/ui/layouts/BaseLayout.tsx +155 -10
  453. package/src/ui/layouts/SiteLayout.tsx +192 -34
  454. package/src/ui/layouts/__tests__/BaseLayout.test.tsx +196 -17
  455. package/src/ui/layouts/__tests__/SiteLayout.test.tsx +109 -85
  456. package/src/ui/pages/ArchivePage.tsx +282 -289
  457. package/src/ui/pages/CollectionPage.tsx +86 -76
  458. package/src/ui/pages/CollectionsPage.tsx +5 -0
  459. package/src/ui/pages/ComposePage.tsx +8 -0
  460. package/src/ui/pages/PostPage.tsx +47 -0
  461. package/src/ui/pages/SearchPage.tsx +3 -3
  462. package/src/ui/pages/SmartCollectionPage.tsx +434 -0
  463. package/src/ui/pages/SubscribePage.tsx +236 -0
  464. package/src/ui/pages/__tests__/ArchivePage.test.tsx +65 -9
  465. package/src/ui/pages/__tests__/collections-directory-rows.test.tsx +92 -0
  466. package/src/ui/pages/__tests__/collections-view-links.test.tsx +81 -0
  467. package/src/ui/shared/CollectionDirectory.tsx +178 -3
  468. package/src/ui/shared/CollectionsManager.tsx +70 -17
  469. package/src/ui/shared/CopyField.tsx +127 -0
  470. package/src/ui/shared/EmptyPostContent.tsx +23 -0
  471. package/src/ui/shared/MediaGallery.tsx +125 -46
  472. package/src/ui/shared/Pagination.tsx +20 -5
  473. package/src/ui/shared/PostFooter.tsx +20 -11
  474. package/src/ui/shared/__tests__/archive-labels.test.ts +100 -0
  475. package/src/ui/shared/__tests__/media-gallery.test.ts +210 -2
  476. package/src/ui/shared/__tests__/navigation-labels.test.ts +16 -22
  477. package/src/ui/shared/__tests__/pagination.test.ts +38 -24
  478. package/src/ui/shared/__tests__/post-footer.test.ts +32 -0
  479. package/src/ui/shared/archive-labels.ts +98 -0
  480. package/src/ui/shared/collection-management-labels.ts +94 -16
  481. package/src/ui/shared/navigation-labels.ts +45 -71
  482. package/src/ui/shared/post-article-attributes.ts +2 -0
  483. package/src/ui/shared/smart-collection-labels.ts +343 -0
  484. package/dist/app-C75Zc8dD.js +0 -6
  485. package/dist/client/_assets/chunks/heic-to-DUUaO23q.js +0 -1
  486. package/dist/client/_assets/chunks/module-DcsAZQZ_.js +0 -716
  487. package/dist/client/_assets/chunks/url-CU9pEbk5.js +0 -1
  488. package/dist/client/_assets/client-Bq0vre8Y.js +0 -296
  489. package/dist/client/_assets/client-D3VZyG4L.css +0 -2
  490. package/dist/client/_assets/client-auth-F2rb9WfF.js +0 -5571
  491. package/dist/env-BPYViDJJ.js +0 -281
  492. package/dist/github-api-BNF35Lkx.js +0 -176
  493. package/dist/github-app-DdiD-bC4.js +0 -275
  494. package/dist/github-sync-Bqg62IvV.js +0 -4
  495. package/dist/github-sync-HdK2EgvJ.js +0 -431
  496. package/dist/url-CbLAtlZe.js +0 -900
  497. package/src/client/__tests__/collection-created-notice.test.ts +0 -36
  498. package/src/client/__tests__/collection-form-bridge.test.ts +0 -216
  499. package/src/client/__tests__/post-form-bridge.test.ts +0 -82
  500. package/src/client/collection-created-notice.ts +0 -66
  501. package/src/client/collection-form-bridge.ts +0 -191
  502. package/src/client/components/__tests__/jant-post-form.test.ts +0 -193
  503. package/src/client/components/jant-post-form.ts +0 -422
  504. package/src/client/components/post-form-template.ts +0 -352
  505. package/src/client/components/post-form-types.ts +0 -96
  506. package/src/client/media-metadata.ts +0 -247
  507. package/src/client/post-form-bridge.ts +0 -193
  508. 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
 
@@ -159,6 +158,7 @@ function mockSlugApi(
159
158
 
160
159
  const labels: ComposeLabels = {
161
160
  cancel: "Cancel",
161
+ close: "Close",
162
162
  note: "Note",
163
163
  link: "Link",
164
164
  quote: "Quote",
@@ -197,7 +197,11 @@ const labels: ComposeLabels = {
197
197
  altHint: "Alt text improves accessibility",
198
198
  addMore: "Add",
199
199
  removeAttachment: "Remove attachment",
200
+ moveAttachmentEarlier: "Move attachment earlier",
201
+ moveAttachmentLater: "Move attachment later",
200
202
  uploading: "Uploading...",
203
+ publishing: "Publishing...",
204
+ saving: "Saving...",
201
205
  loadingPost: "Loading post...",
202
206
  loadPostFailed: "Couldn't load this post. Try again.",
203
207
  published: "Published!",
@@ -208,7 +212,7 @@ const labels: ComposeLabels = {
208
212
  confirmCloseTitle: "Save to drafts?",
209
213
  confirmCloseSubtitle: "Save to drafts to edit and post at a later time.",
210
214
  confirmCloseSave: "Save",
211
- confirmCloseCancel: "Cancel",
215
+ confirmCloseCancel: "Keep editing",
212
216
  confirmCloseDiscard: "Don't save",
213
217
  confirmAttachedTitle: "Save text attachment?",
214
218
  confirmAttachedSubtitle:
@@ -227,14 +231,32 @@ const labels: ComposeLabels = {
227
231
  deleteDraft: "Delete Draft",
228
232
  draftDeleted: "Draft deleted.",
229
233
  publishFailedDraft: "Couldn't publish. Saved as draft.",
234
+ publishFailed: "Couldn't publish. Try again.",
235
+ publishFailedOffline: "Couldn't reach the server. Try again.",
230
236
  uploadFailedDraft: "Some uploads failed. Saved as draft.",
231
237
  addCollection: "Add Collection",
232
238
  collectionCountLabel: "%name% + %count% more",
233
239
  draftRestored: "Draft restored.",
240
+ draftStoreFailed: "This browser can't keep a local copy. Save as a draft.",
234
241
  reply: "Reply",
235
242
  publishHideFromLatest: "Hide from Latest",
236
243
  publishPrivate: "Post as Private",
237
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",
238
260
  publishVisibilityLabel: "Visibility",
239
261
  publishVisibilityPublic: "Public",
240
262
  publishVisibilityPublicHint: "Appears in Latest.",
@@ -272,7 +294,6 @@ const labels: ComposeLabels = {
272
294
  threadLimitReached: "Threads can include up to 20 posts.",
273
295
  showMore: "Show more",
274
296
  showLess: "Show less",
275
- closeCompose: "Close compose",
276
297
  editing: "Editing",
277
298
  composeDialogLabel: "Compose",
278
299
  slashHint: "Type / for commands",
@@ -302,13 +323,6 @@ const labels: ComposeLabels = {
302
323
  quickHint: "More options are available after you create it.",
303
324
  quickSubmitLabel: "Done",
304
325
  createdLabel: "Collection created.",
305
- descriptionLabel: "Description (optional)",
306
- descriptionPlaceholder: "What's this collection about?",
307
- sortOrderLabel: "Sort Order",
308
- sortNewest: "Newest first",
309
- sortOldest: "Oldest first",
310
- sortRatingDesc: "Highest rated",
311
- submitLabel: "Save",
312
326
  cancelLabel: "Cancel",
313
327
  },
314
328
  };
@@ -652,6 +666,860 @@ describe("JantComposeDialog", () => {
652
666
  expect(el.querySelector(".compose-sheet")).not.toBeNull();
653
667
  });
654
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
+
655
1523
  it("keeps date and permalink on the post, not in the publish panel", async () => {
656
1524
  const el = await createElement();
657
1525
 
@@ -727,7 +1595,7 @@ describe("JantComposeDialog", () => {
727
1595
 
728
1596
  // Inline coordinates only mean anything if the panel is out of the
729
1597
  // scroller — `position: absolute` here is the bug, not a style choice.
730
- const css = readFileSync(resolve("src/styles/ui.css"), "utf8");
1598
+ const css = readComponentCss();
731
1599
  expect(css).toMatch(
732
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;/,
733
1601
  );
@@ -1645,8 +2513,7 @@ describe("JantComposeDialog", () => {
1645
2513
  await editor.updateComplete;
1646
2514
 
1647
2515
  let receivedDetail:
1648
- | (ComposeSubmitDetail & { pendingAttachments: unknown[] })
1649
- | null = null;
2516
+ (ComposeSubmitDetail & { pendingAttachments: unknown[] }) | null = null;
1650
2517
  el.addEventListener("jant:compose-submit-deferred", (event) => {
1651
2518
  const customEvent = event as CustomEvent<
1652
2519
  ComposeSubmitDetail & { pendingAttachments: unknown[] }
@@ -3296,8 +4163,7 @@ describe("JantComposeDialog", () => {
3296
4163
  await el.updateComplete;
3297
4164
 
3298
4165
  let receivedDetail:
3299
- | (ComposeSubmitDetail & { pendingAttachments: unknown[] })
3300
- | null = null;
4166
+ (ComposeSubmitDetail & { pendingAttachments: unknown[] }) | null = null;
3301
4167
  el.addEventListener("jant:compose-submit-deferred", (event) => {
3302
4168
  const customEvent = event as CustomEvent<
3303
4169
  ComposeSubmitDetail & { pendingAttachments: unknown[] }
@@ -3531,6 +4397,236 @@ describe("JantComposeDialog", () => {
3531
4397
  expect(scroller.scrollTop).toBe(640);
3532
4398
  });
3533
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);
4525
+
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
+ });
4535
+
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
+ }
4580
+
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);
4589
+
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
+ ]);
4604
+ });
4605
+
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);
4623
+
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
+ });
4628
+ });
4629
+
3534
4630
  it("omits visibility from locked edit submissions", async () => {
3535
4631
  const el = await createElement();
3536
4632
  const editor = requireElement(
@@ -4383,7 +5479,7 @@ describe("JantComposeDialog", () => {
4383
5479
  });
4384
5480
 
4385
5481
  it("scrolls in exactly one place across every compose mode", async () => {
4386
- const css = readFileSync(resolve("src/styles/ui.css"), "utf8");
5482
+ const css = readComponentCss();
4387
5483
 
4388
5484
  // `.compose-scroll` owns the scrolling for single-post, reply and thread
4389
5485
  // alike. Anything nested inside it that scrolls on its own is the bug this
@@ -4410,7 +5506,7 @@ describe("JantComposeDialog", () => {
4410
5506
  });
4411
5507
 
4412
5508
  it("lets attachment previews take the room the pinned toolbar used to need", () => {
4413
- const css = readFileSync(resolve("src/styles/ui.css"), "utf8");
5509
+ const css = readComponentCss();
4414
5510
 
4415
5511
  expect(css).toMatch(
4416
5512
  /\.compose-attachment-img\s*\{[\s\S]*max-height:\s*min\(420px,\s*46dvh\);/,
@@ -4418,14 +5514,37 @@ describe("JantComposeDialog", () => {
4418
5514
  expect(css).toMatch(
4419
5515
  /\.compose-attachment:only-child\s+\.compose-attachment-img\s*\{[\s\S]*max-height:\s*min\(460px,\s*52dvh\);/,
4420
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
+ );
4421
5522
  expect(css).toMatch(
4422
- /\.compose-attachment:not\(:only-child\)\s+\.compose-attachment-img\s*\{[\s\S]*height:\s*min\(340px,\s*40dvh\);/,
5523
+ /\.compose-attachment:not\(:only-child\)\s+\.compose-attachment-img\s*\{[\s\S]*height:\s*var\(--compose-strip-height\);/,
4423
5524
  );
4424
5525
  // Reply compose no longer shrinks its previews to 96×72 to spare the
4425
5526
  // toolbar — one preview size everywhere.
4426
5527
  expect(css).not.toMatch(/--compose-reply-attachment-width/);
4427
5528
  });
4428
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
+
4429
5548
  it("routes a format change straight off the single-post editor", async () => {
4430
5549
  const el = await createElement();
4431
5550
  const editor = requireElement(
@@ -4445,7 +5564,7 @@ describe("JantComposeDialog", () => {
4445
5564
  });
4446
5565
 
4447
5566
  it("shows the replied-to post at the same scale as the reply", () => {
4448
- const css = readFileSync(resolve("src/styles/ui.css"), "utf8");
5567
+ const css = readComponentCss();
4449
5568
 
4450
5569
  // Same ceiling as the composer's own previews — the parent is not a
4451
5570
  // thumbnail strip.
@@ -4470,7 +5589,7 @@ describe("JantComposeDialog", () => {
4470
5589
  });
4471
5590
 
4472
5591
  it("keeps reply compose tools inside the editor's text column", () => {
4473
- const css = readFileSync(resolve("src/styles/ui.css"), "utf8");
5592
+ const css = readComponentCss();
4474
5593
 
4475
5594
  // `flex: 1` on the editor is horizontal — it claims the width the rail dot
4476
5595
  // leaves. Height comes from content.
@@ -4481,7 +5600,7 @@ describe("JantComposeDialog", () => {
4481
5600
  /\.compose-reply-compose-layout\s+\.compose-thread-post-header\s*\+\s*\.compose-body\s*\{[\s\S]*padding-top:\s*12px;/,
4482
5601
  );
4483
5602
  expect(css).toMatch(
4484
- /@media \(max-width:\s*760px\),\s*\(hover:\s*none\) and \(pointer:\s*coarse\)\s*\{[\s\S]*\.compose-thread-layout\s*\{[\s\S]*padding-top:\s*0\.75rem;/,
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;/,
4485
5604
  );
4486
5605
  expect(css).toMatch(
4487
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;/,
@@ -4521,7 +5640,7 @@ describe("JantComposeDialog", () => {
4521
5640
  });
4522
5641
 
4523
5642
  it("opens every compose text surface at a floor stated in lines", () => {
4524
- const css = readFileSync(resolve("src/styles/ui.css"), "utf8");
5643
+ const css = readComponentCss();
4525
5644
 
4526
5645
  // Two lines for the editor, four for the quote — as multiples of the
4527
5646
  // leading, not pixels, so the floor follows the type instead of going
@@ -4539,7 +5658,7 @@ describe("JantComposeDialog", () => {
4539
5658
  });
4540
5659
 
4541
5660
  it("runs the thread rail from the first dot to the last post's, and no further", () => {
4542
- const css = readFileSync(resolve("src/styles/ui.css"), "utf8");
5661
+ const css = readComponentCss();
4543
5662
 
4544
5663
  // Drawn per row rather than as one strip down the layout, which is what
4545
5664
  // lets both ends be trimmed back to a dot.
@@ -4566,7 +5685,7 @@ describe("JantComposeDialog", () => {
4566
5685
  });
4567
5686
 
4568
5687
  it("starts every compose row's ink on one text column", () => {
4569
- const css = readFileSync(resolve("src/styles/ui.css"), "utf8");
5688
+ const css = readComponentCss();
4570
5689
 
4571
5690
  // One knob for the column itself, read by every row rather than restated
4572
5691
  // as a literal per row.
@@ -4616,7 +5735,7 @@ describe("JantComposeDialog", () => {
4616
5735
  });
4617
5736
 
4618
5737
  it("declares one marker vocabulary for the compose and reading rails", () => {
4619
- const css = readFileSync(resolve("src/styles/ui.css"), "utf8");
5738
+ const css = readComponentCss();
4620
5739
 
4621
5740
  // Both rails draw the same dot; only the surface behind it differs.
4622
5741
  // `[^}]*` rather than `[\s\S]*` throughout: a greedy any-char run walks past
@@ -4674,7 +5793,7 @@ describe("JantComposeDialog", () => {
4674
5793
  });
4675
5794
 
4676
5795
  it("punches the dot's gap with the surface colour on both rails", () => {
4677
- const css = readFileSync(resolve("src/styles/ui.css"), "utf8");
5796
+ const css = readComponentCss();
4678
5797
 
4679
5798
  // Compose stops at the punch-out; reading layers its ring inside the same
4680
5799
  // radius, so the outermost layer — the one that breaks the rail line — is
@@ -4698,7 +5817,7 @@ describe("JantComposeDialog", () => {
4698
5817
  });
4699
5818
 
4700
5819
  it("marks the post a view is about with an accent fill, not a bigger dot", () => {
4701
- const css = readFileSync(resolve("src/styles/ui.css"), "utf8");
5820
+ const css = readComponentCss();
4702
5821
 
4703
5822
  // Feed hero, curated highlight and the opened detail post share one rule,
4704
5823
  // matching compose's `.is-current`. Size is not a second signal.
@@ -4724,7 +5843,7 @@ describe("JantComposeDialog", () => {
4724
5843
  });
4725
5844
 
4726
5845
  it("centres every rail child on the line by giving them a shared track", () => {
4727
- const css = readFileSync(resolve("src/styles/ui.css"), "utf8");
5846
+ const css = readComponentCss();
4728
5847
 
4729
5848
  // The line's x is derived from the track, never from a dot's own size — a
4730
5849
  // hardcoded half-marker is what put the line down the dot's edge before.
@@ -4749,7 +5868,7 @@ describe("JantComposeDialog", () => {
4749
5868
  });
4750
5869
 
4751
5870
  it("accents the dot of the post holding the cursor, not every editor row", () => {
4752
- const css = readFileSync(resolve("src/styles/ui.css"), "utf8");
5871
+ const css = readComponentCss();
4753
5872
 
4754
5873
  // Which selector carries the highlight — the colour itself is pinned by
4755
5874
  // "marks the post a view is about…", so this stays token-agnostic.
@@ -4794,7 +5913,7 @@ describe("JantComposeDialog", () => {
4794
5913
  });
4795
5914
 
4796
5915
  it("keeps passive footnote references quiet until the editor selects them", () => {
4797
- const css = readFileSync(resolve("src/styles/ui.css"), "utf8");
5916
+ const css = readComponentCss();
4798
5917
 
4799
5918
  expect(css).toMatch(
4800
5919
  /\.compose-tiptap-body \.tiptap \.tiptap-footnote-reference\s*\{[^}]*color:\s*var\(--site-footnote-marker\);/,
@@ -4943,8 +6062,7 @@ describe("JantComposeDialog", () => {
4943
6062
  await editor.updateComplete;
4944
6063
 
4945
6064
  let receivedDetail:
4946
- | (ComposeSubmitDetail & { pendingAttachments: unknown[] })
4947
- | null = null;
6065
+ (ComposeSubmitDetail & { pendingAttachments: unknown[] }) | null = null;
4948
6066
  el.addEventListener("jant:compose-submit-deferred", (event) => {
4949
6067
  const customEvent = event as CustomEvent<
4950
6068
  ComposeSubmitDetail & { pendingAttachments: unknown[] }
@@ -5106,7 +6224,7 @@ describe("JantComposeDialog", () => {
5106
6224
  ),
5107
6225
  "expected close button in the post header",
5108
6226
  );
5109
- expect(closeBtn.getAttribute("aria-label")).toBe(labels.cancel);
6227
+ expect(closeBtn.getAttribute("aria-label")).toBe(labels.close);
5110
6228
 
5111
6229
  closeBtn.click();
5112
6230
  await flushUpdates(el);
@@ -5114,7 +6232,7 @@ describe("JantComposeDialog", () => {
5114
6232
  expect(requestCloseSpy).toHaveBeenCalledTimes(1);
5115
6233
  });
5116
6234
 
5117
- it("hands the header slot back to per-post remove in a thread, and moves the exit into the options panel", async () => {
6235
+ it("hands the header slot back to per-post remove in a thread, and moves the exit to the quick actions row", async () => {
5118
6236
  const el = await createElement();
5119
6237
  el._threadItems = [
5120
6238
  { id: "thread-1", format: "note" },
@@ -5128,42 +6246,61 @@ describe("JantComposeDialog", () => {
5128
6246
  el.querySelectorAll(".compose-thread-post-remove").length,
5129
6247
  ).toBeGreaterThan(0);
5130
6248
 
5131
- await openPublishPanel(el);
5132
- const closeRow = requireElement(
5133
- Array.from(
5134
- el.querySelectorAll<HTMLButtonElement>(
5135
- ".compose-publish-panel .compose-sheet-row",
5136
- ),
5137
- ).find(
5138
- (row) =>
5139
- row.querySelector(".compose-sheet-title")?.textContent?.trim() ===
5140
- labels.closeCompose,
5141
- ) ?? null,
5142
- "expected a close row in the options panel",
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",
5143
6254
  );
6255
+ expect(closeBtn.textContent?.trim()).toBe(labels.close);
5144
6256
 
5145
6257
  const requestCloseSpy = vi.spyOn(el, "requestClose");
5146
- closeRow.click();
6258
+ closeBtn.click();
5147
6259
  await flushUpdates(el);
5148
6260
 
5149
- expect(el._showPublishPanel).toBe(false);
5150
6261
  expect(requestCloseSpy).toHaveBeenCalledTimes(1);
5151
6262
  });
5152
6263
 
5153
- it("keeps the exit out of the options panel when the × already offers one", async () => {
6264
+ it("keeps the thread exit out of single-post mode, where the × already offers one", async () => {
5154
6265
  const el = await createElement();
5155
- await openPublishPanel(el);
6266
+ expect(el.querySelector(".compose-quick-actions-close")).toBeNull();
5156
6267
 
6268
+ await openPublishPanel(el);
5157
6269
  const titles = Array.from(
5158
6270
  el.querySelectorAll<HTMLElement>(
5159
6271
  ".compose-publish-panel .compose-sheet-title",
5160
6272
  ),
5161
6273
  ).map((n) => n.textContent?.trim());
5162
6274
 
5163
- expect(titles).not.toContain(labels.closeCompose);
6275
+ // The exit is never a row in the options sheet, in either mode.
6276
+ expect(titles).not.toContain(labels.close);
5164
6277
  expect(titles).toContain("Drafts");
5165
6278
  });
5166
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
+ });
6303
+
5167
6304
  it("requestClose on empty form closes immediately without confirmation", async () => {
5168
6305
  const el = await createElement();
5169
6306
 
@@ -6157,6 +7294,66 @@ describe("JantComposeDialog", () => {
6157
7294
  expect(el.querySelector("[data-compose-link-title-error]")).toBeNull();
6158
7295
  });
6159
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
+
6160
7357
  it("Cmd/Ctrl+Enter finishes an attached text editor instead of publishing", async () => {
6161
7358
  const el = await createElement();
6162
7359
  (
@@ -6370,7 +7567,7 @@ describe("JantComposeDialog", () => {
6370
7567
  ).toBe("Don't save");
6371
7568
  expect(
6372
7569
  el.querySelector(".compose-confirm-cancel")?.textContent?.trim(),
6373
- ).toBe("Cancel");
7570
+ ).toBe("Keep editing");
6374
7571
  });
6375
7572
 
6376
7573
  it("cancel on attached text confirm returns focus to the attached editor", async () => {